在上一篇中,我们实现了设备授权码模式,本篇我们将实现自定义的密码模式。虽然 OAuth2.1 移除了密码模式(password),但是在 Spring Authorization Server 中,通过拓展授权模式可以实现自定义的密码模式。在实际应用中,客户端、认证服务器、资源服务器都是同一家公司的产品,那么这个时候,使用账号、密码进行登录的情形也比较常见。例如,使用账号、密码登录微信 app,你自然是把微信平台的账号、密码提交给微信 app 进行登录,对于微信来说,无论是客户端(app)、认证服务器、资源服务器,都是自己家的产品,这种情况下,就不存在将账号、密码提供给第三方(其他公司的客户端)的情形了。本篇,我们将使用 postman 作为客户端,在 postman 上输入用户名、密码,向认证服务器获取令牌(token),时序图如下。
代码思路介绍
我们在上一篇实现了设备授权码登录,现在要来实现用户名、密码登录,实现起来也是比较相似,因为都是添加 XXXAuthenticationToken、XXXAuthenticationConverter、XXXAuthenticationProvider 这几个对象,然后在配置类中做相应的设置。对于如何实现拓展授权模式,官网(链接地址)也有比较详细的介绍。
实现步骤如下。
认证服务器改造
为避免 spring-oauth-server 工程经过较多改造后,影响之前篇章的对照,我们还是像上一篇那样,另起一个项目,我们将 spring-oauth-device-server 工程拷贝一份取名为 spring-oauth-password-server,作为新的认证服务器(端口不变),在此基础上进行修改,在 org.oauth.server.authentication包路径下新建 device、password 两个目录,将 DeviceClientAuthenticationToken、DeviceClientAuthenticationConverter、DeviceClientAuthenticationProvider 移动到 org.oauth.server.authentication.device 包目录下。将启动类改名为 SpringOauthPasswordServerApplication,此时项目结构如下。
pom.xml 文件如下。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.oauth</groupId>
<artifactId>spring-oauth-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-oauth-password-server</artifactId>
<name>密码模式认证服务器</name>
<dependencies>
<!--Spring Authorization Server-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<!--添加spring security cas支持-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-cas</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--mysql-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- mybatis-plus 3.5.3及以上版本 才支持 spring boot 3-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- spring-boot-starter-thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- webjars-locator-core -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
<!-- bootstrap:5.2.3 -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>5.2.3</version>
</dependency>
<!-- jquery:3.6.4 -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.6.4</version>
</dependency>
</dependencies>
</project>
application.yml 配置如下
server:
ip: spring-oauth-server
port: 9000
logging:
level:
org.springframework.security: trace
spring:
application:
name: spring-oauth-server
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth-server?serverTimezone=UTC&userUnicode=true&characterEncoding=utf-8
username: root
password: root
启动类如下。
@SpringBootApplication
public class SpringOauthPasswordServerApplication {
public static void main(String[] args) {
SpringApplication.run(SpringOauthPasswordServerApplication.class,args);
}
}
开始添加自定义密码模式的代码。
在 org.oauth.server 包路径下创建 constant 目录,作为常量类的管理目录,在 org.oauth.server.constant 目录下创建常量类,取名为 OAuth2Constant,内容如下。
public class OAuth2Constant {
/**
* 密码模式(自定义)
*/
public static final String GRANT_TYPE_PASSWORD = "authorization_password";
/**
* 构造方法私有化
*/
private OAuth2Constant(){
}
}
在 org.oauth.server.authentication.password 包目录下创建 AbstractAuthenticationToken 的实现类。在这里,我们学习官网 demo(链接地址),不去直接继承 AbstractAuthenticationToken 类,而是继承 AbstractAuthenticationToken 类的子类 OAuth2AuthorizationGrantAuthenticationToken,取名为 PasswordGrantAuthenticationToken,内容如下。
package org.oauth.server.authentication.password;
import org.oauth.server.constant.OAuth2Constant;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
import org.springframework.util.Assert;
import java.util.Map;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/20-14:31
* @description TODO
*/
public class PasswordGrantAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
public PasswordGrantAuthenticationToken(Authentication clientPrincipal,
@Nullable Map<String, Object> additionalParameters) {
super(new AuthorizationGrantType(OAuth2Constant.GRANT_TYPE_PASSWORD),
clientPrincipal, additionalParameters);
}
}
创建 AuthenticationConverter 的子类,取名为 PasswordGrantAuthenticationConverter,内容如下(直接从官网文档复制修改)。
package org.oauth.server.authentication.password;
import jakarta.servlet.http.HttpServletRequest;
import org.oauth.server.constant.OAuth2Constant;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
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.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/20-14:37
* @description TODO
*/
public class PasswordGrantAuthenticationConverter implements AuthenticationConverter {
@Nullable
@Override
public Authentication convert(HttpServletRequest request) {
// grant_type (REQUIRED)
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
if (!OAuth2Constant.GRANT_TYPE_PASSWORD.equals(grantType)) {
return null;
}
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
//从request中提取请求参数,然后存入MultiValueMap<String, String>
MultiValueMap<String, String> parameters = getParameters(request);
// username (REQUIRED)
String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
if (!StringUtils.hasText(username) ||
parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) {
throw new OAuth2AuthenticationException("无效请求,用户名不能为空!");
}
String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
if (!StringUtils.hasText(password) ||
parameters.get(OAuth2ParameterNames.PASSWORD).size() != 1) {
throw new OAuth2AuthenticationException("无效请求,密码不能为空!");
}
//收集要传入PasswordGrantAuthenticationToken构造方法的参数,
//该参数接下来在PasswordGrantAuthenticationProvider中使用
Map<String, Object> additionalParameters = new HashMap<>();
//遍历从request中提取的参数,排除掉grant_type、client_id、code等字段参数,其他参数收集到additionalParameters中
parameters.forEach((key, value) -> {
if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) &&
!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
!key.equals(OAuth2ParameterNames.CODE)) {
additionalParameters.put(key, value.get(0));
}
});
//返回自定义的PasswordGrantAuthenticationToken对象
return new PasswordGrantAuthenticationToken(clientPrincipal, additionalParameters);
}
/**
*从request中提取请求参数,然后存入MultiValueMap<String, String>
*/
private static MultiValueMap<String, String> getParameters(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(parameterMap.size());
parameterMap.forEach((key, values) -> {
if (values.length > 0) {
for (String value : values) {
parameters.add(key, value);
}
}
});
return parameters;
}
}
创建 AuthenticationProvider 的子类,取名为 PasswordGrantAuthenticationProvider,内容如下(直接从官网文档复制修改)。
package org.oauth.server.authentication.password;
import jakarta.annotation.Resource;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
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.*;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
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.util.Assert;
import java.util.Map;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/20-14:41
* @description TODO
*/
public class PasswordGrantAuthenticationProvider implements AuthenticationProvider {
@Resource
private UserDetailsService userDetailsService;
@Resource
private PasswordEncoder passwordEncoder;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
public PasswordGrantAuthenticationProvider(OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PasswordGrantAuthenticationToken passwordGrantAuthenticationToken =
(PasswordGrantAuthenticationToken) authentication;
Map<String, Object> additionalParameters = passwordGrantAuthenticationToken.getAdditionalParameters();
//授权类型
AuthorizationGrantType authorizationGrantType = passwordGrantAuthenticationToken.getGrantType();
//用户名
String username = (String)additionalParameters.get(OAuth2ParameterNames.USERNAME);
//密码
String password = (String)additionalParameters.get(OAuth2ParameterNames.PASSWORD);
// Ensure the client is authenticated
OAuth2ClientAuthenticationToken clientPrincipal =
getAuthenticatedClientElseThrowInvalidClient(passwordGrantAuthenticationToken);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// Ensure the client is configured to use this authorization grant type
if (!registeredClient.getAuthorizationGrantTypes().contains(authorizationGrantType)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
//校验用户名信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(!passwordEncoder.matches(password,userDetails.getPassword())){
throw new OAuth2AuthenticationException("密码不正确!");
}
// Generate the access token
OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(clientPrincipal)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.authorizationGrantType(authorizationGrantType)
.authorizationGrant(passwordGrantAuthenticationToken)
.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(authorizationGrantType);
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 PasswordGrantAuthenticationToken.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);
}
}
在 AuthorizationServerConfig 配置类中,对 PasswordGrantAuthenticationConverter、PasswordGrantAuthenticationProvider 两个类进行设置,代码如下。
package org.oauth.server.config;
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.oauth.server.authentication.device.DeviceClientAuthenticationConverter;
import org.oauth.server.authentication.device.DeviceClientAuthenticationProvider;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationConverter;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationProvider;
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.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
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.token.*;
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;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/10-16:34
* @description TODO
*/
@Configuration
public class AuthorizationServerConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
/**
* Spring Authorization Server 相关配置
* 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
* 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
* 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<?> tokenGenerator)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
//AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
//AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
//设置用户码校验地址
deviceAuthorizationEndpoint.verificationUri("/activate")
)
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
//设置授权页地址
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
//设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
)
.authorizationEndpoint(authorizationEndpoint ->
authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
//设置自定义密码模式
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(
new PasswordGrantAuthenticationConverter())
.authenticationProvider(
new PasswordGrantAuthenticationProvider(
authorizationService, tokenGenerator)))
//开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
.oidc(Customizer.withDefaults());
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 客户端信息
* 对应表:oauth2_registered_client
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
/**
* 授权信息
* 对应表:oauth2_authorization
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 授权确认
*对应表:oauth2_authorization_consent
*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
*配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
* JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
*/
@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);
}
/**
*生成RSA密钥对,给上面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;
}
/**
* 配置jwt解析器
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
*配置认证服务器请求地址
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
//什么都不配置,则使用默认地址
return AuthorizationServerSettings.builder().build();
}
/**
*配置token生成器
*/
@Bean
OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
return new DelegatingOAuth2TokenGenerator(
jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
}
}
密码模式测试
先在客户端信息表 oauth2_registered_client 中增加一条如下客户端记录。
INSERT INTO `oauth-server`.`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 ('d84e9e7c-abb1-46f7-bb0f-4511af362ca6', 'password-client-id', '2023-07-12 07:33:42', '$2a$10$.J0Rfg7y2Mu8AN8Dk2vL.eBFa9NGbOYCPOAFEw.QhgGLVXjO7eFDC', NULL, '密码模式授权平台', 'client_secret_basic', 'refresh_token,authorization_password', '', 'http://127.0.0.1:9000/', '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.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\",7200.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",3600.000000000]}');
postman 输入 http://spring-oauth-server:9000/oauth2/token 地址,Query Params 参数为grant_type=authorization_password、username=user、password=123456,scope=profile,Basic Auth 参数为 Username=password-client-id,Password=secret。
请求结果如下。
从上面的结果中,可以看到,已经能成功返回 token 信息了。
生成 refresh_token
从上面的测试结果中我们看到,返回的结果是没有 refresh_token 的,因为当前的PasswordGrantAuthenticationProvider 是从官网文档中复制下来进行修改的,在 authenticate 方法返回时,没有构造出 OAuth2RefreshToken 对象作为参数传入 OAuth2AccessTokenAuthenticationToken。
OAuth2AccessTokenAuthenticationToken 提供了支持传入 OAuth2RefreshToken 的构造函数重载。
我们在使用授权码模式获取 token 的时候,是有返回 refresh_token 的,而授权码模式是用到框架源码中的 OAuth2AuthorizationCodeAuthenticationProvider 类,
因此可以从 OAuth2AuthorizationCodeAuthenticationProvider 类中拷贝生成 refresh_token 的代码(暂不拷贝生成 id_token 的代码)。
将 OAuth2AuthorizationCodeAuthenticationProvider 生成 refresh_token 的代码整合进PasswordGrantAuthenticationProvider 之后,PasswordGrantAuthenticationProvider 代码如下。
package org.oauth.server.authentication.password;
import jakarta.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
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.util.Assert;
import java.security.Principal;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/20-14:41
* @description TODO
*/
public class PasswordGrantAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
@Resource
private UserDetailsService userDetailsService;
@Resource
private PasswordEncoder passwordEncoder;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final Log logger = LogFactory.getLog(getClass());
public PasswordGrantAuthenticationProvider(OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PasswordGrantAuthenticationToken passwordGrantAuthenticationToken =
(PasswordGrantAuthenticationToken) authentication;
Map<String, Object> additionalParameters = passwordGrantAuthenticationToken.getAdditionalParameters();
//授权类型
AuthorizationGrantType authorizationGrantType = passwordGrantAuthenticationToken.getGrantType();
//用户名
String username = (String)additionalParameters.get(OAuth2ParameterNames.USERNAME);
//密码
String password = (String)additionalParameters.get(OAuth2ParameterNames.PASSWORD);
//请求参数权限范围
String requestScopesStr = (String)additionalParameters.get(OAuth2ParameterNames.SCOPE);
//请求参数权限范围专场集合
Set<String> requestScopeSet = Stream.of(requestScopesStr.split(" ")).collect(Collectors.toSet());
// Ensure the client is authenticated
OAuth2ClientAuthenticationToken clientPrincipal =
getAuthenticatedClientElseThrowInvalidClient(passwordGrantAuthenticationToken);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// Ensure the client is configured to use this authorization grant type
if (!registeredClient.getAuthorizationGrantTypes().contains(authorizationGrantType)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
//校验用户名信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(!passwordEncoder.matches(password,userDetails.getPassword())){
throw new OAuth2AuthenticationException("密码不正确!");
}
//由于在上面已验证过用户名、密码,现在构建一个已认证的对象UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken.authenticated(userDetails,clientPrincipal,userDetails.getAuthorities());
// Initialize the DefaultOAuth2TokenContext
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(usernamePasswordAuthenticationToken)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.authorizationGrantType(authorizationGrantType)
.authorizedScopes(requestScopeSet)
.authorizationGrant(passwordGrantAuthenticationToken);
// Initialize the OAuth2Authorization
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
.principalName(clientPrincipal.getName())
.authorizedScopes(requestScopeSet)
.attribute(Principal.class.getName(), usernamePasswordAuthenticationToken)
.authorizationGrantType(authorizationGrantType);
// ----- 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);
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Generated access token");
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());
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);
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Generated refresh token");
}
refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
authorizationBuilder.refreshToken(refreshToken);
}
//保存认证信息
OAuth2Authorization authorization = authorizationBuilder.build();
this.authorizationService.save(authorization);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Saved authorization");
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Authenticated token request");
}
return new OAuth2AccessTokenAuthenticationToken(
registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
}
@Override
public boolean supports(Class<?> authentication) {
return PasswordGrantAuthenticationToken.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);
}
}
重启认证服务器,postman 重新请求 http://spring-oauth-server:9000/oauth2/token 地址,参数不变,返回结果如下。
此时我们看到,结果已经返回 refresh_token 了。
测试一下 refresh_token,将返回结果中的 refresh_token 使用 token 刷新模式(grant_type=refresh_token)请求 http://spring-oauth-server:9000/oauth2/token 地址,参数如下。
返回结果如下。
从上面的结果中可以看到,通过使用 refresh_token 去获取 token 信息,也成功了。
短信验证码登录
在实际应用中,使用短信验证码作为登录方式的情形非常普遍,在这里简单也提一下短信验证码登录的实现。既然上面已经实现了自定义密码模式,那么要实现短信验证码登录,那就非常简单了。将上面的自定义密码模式的代码复制一份,稍加修改即可。首先,增加一个授权类型,例如:grant_type=authorization_mobile;然后将校验密码的地方,改成校验短信验证码;最后将XXXAuthenticationConverter、XXXAuthenticationProvider 设置到配置类中。
org.oauth.server.constant.OAuth2Constant 添加字段后,代码如下。
public class OAuth2Constant {
/**
* 密码模式(自定义)
*/
public static final String GRANT_TYPE_PASSWORD = "authorization_password";
/**
* 短信验证码模式(自定义)
*/
public static final String GRANT_TYPE_MOBILE = "authorization_mobile";
/**
* 短信验证码
*/
public static final String SMS_CODE = "sms_code";
/**
* 短信验证码默认值
*/
public static final String SMS_CODE_VALUE = "8888";
/**
* 构造方法私有化
*/
private OAuth2Constant(){
}
}
新建 org.oauth.server.authentication.mobile 目录,在该目录下创建 MobileGrantAuthenticationToken、MobileGrantAuthenticationConverter、MobileGrantAuthenticationProvider 三个类。
MobileGrantAuthenticationToken 代码如下。
public class MobileGrantAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
public MobileGrantAuthenticationToken(Authentication clientPrincipal,
@Nullable Map<String, Object> additionalParameters) {
super(new AuthorizationGrantType(OAuth2Constant.GRANT_TYPE_MOBILE),
clientPrincipal, additionalParameters);
}
}
MobileGrantAuthenticationConverter 代码如下。
public class MobileGrantAuthenticationConverter implements AuthenticationConverter {
@Nullable
@Override
public Authentication convert(HttpServletRequest request) {
// grant_type (REQUIRED)
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
if (!OAuth2Constant.GRANT_TYPE_MOBILE.equals(grantType)) {
return null;
}
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
//从request中提取请求参数,然后存入MultiValueMap<String, String>
MultiValueMap<String, String> parameters = getParameters(request);
// username (REQUIRED)
String smsCode = parameters.getFirst(OAuth2Constant.SMS_CODE);
if (!StringUtils.hasText(smsCode) ||
parameters.get(OAuth2Constant.SMS_CODE).size() != 1) {
throw new OAuth2AuthenticationException("无效请求,短信验证码不能为空!");
}
//收集要传入MobileGrantAuthenticationToken构造方法的参数,
//该参数接下来在MobileGrantAuthenticationProvider中使用
Map<String, Object> additionalParameters = new HashMap<>();
//遍历从request中提取的参数,排除掉grant_type、client_id、code等字段参数,其他参数收集到additionalParameters中
parameters.forEach((key, value) -> {
if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) &&
!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
!key.equals(OAuth2ParameterNames.CODE)) {
additionalParameters.put(key, value.get(0));
}
});
//返回自定义的MobileGrantAuthenticationToken对象
return new MobileGrantAuthenticationToken(clientPrincipal, additionalParameters);
}
/**
*从request中提取请求参数,然后存入MultiValueMap<String, String>
*/
private static MultiValueMap<String, String> getParameters(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(parameterMap.size());
parameterMap.forEach((key, values) -> {
if (values.length > 0) {
for (String value : values) {
parameters.add(key, value);
}
}
});
return parameters;
}
}
MobileGrantAuthenticationProvider 代码如下。
public class MobileGrantAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
@Resource
private UserDetailsService userDetailsService;
@Resource
private PasswordEncoder passwordEncoder;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final Log logger = LogFactory.getLog(getClass());
public MobileGrantAuthenticationProvider(OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
MobileGrantAuthenticationToken mobileGrantAuthenticationToken =
(MobileGrantAuthenticationToken) authentication;
Map<String, Object> additionalParameters = mobileGrantAuthenticationToken.getAdditionalParameters();
//授权类型
AuthorizationGrantType authorizationGrantType = mobileGrantAuthenticationToken.getGrantType();
//用户名
String username = (String)additionalParameters.get(OAuth2ParameterNames.USERNAME);
//短信验证码
String smsCode = (String)additionalParameters.get(OAuth2Constant.SMS_CODE);
//请求参数权限范围
String requestScopesStr = (String)additionalParameters.get(OAuth2ParameterNames.SCOPE);
//请求参数权限范围专场集合
Set<String> requestScopeSet = Stream.of(requestScopesStr.split(" ")).collect(Collectors.toSet());
// Ensure the client is authenticated
OAuth2ClientAuthenticationToken clientPrincipal =
getAuthenticatedClientElseThrowInvalidClient(mobileGrantAuthenticationToken);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// Ensure the client is configured to use this authorization grant type
if (!registeredClient.getAuthorizationGrantTypes().contains(authorizationGrantType)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
//校验用户名信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
//校验短信验证码,默认8888
if(!OAuth2Constant.SMS_CODE_VALUE.equals(smsCode)){
throw new OAuth2AuthenticationException("短信验证码不正确!");
}
//由于在上面已验证过用户名、密码,现在构建一个已认证的对象UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken.authenticated(userDetails,clientPrincipal,userDetails.getAuthorities());
// Initialize the DefaultOAuth2TokenContext
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(usernamePasswordAuthenticationToken)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.authorizationGrantType(authorizationGrantType)
.authorizedScopes(requestScopeSet)
.authorizationGrant(mobileGrantAuthenticationToken);
// Initialize the OAuth2Authorization
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
.principalName(clientPrincipal.getName())
.authorizedScopes(requestScopeSet)
.attribute(Principal.class.getName(), usernamePasswordAuthenticationToken)
.authorizationGrantType(authorizationGrantType);
// ----- 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);
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Generated access token");
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());
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);
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Generated refresh token");
}
refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
authorizationBuilder.refreshToken(refreshToken);
}
//保存认证信息
OAuth2Authorization authorization = authorizationBuilder.build();
this.authorizationService.save(authorization);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Saved authorization");
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Authenticated token request");
}
return new OAuth2AccessTokenAuthenticationToken(
registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
}
@Override
public boolean supports(Class<?> authentication) {
return MobileGrantAuthenticationToken.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);
}
}
AuthorizationServerConfig 配置类代码如下。
package org.oauth.server.config;
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.oauth.server.authentication.device.DeviceClientAuthenticationConverter;
import org.oauth.server.authentication.device.DeviceClientAuthenticationProvider;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationConverter;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationProvider;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationConverter;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationProvider;
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.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
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.token.*;
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;
/**
* @author Rommel
* @version 1.0
* @date 2023/7/10-16:34
* @description TODO
*/
@Configuration
public class AuthorizationServerConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
/**
* Spring Authorization Server 相关配置
* 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
* 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
* 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<?> tokenGenerator)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
//AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
//AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
//设置用户码校验地址
deviceAuthorizationEndpoint.verificationUri("/activate")
)
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
//设置授权页地址
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
//设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
)
.authorizationEndpoint(authorizationEndpoint ->
authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
//设置自定义密码模式
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(
new PasswordGrantAuthenticationConverter())
.authenticationProvider(
new PasswordGrantAuthenticationProvider(
authorizationService, tokenGenerator)))
//设置自定义手机验证码模式
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(
new MobileGrantAuthenticationConverter())
.authenticationProvider(
new MobileGrantAuthenticationProvider(
authorizationService, tokenGenerator)))
//开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
.oidc(Customizer.withDefaults());
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 客户端信息
* 对应表:oauth2_registered_client
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
/**
* 授权信息
* 对应表:oauth2_authorization
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 授权确认
*对应表:oauth2_authorization_consent
*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
*配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
* JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
*/
@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);
}
/**
*生成RSA密钥对,给上面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;
}
/**
* 配置jwt解析器
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
*配置认证服务器请求地址
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
//什么都不配置,则使用默认地址
return AuthorizationServerSettings.builder().build();
}
/**
*配置token生成器
*/
@Bean
OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
return new DelegatingOAuth2TokenGenerator(
jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
}
}
短信验证码登录测试
sys_user 表增加一条手机号码的记录如下。
INSERT INTO `oauth-server`.`sys_user` (`id`, `username`, `password`, `name`, `description`, `status`) VALUES (2, '13000000001', '$2a$10$8fyY0WbNAr980e6nLcPL5ugmpkLLH3serye5SJ3UcDForTW5b0Sx.', '测试用户', '手机号码 测试用户', 1);
oauth2_registered_client 表增加支持手机验证码模式的记录如下。
INSERT INTO `oauth-server`.`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 ('d84e9e7c-abb1-46f7-bb0f-4511af362ca7', 'mobile-client-id', '2023-07-12 07:33:42', '$2a$10$.J0Rfg7y2Mu8AN8Dk2vL.eBFa9NGbOYCPOAFEw.QhgGLVXjO7eFDC', NULL, '手机验证码授权平台', 'client_secret_basic', 'refresh_token,authorization_password,authorization_mobile', '', 'http://127.0.0.1:9000/', '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.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\",7200.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",3600.000000000]}');
postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,输入如下参数。
返回如下成功结果。
注意:在上面短信验证码的实现和测试过程中,我们将短信验证码写死为“8888”,在实际应用中,短信验证码要从 redis 中获取。
总结
本篇介绍了如何实现 Spring Authorization Server 的拓展类型,文中先是介绍了自定义密码模式的实现,在实现自定义密码模式的过程中,也介绍了如何生成 refresh_token,最后在实现自定义密码模式的基础上也顺带介绍了短信验证码的实现。
项目代码地址:链接地址