Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战
作者:mmseoamin日期:2023-12-05

开局一张图

项目源码:youlai-mall

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

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

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

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

前言

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 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战,第3张

数据库初始化

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

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

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

  • 令牌发放记录表: oauth2-authorization-schema.sql
  • 授权记录表: oauth2-authorization-consent-schema.sql
  • 客户端信息表: oauth2-registered-client-schema.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 添加授权服务器依赖

    
    
        org.springframework.security
        spring-security-oauth2-authorization-server
        1.1.1
    
    

    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

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

    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 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 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 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 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 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

    • 参考 Spring Authorization Server 官方示例 demo-authorizationserver 下的 DefaultSecurityConfig.java 进行安全配置
      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 scopes;
          /**
           * 密码模式身份验证令牌
           *
           * @param clientPrincipal      客户端信息
           * @param scopes               令牌申请访问范围
           * @param additionalParameters 自定义额外参数(用户名和密码)
           */
          public PasswordAuthenticationToken(
                  Authentication clientPrincipal,
                  Set scopes,
                  @Nullable Map 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 getScopes() {
              return scopes;
          }
      }
      

      PasswordAuthenticationConverter

      package com.youlai.auth.authentication.password;
      /**
       * 密码模式参数解析器
       * 

      * 解析请求参数中的用户名和密码,并构建相应的身份验证(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 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 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 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;
      /**
       * 密码模式身份验证提供者
       * 

      * 处理基于用户名和密码的身份验证 * * @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 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 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 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 authorizedScopes = registeredClient.getScopes(); Set requestedScopes = resourceOwnerPasswordAuthentication.getScopes(); if (!CollectionUtils.isEmpty(requestedScopes)) { Set 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)相匹配--模板方法 *

      * 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 haoxr
       * @since 3.0.0
       */
      @Configuration
      @RequiredArgsConstructor
      public class JwtTokenClaimsConfig {
          private final RedisTemplate redisTemplate;
          @Bean
          public OAuth2TokenCustomizer 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) { 
      						// 系统用户添加自定义字段
                              Long userId = userDetails.getUserId();
                              claims.claim("user_id", userId);  // 添加系统用户ID
                              // 角色集合存JWT
                              var authorities = AuthorityUtils.authorityListToSet(context.getPrincipal().getAuthorities())
                                      .stream()
                                      .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
                              claims.claim(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY, authorities);
                              // 权限集合存Redis(数据多)
                              Set perms = userDetails.getPerms();
                              redisTemplate.opsForValue().set(SecurityConstants.USER_PERMS_CACHE_PREFIX + userId, perms);
                          } else if (principal instanceof MemberDetails userDetails) { 
                              // 商城会员添加自定义字段
                              claims.claim("member_id", String.valueOf(userDetails.getId())); // 添加会员ID
                          }
                      });
                  }
              };
          }
      }
      

      自定义认证响应

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

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

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

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

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

      根据源码阅读,发现只要重写✅ 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 accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();
          private Converter> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();
          /**
           * 自定义认证成功响应数据结构
           *
           * @param request the request which caused the successful authentication
           * @param response the response
           * @param authentication the Authentication 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 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 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 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

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

      Postman 测试

      • 请求参数Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战,第9张

      • 认证参数

        Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456), Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战,第10张

        资源服务器

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

        maven 依赖

        
        
              org.springframework.boot
              spring-boot-starter-oauth2-resource-server
        
        

        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 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 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(网关)

        登录认证授权