文章较长,步骤比较繁琐,请各位读者耐心观看。
上篇文章大概了解了下框架的相关理论,本篇文章将带大家一步步构建一个简单的认证服务器
开始之前先放一下文档的链接:官网文档
Spring Boot版本选择3.1.0,Java版本选择17以上,在Dependencies中勾选Spring Authorization Server和spring web依赖,其它看自己需要
引入持久层框架(本人用的是MybatisPlus,读者可自选)
com.baomidou mybatis-plus-boot-starter 3.5.3.1
引入webjars和bootstrap,自定义登录页和确认页面时使用
org.webjars webjars-locator-core org.webjars bootstrap 5.2.3
项目pom.xml示例
4.0.0 org.springframework.boot spring-boot-starter-parent 3.1.0 com.example authorization-example 0.0.1-SNAPSHOT authorization-example authorization-example 17 org.springframework.boot spring-boot-starter-oauth2-authorization-server org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-web com.mysql mysql-connector-j runtime org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test com.baomidou mybatis-plus-boot-starter 3.5.3.1 org.webjars webjars-locator-core org.webjars bootstrap 5.2.3 org.springframework.boot spring-boot-maven-plugin org.projectlombok lombok
初始化框架自带数据库表
schema位置如图
修改后适配MySQL的SQL如下
-- 用户授权确认表 CREATE TABLE oauth2_authorization_consent ( registered_client_id varchar(100) NOT NULL, principal_name varchar(200) NOT NULL, authorities varchar(1000) NOT NULL, PRIMARY KEY (registered_client_id, principal_name) ); -- 用户认证信息表 CREATE TABLE oauth2_authorization ( id varchar(100) NOT NULL, registered_client_id varchar(100) NOT NULL, principal_name varchar(200) NOT NULL, authorization_grant_type varchar(100) NOT NULL, authorized_scopes varchar(1000) DEFAULT NULL, attributes blob DEFAULT NULL, state varchar(500) DEFAULT NULL, authorization_code_value blob DEFAULT NULL, authorization_code_issued_at DATETIME DEFAULT NULL, authorization_code_expires_at DATETIME DEFAULT NULL, authorization_code_metadata blob DEFAULT NULL, access_token_value blob DEFAULT NULL, access_token_issued_at DATETIME DEFAULT NULL, access_token_expires_at DATETIME DEFAULT NULL, access_token_metadata blob DEFAULT NULL, access_token_type varchar(100) DEFAULT NULL, access_token_scopes varchar(1000) DEFAULT NULL, oidc_id_token_value blob DEFAULT NULL, oidc_id_token_issued_at DATETIME DEFAULT NULL, oidc_id_token_expires_at DATETIME DEFAULT NULL, oidc_id_token_metadata blob DEFAULT NULL, refresh_token_value blob DEFAULT NULL, refresh_token_issued_at DATETIME DEFAULT NULL, refresh_token_expires_at DATETIME DEFAULT NULL, refresh_token_metadata blob DEFAULT NULL, user_code_value blob DEFAULT NULL, user_code_issued_at DATETIME DEFAULT NULL, user_code_expires_at DATETIME DEFAULT NULL, user_code_metadata blob DEFAULT NULL, device_code_value blob DEFAULT NULL, device_code_issued_at DATETIME DEFAULT NULL, device_code_expires_at DATETIME DEFAULT NULL, device_code_metadata blob DEFAULT NULL, PRIMARY KEY (id) ); -- 客户端表 CREATE TABLE oauth2_registered_client ( id varchar(100) NOT NULL, client_id varchar(100) NOT NULL, client_id_issued_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, client_secret varchar(200) DEFAULT NULL, client_secret_expires_at DATETIME DEFAULT NULL, client_name varchar(200) NOT NULL, client_authentication_methods varchar(1000) NOT NULL, authorization_grant_types varchar(1000) NOT NULL, redirect_uris varchar(1000) DEFAULT NULL, post_logout_redirect_uris varchar(1000) DEFAULT NULL, scopes varchar(1000) NOT NULL, client_settings varchar(2000) NOT NULL, token_settings varchar(2000) NOT NULL, PRIMARY KEY (id) );
/** * 配置端点的过滤器链 * * @param http spring security核心配置类 * @return 过滤器链 * @throws Exception 抛出 */ @Bean public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { // 配置默认的设置,忽略认证端点的csrf校验 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // 开启OpenID Connect 1.0协议相关端点 .oidc(Customizer.withDefaults()) // 设置自定义用户确认授权页 .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)); http // 当未登录时访问认证端点时重定向至login页面 .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) // 处理使用access token访问用户信息端点和客户端注册端点 .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }
/** * 配置认证相关的过滤器链 * * @param http spring security核心配置类 * @return 过滤器链 * @throws Exception 抛出 */ @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) -> authorize // 放行静态资源 .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll() .anyRequest().authenticated() ) // 指定登录页面 .formLogin(formLogin -> formLogin.loginPage("/login") ); // 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }
/** * 配置密码解析器,使用BCrypt的方式对密码进行加密和验证 * * @return BCryptPasswordEncoder */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
/** * 配置客户端Repository * * @param jdbcTemplate db 数据源信息 * @param passwordEncoder 密码解析器 * @return 基于数据库的repository */ @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) // 客户端id .clientId("messaging-client") // 客户端秘钥,使用密码解析器加密 .clientSecret(passwordEncoder.encode("123456")) // 客户端认证方式,基于请求头的认证 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // 配置资源服务器使用该客户端获取授权时支持的方式 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") // 配置一个百度的域名回调,稍后使用该回调获取code .redirectUri("https://www.baidu.com") // 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) // 自定scope .scope("message.read") .scope("message.write") // 客户端设置,设置用户需要确认授权 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); // 基于db存储客户端,还有一个基于内存的实现 InMemoryRegisteredClientRepository JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); // 初始化客户端 RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClient.getClientId()); if (repositoryByClientId == null) { registeredClientRepository.save(registeredClient); } // 设备码授权客户端 RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("device-message-client") // 公共客户端 .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // 设备码授权 .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // 自定scope .scope("message.read") .scope("message.write") .build(); RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId()); if (byClientId == null) { registeredClientRepository.save(deviceClient); } return registeredClientRepository; }
/** * 配置客户端Repository * * @param jdbcTemplate db 数据源信息 * @return 基于数据库的repository */ @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { return new JdbcRegisteredClientRepository(jdbcTemplate); }
/** * 配置基于db的oauth2的授权管理服务 * * @param jdbcTemplate db数据源信息 * @param registeredClientRepository 上边注入的客户端repository * @return JdbcOAuth2AuthorizationService */ @Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { // 基于db的oauth2认证服务,还有一个基于内存的服务InMemoryOAuth2AuthorizationService return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); }
/** * 配置基于db的授权确认管理服务 * * @param jdbcTemplate db数据源信息 * @param registeredClientRepository 客户端repository * @return JdbcOAuth2AuthorizationConsentService */ @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { // 基于db的授权确认管理服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationConsentService return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); }
/** * 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法 * * @return JWKSource */ @Bean public JWKSourcejwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } /** * 生成rsa密钥对,提供给jwk * * @return 密钥对 */ private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; }
/** * 配置jwt解析器 * * @param jwkSource jwk源 * @return JwtDecoder */ @Bean public JwtDecoder jwtDecoder(JWKSourcejwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); }
/** * 添加认证服务器配置,设置jwt签发者、默认端点请求地址等 * * @return AuthorizationServerSettings */ @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); }
/** * 先暂时配置一个基于内存的用户,框架在用户认证时会默认调用 * {@link UserDetailsService#loadUserByUsername(String)} 方法根据 * 账号查询用户信息,一般是重写该方法实现自己的逻辑 * * @param passwordEncoder 密码解析器 * @return UserDetailsService */ @Bean public UserDetailsService users(PasswordEncoder passwordEncoder) { UserDetails user = User.withUsername("admin") .password(passwordEncoder.encode("123456")) .roles("admin", "normal") .authorities("app", "web") .build(); return new InMemoryUserDetailsManager(user); }
package com.example.config; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.access.annotation.Secured; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.UUID; /** * 认证配置 * {@link EnableMethodSecurity} 开启全局方法认证,启用JSR250注解支持,启用注解 {@link Secured} 支持, * 在Spring Security 6.0版本中将@Configuration注解从@EnableWebSecurity, @EnableMethodSecurity, @EnableGlobalMethodSecurity * 和 @EnableGlobalAuthentication 中移除,使用这些注解需手动添加 @Configuration 注解 * {@link EnableWebSecurity} 注解有两个作用: * 1. 加载了WebSecurityConfiguration配置类, 配置安全认证策略。 * 2. 加载了AuthenticationConfiguration, 配置了认证信息。 * * @author vains */ @Configuration @EnableWebSecurity @EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true) public class AuthorizationConfig { private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent"; /** * 配置端点的过滤器链 * * @param http spring security核心配置类 * @return 过滤器链 * @throws Exception 抛出 */ @Bean public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { // 配置默认的设置,忽略认证端点的csrf校验 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // 开启OpenID Connect 1.0协议相关端点 .oidc(Customizer.withDefaults()) // 设置自定义用户确认授权页 .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)); http // 当未登录时访问认证端点时重定向至login页面 .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) // 处理使用access token访问用户信息端点和客户端注册端点 .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); } /** * 配置认证相关的过滤器链 * * @param http spring security核心配置类 * @return 过滤器链 * @throws Exception 抛出 */ @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) -> authorize // 放行静态资源 .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll() .anyRequest().authenticated() ) // 指定登录页面 .formLogin(formLogin -> formLogin.loginPage("/login") ); // 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); } /** * 配置密码解析器,使用BCrypt的方式对密码进行加密和验证 * * @return BCryptPasswordEncoder */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 配置客户端Repository * * @param jdbcTemplate db 数据源信息 * @param passwordEncoder 密码解析器 * @return 基于数据库的repository */ @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) // 客户端id .clientId("messaging-client") // 客户端秘钥,使用密码解析器加密 .clientSecret(passwordEncoder.encode("123456")) // 客户端认证方式,基于请求头的认证 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // 配置资源服务器使用该客户端获取授权时支持的方式 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问 .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") .redirectUri("https://www.baidu.com") // 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) // 自定scope .scope("message.read") .scope("message.write") // 客户端设置,设置用户需要确认授权 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); // 基于db存储客户端,还有一个基于内存的实现 InMemoryRegisteredClientRepository JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); // 初始化客户端 RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClient.getClientId()); if (repositoryByClientId == null) { registeredClientRepository.save(registeredClient); } // 设备码授权客户端 RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("device-message-client") // 公共客户端 .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // 设备码授权 .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // 自定scope .scope("message.read") .scope("message.write") .build(); RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId()); if (byClientId == null) { registeredClientRepository.save(deviceClient); } return registeredClientRepository; } /** * 配置基于db的oauth2的授权管理服务 * * @param jdbcTemplate db数据源信息 * @param registeredClientRepository 上边注入的客户端repository * @return JdbcOAuth2AuthorizationService */ @Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { // 基于db的oauth2认证服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationService return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } /** * 配置基于db的授权确认管理服务 * * @param jdbcTemplate db数据源信息 * @param registeredClientRepository 客户端repository * @return JdbcOAuth2AuthorizationConsentService */ @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { // 基于db的授权确认管理服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationConsentService return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } /** * 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法 * * @return JWKSource */ @Bean public JWKSourcejwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } /** * 生成rsa密钥对,提供给jwk * * @return 密钥对 */ private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; } /** * 配置jwt解析器 * * @param jwkSource jwk源 * @return JwtDecoder */ @Bean public JwtDecoder jwtDecoder(JWKSource jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } /** * 添加认证服务器配置,设置jwt签发者、默认端点请求地址等 * * @return AuthorizationServerSettings */ @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } /** * 先暂时配置一个基于内存的用户,框架在用户认证时会默认调用 * {@link UserDetailsService#loadUserByUsername(String)} 方法根据 * 账号查询用户信息,一般是重写该方法实现自己的逻辑 * * @param passwordEncoder 密码解析器 * @return UserDetailsService */ @Bean public UserDetailsService users(PasswordEncoder passwordEncoder) { UserDetails user = User.withUsername("admin") .password(passwordEncoder.encode("123456")) .roles("admin", "normal", "unAuthentication") .authorities("app", "web", "/test2", "/test3") .build(); return new InMemoryUserDetailsManager(user); } }
注意,配置类中提到的基于内存存储的类禁止用于生产环境
以下代码摘抄自官方示例
使用thymeleaf框架渲染页面
package com.example.controller; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import java.security.Principal; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; /** * 认证服务器相关自定接口 * * @author vains */ @Controller @RequiredArgsConstructor public class AuthorizationController { private final RegisteredClientRepository registeredClientRepository; private final OAuth2AuthorizationConsentService authorizationConsentService; @GetMapping("/login") public String login() { return "login"; } @GetMapping(value = "/oauth2/consent") public String consent(Principal principal, Model model, @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId, @RequestParam(OAuth2ParameterNames.SCOPE) String scope, @RequestParam(OAuth2ParameterNames.STATE) String state, @RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) { // Remove scopes that were already approved SetscopesToApprove = new HashSet<>(); Set previouslyApprovedScopes = new HashSet<>(); RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId); if (registeredClient == null) { throw new RuntimeException("客户端不存在"); } OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(registeredClient.getId(), principal.getName()); Set authorizedScopes; if (currentAuthorizationConsent != null) { authorizedScopes = currentAuthorizationConsent.getScopes(); } else { authorizedScopes = Collections.emptySet(); } for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) { if (OidcScopes.OPENID.equals(requestedScope)) { continue; } if (authorizedScopes.contains(requestedScope)) { previouslyApprovedScopes.add(requestedScope); } else { scopesToApprove.add(requestedScope); } } model.addAttribute("clientId", clientId); model.addAttribute("state", state); model.addAttribute("scopes", withDescription(scopesToApprove)); model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes)); model.addAttribute("principalName", principal.getName()); model.addAttribute("userCode", userCode); if (StringUtils.hasText(userCode)) { model.addAttribute("requestURI", "/oauth2/device_verification"); } else { model.addAttribute("requestURI", "/oauth2/authorize"); } return "consent"; } private static Set withDescription(Set scopes) { Set scopeWithDescriptions = new HashSet<>(); for (String scope : scopes) { scopeWithDescriptions.add(new ScopeWithDescription(scope)); } return scopeWithDescriptions; } @Data public static class ScopeWithDescription { private static final String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this."; private static final Map scopeDescriptions = new HashMap<>(); static { scopeDescriptions.put( OidcScopes.PROFILE, "This application will be able to read your profile information." ); scopeDescriptions.put( "message.read", "This application will be able to read your message." ); scopeDescriptions.put( "message.write", "This application will be able to add new messages. It will also be able to edit and delete existing messages." ); scopeDescriptions.put( "other.scope", "This is another scope example of a scope description." ); } public final String scope; public final String description; ScopeWithDescription(String scope) { this.scope = scope; this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION); } } }
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/authorization-example?serverTimezone=UTC&userUnicode=true&characterEncoding=utf-8 username: root password: root
以下代码摘抄自官方示例
Spring Authorization Server sample
html, body { height: 100%; } body { display: flex; align-items: start; padding-top: 100px; background-color: #f5f5f5; } .form-signin { max-width: 330px; padding: 15px; } .form-signin .form-floating:focus-within { z-index: 2; } .form-signin input[type="username"] { margin-bottom: -1px; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .form-signin input[type="password"] { margin-bottom: 10px; border-top-left-radius: 0; border-top-right-radius: 0; }
Custom consent page - Consent required App permissions
The application wants to access your account
You have provided the code . Verify that this code matches what is shown on your device.
The following permissions are requested by the above app.
Please review these and consent if you approve.Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.
至此,一个简单的认证服务就搭建成功了。
本来不想设置自定义页面的,但是不知道是本人的网络问题,还是默认的页面里的css相关cdn无法访问,页面加载巨慢还丑,只能从官方示例中拿一下登录页面和用户授权确认页面,css改为从项目的webjars中引入
最后放一下项目结构图
http://127.0.0.1:8080/oauth2/authorize?client_id=messaging-client&response_type=code&scope=message.read&redirect_uri=https%3A%2F%2Fwww.baidu.com
账号:admin, 密码:123456
登录成功跳转至第1步的授权接口,授权接口检测到用户未确认授权,跳转至授权确认页面
选择对应的scope并提交确认权限
授权接口生成code并重定向至第1步请求授权接口时携带的redirectUri地址,重定向时携带上参数code和state,我这里省略掉了state参数,重定向之后只会携带code参数;state用来防止CSRF攻击,正式请求需生成并携带state参数。
一般来说配置的回调地址都是客户端的接口,接口在接收到回调时根据code去换取accessToken,接下来我会用postman模拟客户端发起一个http请求去换取token
不知道为什么在手机浏览器上看回调至百度的图片在平台显示违规,这里我放一张另一个回调地址的图片替代
请求/oauth2/token接口
之前客户端设置的认证方式是CLIENT_SECRET_BASIC,所以需将客户端信息添加至请求头
下列表单数据可添加至form-data也可添加至url params
参数中的code就是第6步回调时携带的code
注意:添加url params时redirect_uri参数要经过encodeURIComponent函数对回调地址进行编码
1. client_id: 客户端的id 2. client_secret: 客户端秘钥 3. redirect_uri:申请授权成功后的回调地址 4. response_type:授权码模式固定参数code 5. code_verifier:一段随机字符串 6. code_challenge:根据指定的加密方式将code_verifier加密后得到的字符串 7. code_challenge_method:加密方式 8. scope:客户端申请的授权范围 9. state:跟随authCode原样返回,防止CSRF攻击 10. grant_type:指定获取token 的方式: 1. refresh_token:刷新token 2. authorization_code:根据授权码模式的授权码获取 3. client_credentials:客户端模式获取
本篇文章从0到1搭建了一个简单认证服务,解释了认证服务的各项配置用意,如何设置自己的登录页和授权确认页,如何让认证服务解析请求时携带的token,文章过长难免有遗漏的地方,如果文章中有遗漏或错误的地方请各位读者在评论区指出。