项目源码: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 模块作为认证授权服务器
在 youlai-auth 模块的 pom.xml 添加授权服务器依赖
org.springframework.security spring-security-oauth2-authorization-server 1.1.1
认证中心配置 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 官方示例 demo-authorizationserver 下的 AuthorizationServerConfig.java 进行授权服务器配置
package com.youlai.auth.config; /** * 授权服务器配置 * * @author haoxr * @since 3.0.0 */ @Configuration @RequiredArgsConstructor @Slf4j public class AuthorizationServerConfig { private final OAuth2TokenCustomizerjwtCustomizer; /** * 授权服务器端点配置 */ @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); } }
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/**" ); } }
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 Setscopes; /** * 密码模式身份验证令牌 * * @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; } }
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 ); } }
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 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
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); } }
参考官方 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 OAuth2TokenCustomizerjwtTokenCustomizer() { 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 原生返回(⬅️ ),大多数情况下,我们希望返回带有业务码的数据(➡️),以方便前端进行处理。
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
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
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
请求参数
认证参数
Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),
youlai-system 系统管理模块也作为资源服务器
org.springframework.boot spring-boot-starter-oauth2-resource-server
通过 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 ListwhitelistPaths; @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(网关)
请求参数
认证参数
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
Spring Security 弃用 授权服务器和资源服务器
Spring Security OAuth 生命周期终止通知
Spring Security OAuth 2.0 更新路线图