发布时间:2025-06-24 17:34:23 作者:北方职教升学中心 阅读量:301
令牌刷新这三种模式,以及如何自己扩展授权模式(以账号密码模式为例)
框架提供两种token格式
- Self-contained (JWT) (信息透明)
- Reference (Opaque) (信息不透明)
本篇文章会讲解两种token的原理,如何使用不同格式的token,如何自定义token内容的扩展,以及如何自己自定义token生成器(以用短字符串自定义不透明token为例)
入门
创建一个新项目 添加依赖
使用目前最新版Spring Boot 3.4.0 要求JDK版本大于等于17
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> </dependency> </dependencies>
创建配置类,添加基本配置
package chick.authorization.security;import com.nimbusds.jose.jwk.JWKSet;import com.nimbusds.jose.jwk.RSAKey;import com.nimbusds.jose.jwk.source.ImmutableJWKSet;import com.nimbusds.jose.jwk.source.JWKSource;import com.nimbusds.jose.proc.SecurityContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.annotation.Order;import org.springframework.http.MediaType;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.oauth2.core.AuthorizationGrantType;import org.springframework.security.oauth2.core.ClientAuthenticationMethod;import org.springframework.security.oauth2.core.oidc.OidcScopes;import org.springframework.security.oauth2.jwt.JwtDecoder;import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;import java.security.KeyPair;import java.security.KeyPairGenerator;import java.security.interfaces.RSAPrivateKey;import java.security.interfaces.RSAPublicKey;import java.util.UUID;@Configuration@EnableWebSecuritypublic class SecurityConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer.authorizationServer(); http .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, Customizer.withDefaults()) .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated() ) .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ); http .getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); return http.build(); } @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); } // 用于检索用户进行身份验证 @Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User.withUsername("admin") .password(passwordEncoder().encode("123123")) .roles("admin") .build(); return new InMemoryUserDetailsManager(userDetails); } // 用于密码加密 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 用于管理客户端 @Bean public RegisteredClientRepository registeredClientRepository() { TokenSettings tokenSettings = TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) // 设置访问令牌有效期为1小时 .refreshTokenTimeToLive(Duration.ofDays(30)) // 设置刷新令牌有效期为30天 //.accessTokenFormat(OAuth2TokenFormat.REFERENCE) // 这个设置是开启不透明token .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // 使用透明token .build(); RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("chick") .clientSecret(passwordEncoder().encode("123456")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("https://www.baidu.com") .postLogoutRedirectUri("http://127.0.0.1:8000/") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .tokenSettings(tokenSettings) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); return new InMemoryRegisteredClientRepository(oidcClient); } // 用于签署访问令牌 @Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } // 启动时生成的密钥,用于创建上面的JWKSource private static KeyPair generateRsaKey() { 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); } // 用于配置 Spring Authorization Server @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); }}
目前已解锁
- Authorization Code(授权码模式)
- Client Credentials(客户端模式)
- Refresh Token(令牌刷新)
测试授权码模式
浏览器访问:http://127.0.0.1:8000/oauth2/authorizeresponse_type=code&client_id=chick&scope=openid&redirect_uri=https://www.baidu.com
会重定向到登录页
输入上面UserDetailsService中设置的用户名密码 登录会重定向到百度并 获取到code
使用code获取token等信息
POST /oauth2/token HTTP/1.1Authorization: Basic Base64Encode(client_id:client_secret)Content-Type: application/x-www-form-urlencodedgrant_type=authorization_code&redirect_uri=https://www.baidu.com&code=9ZAclrji.....
测试令牌刷新模式
使用上一步返回的refresh_token
POST /oauth2/token HTTP/1.1Authorization: Basic Base64Encode(client_id:client_secret)Content-Type: application/x-www-form-urlencodedgrant_type=refresh_token&code=9ZAclrji.....
测试客户端授权模式
有多种方式,这里介绍常用的client_secret_basic和client_secret_post
POST /oauth2/token HTTP/1.1Authorization: Basic Base64Encode(client_id:client_secret)Content-Type: application/x-www-form-urlencodedgrant_type=client_credentials
POST /oauth2/token HTTP/1.1Content-Type: application/x-www-form-urlencodedgrant_type=client_credentials&client_id=chick&client_secret=123456&scope=openid
参数都是对应创建客户端时的设置的
总结
通过项目引入的依赖以及一些简单的配置,即可完成授权服务的基础搭建,但是目前所完成的部分仅适用于自己进行功能的基本测试,无法应用到真正的项目中,真正做到定制化还远远不够,还存在例如目前客户端、配置要使用的token生成器 **/ @Bean public OAuth2TokenGenerator<?> tokenGenerator() { // 当客户端的tokenSetting的OAuth2TokenFormat设置为OAuth2TokenFormat.SELF_CONTAINED时 使用下面的 JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder);// jwtToken生成器(当客户端的token格式为self-contained时使用) jwtGenerator.setJwtCustomizer(chickSelfContainedTokenEnhancer);// 设置jwt-token自定义扩展 // 当客户端的tokenSetting的OAuth2TokenFormat设置为OAuth2TokenFormat.REFERENCE 使用下面的 OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();// 不透明的token生成器 accessTokenGenerator.setAccessTokenCustomizer(chickReferenceTokenEnhancer);// 设置id-token自定义扩展 // refreshToken生成器 OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();// refreshToken生成器 return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator); }
测试登录
发现不透明和透明token都加上了我们自定义的信息
自定义token生成器
以不透名token为例
package chick.authorization.token;import org.springframework.security.crypto.keygen.StringKeyGenerator;import java.util.UUID;/* uuid生成 */public class UUIDKeyGenerator implements StringKeyGenerator { @Override public String generateKey() { return UUID.randomUUID().toString().toLowerCase(); }}
创建token生成器
package chick.authorization.token;import org.springframework.security.crypto.keygen.StringKeyGenerator;import org.springframework.security.oauth2.core.ClaimAccessor;import org.springframework.security.oauth2.core.OAuth2AccessToken;import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsSet;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;import org.springframework.util.CollectionUtils;import org.springframework.util.StringUtils;import java.time.Instant;import java.util.Collections;import java.util.Map;import java.util.Set;import java.util.UUID;/*** @Author xkx* @Description 自定义token生成器 * @Date 2024/12/3 22:33* @Param* @return**/public class UUIDOAuth2TokenGenerator implements OAuth2TokenGenerator<OAuth2AccessToken> { private final StringKeyGenerator accessTokenGenerator = new UUIDKeyGenerator(); @Override public OAuth2AccessToken generate(OAuth2TokenContext context) { // @formatter:off if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) || !OAuth2TokenFormat.REFERENCE.equals(context.getRegisteredClient().getTokenSettings().getAccessTokenFormat())) { return null; } // @formatter:on String issuer = null; if (context.getAuthorizationServerContext() != null) { issuer = context.getAuthorizationServerContext().getIssuer(); } RegisteredClient registeredClient = context.getRegisteredClient(); Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(registeredClient.getTokenSettings().getAccessTokenTimeToLive()); // @formatter:off OAuth2TokenClaimsSet.Builder claimsBuilder = OAuth2TokenClaimsSet.builder(); if (StringUtils.hasText(issuer)) { claimsBuilder.issuer(issuer); } claimsBuilder .subject(context.getPrincipal().getName()) .audience(Collections.singletonList(registeredClient.getClientId())) .issuedAt(issuedAt) .expiresAt(expiresAt) .notBefore(issuedAt) .id(UUID.randomUUID().toString()); if (!CollectionUtils.isEmpty(context.getAuthorizedScopes())) { claimsBuilder.claim(OAuth2ParameterNames.SCOPE, context.getAuthorizedScopes()); } OAuth2TokenClaimsSet accessTokenClaimsSet = claimsBuilder.build(); return new OAuth2AccessTokenClaims(OAuth2AccessToken.TokenType.BEARER, this.accessTokenGenerator.generateKey(), accessTokenClaimsSet.getIssuedAt(), accessTokenClaimsSet.getExpiresAt(), context.getAuthorizedScopes(), accessTokenClaimsSet.getClaims()); } private static final class OAuth2AccessTokenClaims extends OAuth2AccessToken implements ClaimAccessor { private final Map<String, Object> claims; private OAuth2AccessTokenClaims(TokenType tokenType, String tokenValue, Instant issuedAt, Instant expiresAt, Set<String> scopes, Map<String, Object> claims) { super(tokenType, tokenValue, issuedAt, expiresAt, scopes); this.claims = claims; } @Override public Map<String, Object> getClaims() { return this.claims; } }}
创建refreshToken生成器
package chick.authorization.token;import io.micrometer.common.lang.Nullable;import org.springframework.security.crypto.keygen.StringKeyGenerator;import org.springframework.security.oauth2.core.OAuth2RefreshToken;import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;import java.time.Instant;/*** @Author xkx* @Description 自定义refreshToken生成器* @Date 2024/12/3 22:33* @Param* @return**/public class UUIDOAuth2RefreshTokenGenerator implements OAuth2TokenGenerator<OAuth2RefreshToken> { private final StringKeyGenerator refreshTokenGenerator = new UUIDKeyGenerator(); @Nullable @Override public OAuth2RefreshToken generate(OAuth2TokenContext context) { if (!OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) { return null; } Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getRefreshTokenTimeToLive()); return new OAuth2RefreshToken(this.refreshTokenGenerator.generateKey(), issuedAt, expiresAt); }}
配置
@Bean public OAuth2TokenGenerator<?> tokenGenerator() { JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder); UUIDOAuth2TokenGenerator accessTokenGenerator = new UUIDOAuth2TokenGenerator();// 不透明的token生成器 UUIDOAuth2RefreshTokenGenerator refreshTokenGenerator = new UUIDOAuth2RefreshTokenGenerator();// refreshToken生成器 return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator); }
测试登录
总结
测试完成发现不透明token变成了uuid的形式,成功
扩展授权类型
自定义GrantType类型
package chick.authorization.granter;import org.springframework.security.oauth2.core.AuthorizationGrantType;/* 扩展GrantType类型 */public record CustomAuthorizationGrantType(String value) { // 账号密码模式 public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password");}
工具类
package chick.authorization.utils;import jakarta.servlet.http.HttpServletRequest;import org.springframework.security.oauth2.core.AuthorizationGrantType;import org.springframework.security.oauth2.core.OAuth2AuthenticationException;import org.springframework.security.oauth2.core.OAuth2Error;import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;import org.springframework.util.Assert;import org.springframework.util.LinkedMultiValueMap;import org.springframework.util.MultiValueMap;import org.springframework.util.StringUtils;import java.util.Collections;import java.util.HashMap;import java.util.Locale;import java.util.Map;/*** @Author xkx* @Description 端点工具类* @Date 2024/12/1 19:55* @Param* @return**/public class OAuth2EndpointUtils { private OAuth2EndpointUtils() { } public static MultiValueMap<String, String> getFormParameters(HttpServletRequest request) { Map<String, String[]> parameterMap = request.getParameterMap(); MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(); parameterMap.forEach((key, values) -> { String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; // If not query parameter then it's a form parameter if (!queryString.contains(key) && values.length > 0) { for (String value : values) { parameters.add(key, value); } } }); return parameters; } public static MultiValueMap<String, String> getQueryParameters(HttpServletRequest request) { Map<String, String[]> parameterMap = request.getParameterMap(); MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(); parameterMap.forEach((key, values) -> { String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : ""; if (queryString.contains(key) && values.length > 0) { for (String value : values) { parameters.add(key, value); } } }); return parameters; } public static Map<String, Object> getParametersIfMatchesAuthorizationCodeGrantRequest(HttpServletRequest request, String... exclusions) { if (!matchesAuthorizationCodeGrantRequest(request)) { return Collections.emptyMap(); } MultiValueMap<String, String> multiValueParameters = "GET".equals(request.getMethod()) ? getQueryParameters(request) : getFormParameters(request); for (String exclusion : exclusions) { multiValueParameters.remove(exclusion); } Map<String, Object> parameters = new HashMap<>(); multiValueParameters.forEach( (key, value) -> parameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]))); return parameters; } public static boolean matchesAuthorizationCodeGrantRequest(HttpServletRequest request) { return AuthorizationGrantType.AUTHORIZATION_CODE.getValue() .equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE)) && request.getParameter(OAuth2ParameterNames.CODE) != null; } public static boolean matchesPkceTokenRequest(HttpServletRequest request) { return matchesAuthorizationCodeGrantRequest(request) && request.getParameter(PkceParameterNames.CODE_VERIFIER) != null; } public static void throwError(String errorCode, String parameterName, String errorUri) { OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); throw new OAuth2AuthenticationException(error); } public static String normalizeUserCode(String userCode) { Assert.hasText(userCode, "userCode cannot be empty"); StringBuilder sb = new StringBuilder(userCode.toUpperCase(Locale.ENGLISH).replaceAll("[^A-Z\d]+", "")); Assert.isTrue(sb.length() == 8, "userCode must be exactly 8 alpha/numeric characters"); sb.insert(4, '-'); return sb.toString(); } public static boolean validateUserCode(String userCode) { return (userCode != null && userCode.toUpperCase(Locale.ENGLISH).replaceAll("[^A-Z\d]+", "").length() == 8); }}
实现OAuth2AuthorizationGrantAuthenticationToken
package chick.authorization.granter.password;import chick.authorization.granter.CustomAuthorizationGrantType;import org.springframework.security.core.Authentication;import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;import java.util.Map;/*** @Author xkx* @Description 用户名密码令牌扩展* @Date 2024/12/1 20:16* @Param* @return**/public class OAuth2PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { private final String username; private final String password; public OAuth2PasswordAuthenticationToken(String username, String password, Authentication clientPrincipal, Map<String, Object> additionalParameters) { super(CustomAuthorizationGrantType.PASSWORD, clientPrincipal, additionalParameters); this.username = username; this.password = password; } public String getUsername() { return username; } public String getPassword() { return password; }}
实现AuthenticationConverter 返回Authentication
框架会依次执行AuthenticationConverter的实现类,直到有一个可以处理并且返回的不是null为止
package chick.authorization.granter.password;import chick.authorization.granter.CustomAuthorizationGrantType;import chick.authorization.utils.OAuth2EndpointUtils;import jakarta.servlet.http.HttpServletRequest;import org.springframework.security.core.Authentication;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.oauth2.core.OAuth2ErrorCodes;import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;import org.springframework.security.web.authentication.AuthenticationConverter;import org.springframework.stereotype.Service;import org.springframework.util.MultiValueMap;import org.springframework.util.StringUtils;import java.util.HashMap;import java.util.Map;@Servicepublic class OAuth2PasswordAuthenticationConverter implements AuthenticationConverter { @Override public Authentication convert(HttpServletRequest request) { String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); // 校验grant_type为password的 if (!CustomAuthorizationGrantType.PASSWORD.getValue().equals(grantType)) { return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getFormParameters(request); // 用户名不能为空 String username = parameters.getFirst(OAuth2ParameterNames.USERNAME); if (!StringUtils.hasText(username) || parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) { OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.USERNAME, ""); } String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD); Map<String, Object> additionalParameters = new HashMap<>(); parameters.forEach((key, value) -> { if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.CLIENT_ID) && !key.equals(OAuth2ParameterNames.USERNAME) && !key.equals(OAuth2ParameterNames.PASSWORD)) { additionalParameters.put(key, value.getFirst()); } }); return new OAuth2PasswordAuthenticationToken(username, password, clientPrincipal, additionalParameters); }}
实现AuthenticationProvider
框架会调用所有AuthenticationProvider的实现类的supports方法,如果返回true,代表该provider可以处理当前的Authentication
package chick.authorization.granter.password;import org.springframework.security.authentication.AuthenticationProvider;import org.springframework.security.core.Authentication;import org.springframework.security.core.AuthenticationException;import org.springframework.security.oauth2.core.*;import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;@Servicepublic class OAuth2PasswordAuthenticationProvider implements AuthenticationProvider { private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator; private final OAuth2AuthorizationService authorizationService; public OAuth2PasswordAuthenticationProvider(OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator, OAuth2AuthorizationService authorizationService) { this.tokenGenerator = tokenGenerator; this.authorizationService = authorizationService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2PasswordAuthenticationToken passwordAuthentication = (OAuth2PasswordAuthenticationToken) authentication; // Ensure the client is authenticated OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(passwordAuthentication); RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); // Ensure the client is configured to use this authorization grant type assert registeredClient != null; if (!registeredClient.getAuthorizationGrantTypes().contains(passwordAuthentication.getGrantType())) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); } // 校验账户 String username = passwordAuthentication.getUsername(); if (!StringUtils.hasText(username)) { throw new OAuth2AuthenticationException("账户不能为空"); } // 校验密码 String password = passwordAuthentication.getPassword(); if (!StringUtils.hasText(password)) { throw new OAuth2AuthenticationException("密码不能为空"); } // 查询账户信息 实际要根据自己的业务来写// SsoUserDetail ssoUserDetail = (SsoUserDetail) userDetailService.loadUserByUsername(username);// if (ssoUserDetail == null) {// throw new OAuth2AuthenticationException("账户信息不存在,请联系管理员");//}// 校验密码// if (!passwordEncoder.matches(password, ssoUserDetail.getPassword())) {// throw new OAuth2AuthenticationException("密码不正确");//} // Generate the access token OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder() .registeredClient(registeredClient) .principal(clientPrincipal) .authorizationServerContext(AuthorizationServerContextHolder.getContext()) .tokenType(OAuth2TokenType.ACCESS_TOKEN) .authorizationGrantType(passwordAuthentication.getGrantType()) .authorizationGrant(passwordAuthentication) .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.", null); throw new OAuth2AuthenticationException(error); } OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), generatedAccessToken.getExpiresAt(), null); // Initialize the OAuth2Authorization OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient) .principalName(clientPrincipal.getName()) .authorizationGrantType(passwordAuthentication.getGrantType()); if (generatedAccessToken instanceof ClaimAccessor) { authorizationBuilder.token(accessToken, (metadata) -> metadata.put( OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()) ); } else { authorizationBuilder.accessToken(accessToken); } OAuth2Authorization authorization = authorizationBuilder.build(); // Save the OAuth2Authorization this.authorizationService.save(authorization); return new OAuth2AccessTokenAuthenticationToken( registeredClient, clientPrincipal, accessToken); } @Override public boolean supports(Class<?> authentication) { return OAuth2PasswordAuthenticationToken.class.isAssignableFrom(authentication); } private static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(Authentication authentication) { OAuth2ClientAuthenticationToken clientPrincipal = null; if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) { clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal(); } if (clientPrincipal != null && clientPrincipal.isAuthenticated()) { return clientPrincipal; } throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); }}
配置
// 透明token扩展 private final ChickSelfContainedTokenEnhancer chickSelfContainedTokenEnhancer; // 非透明token扩展 private final ChickReferenceTokenEnhancer chickReferenceTokenEnhancer; // 数据库链接 private final JdbcTemplate jdbcTemplate; // 密码编码器 private final PasswordEncoder passwordEncoder; // jwt编码器 private final JwtEncoder jwtEncoder; // 客户端管理 private final RegisteredClientRepository registeredClientRepository; // 用户检索 private final UserDetailsService userDetailsService; // 扩展Provider private final OAuth2PasswordAuthenticationProvider oAuth2PasswordAuthenticationProvider; // 扩展Converter private final OAuth2PasswordAuthenticationConverter oAuth2PasswordAuthenticationConverter; public SecurityConfig(ChickSelfContainedTokenEnhancer chickSelfContainedTokenEnhancer, ChickReferenceTokenEnhancer chickReferenceTokenEnhancer, JdbcTemplate jdbcTemplate, @Lazy PasswordEncoder passwordEncoder, @Lazy JwtEncoder jwtEncoder, @Lazy RegisteredClientRepository registeredClientRepository, @Lazy UserDetailsService userDetailsService, @Lazy OAuth2PasswordAuthenticationProvider oAuth2PasswordAuthenticationProvider, @Lazy OAuth2PasswordAuthenticationConverter oAuth2PasswordAuthenticationConverter) { this.chickSelfContainedTokenEnhancer = chickSelfContainedTokenEnhancer; this.chickReferenceTokenEnhancer = chickReferenceTokenEnhancer; this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; this.jwtEncoder = jwtEncoder; this.registeredClientRepository = registeredClientRepository; this.userDetailsService = userDetailsService; this.oAuth2PasswordAuthenticationProvider = oAuth2PasswordAuthenticationProvider; this.oAuth2PasswordAuthenticationConverter = oAuth2PasswordAuthenticationConverter; } // 配置自定义的授权类型 @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer.authorizationServer(); http .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, Customizer.withDefaults()) .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated() ) .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ).oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())) .with(authorizationServerConfigurer, (authorizationServer) -> authorizationServer .tokenEndpoint(tokenEndpoint -> tokenEndpoint .accessTokenRequestConverter(oAuth2PasswordAuthenticationConverter) .authenticationProvider(oAuth2PasswordAuthenticationProvider) ) ); http .getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); return http.build(); } // 客户端增加支持的授权类型 @Bean public RegisteredClientRepository registeredClientRepository() { // JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); TokenSettings tokenSettings = TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) // 设置访问令牌有效期为1小时 .refreshTokenTimeToLive(Duration.ofDays(30)) // 设置刷新令牌有效期为30天 //.accessTokenFormat(OAuth2TokenFormat.REFERENCE) // 这个设置是开启不透明token .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // 使用透明token(默认) .build(); RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("chick") .clientSecret(passwordEncoder().encode("123456")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // 新增 .authorizationGrantType(CustomAuthorizationGrantType.PASSWORD) .redirectUri("https://www.baidu.com") .postLogoutRedirectUri("http://127.0.0.1:8000/") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .tokenSettings(tokenSettings) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); return new InMemoryRegisteredClientRepository(oidcClient); //jdbcRegisteredClientRepository.save(oidcClient);//第一次启动可以打开这个 将客户端保存到数据库 //return jdbcRegisteredClientRepository; }
测试登录
POST /oauth2/token HTTP/1.1Authorization: Basic Base64Encode(client_id:client_secret)Content-Type: application/x-www-form-urlencodedgrant_type=password&username=admin&password=123123
成功
总结
框架提供了授权类型扩展,可以更好的适应不同项目的不同需求,也可以增加手机号码、轻量级和可定制的基础。客户端和用户是写死的,无法动态的加载等,如果我们想在token中加我我们自己自定义的数据该怎么做?不透明token和refresh太长了,如果我们想将他变短些,例如使用uuid来替代他该怎么做,这就需要一些进阶的玩法。客户端模式、
Spring Authorization Server 介绍
Spring Authorization Server 是一个提供OAuth 2.1和OpenID Connect 1.0规范以及其他相关规范的实现的框架。用户、登录信息,密钥等都还保存在内存中的问题,应用重启后密钥会刷新导致之前的token无法验签,重启后登录信息会消失导致用户需要重新登录、邮箱等登录方式
框架提供五种授权模式
- Authorization Code(授权码模式)
- Client Credentials(客户端模式)
- Refresh Token(令牌刷新)
- Device Code(设备码模式)
- Token Exchange(token交换)
本篇文章主要讲解常用的授权码模式、
进阶
客户端持久化
首先翻开源码找到客户端需要的数据库表,在数据库中创建该表
添加数据库相关依赖
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.21</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency>
配置数据库
server: port: 8000spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver username: root password: xxxxxx url: jdbc:mysql://ip:port/数据库名?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&allowMultiQueries=true&allowPublicKeyRetrieval=true&autoReconnect=true
修改配置-RegisteredClientRepository
// 注入JdbcTemplate private final JdbcTemplate jdbcTemplate; public SecurityConfig(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Bean public RegisteredClientRepository registeredClientRepository() { return new JdbcRegisteredClientRepository(jdbcTemplate); }
将之前的客户端插入到数据库中
INSERT INTO oauth2_registered_client (id, client_id, client_id_issued_at, client_secret, client_secret_expires_at, client_name, client_authentication_methods, authorization_grant_types, redirect_uris, post_logout_redirect_uris, scopes, client_settings, token_settings) VALUES ('79e31552-9ffb-42f3-8aa6-ff83d0860215', 'chick', '2024-12-04 14:23:41', '$2a$10$ftuHVREYeyjJvzzbkS67.uxo.JfU2SJ7OAeZMC7swZPsFfc69/tJm', null, '79e31552-9ffb-42f3-8aa6-ff83d0860215', 'client_secret_post,client_secret_jwt,client_secret_basic', 'refresh_token,client_credentials,authorization_code', 'https://www.baidu.com', 'http://127.0.0.1:8000/', 'openid,profile', '{"@class":"java.util.Collections$UnmodifiableMap","settings.client.require-proof-key":false,"settings.client.require-authorization-consent":true}', '{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.x509-certificate-bound-access-tokens":false,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",2592000.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}')
至此客户端的持久化实现完毕,可以按照上面的方法测试三种授权模式
自定义持久化
可以自己实现RegisteredClientRepository接口,按自己的逻辑实现方法即可
public interface RegisteredClientRepository { /** * Saves the registered client. * * <p> * IMPORTANT: Sensitive information should be encoded externally from the * implementation, e.g. {@link RegisteredClient#getClientSecret()} * @param registeredClient the {@link RegisteredClient} */ void save(RegisteredClient registeredClient); /** * Returns the registered client identified by the provided {@code id}, or * {@code null} if not found. * @param id the registration identifier * @return the {@link RegisteredClient} if found, otherwise {@code null} */ @Nullable RegisteredClient findById(String id); /** * Returns the registered client identified by the provided {@code clientId}, or * {@code null} if not found. * @param clientId the client identifier * @return the {@link RegisteredClient} if found, otherwise {@code null} */ @Nullable RegisteredClient findByClientId(String clientId);}
总结
客户端的持久化比较简单,可以直接使用官方提供的JdbcRegisteredClientRepository(),也可以自己实现RegisteredClientRepository,按照自己的逻辑实现客户端保存和查询功能,只需要将实现类作为bean注入到ioc容器中即可
用户持久化
使用JdbcDaoImpl(不推荐,一般也不会用这个)
创建表
找到框架自带的数据库表脚本
修改配置-UserDetailsService
private final JdbcTemplate jdbcTemplate; public SecurityConfig(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Bean public UserDetailsService userDetailsService() { JdbcDaoImpl jdbcDao = new JdbcDaoImpl(); jdbcDao.setJdbcTemplate(jdbcTemplate); return jdbcDao; }
将之前的用户据插入到数据库中
INSERT INTO users (username, password, enabled) VALUES ('admin', '$2a$10$TdhQiv3We.BvTayb.KXoUOvI19xgHnw8fmKcE6kLuBD.LhehczevG', 1);INSERT INTO authorities (username, authority) VALUES ('admin', 'user');
至此用户的持久化实现完毕,可以按照上面的方法测试三种授权模式
自定义持久化(推荐)
实现UserDetails
自定义自己的用户属性
package chick.authorization.security;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.UserDetails;import java.util.Collection;import java.util.Set;@JsonIgnoreProperties(ignoreUnknown = true)public class ChickUserDetails implements UserDetails { private String username; private String password; private String other; private Collection<? extends GrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return this.authorities; } @Override public String getPassword() { return this.password; } @Override public String getUsername() { return this.username; } public void setUsername(String username) { this.username = username; } public void setPassword(String password) { this.password = password; } public String getOther() { return other; } public void setOther(String other) { this.other = other; } public void setAuthorities(Set<GrantedAuthority> authorities) { this.authorities = authorities; }}
实现UserDetailsService
package chick.authorization.security;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.stereotype.Service;import org.springframework.util.ObjectUtils;import java.util.*;@Servicepublic class ChickUserDetailsService implements UserDetailsService { private final JdbcTemplate jdbcTemplate; public ChickUserDetailsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 查询逻辑 通过用户名查询用户信息,按照自己的逻辑写 String query = "SELECT username, password FROM users WHERE username = ?"; ChickUserDetails chickUserDetails = jdbcTemplate.queryForObject(query, (rs, rowNum) -> { ChickUserDetails user = new ChickUserDetails(); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); return user; }, username); if (ObjectUtils.isEmpty(chickUserDetails)){ throw new UsernameNotFoundException(username + " not found"); } String queryAuthority = "SELECT authority FROM authorities WHERE username = '" + username + "'"; List<String> authorities = jdbcTemplate.query(queryAuthority, (rs, rowNum) -> rs.getString("authority")); Set<GrantedAuthority> simpleGrantedAuthorities = new HashSet<>(); authorities.forEach(authority -> { SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority); simpleGrantedAuthorities.add(simpleGrantedAuthority); }); chickUserDetails.setAuthorities(simpleGrantedAuthorities); return chickUserDetails; }}
去除之前配置文件中的UserDetailsService
// @Bean// public UserDetailsService userDetailsService() {// JdbcDaoImpl jdbcDao = new JdbcDaoImpl();// jdbcDao.setJdbcTemplate(jdbcTemplate);// //UserDetails userDetails = User.withUsername("admin")// // .password(passwordEncoder().encode("123123"))// // .roles("admin")// // .build();// //return new InMemoryUserDetailsManager(userDetails);// return jdbcDao;//}
完成并测试
总结
使用自己实现UserDetailsService的bean更具有灵活性,也可以结合redis等缓存技术给系统提速
token等登录信息持久化
创建表
找到框架中的脚本 在数据库中执行创建表
配置
// 注入依赖 private final JdbcTemplate jdbcTemplate; private final UserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; private final RegisteredClientRepository registeredClientRepository; public SecurityConfig(JdbcTemplate jdbcTemplate, UserDetailsService userDetailsService, @Lazy PasswordEncoder passwordEncoder, @Lazy RegisteredClientRepository registeredClientRepository) { this.jdbcTemplate = jdbcTemplate; this.userDetailsService = userDetailsService; this.passwordEncoder = passwordEncoder; this.registeredClientRepository = registeredClientRepository; } // 将OAuth2AuthorizationServiceBean注入容器 @Bean public OAuth2AuthorizationService oAuth2AuthorizationService() { // return new InMemoryOAuth2AuthorizationService(); 使用内存 return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); // 使用数据库 }
完成并测试,登录成功后可以发现token的信息保存到数据库中了
Opaque Token(不透明Token)
更改配置
不透明token是返回一个没有意义的id,然后通过id再去获取用户的token,使用不透明id只需要将客户端的TokenSettings的accessTokenFormat设置为OAuth2TokenFormat.REFERENCE
如果已实现持久化,可以直接更改数据库中的配置
测试登录
解析token
POST /oauth2/introspect HTTP/1.1Authorization: Basic Base64Encode(client_id:client_secret)Content-Type: application/x-www-form-urlencodedtoken=YxxTPrfmz__15X9UYH.......
token信息扩展
透明token扩展
package chick.authorization.token;import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;import org.springframework.stereotype.Service;/** * @Author xkx * @Description 透明token扩展 * @Date 2024/11/28 21:33 * @Param * @return **/@Servicepublic class ChickSelfContainedTokenEnhancer implements OAuth2TokenCustomizer<JwtEncodingContext> { @Override public void customize(JwtEncodingContext context) { context.getClaims().claims(claims -> { claims.put("custom1", "1"); claims.put("custom2", "2"); }); }}
不透明token扩展
package chick.authorization.token;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;import org.springframework.stereotype.Service;/*** @Author xkx* @Description 不透明token扩展* @Date 2024/11/28 21:33* @Param* @return**/@Servicepublic class ChickReferenceTokenEnhancer implements OAuth2TokenCustomizer<OAuth2TokenClaimsContext> { @Override public void customize(OAuth2TokenClaimsContext context) { context.getClaims().claims(claims -> { claims.put("custom1", "1"); claims.put("custom2", "2"); }); }}
配置修改
// 透明token扩展 private final ChickSelfContainedTokenEnhancer chickSelfContainedTokenEnhancer; // 非透明token扩展 private final ChickReferenceTokenEnhancer chickReferenceTokenEnhancer; // 数据库链接 private final JdbcTemplate jdbcTemplate; // 密码编码器 private final PasswordEncoder passwordEncoder; // jwt编码器 private final JwtEncoder jwtEncoder; // 客户端管理 private final RegisteredClientRepository registeredClientRepository; // 用户检索 private final UserDetailsService userDetailsService; public SecurityConfig(ChickSelfContainedTokenEnhancer chickSelfContainedTokenEnhancer, ChickReferenceTokenEnhancer chickReferenceTokenEnhancer, JdbcTemplate jdbcTemplate, @Lazy PasswordEncoder passwordEncoder, @Lazy JwtEncoder jwtEncoder, @Lazy RegisteredClientRepository registeredClientRepository, @Lazy UserDetailsService userDetailsService) { this.chickSelfContainedTokenEnhancer = chickSelfContainedTokenEnhancer; this.chickReferenceTokenEnhancer = chickReferenceTokenEnhancer; this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; this.jwtEncoder = jwtEncoder; this.registeredClientRepository = registeredClientRepository; this.userDetailsService = userDetailsService; } /** * token生成器。