開局一張圖
項目源碼:youlai-mall
通過 Spring Cloud Gateway 訪問認(rèn)證中心進(jìn)行認(rèn)證并獲取得到訪問令牌。
再根據(jù)訪問令牌 access_token
獲取當(dāng)前登錄的用戶信息。
前言
Spring Security OAuth2 的最終版本是2.5.2,并于2022年6月5日正式宣布停止維護(hù)。Spring 官方為此推出了新的替代產(chǎn)品,即 Spring Authorization Server。然而,出于安全考慮,Spring Authorization Server 不再支持密碼模式,因為密碼模式要求客戶端直接處理用戶的密碼。但對于受信任的第一方系統(tǒng)(自有APP和管理系統(tǒng)等),許多情況下需要使用密碼模式。在這種情況下,需要在 Spring Authorization Server 的基礎(chǔ)上擴(kuò)展密碼模式的支持。本文基于開源微服務(wù)商城項目 youlai-mall、Spring Boot 3 和 Spring Authorization Server 1.1 版本,演示了如何擴(kuò)展密碼模式,以及如何將其應(yīng)用于 Spring Cloud 微服務(wù)實戰(zhàn)。
數(shù)據(jù)庫初始化
Spring Authorization Server 官方提供的授權(quán)服務(wù)器示例 demo-authorizationserver 初始化數(shù)據(jù)庫所使用的3個SQL腳本路徑如下:
根據(jù)路徑找到3張表的SQL腳本
- 令牌發(fā)放記錄表: oauth2-authorization-schema.sql
- 授權(quán)記錄表: oauth2-authorization-consent-schema.sql
- 客戶端信息表: oauth2-registered-client-schema.sql
整合后的完整數(shù)據(jù)庫 SQL 腳本如下:
-- ----------------------------
-- 1. 創(chuàng)建數(shù)據(jù)庫
-- ----------------------------
CREATE DATABASE IF NOT EXISTS oauth2_server DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;
-- ----------------------------
-- 2. 創(chuàng)建表
-- ----------------------------
use oauth2_server;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 2.1 oauth2_authorization 令牌發(fā)放記錄表
-- ----------------------------
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 授權(quán)記錄表
-- ----------------------------
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)
);
授權(quán)服務(wù)器
youlai-auth 模塊作為認(rèn)證授權(quán)服務(wù)器
maven 依賴
在 youlai-auth 模塊的 pom.xml 添加授權(quán)服務(wù)器依賴
<!-- Spring Authorization Server 授權(quán)服務(wù)器依賴 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>1.1.1</version>
</dependency>
application.yml
認(rèn)證中心配置 oauth2_server 數(shù)據(jù)庫連接信息
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
授權(quán)服務(wù)器配置
參考 Spring Authorization Server 官方示例 demo-authorizationserver
AuthorizationServierConfig
參考: Spring Authorization Server 官方示例 demo-authorizationserver 下的 AuthorizationServerConfig.java 進(jìn)行授權(quán)服務(wù)器配置
package com.youlai.auth.config;
/**
* 授權(quán)服務(wù)器配置
*
* @author haoxr
* @since 3.0.0
*/
@Configuration
@RequiredArgsConstructor
@Slf4j
public class AuthorizationServerConfig {
private final OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
/**
* 授權(quán)服務(wù)器端點配置
*/
@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(
// 自定義授權(quán)模式轉(zhuǎn)換器(Converter)
List.of(
new PasswordAuthenticationConverter()
)
)
)
.authenticationProviders(authenticationProviders ->// <2>
authenticationProviders.addAll(
// 自定義授權(quán)模式提供者(Provider)
List.of(
new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator)
)
)
)
.accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定義成功響應(yīng)
.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定義失敗響應(yīng)
);
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();
}
/**
* 初始化創(chuàng)建商城管理客戶端
*
* @param registeredClientRepository
*/
private void initMallAdminClient(JdbcRegisteredClientRepository registeredClientRepository) {
String clientId = "mall-admin";
String clientSecret = "123456";
String clientName = "商城管理客戶端";
/*
如果使用明文,客戶端認(rèn)證時會自動升級加密方式,換句話說直接修改客戶端密碼,所以直接使用 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);
}
/**
* 初始化創(chuàng)建商城APP客戶端
*
* @param registeredClientRepository
*/
private void initMallAppClient(JdbcRegisteredClientRepository registeredClientRepository) {
String clientId = "mall-app";
String clientSecret = "123456";
String clientName = "商城APP客戶端";
// 如果使用明文,在客戶端認(rèn)證的時候會自動升級加密方式,直接使用 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 進(jìn)行安全配置
package com.youlai.auth.config;
/**
* 授權(quán)服務(wù)器安全配置
*
* @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) ->
// 不走過濾器鏈(場景:靜態(tài)資源js、css、html)
web.ignoring().requestMatchers(
"/webjars/**",
"/doc.html",
"/swagger-resources/**",
"/v3/api-docs/**",
"/swagger-ui/**"
);
}
}
密碼模式擴(kuò)展
PasswordAuthenticationToken
package com.youlai.auth.authentication.password;
/**
* 密碼授權(quán)模式身份驗證令牌(包含用戶名和密碼等)
*
* @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 自定義額外參數(shù)(用戶名和密碼)
*/
public PasswordAuthenticationToken(
Authentication clientPrincipal,
Set<String> scopes,
@Nullable Map<String, Object> additionalParameters
) {
super(PASSWORD, clientPrincipal, additionalParameters);
this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet<>(scopes) : Collections.emptySet());
}
/**
* 用戶憑證(密碼)
*/
@Override
public Object getCredentials() {
return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);
}
public Set<String> getScopes() {
return scopes;
}
}
PasswordAuthenticationConverter
package com.youlai.auth.authentication.password;
/**
* 密碼模式參數(shù)解析器
* <p>
* 解析請求參數(shù)中的用戶名和密碼,并構(gòu)建相應(yīng)的身份驗證(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) {
// 授權(quán)類型 (必需)
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {
return null;
}
// 客戶端信息
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
// 參數(shù)提取驗證
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
);
}
// 附加參數(shù)(保存用戶名/密碼傳遞給 PasswordAuthenticationProvider 用于身份認(rèn)證)
Map<String, Object> additionalParameters = parameters
.entrySet()
.stream()
.filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&
!e.getKey().equals(OAuth2ParameterNames.SCOPE)
).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
return new PasswordAuthenticationToken(
clientPrincipal,
requestedScopes,
additionalParameters
);
}
}
PasswordAuthenticationProvider
package com.youlai.auth.authentication.password;
/**
* 密碼模式身份驗證提供者
* <p>
* 處理基于用戶名和密碼的身份驗證
*
* @author haoxr
* @since 3.0.0
*/
@Slf4j
public class PasswordAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
private final AuthenticationManager authenticationManager;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
/**
* Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.
*
* @param authenticationManager the authentication manager
* @param authorizationService the authorization service
* @param tokenGenerator the token generator
* @since 0.2.3
*/
public PasswordAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator
) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authenticationManager = authenticationManager;
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PasswordAuthenticationToken resourceOwnerPasswordAuthentication = (PasswordAuthenticationToken) authentication;
OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils
.getAuthenticatedClientElseThrowInvalidClient(resourceOwnerPasswordAuthentication);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// 驗證客戶端是否支持授權(quán)類型(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);
// 用戶名密碼身份驗證,成功后返回帶有權(quán)限的認(rèn)證信息
Authentication usernamePasswordAuthentication;
try {
usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
} catch (Exception e) {
// 需要將其他類型的異常轉(zhuǎn)換為 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) 構(gòu)造器
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(usernamePasswordAuthentication) // 身份驗證成功的認(rèn)證信息(用戶名、權(quán)限等信息)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.authorizedScopes(authorizedScopes)
.authorizationGrantType(AuthorizationGrantType.PASSWORD) // 授權(quán)方式
.authorizationGrant(resourceOwnerPasswordAuthentication) // 授權(quán)具體對象
;
// 生成訪問令牌(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());
// 權(quán)限數(shù)據(jù)(perms)比較多通過反射移除,不隨令牌一起持久化至數(shù)據(jù)庫
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();
// 持久化令牌發(fā)放記錄到數(shù)據(jù)庫
this.authorizationService.save(authorization);
additionalParameters = Collections.emptyMap();
return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
}
/**
* 判斷傳入的 authentication 類型是否與當(dāng)前認(rèn)證提供者(AuthenticationProvider)相匹配--模板方法
* <p>
* ProviderManager#authenticate 遍歷 providers 找到支持對應(yīng)認(rèn)證請求的 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<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) {
// 系統(tǒng)用戶添加自定義字段
Long userId = userDetails.getUserId();
claims.claim("user_id", userId); // 添加系統(tǒng)用戶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);
// 權(quán)限集合存Redis(數(shù)據(jù)多)
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
}
});
}
};
}
}
自定義認(rèn)證響應(yīng)
?? 如何自定義 OAuth2 認(rèn)證成功或失敗的響應(yīng)數(shù)據(jù)結(jié)構(gòu)符合當(dāng)前系統(tǒng)統(tǒng)一的規(guī)范?
下圖左側(cè)部份是 OAuth2 原生返回(?? ),大多數(shù)情況下,我們希望返回帶有業(yè)務(wù)碼的數(shù)據(jù)(??),以方便前端進(jìn)行處理。
OAuth2 處理認(rèn)證成功或失敗源碼坐標(biāo) OAuth2TokenEndpointFilter#doFilterInternal ,如下圖:
根據(jù)源碼閱讀,發(fā)現(xiàn)只要重寫? AuthenticationSuccessHandler
和? AuthenticationFailureHandler
的邏輯,就能夠自定義認(rèn)證成功和認(rèn)證失敗時的響應(yīng)數(shù)據(jù)格式。
認(rèn)證成功響應(yīng)
package com.youlai.auth.handler;
/**
* 認(rèn)證成功處理器
*
* @author haoxr
* @since 3.0.0
*/
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
/**
* MappingJackson2HttpMessageConverter 是 Spring 框架提供的一個 HTTP 消息轉(zhuǎn)換器,用于將 HTTP 請求和響應(yīng)的 JSON 數(shù)據(jù)與 Java 對象之間進(jìn)行轉(zhuǎn)換
*/
private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();
private Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();
/**
* 自定義認(rèn)證成功響應(yīng)數(shù)據(jù)結(jié)構(gòu)
*
* @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);
}
}
認(rèn)證失敗響應(yīng)
package com.youlai.auth.handler;
/**
* 認(rèn)證失敗處理器
*
* @author haoxr
* @since 2023/7/6
*/
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
/**
* MappingJackson2HttpMessageConverter 是 Spring 框架提供的一個 HTTP 消息轉(zhuǎn)換器,用于將 HTTP 請求和響應(yīng)的 JSON 數(shù)據(jù)與 Java 對象之間進(jìn)行轉(zhuǎn)換
*/
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()) // 自定義成功響應(yīng)
.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定義失敗響應(yīng)
);
}
密碼模式測試
單元測試
啟動 youlai-system 模塊,需要從其獲取系統(tǒng)用戶信息(用戶名、密碼)進(jìn)行認(rèn)證
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());
}
}
單元測試通過,打印響應(yīng)數(shù)據(jù)可以看到返回的 access_token 和 refresh_token
Postman 測試
-
請求參數(shù)
-
認(rèn)證參數(shù)
Authorization Type 選擇 Basic Auth , 填寫客戶端ID(mall-admin)和密鑰(123456),
資源服務(wù)器
youlai-system 系統(tǒng)管理模塊也作為資源服務(wù)器
maven 依賴
<!-- Spring Authorization Server 授權(quán)服務(wù)器依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
application.yml
通過 Feign 請求 youlai-system
服務(wù)以獲取系統(tǒng)用戶認(rèn)證信息(用戶名和密碼),在用戶尚未登錄的情況下,需要將此請求的路徑配置到白名單中以避免攔截。
security:
# 允許無需認(rèn)證的路徑列表
whitelist-paths:
# 獲取系統(tǒng)用戶的認(rèn)證信息用于賬號密碼判讀
- /api/v1/users/{username}/authInfo
資源服務(wù)器配置
配置 ResourceServerConfig 位于資源服務(wù)器公共模塊 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;
/**
* 資源服務(wù)器配置
*
* @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;
}
}
認(rèn)證流程測試
分別啟動 youlai-mall 的 youai-auth (認(rèn)證中心)、youlai-system(系統(tǒng)管理模塊)、youali-gateway(網(wǎng)關(guān))
登錄認(rèn)證授權(quán)
-
請求參數(shù)
-
認(rèn)證參數(shù)
Authorization Type 選擇 Basic Auth , 填寫客戶端ID(mall-admin)和密鑰(123456),
-
成功響應(yīng)
認(rèn)證成功,獲取到訪問令牌(access_token )
獲取用戶信息
使用已獲得的訪問令牌 (access_token
) 向資源服務(wù)器發(fā)送請求以獲取登錄用戶信息
成功地獲取登錄用戶信息的響應(yīng),而不是出現(xiàn)未授權(quán)的401錯誤。
結(jié)語
關(guān)于 Spring Authorization Server 1.1 版本的密碼模式擴(kuò)展和在 Spring Cloud 中使用新的授權(quán)方式,可以說與 Spring Security OAuth2 的代碼相似度極高。如果您已經(jīng)熟悉 Spring Security OAuth2,那么學(xué)習(xí) Spring Authorization Server 將變得輕而易舉。后續(xù)文章會更新其他常見授權(quán)模式的擴(kuò)展,敬請期待~
源碼
本文完整源碼: youlai-mall
參考
-
Spring Security 棄用 授權(quán)服務(wù)器和資源服務(wù)器
-
Spring Security OAuth 生命周期終止通知文章來源:http://www.zghlxwxcb.cn/news/detail-757528.html
Spring Security OAuth 2.0 更新路線圖文章來源地址http://www.zghlxwxcb.cn/news/detail-757528.html
到了這里,關(guān)于Spring Authorization Server 1.1 擴(kuò)展實現(xiàn) OAuth2 密碼模式與 Spring Cloud 的整合實戰(zhàn)的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!