Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战(上):
https://developer.aliyun.com/article/1395817
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<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) { // 系统用户添加自定义字段 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<String> 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<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 将变得轻而易举。后续文章会更新其他常见授权模式的扩展,敬请期待~