mirror of
https://gitee.com/youlaitech/youlai-mall.git
synced 2024-12-22 12:48:59 +08:00
feat: OAuth2 支持 OIDC
This commit is contained in:
parent
930e536fe3
commit
4e56b18bc7
@ -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>
|
||||
|
@ -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/**")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
@ -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()));
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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() {
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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()
|
||||
|
@ -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("验证码错误");
|
||||
}
|
||||
|
||||
// 生成用户名密码身份验证令牌
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
@ -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())
|
||||
|
Loading…
Reference in New Issue
Block a user