目录
前言
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 Cloud Gateway 访问认证中心认证成功获取到访问令牌。完整源码:youlai-mall
创建数据库
Spring Authorization Server 官方提供的授权服务器示例 demo-authorizationserver 初始化数据库所使用的3个SQL脚本路径如下:
根据路径找到3张表的SQL脚本
- 令牌发放记录表: oauth2-authorization-schema.sql
- 授权记录表: <oauth2-authorization-consent-schema.sql>
- 客户端信息表: oauth2-registered-client-schema.sql
整合后的完整数据库 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 添加授权服务器依赖
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 数据库连接信息
yaml
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 进行授权服务器配置
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
- 参考 Spring Authorization Server 官方示例 demo-authorizationserver 下的 DefaultSecurityConfig.java 进行安全配置
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
java
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
java
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
java
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
java
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
的逻辑,就能够自定义认证成功和认证失败时的响应数据格式。
认证成功响应
java
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);
}
}
认证失败响应
java
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
java
public SecurityFilterChain authorizationServerSecurityFilterChain() throws Exception {
// ...
authorizationServerConfigurer
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
// ...
.accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应
.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应
);
}
密码模式测试
单元测试
启动 youlai-system 模块,需要从其获取系统用户信息(用户名、密码)进行认证
java
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 依赖
xml
<!-- Spring Authorization Server 授权服务器依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
application.yml
通过 Feign 请求 youlai-system
服务以获取系统用户认证信息(用户名和密码),在用户尚未登录的情况下,需要将此请求的路径配置到白名单中以避免拦截。
yaml
security:
# 允许无需认证的路径列表
whitelist-paths:
# 获取系统用户的认证信息用于账号密码判读
- /api/v1/users/{username}/authInfo
资源服务器配置
配置 ResourceServerConfig 位于资源服务器公共模块 common-security 中
java
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> ignoreUrls;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
log.info("whitelist path:{}", JSONUtil.toJsonStr(ignoreUrls));
http.authorizeHttpRequests(requestMatcherRegistry ->
{
if (CollectionUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(Convert.toStrArray(ignoreUrls)).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