feat: OAuth2 支持 OIDC

This commit is contained in:
Ray Hao 2024-02-28 18:27:17 +08:00
parent 930e536fe3
commit 4e56b18bc7
18 changed files with 909 additions and 127 deletions

View File

@ -88,6 +88,11 @@
<artifactId>common-apidoc</artifactId>
</dependency>
<dependency>
<groupId>com.youlai</groupId>
<artifactId>common-sms</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@ -2,6 +2,7 @@ package com.youlai.auth.config;
import cn.hutool.core.collection.CollectionUtil;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -9,9 +10,11 @@ import org.springframework.core.annotation.Order;
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.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
import java.util.List;
@ -56,4 +59,20 @@ public class SecurityConfig {
return http.build();
}
/**
* 不走过滤器链的放行配置
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(
AntPathRequestMatcher.antMatcher("/webjars/**"),
AntPathRequestMatcher.antMatcher("/doc.html"),
AntPathRequestMatcher.antMatcher("/swagger-resources/**"),
AntPathRequestMatcher.antMatcher("/v3/api-docs/**"),
AntPathRequestMatcher.antMatcher("/swagger-ui/**")
);
}
}

View File

@ -4,11 +4,9 @@ import com.youlai.auth.model.CaptchaResult;
import com.youlai.auth.service.AuthService;
import com.youlai.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
/**
* 认证控制器
@ -26,7 +24,6 @@ public class AuthController {
private final AuthService authService;
@Operation(summary = "获取验证码")
@GetMapping("/captcha")
public Result<CaptchaResult> getCaptcha() {
@ -34,12 +31,15 @@ public class AuthController {
return Result.success(captchaResult);
}
@Operation(summary = "注销登出")
@DeleteMapping("/logout")
public Result logout() {
boolean result = authService.logout();
@Operation(summary = "发送手机短信验证码")
@PostMapping("/sms_code")
public Result sendLoginSmsCode(
@Parameter(description = "手机号") @RequestParam String mobile
) {
boolean result = authService.sendLoginSmsCode(mobile);
return Result.judge(result);
}
}

View File

@ -1,5 +1,5 @@
package com.youlai.auth.config;
package com.youlai.auth.oauth2.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.captcha.generator.CodeGenerator;
@ -12,6 +12,8 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.youlai.auth.model.SysUserDetails;
import com.youlai.auth.oauth2.extension.captcha.CaptchaAuthenticationConverter;
import com.youlai.auth.oauth2.extension.captcha.CaptchaAuthenticationProvider;
import com.youlai.auth.oauth2.extension.miniapp.WxMiniAppAuthenticationConverter;
import com.youlai.auth.oauth2.extension.miniapp.WxMiniAppAuthenticationProvider;
import com.youlai.auth.oauth2.extension.miniapp.WxMiniAppAuthenticationToken;
@ -23,6 +25,9 @@ import com.youlai.auth.oauth2.extension.sms.SmsAuthenticationToken;
import com.youlai.auth.oauth2.handler.MyAuthenticationFailureHandler;
import com.youlai.auth.oauth2.handler.MyAuthenticationSuccessHandler;
import com.youlai.auth.oauth2.jackson.SysUserMixin;
import com.youlai.auth.oauth2.oidc.CustomOidcAuthenticationConverter;
import com.youlai.auth.oauth2.oidc.CustomOidcAuthenticationProvider;
import com.youlai.auth.oauth2.oidc.CustomOidcUserInfoService;
import com.youlai.auth.service.MemberDetailsService;
import com.youlai.common.constant.RedisConstants;
import lombok.RequiredArgsConstructor;
@ -32,7 +37,6 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
@ -78,7 +82,7 @@ import java.util.UUID;
/**
* 授权服务器配置
*
* @author haoxr
* @author Ray Hao
* @see <a href="https://github.com/spring-projects/spring-authorization-server/blob/49b199c5b41b5f9279d9758fc2f5d24ed1fe4afa/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java#L112">AuthorizationServerConfig</a>
* @since 3.0.0
*/
@ -88,14 +92,18 @@ import java.util.UUID;
public class AuthorizationServerConfig {
private final WxMaService wxMaService;
private final RedisTemplate<String, String> redisTemplate;
private final MemberDetailsService memberDetailsService;
private final CustomOidcUserInfoService customOidcUserInfoService;
private final OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent"; // 自定义授权页
private static final String CUSTOM_LOGIN_PAGE_URI = "/login"; // 自定义登录页
private static final String CUSTOM_LOGIN_PAGE_URI = "/sso-login"; // 自定义登录页
private final StringRedisTemplate redisTemplate;
private final CodeGenerator codeGenerator;
/**
* 授权服务器端点配置
*/
@ -112,27 +120,28 @@ public class AuthorizationServerConfig {
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)) // 自定义授权页
.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0
// 自定义授权模式转换器(Converter)
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenRequestConverters(
authenticationConverters ->// <1>
authenticationConverters -> // <1>
// 自定义授权模式转换器(Converter)
authenticationConverters.addAll(
List.of(
new PasswordAuthenticationConverter(),
new CaptchaAuthenticationConverter(),
new WxMiniAppAuthenticationConverter(),
new SmsAuthenticationConverter()
)
)
)
.authenticationProviders(
authenticationProviders ->// <2>
authenticationProviders -> // <2>
// 自定义授权模式提供者(Provider)
authenticationProviders.addAll(
List.of(
new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator, redisTemplate, codeGenerator),
new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator),
new CaptchaAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator, redisTemplate, codeGenerator),
new WxMiniAppAuthenticationProvider(authorizationService, tokenGenerator, memberDetailsService, wxMaService),
new SmsAuthenticationProvider(authorizationService, tokenGenerator, memberDetailsService, redisTemplate)
)
@ -140,9 +149,17 @@ public class AuthorizationServerConfig {
)
.accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应
.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应
)
// Enable OpenID Connect 1.0 自定义
.oidc(oidcCustomizer ->
oidcCustomizer.userInfoEndpoint(userInfoEndpointCustomizer ->
{
userInfoEndpointCustomizer.userInfoRequestConverter(new CustomOidcAuthenticationConverter(customOidcUserInfoService));
userInfoEndpointCustomizer.authenticationProvider(new CustomOidcAuthenticationProvider(authorizationService));
}
)
);
http
// 当用户未登录且尝试访问需要认证的端点时重定向至登录页面
.exceptionHandling((exceptions) -> exceptions
@ -151,7 +168,7 @@ public class AuthorizationServerConfig {
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 处理 OIDC 获取用户信息端点
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));

View File

@ -0,0 +1,116 @@
package com.youlai.auth.oauth2.extension.captcha;
import cn.hutool.core.util.StrUtil;
import com.youlai.auth.util.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.util.MultiValueMap;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 验证码模式参数解析器
* <p>
* 解析请求参数中的用户名和密码并构建相应的身份验证(Authentication)对象
*
* @author haoxr
* @see org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter
* @since 3.0.0
*/
public class CaptchaAuthenticationConverter implements AuthenticationConverter {
@Override
public Authentication convert(HttpServletRequest request) {
// 授权类型 (必需)
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
if (!CaptchaAuthenticationToken.CAPTCHA.getValue().equals(grantType)) {
return null;
}
// 客户端信息
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
// 参数提取验证
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
// 令牌申请访问范围验证 (可选)
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
if (StringUtils.hasText(scope) &&
parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
OAuth2ParameterNames.SCOPE,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
}
Set<String> requestedScopes = null;
if (StringUtils.hasText(scope)) {
requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
}
// 用户名验证(必需)
String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
if (StrUtil.isBlank(username)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
OAuth2ParameterNames.USERNAME,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 密码验证(必需)
String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
if (StrUtil.isBlank(password)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
OAuth2ParameterNames.PASSWORD,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 验证码ID(必需)
String captchaId = parameters.getFirst(CaptchaParameterNames.CAPTCHA_ID);
if (StrUtil.isBlank(captchaId)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
CaptchaParameterNames.CAPTCHA_ID,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 验证码Code(必需)
String captchaCode = parameters.getFirst(CaptchaParameterNames.CAPTCHA_CODE);
if (StrUtil.isBlank(captchaCode)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
CaptchaParameterNames.CAPTCHA_CODE,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 附加参数(保存用户名/密码传递给 PasswordAuthenticationProvider 用于身份认证)
Map<String, Object> additionalParameters = parameters
.entrySet()
.stream()
.filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&
!e.getKey().equals(OAuth2ParameterNames.SCOPE)
).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
return new CaptchaAuthenticationToken(
clientPrincipal,
requestedScopes,
additionalParameters
);
}
}

View File

@ -0,0 +1,242 @@
package com.youlai.auth.oauth2.extension.captcha;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.lang.Assert;
import com.youlai.auth.util.OAuth2AuthenticationProviderUtils;
import com.youlai.common.constant.RedisConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
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.oauth2.core.*;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.jwt.Jwt;
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.CollectionUtils;
import java.security.Principal;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 验证码模式身份验证提供者
* <p>
* 处理基于用户名和密码的身份验证
*
* @author haoxr
* @since 3.0.0
*/
@Slf4j
public class CaptchaAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
private final AuthenticationManager authenticationManager;
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final StringRedisTemplate redisTemplate;
private final CodeGenerator codeGenerator;
/**
* Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.
*
* @param authenticationManager the authentication manager
* @param authorizationService the authorization service
* @param tokenGenerator the token generator
* @since 0.2.3
*/
public CaptchaAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
StringRedisTemplate redisTemplate,
CodeGenerator codeGenerator
) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authenticationManager = authenticationManager;
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
this.redisTemplate = redisTemplate;
this.codeGenerator = codeGenerator;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
CaptchaAuthenticationToken passwordAuthenticationToken = (CaptchaAuthenticationToken) authentication;
OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils
.getAuthenticatedClientElseThrowInvalidClient(passwordAuthenticationToken);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// 验证客户端是否支持授权类型(grant_type=captcha)
if (!registeredClient.getAuthorizationGrantTypes().contains(CaptchaAuthenticationToken.CAPTCHA)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
Map<String, Object> additionalParameters = passwordAuthenticationToken.getAdditionalParameters();
// 验证码校验
String captchaId = (String) additionalParameters.get(CaptchaParameterNames.CAPTCHA_ID);
String captchaCode = (String) additionalParameters.get(CaptchaParameterNames.CAPTCHA_CODE);
String cacheCaptchaCode = redisTemplate.opsForValue().get(RedisConstants.CAPTCHA_CODE_PREFIX + captchaId);
// 验证码比对
if (!codeGenerator.verify(cacheCaptchaCode, captchaCode)) {
throw new OAuth2AuthenticationException("验证码错误");
}
// 生成用户名密码身份验证令牌
String username = (String) additionalParameters.get(OAuth2ParameterNames.USERNAME);
String password = (String) additionalParameters.get(OAuth2ParameterNames.PASSWORD);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 用户名密码身份验证成功后返回带有权限的认证信息
Authentication usernamePasswordAuthentication;
try {
usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
} catch (Exception e) {
// 需要将其他类型的异常转换为 OAuth2AuthenticationException 才能被自定义异常捕获处理逻辑源码 OAuth2TokenEndpointFilter#doFilterInternal
throw new OAuth2AuthenticationException(e.getCause() != null ? e.getCause().getMessage() : e.getMessage());
}
// 验证申请访问范围(Scope)
Set<String> authorizedScopes = registeredClient.getScopes();
Set<String> requestedScopes = passwordAuthenticationToken.getScopes();
if (!CollectionUtils.isEmpty(requestedScopes)) {
Set<String> unauthorizedScopes = requestedScopes.stream()
.filter(requestedScope -> !registeredClient.getScopes().contains(requestedScope))
.collect(Collectors.toSet());
if (!CollectionUtils.isEmpty(unauthorizedScopes)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
}
authorizedScopes = new LinkedHashSet<>(requestedScopes);
}
// 访问令牌(Access Token) 构造器
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(usernamePasswordAuthentication) // 身份验证成功的认证信息(用户名权限等信息)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.authorizedScopes(authorizedScopes)
.authorizationGrantType(CaptchaAuthenticationToken.CAPTCHA) // 授权方式
.authorizationGrant(passwordAuthenticationToken) // 授权具体对象
;
// 生成访问令牌(Access Token)
OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType((OAuth2TokenType.ACCESS_TOKEN)).build();
OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
if (generatedAccessToken == null) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the access token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
.principalName(usernamePasswordAuthentication.getName())
.authorizationGrantType(CaptchaAuthenticationToken.CAPTCHA)
.authorizedScopes(authorizedScopes)
.attribute(Principal.class.getName(), usernamePasswordAuthentication); // attribute 字段
if (generatedAccessToken instanceof ClaimAccessor) {
authorizationBuilder.token(accessToken, (metadata) ->
metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));
} else {
authorizationBuilder.accessToken(accessToken);
}
// 生成刷新令牌(Refresh Token)
OAuth2RefreshToken refreshToken = null;
if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&
// Do not issue refresh token to public client
!clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {
tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);
if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the refresh token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
authorizationBuilder.refreshToken(refreshToken);
}
// ----- ID token -----
OidcIdToken idToken;
if (requestedScopes.contains(OidcScopes.OPENID)) {
// @formatter:off
tokenContext = tokenContextBuilder
.tokenType(ID_TOKEN_TOKEN_TYPE)
.authorization(authorizationBuilder.build()) // ID token customizer may need access to the access token and/or refresh token
.build();
// @formatter:on
OAuth2Token generatedIdToken = this.tokenGenerator.generate(tokenContext);
if (!(generatedIdToken instanceof Jwt)) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the ID token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
if (log.isTraceEnabled()) {
log.trace("Generated id token");
}
idToken = new OidcIdToken(generatedIdToken.getTokenValue(), generatedIdToken.getIssuedAt(),
generatedIdToken.getExpiresAt(), ((Jwt) generatedIdToken).getClaims());
authorizationBuilder.token(idToken, (metadata) ->
metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()));
} else {
idToken = null;
}
// 持久化令牌发放记录到数据库
OAuth2Authorization authorization = authorizationBuilder.build();
this.authorizationService.save(authorization);
additionalParameters = (idToken != null)
? Collections.singletonMap(OidcParameterNames.ID_TOKEN, idToken.getTokenValue())
: Collections.emptyMap();
return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
}
/**
* 判断传入的 authentication 类型是否与当前认证提供者(AuthenticationProvider)相匹配--模板方法
* <p>
* ProviderManager#authenticate 遍历 providers 找到支持对应认证请求的 provider-迭代器模式
*
* @param authentication 认证请求
* @return boolean
*/
@Override
public boolean supports(Class<?> authentication) {
return CaptchaAuthenticationToken.class.isAssignableFrom(authentication);
}
}

View File

@ -0,0 +1,60 @@
package com.youlai.auth.oauth2.extension.captcha;
import jakarta.annotation.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 验证码授权模式身份验证令牌(包含用户名和密码等)
*
* @author haoxr
* @since 3.0.0
*/
public class CaptchaAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
/**
* 令牌申请访问范围
*/
private final Set<String> scopes;
/**
* 授权类型(验证码: captcha)
*/
public static final AuthorizationGrantType CAPTCHA = new AuthorizationGrantType("captcha");
/**
* 验证码模式身份验证令牌
*
* @param clientPrincipal 客户端信息
* @param scopes 令牌申请访问范围
* @param additionalParameters 自定义额外参数(用户名密码验证码)
*/
public CaptchaAuthenticationToken(
Authentication clientPrincipal,
Set<String> scopes,
@Nullable Map<String, Object> additionalParameters
) {
super(CAPTCHA, clientPrincipal, additionalParameters);
this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet<>(scopes) : Collections.emptySet());
}
/**
* 用户凭证(密码)
*/
@Override
public Object getCredentials() {
return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);
}
public Set<String> getScopes() {
return scopes;
}
}

View File

@ -0,0 +1,30 @@
package com.youlai.auth.oauth2.extension.captcha;
/**
* 验证码模式请求参数名
*
* @author Ray Hao
* @since 3.0.0
*/
public class CaptchaParameterNames {
/**
* 验证码ID
*/
public static final String CAPTCHA_ID = "captchaId";
/**
* 验证码 Code
*/
public static final String CAPTCHA_CODE = "captchaCode";
private CaptchaParameterNames() {
}
}

View File

@ -1,7 +1,6 @@
package com.youlai.auth.oauth2.extension.password;
import cn.hutool.core.util.StrUtil;
import com.youlai.common.constant.OAuth2Constants;
import com.youlai.auth.util.OAuth2EndpointUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
@ -79,26 +78,6 @@ public class PasswordAuthenticationConverter implements AuthenticationConverter
);
}
// 验证码ID(必需)
String verifyKey = parameters.getFirst(OAuth2Constants.CAPTCHA_ID);
if (StrUtil.isBlank(verifyKey)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
OAuth2Constants.CAPTCHA_ID,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 验证码Code(必需)
String verifyCode = parameters.getFirst(OAuth2Constants.CAPTCHA_CODE);
if (StrUtil.isBlank(verifyCode)) {
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
OAuth2Constants.CAPTCHA_CODE,
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
);
}
// 附加参数(保存用户名/密码传递给 PasswordAuthenticationProvider 用于身份认证)
Map<String, Object> additionalParameters = parameters
.entrySet()

View File

@ -2,14 +2,11 @@ package com.youlai.auth.oauth2.extension.password;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.core.lang.Assert;
import com.youlai.common.constant.OAuth2Constants;
import com.youlai.auth.util.OAuth2AuthenticationProviderUtils;
import com.youlai.common.constant.RedisConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -57,10 +54,6 @@ public class PasswordAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AuthorizationService authorizationService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final RedisTemplate<String,String> redisTemplate;
private final CodeGenerator codeGenerator;
/**
* Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.
*
@ -71,17 +64,13 @@ public class PasswordAuthenticationProvider implements AuthenticationProvider {
*/
public PasswordAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
RedisTemplate<String,String> redisTemplate,
CodeGenerator codeGenerator
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator
) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
this.authenticationManager = authenticationManager;
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
this.redisTemplate = redisTemplate;
this.codeGenerator = codeGenerator;
}
@Override
@ -99,16 +88,6 @@ public class PasswordAuthenticationProvider implements AuthenticationProvider {
Map<String, Object> additionalParameters = passwordAuthenticationToken.getAdditionalParameters();
// 验证码校验
String captchaId = (String) additionalParameters.get(OAuth2Constants.CAPTCHA_ID);
String captchaCode = (String) additionalParameters.get(OAuth2Constants.CAPTCHA_CODE);
String cacheCode = redisTemplate.opsForValue().get(RedisConstants.CAPTCHA_CODE_PREFIX + captchaId);
// 验证码比对
if (!codeGenerator.verify(cacheCode, captchaCode)) {
throw new OAuth2AuthenticationException("验证码错误");
}
// 生成用户名密码身份验证令牌

View File

@ -0,0 +1,29 @@
package com.youlai.auth.oauth2.oidc;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.web.authentication.AuthenticationConverter;
/**
* 自定义 OIDC 认证转换器
*
* @author Ray Hao
* @since 3.1.0
*/
public class CustomOidcAuthenticationConverter implements AuthenticationConverter {
private final CustomOidcUserInfoService customOidcUserInfoService;
public CustomOidcAuthenticationConverter(CustomOidcUserInfoService customOidcUserInfoService) {
this.customOidcUserInfoService = customOidcUserInfoService;
}
@Override
public Authentication convert(HttpServletRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomOidcUserInfo customOidcUserInfo = customOidcUserInfoService.loadUserByUsername(authentication.getName());
return new OidcUserInfoAuthenticationToken(authentication, customOidcUserInfo);
}
}

View File

@ -0,0 +1,130 @@
package com.youlai.auth.oauth2.oidc;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
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.oidc.authentication.OidcUserInfoAuthenticationContext;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken;
import org.springframework.util.Assert;
import java.util.*;
import java.util.function.Function;
/**
* 自定义 OIDC 认证提供者
*
* @author Ray Hao
* @since 3.1.0
*/
@Slf4j
public class CustomOidcAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AuthorizationService authorizationService;
public CustomOidcAuthenticationProvider(OAuth2AuthorizationService authorizationService) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
this.authorizationService = authorizationService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OidcUserInfoAuthenticationToken userInfoAuthentication = (OidcUserInfoAuthenticationToken) authentication;
AbstractOAuth2TokenAuthenticationToken<?> accessTokenAuthentication = null;
if (AbstractOAuth2TokenAuthenticationToken.class.isAssignableFrom(userInfoAuthentication.getPrincipal().getClass())) {
accessTokenAuthentication = (AbstractOAuth2TokenAuthenticationToken) userInfoAuthentication.getPrincipal();
}
if (accessTokenAuthentication != null && accessTokenAuthentication.isAuthenticated()) {
String accessTokenValue = accessTokenAuthentication.getToken().getTokenValue();
OAuth2Authorization authorization = this.authorizationService.findByToken(accessTokenValue, OAuth2TokenType.ACCESS_TOKEN);
if (authorization == null) {
throw new OAuth2AuthenticationException("invalid_token");
} else {
if (log.isTraceEnabled()) {
log.trace("Retrieved authorization with access token");
}
OAuth2Authorization.Token<OAuth2AccessToken> authorizedAccessToken = authorization.getAccessToken();
if (!authorizedAccessToken.isActive()) {
throw new OAuth2AuthenticationException("invalid_token");
} else {
// 从认证结果中获取userInfo
CustomOidcUserInfo customOidcUserInfo = (CustomOidcUserInfo) userInfoAuthentication.getUserInfo();
// 从authorizedAccessToken中获取授权范围
Set<String> scopeSet = (HashSet<String>) authorizedAccessToken.getClaims().get("scope");
// 获取授权范围对应userInfo的字段信息
Map<String, Object> claims = DefaultOidcUserInfoMapper.getClaimsRequestedByScope(customOidcUserInfo.getClaims(), scopeSet);
if (log.isTraceEnabled()) {
log.trace("Authenticated user info request");
}
return new CustomOidcToken(accessTokenAuthentication, new CustomOidcUserInfo(claims));
}
}
} else {
throw new OAuth2AuthenticationException("invalid_token");
}
}
@Override
public boolean supports(Class<?> authentication) {
return OidcUserInfoAuthenticationToken.class.isAssignableFrom(authentication);
}
private static final class DefaultOidcUserInfoMapper implements Function<OidcUserInfoAuthenticationContext, CustomOidcUserInfo> {
private static final List<String> EMAIL_CLAIMS = Arrays.asList("email", "email_verified");
private static final List<String> PHONE_CLAIMS = Arrays.asList("phone_number", "phone_number_verified");
private static final List<String> PROFILE_CLAIMS = Arrays.asList("username", "nickname", "status", "profile");
private DefaultOidcUserInfoMapper() {
}
@Override
public CustomOidcUserInfo apply(OidcUserInfoAuthenticationContext authenticationContext) {
OAuth2Authorization authorization = authenticationContext.getAuthorization();
OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken();
OAuth2AccessToken accessToken = authenticationContext.getAccessToken();
Map<String, Object> scopeRequestedClaims = getClaimsRequestedByScope(idToken.getClaims(), accessToken.getScopes());
return new CustomOidcUserInfo(scopeRequestedClaims);
}
/**
* 根据不同权限抽取不同数据
*/
private static Map<String, Object> getClaimsRequestedByScope(Map<String, Object> claims, Set<String> requestedScopes) {
Set<String> scopeRequestedClaimNames = new HashSet<>(32);
scopeRequestedClaimNames.add("sub");
if (requestedScopes.contains("address")) {
scopeRequestedClaimNames.add("address");
}
if (requestedScopes.contains("email")) {
scopeRequestedClaimNames.addAll(EMAIL_CLAIMS);
}
if (requestedScopes.contains("phone")) {
scopeRequestedClaimNames.addAll(PHONE_CLAIMS);
}
if (requestedScopes.contains("profile")) {
scopeRequestedClaimNames.addAll(PROFILE_CLAIMS);
}
Map<String, Object> requestedClaims = new HashMap<>(claims);
requestedClaims.keySet().removeIf((claimName) -> !scopeRequestedClaimNames.contains(claimName));
return requestedClaims;
}
}
}

View File

@ -0,0 +1,53 @@
package com.youlai.auth.oauth2.oidc;
import cn.hutool.core.lang.Assert;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
/**
* 自定义 OidcToken
*
* @author Ray Hao
* @since 3.1.0
*/
public class CustomOidcToken extends OidcUserInfoAuthenticationToken {
private final Authentication principal;
private final CustomOidcUserInfo userInfo;
public CustomOidcToken(Authentication principal) {
super(principal);
Assert.notNull(principal, "principal cannot be null");
this.principal = principal;
this.userInfo = null;
this.setAuthenticated(false);
}
public CustomOidcToken(Authentication principal, CustomOidcUserInfo userInfo) {
super(principal, userInfo);
Assert.notNull(principal, "principal cannot be null");
Assert.notNull(userInfo, "userInfo cannot be null");
this.principal = principal;
this.userInfo = userInfo;
this.setAuthenticated(true);
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public Object getCredentials() {
return "";
}
@Override
public OidcUserInfo getUserInfo() {
return this.userInfo;
}
}

View File

@ -0,0 +1,102 @@
package com.youlai.auth.oauth2.oidc;
import cn.hutool.core.lang.Assert;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
/**
* 自定义 OidcUserInfo
*
* @author Ray Hao
* @since 3.1.0
*/
public class CustomOidcUserInfo extends OidcUserInfo {
private static final long serialVersionUID = 610L;
private final Map<String, Object> claims;
public CustomOidcUserInfo(Map<String, Object> claims) {
super(claims);
Assert.notEmpty(claims, "claims cannot be empty");
this.claims = Collections.unmodifiableMap(new LinkedHashMap(claims));
}
public Map<String, Object> getClaims() {
return this.claims;
}
public boolean equals(Object obj) {
if (this == obj) {
return true;
} else if (obj != null && this.getClass() == obj.getClass()) {
CustomOidcUserInfo that = (CustomOidcUserInfo)obj;
return this.getClaims().equals(that.getClaims());
} else {
return false;
}
}
public int hashCode() {
return this.getClaims().hashCode();
}
public static Builder customBuilder() {
return new Builder();
}
public static final class Builder {
private final Map<String, Object> claims = new LinkedHashMap();
private Builder() {
}
public Builder claim(String name, Object value) {
this.claims.put(name, value);
return this;
}
public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
claimsConsumer.accept(this.claims);
return this;
}
public Builder username(String username) {
return this.claim("username", username);
}
public Builder nickname(String nickname) {
return this.claim("nickname", nickname);
}
public Builder description(String description) {
return this.claim("description", description);
}
public Builder status(Integer status) {
return this.claim("status", status);
}
public Builder phoneNumber(String phoneNumber) {
return this.claim("phone_number", phoneNumber);
}
public Builder email(String email) {
return this.claim("email", email);
}
public Builder profile(String profile) {
return this.claim("profile", profile);
}
public Builder address(String address) {
return this.claim("address", address);
}
public CustomOidcUserInfo build() {
return new CustomOidcUserInfo(this.claims);
}
}
}

View File

@ -0,0 +1,53 @@
package com.youlai.auth.oauth2.oidc;
import com.youlai.system.api.UserFeignClient;
import com.youlai.system.dto.UserAuthInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
/**
* 自定义 OIDC 用户信息服务
*
* @author Ray Hao
* @since 3.1.0
*/
@Service
@Slf4j
public class CustomOidcUserInfoService {
private final UserFeignClient userFeignClient;
public CustomOidcUserInfoService(UserFeignClient userFeignClient) {
this.userFeignClient = userFeignClient;
}
public CustomOidcUserInfo loadUserByUsername(String username) {
UserAuthInfo userAuthInfo = null;
try {
userAuthInfo = userFeignClient.getUserAuthInfo(username);
if (userAuthInfo == null) {
return null;
}
return new CustomOidcUserInfo(createUser(userAuthInfo));
} catch (Exception e) {
log.error("获取用户信息失败", e);
return null;
}
}
private Map<String, Object> createUser(UserAuthInfo userAuthInfo) {
return CustomOidcUserInfo.customBuilder()
.username(userAuthInfo.getUsername())
.nickname(userAuthInfo.getNickname())
.status(userAuthInfo.getStatus())
.phoneNumber(userAuthInfo.getMobile())
.email(userAuthInfo.getEmail())
.profile(userAuthInfo.getAvatar())
.build()
.getClaims();
}
}

View File

@ -2,15 +2,18 @@ package com.youlai.auth.service;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.json.JSONUtil;
import com.youlai.auth.config.CaptchaProperties;
import com.youlai.auth.model.CaptchaResult;
import com.youlai.auth.util.SecurityUtils;
import com.youlai.common.constant.RedisConstants;
import com.youlai.common.sms.property.AliyunSmsProperties;
import com.youlai.common.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
@ -23,9 +26,14 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
public class AuthService {
private final CaptchaService captchaService;
private final RedisTemplate<String, String> redisTemplate;
private final CaptchaProperties captchaProperties;
private final CaptchaService captchaService;
private final AliyunSmsProperties aliyunSmsProperties;
private final SmsService smsService;
private final StringRedisTemplate redisTemplate;
/**
* 获取图形验证码
@ -50,35 +58,34 @@ public class AuthService {
.captchaBase64(captcha.getImageBase64Data())
.build();
return captchaResult;
}
/**
* 注销登出
* 发送登录短信验证码
*
* @return Result
* @param mobile 手机号
* @return true|false 是否发送成功
*/
public boolean logout() {
String jti = SecurityUtils.getJti();
Optional<Long> expireTimeOpt = Optional.ofNullable(SecurityUtils.getExp());
public boolean sendLoginSmsCode(String mobile) {
// 获取短信模板代码
String templateCode = aliyunSmsProperties.getTemplateCodes().get("login");
long currentTimeInSeconds = System.currentTimeMillis() / 1000; // 当前时间单位
// 生成随机4位数验证码
String code = RandomUtil.randomNumbers(4);
expireTimeOpt.ifPresent(expireTime -> {
if (expireTime > currentTimeInSeconds) {
// token未过期添加至缓存作为黑名单缓存时间为token剩余的有效时间
long remainingTimeInSeconds = expireTime - currentTimeInSeconds;
redisTemplate.opsForValue().set(RedisConstants.TOKEN_BLACKLIST_PREFIX + jti, "", remainingTimeInSeconds, TimeUnit.SECONDS);
}
});
// 短信模板: 您的验证码${code}该验证码5分钟内有效请勿泄漏于他人
// 其中 ${code} 是模板参数使用时需要替换为实际值
String templateParams = JSONUtil.toJsonStr(Collections.singletonMap("code", code));
if (expireTimeOpt.isEmpty()) {
// token 永不过期则永久加入黑名单
redisTemplate.opsForValue().set(RedisConstants.TOKEN_BLACKLIST_PREFIX + jti, "");
boolean result = smsService.sendSms(mobile, templateCode, templateParams);
if (result) {
// 将验证码存入redis有效期5分钟
redisTemplate.opsForValue().set(RedisConstants.REGISTER_SMS_CODE_PREFIX + mobile, code, 5, TimeUnit.MINUTES);
// TODO 考虑记录每次发送短信的详情如发送时间手机号和短信内容等以便后续审核或分析短信发送效果
}
return true;
return result;
}
}

View File

@ -1,36 +0,0 @@
package com.youlai.auth.util;
import cn.hutool.core.convert.Convert;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import java.util.Map;
/**
* Spring Security 工具类
*
* @since 3.1.0
* @author Ray Hao
*/
public class SecurityUtils {
public static Map<String, Object> getTokenAttributes() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
return jwtAuthenticationToken.getTokenAttributes();
}
public static String getJti() {
return String.valueOf(getTokenAttributes().get("jti"));
}
public static Long getExp() {
return Convert.toLong(getTokenAttributes().get("exp"));
}
}

View File

@ -1,6 +1,5 @@
package com.youlai.auth.oauth2;
import com.youlai.common.constant.OAuth2Constants;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -39,8 +38,6 @@ public class PasswordAuthenticationTests {
.param(OAuth2ParameterNames.GRANT_TYPE, "password") // 密码模式
.param(OAuth2ParameterNames.USERNAME, "admin") // 用户名
.param(OAuth2ParameterNames.PASSWORD, "123456") // 密码
.param( OAuth2Constants.CAPTCHA_ID, "******") // 密码
.param(OAuth2Constants.CAPTCHA_CODE, "******") // 密码
.headers(headers))
.andDo(print())
.andExpect(status().isOk())