Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战

avatar
作者
猴君
阅读量:0

开局一张图

项目源码youlai-mall

通过 Spring Cloud Gateway 访问认证中心进行认证并获取得到访问令牌。

再根据访问令牌 access_token 获取当前登录的用户信息。

前言

Spring Security OAuth2 的最终版本是2.5.2,并于2022年6月5日正式宣布停止维护。Spring 官方为此推出了新的替代产品,即 Spring Authorization Server。然而,出于安全考虑,Spring Authorization Server 不再支持密码模式,因为密码模式要求客户端直接处理用户的密码。但对于受信任的第一方系统(自有APP和管理系统等),许多情况下需要使用密码模式。在这种情况下,需要在 Spring Authorization Server 的基础上扩展密码模式的支持。本文基于开源微服务商城项目 youlai-mall、Spring Boot 3 和 Spring Authorization Server 1.1 版本,演示了如何扩展密码模式,以及如何将其应用于 Spring Cloud 微服务实战。

数据库初始化

Spring Authorization Server 官方提供的授权服务器示例 demo-authorizationserver 初始化数据库所使用的3个SQL脚本路径如下:


根据路径找到3张表的SQL脚本

整合后的完整数据库 SQL 脚本如下:

-- ---------------------------- -- 1. 创建数据库 -- ---------------------------- CREATE DATABASE IF NOT EXISTS oauth2_server DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;  -- ---------------------------- -- 2. 创建表 -- ---------------------------- use oauth2_server;  SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- 2.1 oauth2_authorization 令牌发放记录表 -- ---------------------------- 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 timestamp DEFAULT NULL,     authorization_code_expires_at timestamp DEFAULT NULL,     authorization_code_metadata blob DEFAULT NULL,     access_token_value blob DEFAULT NULL,     access_token_issued_at timestamp DEFAULT NULL,     access_token_expires_at timestamp 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 timestamp DEFAULT NULL,     oidc_id_token_expires_at timestamp DEFAULT NULL,     oidc_id_token_metadata blob DEFAULT NULL,     refresh_token_value blob DEFAULT NULL,     refresh_token_issued_at timestamp DEFAULT NULL,     refresh_token_expires_at timestamp DEFAULT NULL,     refresh_token_metadata blob DEFAULT NULL,     user_code_value blob DEFAULT NULL,     user_code_issued_at timestamp DEFAULT NULL,     user_code_expires_at timestamp DEFAULT NULL,     user_code_metadata blob DEFAULT NULL,     device_code_value blob DEFAULT NULL,     device_code_issued_at timestamp DEFAULT NULL,     device_code_expires_at timestamp DEFAULT NULL,     device_code_metadata blob DEFAULT NULL,     PRIMARY KEY (id) );  -- ---------------------------- -- 2.2 oauth2_authorization_consent 授权记录表 -- ---------------------------- 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) );  -- ---------------------------- -- 2.3 oauth2-registered-client OAuth2 客户端信息表 -- ---------------------------- CREATE TABLE oauth2_registered_client (     id varchar(100) NOT NULL,     client_id varchar(100) NOT NULL,     client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,     client_secret varchar(200) DEFAULT NULL,     client_secret_expires_at timestamp 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) );  

授权服务器

youlai-auth 模块作为认证授权服务器

maven 依赖

在 youlai-auth 模块的 pom.xml 添加授权服务器依赖

<!-- Spring Authorization Server 授权服务器依赖 --> <dependency>     <groupId>org.springframework.security</groupId>     <artifactId>spring-security-oauth2-authorization-server</artifactId>     <version>1.1.1</version> </dependency> 

application.yml

认证中心配置 oauth2_server 数据库连接信息

spring:   datasource:     type: com.alibaba.druid.pool.DruidDataSource     driver-class-name: com.mysql.cj.jdbc.Driver      url: jdbc:mysql://localhost:3306/oauth2_server?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true     username: root     password: 123456 

授权服务器配置

参考 Spring Authorization Server 官方示例 demo-authorizationserver

AuthorizationServierConfig

参考: Spring Authorization Server 官方示例 demo-authorizationserver 下的 AuthorizationServerConfig.java 进行授权服务器配置

package com.youlai.auth.config;  /**  * 授权服务器配置  *  * @author haoxr  * @since 3.0.0  */ @Configuration @RequiredArgsConstructor @Slf4j public class AuthorizationServerConfig {      private final OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;      /**      * 授权服务器端点配置      */     @Bean     @Order(Ordered.HIGHEST_PRECEDENCE)     public SecurityFilterChain authorizationServerSecurityFilterChain(             HttpSecurity http,             AuthenticationManager authenticationManager,             OAuth2AuthorizationService authorizationService,             OAuth2TokenGenerator<?> tokenGenerator      ) throws Exception {          OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();          authorizationServerConfigurer                 .tokenEndpoint(tokenEndpoint ->                         tokenEndpoint                                 .accessTokenRequestConverters(                                         authenticationConverters ->// <1>                                                 authenticationConverters.addAll(                                                         // 自定义授权模式转换器(Converter)                                                         List.of(                                                                 new PasswordAuthenticationConverter()                                                         )                                                 )                                 )                                .authenticationProviders(authenticationProviders ->// <2>                                         authenticationProviders.addAll(                                             	// 自定义授权模式提供者(Provider)                                                 List.of(                                                         new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator)                                                 )                                         )                                 )                                 .accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应                                 .errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应                 );           RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();         http.securityMatcher(endpointsMatcher)                 .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())                 .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))                 .apply(authorizationServerConfigurer);          return http.build();     }       @Bean // <5>     public JWKSource<SecurityContext> jwkSource() {         KeyPair keyPair = generateRsaKey();         RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();         RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();         // @formatter:off         RSAKey rsaKey = new RSAKey.Builder(publicKey)                 .privateKey(privateKey)                 .keyID(UUID.randomUUID().toString())                 .build();         // @formatter:on         JWKSet jwkSet = new JWKSet(rsaKey);         return new ImmutableJWKSet<>(jwkSet);     }      private static KeyPair generateRsaKey() { // <6>         KeyPair keyPair;         try {             KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");             keyPairGenerator.initialize(2048);             keyPair = keyPairGenerator.generateKeyPair();         } catch (Exception ex) {             throw new IllegalStateException(ex);         }         return keyPair;     }      @Bean     public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {         return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);     }      @Bean     public AuthorizationServerSettings authorizationServerSettings() {         return AuthorizationServerSettings.builder().build();     }      @Bean     public PasswordEncoder passwordEncoder() {         return PasswordEncoderFactories.createDelegatingPasswordEncoder();     }      @Bean     public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {         JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);          // 初始化 OAuth2 客户端         initMallAppClient(registeredClientRepository);         initMallAdminClient(registeredClientRepository);          return registeredClientRepository;     }       @Bean     public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,                                                            RegisteredClientRepository registeredClientRepository) {          JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);         JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper = new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);         rowMapper.setLobHandler(new DefaultLobHandler());         ObjectMapper objectMapper = new ObjectMapper();         ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();         List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);         objectMapper.registerModules(securityModules);         objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());         // 使用刷新模式,需要从 oauth2_authorization 表反序列化attributes字段得到用户信息(SysUserDetails)         objectMapper.addMixIn(SysUserDetails.class, SysUserMixin.class);         objectMapper.addMixIn(Long.class, Object.class);                  rowMapper.setObjectMapper(objectMapper);         service.setAuthorizationRowMapper(rowMapper);         return service;     }      @Bean     public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,                                                                          RegisteredClientRepository registeredClientRepository) {         // Will be used by the ConsentController         return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);     }       @Bean     OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {         JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));         jwtGenerator.setJwtCustomizer(jwtCustomizer);          OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();         OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();         return new DelegatingOAuth2TokenGenerator(                 jwtGenerator, accessTokenGenerator, refreshTokenGenerator);     }       @Bean     public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {         return authenticationConfiguration.getAuthenticationManager();     }      /**      * 初始化创建商城管理客户端      *      * @param registeredClientRepository      */     private void initMallAdminClient(JdbcRegisteredClientRepository registeredClientRepository) {          String clientId = "mall-admin";         String clientSecret = "123456";         String clientName = "商城管理客户端";          /*           如果使用明文,客户端认证时会自动升级加密方式,换句话说直接修改客户端密码,所以直接使用 bcrypt 加密避免不必要的麻烦           官方ISSUE: https://github.com/spring-projects/spring-authorization-server/issues/1099          */         String encodeSecret = passwordEncoder().encode(clientSecret);          RegisteredClient registeredMallAdminClient = registeredClientRepository.findByClientId(clientId);         String id = registeredMallAdminClient != null ? registeredMallAdminClient.getId() : UUID.randomUUID().toString();          RegisteredClient mallAppClient = RegisteredClient.withId(id)                 .clientId(clientId)                 .clientSecret(encodeSecret)                 .clientName(clientName)                 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)                 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)                 .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)                 .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)                 .authorizationGrantType(AuthorizationGrantType.PASSWORD) // 密码模式                 .authorizationGrantType(CaptchaAuthenticationToken.CAPTCHA) // 验证码模式                 .redirectUri("http://127.0.0.1:8080/authorized")                 .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")                 .scope(OidcScopes.OPENID)                 .scope(OidcScopes.PROFILE)                 .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build())                 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())                 .build();         registeredClientRepository.save(mallAppClient);     }      /**      * 初始化创建商城APP客户端      *      * @param registeredClientRepository      */     private void initMallAppClient(JdbcRegisteredClientRepository registeredClientRepository) {          String clientId = "mall-app";         String clientSecret = "123456";         String clientName = "商城APP客户端";          // 如果使用明文,在客户端认证的时候会自动升级加密方式,直接使用 bcrypt 加密避免不必要的麻烦         String encodeSecret = passwordEncoder().encode(clientSecret);          RegisteredClient registeredMallAppClient = registeredClientRepository.findByClientId(clientId);         String id = registeredMallAppClient != null ? registeredMallAppClient.getId() : UUID.randomUUID().toString();          RegisteredClient mallAppClient = RegisteredClient.withId(id)                 .clientId(clientId)                 .clientSecret(encodeSecret)                 .clientName(clientName)                 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)                 .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)                 .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)                 .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)                 .authorizationGrantType(WxMiniAppAuthenticationToken.WECHAT_MINI_APP) // 微信小程序模式                 .authorizationGrantType(SmsCodeAuthenticationToken.SMS_CODE) // 短信验证码模式                 .redirectUri("http://127.0.0.1:8080/authorized")                 .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")                 .scope(OidcScopes.OPENID)                 .scope(OidcScopes.PROFILE)                 .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build())                 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())                 .build();         registeredClientRepository.save(mallAppClient);     } } 

DefaultSecutiryConfig

package com.youlai.auth.config;  /**  * 授权服务器安全配置  *  * @author haoxr  * @since 3.0.0  */ @EnableWebSecurity @Configuration(proxyBeanMethods = false) public class DefaultSecurityConfig {          /**      * Spring Security 安全过滤器链配置      */     @Bean     @Order(0)     SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {         http                 .authorizeHttpRequests(requestMatcherRegistry ->                         {                             requestMatcherRegistry.anyRequest().authenticated();                         }                 )                 .csrf(AbstractHttpConfigurer::disable)                 .formLogin(Customizer.withDefaults());          return http.build();     }      /**      * Spring Security 自定义安全配置      */     @Bean     public WebSecurityCustomizer webSecurityCustomizer() {         return (web) ->                 // 不走过滤器链(场景:静态资源js、css、html)                 web.ignoring().requestMatchers(                         "/webjars/**",                         "/doc.html",                         "/swagger-resources/**",                         "/v3/api-docs/**",                         "/swagger-ui/**"                 );     } } 

密码模式扩展

PasswordAuthenticationToken

package com.youlai.auth.authentication.password;  /**  * 密码授权模式身份验证令牌(包含用户名和密码等)  *  * @author haoxr  * @since 3.0.0  */ public class PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {      public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password");       /**      * 令牌申请访问范围      */     private final Set<String> scopes;      /**      * 密码模式身份验证令牌      *      * @param clientPrincipal      客户端信息      * @param scopes               令牌申请访问范围      * @param additionalParameters 自定义额外参数(用户名和密码)      */     public PasswordAuthenticationToken(             Authentication clientPrincipal,             Set<String> scopes,             @Nullable Map<String, Object> additionalParameters     ) {         super(PASSWORD, clientPrincipal, additionalParameters);         this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet<>(scopes) : Collections.emptySet());      }      /**      * 用户凭证(密码)      */     @Override     public Object getCredentials() {         return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);     }      public Set<String> getScopes() {         return scopes;     } } 

PasswordAuthenticationConverter

package com.youlai.auth.authentication.password;  /**  * 密码模式参数解析器  * <p>  * 解析请求参数中的用户名和密码,并构建相应的身份验证(Authentication)对象  *  * @author haoxr  * @see org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter  * @since 3.0.0  */ public class PasswordAuthenticationConverter implements AuthenticationConverter {      @Override     public Authentication convert(HttpServletRequest request) {          // 授权类型 (必需)         String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);         if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {             return null;         }          // 客户端信息         Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();          // 参数提取验证         MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);          // 令牌申请访问范围验证 (可选)         String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);         if (StringUtils.hasText(scope) &&                 parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {             OAuth2EndpointUtils.throwError(                     OAuth2ErrorCodes.INVALID_REQUEST,                     OAuth2ParameterNames.SCOPE,                     OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);         }         Set<String> requestedScopes = null;         if (StringUtils.hasText(scope)) {             requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));         }          // 用户名验证(必需)         String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);         if (StrUtil.isBlank(username)) {             OAuth2EndpointUtils.throwError(                     OAuth2ErrorCodes.INVALID_REQUEST,                     OAuth2ParameterNames.USERNAME,                     OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI             );         }          // 密码验证(必需)         String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);         if (StrUtil.isBlank(password)) {             OAuth2EndpointUtils.throwError(                     OAuth2ErrorCodes.INVALID_REQUEST,                     OAuth2ParameterNames.PASSWORD,                     OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI             );         }          // 附加参数(保存用户名/密码传递给 PasswordAuthenticationProvider 用于身份认证)         Map<String, Object> additionalParameters = parameters                 .entrySet()                 .stream()                 .filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&                         !e.getKey().equals(OAuth2ParameterNames.SCOPE)                 ).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));          return new PasswordAuthenticationToken(                 clientPrincipal,                 requestedScopes,                 additionalParameters         );     }  } 

PasswordAuthenticationProvider

package com.youlai.auth.authentication.password;  /**  * 密码模式身份验证提供者  * <p>  * 处理基于用户名和密码的身份验证  *  * @author haoxr  * @since 3.0.0  */ @Slf4j public class PasswordAuthenticationProvider implements AuthenticationProvider {      private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";     private final AuthenticationManager authenticationManager;     private final OAuth2AuthorizationService authorizationService;     private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;      /**      * Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.      *      * @param authenticationManager the authentication manager      * @param authorizationService  the authorization service      * @param tokenGenerator        the token generator      * @since 0.2.3      */     public PasswordAuthenticationProvider(AuthenticationManager authenticationManager,                                           OAuth2AuthorizationService authorizationService,                                           OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator     ) {         Assert.notNull(authorizationService, "authorizationService cannot be null");         Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");         this.authenticationManager = authenticationManager;         this.authorizationService = authorizationService;         this.tokenGenerator = tokenGenerator;     }      @Override     public Authentication authenticate(Authentication authentication) throws AuthenticationException {          PasswordAuthenticationToken resourceOwnerPasswordAuthentication = (PasswordAuthenticationToken) authentication;         OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils                 .getAuthenticatedClientElseThrowInvalidClient(resourceOwnerPasswordAuthentication);         RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();          // 验证客户端是否支持授权类型(grant_type=password)         if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.PASSWORD)) {             throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);         }          // 生成用户名密码身份验证令牌         Map<String, Object> additionalParameters = resourceOwnerPasswordAuthentication.getAdditionalParameters();         String username = (String) additionalParameters.get(OAuth2ParameterNames.USERNAME);         String password = (String) additionalParameters.get(OAuth2ParameterNames.PASSWORD);          UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);          // 用户名密码身份验证,成功后返回带有权限的认证信息         Authentication usernamePasswordAuthentication;         try {             usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);         } catch (Exception e) {             // 需要将其他类型的异常转换为 OAuth2AuthenticationException 才能被自定义异常捕获处理,逻辑源码 OAuth2TokenEndpointFilter#doFilterInternal             throw new OAuth2AuthenticationException(e.getCause() != null ? e.getCause().getMessage() : e.getMessage());         }          // 验证申请访问范围(Scope)         Set<String> authorizedScopes = registeredClient.getScopes();         Set<String> requestedScopes = resourceOwnerPasswordAuthentication.getScopes();         if (!CollectionUtils.isEmpty(requestedScopes)) {             Set<String> unauthorizedScopes = requestedScopes.stream()                     .filter(requestedScope -> !registeredClient.getScopes().contains(requestedScope))                     .collect(Collectors.toSet());             if (!CollectionUtils.isEmpty(unauthorizedScopes)) {                 throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);             }             authorizedScopes = new LinkedHashSet<>(requestedScopes);         }          // 访问令牌(Access Token) 构造器         DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()                 .registeredClient(registeredClient)                 .principal(usernamePasswordAuthentication) // 身份验证成功的认证信息(用户名、权限等信息)                 .authorizationServerContext(AuthorizationServerContextHolder.getContext())                 .authorizedScopes(authorizedScopes)                 .authorizationGrantType(AuthorizationGrantType.PASSWORD) // 授权方式                 .authorizationGrant(resourceOwnerPasswordAuthentication) // 授权具体对象                 ;          // 生成访问令牌(Access Token)         OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType((OAuth2TokenType.ACCESS_TOKEN)).build();         OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);         if (generatedAccessToken == null) {             OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,                     "The token generator failed to generate the access token.", ERROR_URI);             throw new OAuth2AuthenticationException(error);         }           OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,                 generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),                 generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());          // 权限数据(perms)比较多通过反射移除,不随令牌一起持久化至数据库         ReflectUtil.setFieldValue(usernamePasswordAuthentication.getPrincipal(), "perms", null);          OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)                 .principalName(usernamePasswordAuthentication.getName())                 .authorizationGrantType(AuthorizationGrantType.PASSWORD)                 .authorizedScopes(authorizedScopes)                 .attribute(Principal.class.getName(), usernamePasswordAuthentication); // attribute 字段         if (generatedAccessToken instanceof ClaimAccessor) {             authorizationBuilder.token(accessToken, (metadata) ->                     metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));         } else {             authorizationBuilder.accessToken(accessToken);         }          // 生成刷新令牌(Refresh Token)         OAuth2RefreshToken refreshToken = null;         if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&                 // Do not issue refresh token to public client                 !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {              tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();             OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);             if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {                 OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,                         "The token generator failed to generate the refresh token.", ERROR_URI);                 throw new OAuth2AuthenticationException(error);             }              refreshToken = (OAuth2RefreshToken) generatedRefreshToken;             authorizationBuilder.refreshToken(refreshToken);         }          OAuth2Authorization authorization = authorizationBuilder.build();          // 持久化令牌发放记录到数据库         this.authorizationService.save(authorization);         additionalParameters = Collections.emptyMap();          return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);     }      /**      * 判断传入的 authentication 类型是否与当前认证提供者(AuthenticationProvider)相匹配--模板方法      * <p>      * ProviderManager#authenticate 遍历 providers 找到支持对应认证请求的 provider-迭代器模式      *      * @param authentication      * @return      */     @Override     public boolean supports(Class<?> authentication) {         return PasswordAuthenticationToken.class.isAssignableFrom(authentication);     } } 

JWT 自定义字段

参考官方 ISSUE :Adds how-to guide on adding authorities to access tokens

package com.youlai.auth.config;  /**  * JWT 自定义字段  *  * @author Ray Hao  * @since 3.0.0  */ @Configuration public class JwtTokenCustomizerConfig {      /**      * JWT 自定义字段      * @see <a href="https://docs.spring.io/spring-authorization-server/reference/guides/how-to-custom-claims-authorities.html">Add custom claims to JWT access tokens</a>      */     @Bean     public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {         return context -> {             if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) && context.getPrincipal() instanceof UsernamePasswordAuthenticationToken) {                 // Customize headers/claims for access_token                 Optional.ofNullable(context.getPrincipal().getPrincipal()).ifPresent(principal -> {                     JwtClaimsSet.Builder claims = context.getClaims();                     if (principal instanceof SysUserDetails userDetails) { // 系统用户添加自定义字段                          claims.claim(JwtClaimConstants.USER_ID, userDetails.getUserId());                         claims.claim(JwtClaimConstants.USERNAME, userDetails.getUsername());                         claims.claim(JwtClaimConstants.DEPT_ID, userDetails.getDeptId());                         claims.claim(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope());                          // 这里存入角色至JWT,解析JWT的角色用于鉴权的位置: ResourceServerConfig#jwtAuthenticationConverter                         var authorities = AuthorityUtils.authorityListToSet(context.getPrincipal().getAuthorities())                                 .stream()                                 .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));                         claims.claim(JwtClaimConstants.AUTHORITIES, authorities);                      } else if (principal instanceof MemberDetails userDetails) { // 商城会员添加自定义字段                         claims.claim(JwtClaimConstants.MEMBER_ID, String.valueOf(userDetails.getId()));                     }                 });             }         };     }  } 

自定义认证响应

🤔 如何自定义 OAuth2 认证成功或失败的响应数据结构符合当前系统统一的规范?

下图左侧部份是 OAuth2 原生返回(⬅️ ),大多数情况下,我们希望返回带有业务码的数据(➡️),以方便前端进行处理。

OAuth2 处理认证成功或失败源码坐标 OAuth2TokenEndpointFilter#doFilterInternal ,如下图:

根据源码阅读,发现只要重写✅ AuthenticationSuccessHandler 和❌ AuthenticationFailureHandler 的逻辑,就能够自定义认证成功和认证失败时的响应数据格式。

认证成功响应

package com.youlai.auth.handler;  /**  * 认证成功处理器  *  * @author haoxr  * @since 3.0.0  */ public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {      /**      * MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换      */     private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();     private Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();       /**      * 自定义认证成功响应数据结构      *      * @param request the request which caused the successful authentication      * @param response the response      * @param authentication the <tt>Authentication</tt> object which was created during      * the authentication process.      * @throws IOException      * @throws ServletException      */      @Override     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {         OAuth2AccessTokenAuthenticationToken accessTokenAuthentication =                 (OAuth2AccessTokenAuthenticationToken) authentication;          OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();         OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();         Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();          OAuth2AccessTokenResponse.Builder builder =                 OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())                         .tokenType(accessToken.getTokenType());         if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {             builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));         }         if (refreshToken != null) {             builder.refreshToken(refreshToken.getTokenValue());         }         if (!CollectionUtils.isEmpty(additionalParameters)) {             builder.additionalParameters(additionalParameters);         }         OAuth2AccessTokenResponse accessTokenResponse = builder.build();          Map<String, Object> tokenResponseParameters = this.accessTokenResponseParametersConverter                 .convert(accessTokenResponse);         ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);          this.accessTokenHttpResponseConverter.write(Result.success(tokenResponseParameters), null, httpResponse);     } }  

认证失败响应

package com.youlai.auth.handler;  /**  * 认证失败处理器  *  * @author haoxr  * @since 2023/7/6  */ @Slf4j public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {      /**      * MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换      */     private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();       @Override     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {         OAuth2Error error = ((OAuth2AuthenticationException) exception).getError();         ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);         Result result = Result.failed(error.getErrorCode());         accessTokenHttpResponseConverter.write(result, null, httpResponse);     } } 

配置自定义处理器

AuthorizationServierConfig

public SecurityFilterChain authorizationServerSecurityFilterChain() throws Exception {      // ...     authorizationServerConfigurer         .tokenEndpoint(tokenEndpoint ->                        tokenEndpoint                        // ...                        .accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应                        .errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应                       );  } 

密码模式测试

单元测试

启动 youlai-system 模块,需要从其获取系统用户信息(用户名、密码)进行认证

package com.youlai.auth.authentication;  /**  * OAuth2 密码模式单元测试  */ @SpringBootTest @AutoConfigureMockMvc @Slf4j public class PasswordAuthenticationTests {      @Autowired     private MockMvc mvc;      /**      * 测试密码模式登录      */     @Test     void testPasswordLogin() throws Exception {         HttpHeaders headers = new HttpHeaders();         // 客户端ID和密钥         headers.setBasicAuth("mall-admin", "123456");          this.mvc.perform(post("/oauth2/token")                         .param(OAuth2ParameterNames.GRANT_TYPE, "password") // 密码模式                         .param(OAuth2ParameterNames.USERNAME, "admin") // 用户名                         .param(OAuth2ParameterNames.PASSWORD, "123456") // 密码                         .headers(headers))                 .andDo(print())                 .andExpect(status().isOk())                 .andExpect(jsonPath("$.data.access_token").isNotEmpty());     } } 

单元测试通过,打印响应数据可以看到返回的 access_token 和 refresh_token

Postman 测试

  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

资源服务器

youlai-system 系统管理模块也作为资源服务器

maven 依赖

<!-- Spring Authorization Server 授权服务器依赖 --> <dependency>       <groupId>org.springframework.boot</groupId>       <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> 

application.yml

通过 Feign 请求 youlai-system 服务以获取系统用户认证信息(用户名和密码),在用户尚未登录的情况下,需要将此请求的路径配置到白名单中以避免拦截。

security:   # 允许无需认证的路径列表   whitelist-paths:     # 获取系统用户的认证信息用于账号密码判读     - /api/v1/users/{username}/authInfo 

资源服务器配置

配置 ResourceServerConfig 位于资源服务器公共模块 common-security 中

package com.youlai.common.security.config;  import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.convert.Convert; import cn.hutool.json.JSONUtil; import com.youlai.common.constant.SecurityConstants; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.util.Strings; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain;  import java.util.List;  /**  * 资源服务器配置  *  * @author haoxr  * @since 3.0.0  */ @ConfigurationProperties(prefix = "security") @Configuration @EnableWebSecurity @Slf4j public class ResourceServerConfig {      /**      * 白名单路径列表      */     @Setter     private List<String> whitelistPaths;      @Bean     public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {          log.info("whitelist path:{}", JSONUtil.toJsonStr(whitelistPaths));         http.authorizeHttpRequests(requestMatcherRegistry ->                         {                             if (CollectionUtil.isNotEmpty(whitelistPaths)) {                                 requestMatcherRegistry.requestMatchers(Convert.toStrArray(whitelistPaths)).permitAll();                             }                             requestMatcherRegistry.anyRequest().authenticated();                         }                 )                 .csrf(AbstractHttpConfigurer::disable)         ;         http.oauth2ResourceServer(resourceServerConfigurer ->                 resourceServerConfigurer.jwt(jwtConfigurer -> jwtAuthenticationConverter())         ) ;         return http.build();     }      /**      * 不走过滤器链的放行配置      */     @Bean     public WebSecurityCustomizer webSecurityCustomizer() {         return (web) -> web.ignoring()                 .requestMatchers(                         "/webjars/**",                         "/doc.html",                         "/swagger-resources/**",                         "/v3/api-docs/**",                         "/swagger-ui/**"                 );     }       /**      * 自定义JWT Converter      *      * @return Converter      * @see JwtAuthenticationProvider#setJwtAuthenticationConverter(Converter)      */     @Bean     public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {         JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();         jwtGrantedAuthoritiesConverter.setAuthorityPrefix(Strings.EMPTY);         jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY);          JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();         jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);         return jwtAuthenticationConverter;     } } 

认证流程测试

分别启动 youlai-mall 的 youai-auth (认证中心)、youlai-system(系统管理模块)、youali-gateway(网关)

登录认证授权

  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

  • 成功响应

    认证成功,获取到访问令牌(access_token )

获取用户信息

使用已获得的访问令牌 (access_token) 向资源服务器发送请求以获取登录用户信息
在这里插入图片描述

成功地获取登录用户信息的响应,而不是出现未授权的401错误。

结语

关于 Spring Authorization Server 1.1 版本的密码模式扩展和在 Spring Cloud 中使用新的授权方式,可以说与 Spring Security OAuth2 的代码相似度极高。如果您已经熟悉 Spring Security OAuth2,那么学习 Spring Authorization Server 将变得轻而易举。后续文章会更新其他常见授权模式的扩展,敬请期待~

源码

本文完整源码: youlai-mall

参考

    广告一刻

    为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!