实现oauth2
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
"allow": [
|
||||
"Bash(mvn clean compile:*)",
|
||||
"Bash(mvn spring-javaformat:apply)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"Bash(mvn dependency:tree:*)",
|
||||
"Bash(mvn spring-javaformat:apply:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
8
.idea/dataSources.xml
generated
8
.idea/dataSources.xml
generated
@@ -17,5 +17,13 @@
|
||||
<jdbc-url>jdbc:mysql://10.0.0.10/aioj_dev</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="jdbc:mysql://10.0.0.10/aioj_dev [DEBUG]" group="AIOJAuthApplication" uuid="2fd8684a-b9aa-4507-abb0-f7c259d91286">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<imported>true</imported>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://10.0.0.10/aioj_dev</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/db-forest-config.xml
generated
2
.idea/db-forest-config.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="1:0:AIOJAdminApplication 3:0:UserServiceApplication ---------------------------------------- 2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490 4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207 " />
|
||||
<option name="data" value="1:0:AIOJAdminApplication 3:0:UserServiceApplication 5:0:AIOJAuthApplication ---------------------------------------- 2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490 4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207 6:5:2fd8684a-b9aa-4507-abb0-f7c259d91286 " />
|
||||
</component>
|
||||
</project>
|
||||
@@ -30,12 +30,21 @@
|
||||
<artifactId>aioj-backend-common-feign</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.meowrain</groupId>
|
||||
<artifactId>aioj-backend-common-mybatis</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类 -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-crypto</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-json</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
|
||||
@@ -4,4 +4,26 @@ public class RedisKeyConstants {
|
||||
|
||||
public static String REFRESH_TOKEN_KEY_PREFIX = "refresh_token:%s";
|
||||
|
||||
// ============= OAuth2 相关 Key =============
|
||||
|
||||
/**
|
||||
* 授权码存储 Key 前缀 格式: oauth2:auth_code:{code}
|
||||
*/
|
||||
public static final String OAUTH2_AUTH_CODE_PREFIX = "oauth2:auth_code:%s";
|
||||
|
||||
/**
|
||||
* 用户会话存储 Key 前缀 格式: oauth2:session:{sessionId}
|
||||
*/
|
||||
public static final String OAUTH2_SESSION_PREFIX = "oauth2:session:%s";
|
||||
|
||||
/**
|
||||
* 用户会话索引 Key 前缀 格式: oauth2:user_sessions:{userId} Value: Set<sessionId>
|
||||
*/
|
||||
public static final String OAUTH2_USER_SESSIONS_PREFIX = "oauth2:user_sessions:%s";
|
||||
|
||||
/**
|
||||
* Token 黑名单 Key 前缀(用于单点登出) 格式: oauth2:token_blacklist:{SHA256(token)}
|
||||
*/
|
||||
public static final String OAUTH2_TOKEN_BLACKLIST_PREFIX = "oauth2:token_blacklist:%s";
|
||||
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public class SecurityConfiguration {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/**", "/doc.html", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**",
|
||||
"/v3/api-docs/**", "/favicon.ico")
|
||||
.requestMatchers("/v1/auth/**", "/oauth2/**", "/.well-known/**", "/doc.html", "/swagger-ui/**",
|
||||
"/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/favicon.ico")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.meowrain.aioj.backend.auth.controller;
|
||||
|
||||
import cn.meowrain.aioj.backend.auth.dto.req.UserLoginRequestDTO;
|
||||
import cn.meowrain.aioj.backend.auth.dto.resp.UserLoginResponseDTO;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2SessionService;
|
||||
import cn.meowrain.aioj.backend.auth.service.AuthService;
|
||||
|
||||
import cn.meowrain.aioj.backend.framework.core.web.Result;
|
||||
@@ -16,6 +17,8 @@ public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
private final OAuth2SessionService sessionService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public Result<UserLoginResponseDTO> login(@RequestBody UserLoginRequestDTO userLoginRequest) {
|
||||
UserLoginResponseDTO userLoginResponse = authService.userLogin(userLoginRequest);
|
||||
@@ -42,6 +45,11 @@ public class AuthController {
|
||||
token = authorization.substring(7);
|
||||
}
|
||||
|
||||
// 检查Token黑名单
|
||||
if (token != null && sessionService.isTokenBlacklisted(token)) {
|
||||
return Results.success(false);
|
||||
}
|
||||
|
||||
Boolean isValid = authService.validateToken(token);
|
||||
return Results.success(isValid);
|
||||
}
|
||||
|
||||
@@ -22,85 +22,84 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JWT认证过滤器
|
||||
* 拦截所有请求,验证JWT Token
|
||||
* JWT认证过滤器 拦截所有请求,验证JWT Token
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final AuthService authService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
private static final String TOKEN_PREFIX = "Bearer ";
|
||||
private static final String HEADER_NAME = "Authorization";
|
||||
private final AuthService authService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
private static final String TOKEN_PREFIX = "Bearer ";
|
||||
|
||||
try {
|
||||
String token = extractTokenFromRequest(request);
|
||||
private static final String HEADER_NAME = "Authorization";
|
||||
|
||||
if (StringUtils.hasText(token) && jwtUtil.isTokenValid(token)) {
|
||||
Claims claims = jwtUtil.parseClaims(token);
|
||||
Authentication authentication = createAuthentication(claims);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
log.debug("JWT Authentication successful for user: {}", claims.getSubject());
|
||||
} else {
|
||||
log.debug("No valid JWT token found in request");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("JWT Authentication failed", e);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
try {
|
||||
String token = extractTokenFromRequest(request);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
if (StringUtils.hasText(token) && jwtUtil.isTokenValid(token)) {
|
||||
Claims claims = jwtUtil.parseClaims(token);
|
||||
Authentication authentication = createAuthentication(claims);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
/**
|
||||
* 从请求中提取JWT Token
|
||||
*/
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(HEADER_NAME);
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
|
||||
return bearerToken.substring(TOKEN_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
log.debug("JWT Authentication successful for user: {}", claims.getSubject());
|
||||
}
|
||||
else {
|
||||
log.debug("No valid JWT token found in request");
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("JWT Authentication failed", e);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据JWT Claims创建Authentication对象
|
||||
*/
|
||||
private Authentication createAuthentication(Claims claims) {
|
||||
String userId = claims.getSubject();
|
||||
String userName = claims.get("userName", String.class);
|
||||
String role = claims.get("role", String.class);
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
// 创建权限列表
|
||||
List<SimpleGrantedAuthority> authorities = Collections.singletonList(
|
||||
new SimpleGrantedAuthority("ROLE_" + (role != null ? role : "USER"))
|
||||
);
|
||||
/**
|
||||
* 从请求中提取JWT Token
|
||||
*/
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(HEADER_NAME);
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
|
||||
return bearerToken.substring(TOKEN_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建认证对象
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userId, null, authorities);
|
||||
/**
|
||||
* 根据JWT Claims创建Authentication对象
|
||||
*/
|
||||
private Authentication createAuthentication(Claims claims) {
|
||||
String userId = claims.getSubject();
|
||||
String userName = claims.get("userName", String.class);
|
||||
String role = claims.get("role", String.class);
|
||||
|
||||
return authentication;
|
||||
}
|
||||
// 创建权限列表
|
||||
List<SimpleGrantedAuthority> authorities = Collections
|
||||
.singletonList(new SimpleGrantedAuthority("ROLE_" + (role != null ? role : "USER")));
|
||||
|
||||
// 创建认证对象
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null,
|
||||
authorities);
|
||||
|
||||
return authentication;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
|
||||
String path = request.getRequestURI();
|
||||
// 跳过不需要JWT验证的路径
|
||||
return path.startsWith("/v1/auth/") || path.startsWith("/doc.html") || path.startsWith("/swagger-ui/")
|
||||
|| path.startsWith("/swagger-resources/") || path.startsWith("/webjars/")
|
||||
|| path.startsWith("/v3/api-docs/") || path.equals("/favicon.ico");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
|
||||
String path = request.getRequestURI();
|
||||
// 跳过不需要JWT验证的路径
|
||||
return path.startsWith("/v1/auth/") ||
|
||||
path.startsWith("/doc.html") ||
|
||||
path.startsWith("/swagger-ui/") ||
|
||||
path.startsWith("/swagger-resources/") ||
|
||||
path.startsWith("/webjars/") ||
|
||||
path.startsWith("/v3/api-docs/") ||
|
||||
path.equals("/favicon.ico");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2AuthorizeRequest;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2AuthorizationService;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2ClientService;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.PKCEService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
/**
|
||||
* OAuth2 授权端点 处理授权请求并生成授权码
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oauth2/authorize")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "OAuth2 授权端点", description = "OAuth2 授权相关接口")
|
||||
public class OAuth2AuthorizationController {
|
||||
|
||||
private final OAuth2ClientService clientService;
|
||||
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
private final PKCEService pkceService;
|
||||
|
||||
/**
|
||||
* 授权端点(GET)
|
||||
* @param request 授权请求
|
||||
* @return 重定向到客户端
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "OAuth2 授权(GET)", description = "发起 OAuth2 授权请求")
|
||||
public RedirectView authorize(OAuth2AuthorizeRequest request) {
|
||||
return processAuthorization(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权端点(POST)
|
||||
* @param request 授权请求
|
||||
* @return 重定向到客户端
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "OAuth2 授权(POST)", description = "发起 OAuth2 授权请求")
|
||||
public RedirectView authorizePost(OAuth2AuthorizeRequest request) {
|
||||
return processAuthorization(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理授权请求
|
||||
* @param request 授权请求
|
||||
* @return 重定向视图
|
||||
*/
|
||||
private RedirectView processAuthorization(OAuth2AuthorizeRequest request) {
|
||||
log.info("收到授权请求: clientId={}, redirectUri={}, scope={}", request.getClientId(), request.getRedirectUri(),
|
||||
request.getScope());
|
||||
|
||||
try {
|
||||
// 1. 验证基本参数
|
||||
validateBasicParameters(request);
|
||||
|
||||
// 2. 验证客户端
|
||||
OAuth2Client client = clientService.getClientByClientId(request.getClientId());
|
||||
clientService.validateRedirectUri(client, request.getRedirectUri());
|
||||
|
||||
// 3. 验证 response_type
|
||||
if (!"code".equals(request.getResponseType())) {
|
||||
throw new OAuth2Exception("不支持的 response_type,仅支持 code");
|
||||
}
|
||||
|
||||
// 4. 验证 PKCE(如果客户端要求)
|
||||
pkceService.validatePKCERequest(request.getCodeChallenge(), request.getCodeChallengeMethod(),
|
||||
client.getRequirePkce());
|
||||
|
||||
// 5. 验证 scope
|
||||
String scope = clientService.validateScope(client, request.getScope());
|
||||
request.setScope(scope);
|
||||
|
||||
// 6. 检查用户是否已登录
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()
|
||||
|| "anonymousUser".equals(authentication.getPrincipal())) {
|
||||
// 用户未登录,重定向到登录页面
|
||||
// TODO: 实现登录页面,登录成功后重定向回授权端点
|
||||
String loginUrl = buildLoginRedirectUrl(request);
|
||||
log.info("用户未登录,重定向到登录页面: {}", loginUrl);
|
||||
return new RedirectView(loginUrl);
|
||||
}
|
||||
|
||||
// 7. 提取用户 ID
|
||||
// 从 SecurityContext 中提取用户ID(根据你的实际实现调整)
|
||||
Long userId = extractUserIdFromAuthentication(authentication);
|
||||
request.setUserId(userId);
|
||||
|
||||
// 8. 生成授权码
|
||||
String code = authorizationService.generateAuthorizationCode(request);
|
||||
|
||||
// 9. 构造重定向 URL
|
||||
String redirectUrl = buildRedirectUrl(request.getRedirectUri(), code, request.getState());
|
||||
|
||||
log.info("授权成功,重定向到客户端: {}", redirectUrl);
|
||||
|
||||
return new RedirectView(redirectUrl);
|
||||
}
|
||||
catch (OAuth2Exception e) {
|
||||
// OAuth2 异常:重定向到客户端并携带错误信息
|
||||
String errorUrl = buildErrorRedirectUrl(request.getRedirectUri(), e.getError(), e.getErrorDescription(),
|
||||
request.getState());
|
||||
log.error("授权失败: {}", e.getErrorDescription());
|
||||
return new RedirectView(errorUrl);
|
||||
}
|
||||
catch (Exception e) {
|
||||
// 其他异常:返回 500 错误
|
||||
log.error("授权过程发生异常", e);
|
||||
throw new OAuth2Exception("server_error", "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证基本参数
|
||||
* @param request 授权请求
|
||||
*/
|
||||
private void validateBasicParameters(OAuth2AuthorizeRequest request) {
|
||||
if (StrUtil.isBlank(request.getResponseType())) {
|
||||
throw new OAuth2Exception("response_type 不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(request.getClientId())) {
|
||||
throw new OAuth2Exception("client_id 不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(request.getRedirectUri())) {
|
||||
throw new OAuth2Exception("redirect_uri 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Authentication 中提取用户 ID
|
||||
* @param authentication 认证信息
|
||||
* @return 用户 ID
|
||||
*/
|
||||
private Long extractUserIdFromAuthentication(Authentication authentication) {
|
||||
// 根据你的实际实现调整
|
||||
// 示例:从 JWT 的 Claims 中提取
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof Long) {
|
||||
return (Long) principal;
|
||||
}
|
||||
|
||||
// 如果是字符串,尝试解析
|
||||
try {
|
||||
return Long.parseLong(principal.toString());
|
||||
}
|
||||
catch (NumberFormatException e) {
|
||||
log.error("无法从 Authentication 中提取用户 ID: {}", principal);
|
||||
throw new OAuth2Exception("无法获取用户信息");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造重定向 URL(成功)
|
||||
* @param redirectUri 重定向 URI
|
||||
* @param code 授权码
|
||||
* @param state 状态参数
|
||||
* @return 重定向 URL
|
||||
*/
|
||||
private String buildRedirectUrl(String redirectUri, String code, String state) {
|
||||
StringBuilder url = new StringBuilder(redirectUri);
|
||||
url.append(redirectUri.contains("?") ? "&" : "?");
|
||||
url.append("code=").append(code);
|
||||
|
||||
if (StrUtil.isNotBlank(state)) {
|
||||
url.append("&state=").append(state);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造错误重定向 URL
|
||||
* @param redirectUri 重定向 URI
|
||||
* @param error 错误代码
|
||||
* @param errorDescription 错误描述
|
||||
* @param state 状态参数
|
||||
* @return 错误重定向 URL
|
||||
*/
|
||||
private String buildErrorRedirectUrl(String redirectUri, String error, String errorDescription, String state) {
|
||||
if (StrUtil.isBlank(redirectUri)) {
|
||||
// 如果没有 redirect_uri,无法重定向,直接抛出异常
|
||||
throw new OAuth2Exception(error, errorDescription);
|
||||
}
|
||||
|
||||
StringBuilder url = new StringBuilder(redirectUri);
|
||||
url.append(redirectUri.contains("?") ? "&" : "?");
|
||||
url.append("error=").append(error);
|
||||
|
||||
if (StrUtil.isNotBlank(errorDescription)) {
|
||||
url.append("&error_description=").append(errorDescription);
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(state)) {
|
||||
url.append("&state=").append(state);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造登录重定向 URL
|
||||
* @param request 授权请求
|
||||
* @return 登录 URL
|
||||
*/
|
||||
private String buildLoginRedirectUrl(OAuth2AuthorizeRequest request) {
|
||||
// TODO: 根据实际情况调整登录页面 URL
|
||||
// 登录成功后应该重定向回 /oauth2/authorize 并携带所有参数
|
||||
StringBuilder loginUrl = new StringBuilder("/login");
|
||||
loginUrl.append("?redirect_uri=").append("/oauth2/authorize");
|
||||
loginUrl.append("&client_id=").append(request.getClientId());
|
||||
// ... 添加其他参数
|
||||
|
||||
return loginUrl.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2ClientService;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.OAuth2SessionService;
|
||||
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
/**
|
||||
* OAuth2 登出端点 处理单点登出请求
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oauth2/logout")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "OAuth2 登出端点", description = "OAuth2 登出相关接口")
|
||||
public class OAuth2LogoutController {
|
||||
|
||||
private final OAuth2SessionService sessionService;
|
||||
|
||||
private final OAuth2ClientService clientService;
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* 登出端点(GET)
|
||||
* @param idTokenHint ID Token Hint(可选)
|
||||
* @param postLogoutRedirectUri 登出后重定向URI(可选)
|
||||
* @param clientId 客户端ID(可选,用于验证redirect_uri)
|
||||
* @param state 状态参数(可选)
|
||||
* @return 重定向视图
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "OAuth2 登出(GET)", description = "单点登出,撤销所有会话")
|
||||
public RedirectView logout(@RequestParam(required = false) String idTokenHint,
|
||||
@RequestParam(name = "post_logout_redirect_uri", required = false) String postLogoutRedirectUri,
|
||||
@RequestParam(name = "client_id", required = false) String clientId,
|
||||
@RequestParam(required = false) String state) {
|
||||
return processLogout(idTokenHint, postLogoutRedirectUri, clientId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出端点(POST)
|
||||
* @param idTokenHint ID Token Hint(可选)
|
||||
* @param postLogoutRedirectUri 登出后重定向URI(可选)
|
||||
* @param clientId 客户端ID(可选)
|
||||
* @param state 状态参数(可选)
|
||||
* @return 重定向视图
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "OAuth2 登出(POST)", description = "单点登出,撤销所有会话")
|
||||
public RedirectView logoutPost(@RequestParam(required = false) String idTokenHint,
|
||||
@RequestParam(name = "post_logout_redirect_uri", required = false) String postLogoutRedirectUri,
|
||||
@RequestParam(name = "client_id", required = false) String clientId,
|
||||
@RequestParam(required = false) String state) {
|
||||
return processLogout(idTokenHint, postLogoutRedirectUri, clientId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登出请求
|
||||
* @param idTokenHint ID Token Hint
|
||||
* @param postLogoutRedirectUri 登出后重定向URI
|
||||
* @param clientId 客户端ID
|
||||
* @param state 状态参数
|
||||
* @return 重定向视图
|
||||
*/
|
||||
private RedirectView processLogout(String idTokenHint, String postLogoutRedirectUri, String clientId,
|
||||
String state) {
|
||||
log.info("收到登出请求: clientId={}, postLogoutRedirectUri={}", clientId, postLogoutRedirectUri);
|
||||
|
||||
try {
|
||||
// 1. 提取用户ID
|
||||
Long userId = extractUserIdFromToken(idTokenHint);
|
||||
|
||||
if (userId == null) {
|
||||
log.warn("无法从 id_token_hint 中提取用户ID");
|
||||
throw new OAuth2Exception("invalid_request", "无效的 id_token_hint");
|
||||
}
|
||||
|
||||
// 2. 撤销用户的所有会话
|
||||
sessionService.revokeAllUserSessions(userId);
|
||||
|
||||
log.info("用户登出成功: userId={}", userId);
|
||||
|
||||
// 3. 验证并重定向
|
||||
if (StrUtil.isNotBlank(postLogoutRedirectUri)) {
|
||||
// 验证 redirect_uri(如果提供了 client_id)
|
||||
if (StrUtil.isNotBlank(clientId)) {
|
||||
OAuth2Client client = clientService.getClientByClientId(clientId);
|
||||
clientService.validatePostLogoutRedirectUri(client, postLogoutRedirectUri);
|
||||
}
|
||||
|
||||
// 构造重定向URL
|
||||
String redirectUrl = buildLogoutRedirectUrl(postLogoutRedirectUri, state);
|
||||
log.info("重定向到: {}", redirectUrl);
|
||||
return new RedirectView(redirectUrl);
|
||||
}
|
||||
|
||||
// 4. 如果没有 redirect_uri,返回默认页面
|
||||
log.info("登出成功,无重定向URI");
|
||||
return new RedirectView("/logout-success"); // TODO: 自定义登出成功页面
|
||||
}
|
||||
catch (OAuth2Exception e) {
|
||||
log.error("登出失败: {}", e.getErrorDescription());
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("登出过程发生异常", e);
|
||||
throw new OAuth2Exception("server_error", "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取用户 ID
|
||||
* @param token Token(ID Token 或 Access Token)
|
||||
* @return 用户 ID
|
||||
*/
|
||||
private Long extractUserIdFromToken(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证 Token
|
||||
if (!jwtUtil.isTokenValid(token)) {
|
||||
log.warn("Token 无效或已过期");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析 Token
|
||||
Claims claims = jwtUtil.parseClaims(token);
|
||||
|
||||
// 尝试从 sub claim 提取用户ID
|
||||
String sub = claims.getSubject();
|
||||
if (StrUtil.isNotBlank(sub)) {
|
||||
return Long.parseLong(sub);
|
||||
}
|
||||
|
||||
// 尝试从 userId claim 提取
|
||||
Object userId = claims.get("userId");
|
||||
if (userId != null) {
|
||||
return Long.parseLong(userId.toString());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("解析 Token 失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造登出重定向 URL
|
||||
* @param postLogoutRedirectUri 登出后重定向URI
|
||||
* @param state 状态参数
|
||||
* @return 重定向 URL
|
||||
*/
|
||||
private String buildLogoutRedirectUrl(String postLogoutRedirectUri, String state) {
|
||||
if (StrUtil.isBlank(state)) {
|
||||
return postLogoutRedirectUri;
|
||||
}
|
||||
|
||||
StringBuilder url = new StringBuilder(postLogoutRedirectUri);
|
||||
url.append(postLogoutRedirectUri.contains("?") ? "&" : "?");
|
||||
url.append("state=").append(state);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenRequest;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenResponse;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.service.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OAuth2 Token 端点 处理 Token 请求(授权码换 Token、刷新 Token)
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oauth2/token")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "OAuth2 Token 端点", description = "OAuth2 Token 相关接口")
|
||||
public class OAuth2TokenController {
|
||||
|
||||
private final OAuth2ClientService clientService;
|
||||
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
private final OAuth2TokenService tokenService;
|
||||
|
||||
private final PKCEService pkceService;
|
||||
|
||||
/**
|
||||
* Token 端点
|
||||
* @param request Token 请求
|
||||
* @return Token 响应
|
||||
*/
|
||||
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@Operation(summary = "获取 Token", description = "使用授权码或刷新令牌获取访问令牌")
|
||||
public OAuth2TokenResponse token(OAuth2TokenRequest request) {
|
||||
log.info("收到 Token 请求: grantType={}, clientId={}", request.getGrantType(), request.getClientId());
|
||||
|
||||
// 1. 验证 grant_type
|
||||
if (StrUtil.isBlank(request.getGrantType())) {
|
||||
throw new OAuth2Exception("grant_type 不能为空");
|
||||
}
|
||||
|
||||
// 2. 验证客户端
|
||||
OAuth2Client client = clientService.getClientByClientId(request.getClientId());
|
||||
clientService.validateClientSecret(client, request.getClientSecret());
|
||||
clientService.validateGrantType(client, request.getGrantType());
|
||||
|
||||
// 3. 根据授权类型处理
|
||||
return switch (request.getGrantType()) {
|
||||
case "authorization_code" -> handleAuthorizationCode(client, request);
|
||||
case "refresh_token" -> handleRefreshToken(client, request);
|
||||
default -> throw new OAuth2Exception("不支持的 grant_type: " + request.getGrantType());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理授权码流程
|
||||
* @param client 客户端
|
||||
* @param request Token 请求
|
||||
* @return Token 响应
|
||||
*/
|
||||
private OAuth2TokenResponse handleAuthorizationCode(OAuth2Client client, OAuth2TokenRequest request) {
|
||||
// 1. 验证必需参数
|
||||
if (StrUtil.isBlank(request.getCode())) {
|
||||
throw new OAuth2Exception("code 不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(request.getRedirectUri())) {
|
||||
throw new OAuth2Exception("redirect_uri 不能为空");
|
||||
}
|
||||
|
||||
// 2. 验证并消费授权码
|
||||
Map<String, Object> codeData = authorizationService.validateAndConsumeCode(request.getCode());
|
||||
|
||||
// 3. 验证授权码绑定
|
||||
authorizationService.validateCodeBinding(codeData, request.getClientId(), request.getRedirectUri());
|
||||
|
||||
// 4. 验证 PKCE
|
||||
String codeChallenge = tokenService.extractFromCodeData(codeData, "codeChallenge");
|
||||
String codeChallengeMethod = tokenService.extractFromCodeData(codeData, "codeChallengeMethod");
|
||||
|
||||
if (StrUtil.isNotBlank(codeChallenge)) {
|
||||
if (StrUtil.isBlank(request.getCodeVerifier())) {
|
||||
throw new OAuth2Exception("使用了 PKCE,必须提供 code_verifier");
|
||||
}
|
||||
pkceService.validatePKCE(request.getCodeVerifier(), codeChallenge, codeChallengeMethod);
|
||||
}
|
||||
else if (client.getRequirePkce()) {
|
||||
throw new OAuth2Exception("该客户端必须使用 PKCE");
|
||||
}
|
||||
|
||||
// 5. 提取授权码数据
|
||||
Long userId = tokenService.extractUserIdFromCodeData(codeData);
|
||||
String scope = tokenService.extractFromCodeData(codeData, "scope");
|
||||
String nonce = tokenService.extractFromCodeData(codeData, "nonce");
|
||||
|
||||
// 6. 生成 Token
|
||||
return tokenService.generateTokenResponse(client, userId, scope, nonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理刷新 Token 流程
|
||||
* @param client 客户端
|
||||
* @param request Token 请求
|
||||
* @return Token 响应
|
||||
*/
|
||||
private OAuth2TokenResponse handleRefreshToken(OAuth2Client client, OAuth2TokenRequest request) {
|
||||
// 1. 验证必需参数
|
||||
if (StrUtil.isBlank(request.getRefreshToken())) {
|
||||
throw new OAuth2Exception("refresh_token 不能为空");
|
||||
}
|
||||
|
||||
// 2. 验证 scope(不能超出原范围)
|
||||
String scope = request.getScope();
|
||||
if (StrUtil.isNotBlank(scope)) {
|
||||
scope = clientService.validateScope(client, scope);
|
||||
}
|
||||
|
||||
// 3. 刷新 Token
|
||||
return tokenService.refreshToken(client, request.getRefreshToken(), scope);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.meowrain.aioj.backend.auth.clients.UserClient;
|
||||
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.UserInfoResponse;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
|
||||
|
||||
import cn.meowrain.aioj.backend.framework.core.web.Result;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* OAuth2 UserInfo 端点(OIDC) 返回当前用户的信息
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oauth2/userinfo")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "OAuth2 UserInfo 端点", description = "OIDC UserInfo 相关接口")
|
||||
public class OAuth2UserInfoController {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
private final UserClient userClient;
|
||||
|
||||
/**
|
||||
* UserInfo 端点
|
||||
* @param authorization Authorization Header(Bearer Token)
|
||||
* @return 用户信息
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取用户信息", description = "根据 Access Token 返回用户信息(OIDC)")
|
||||
public UserInfoResponse userInfo(@RequestHeader("Authorization") String authorization) {
|
||||
log.info("收到 UserInfo 请求");
|
||||
|
||||
// 1. 提取 Access Token
|
||||
String accessToken = extractBearerToken(authorization);
|
||||
|
||||
// 2. 验证 Token
|
||||
if (!jwtUtil.isTokenValid(accessToken)) {
|
||||
log.warn("Access Token 无效或已过期");
|
||||
throw new OAuth2Exception("invalid_token", "Access Token 无效或已过期", 401);
|
||||
}
|
||||
|
||||
// 3. 解析 Token 获取用户 ID
|
||||
Claims claims = jwtUtil.parseClaims(accessToken);
|
||||
String userIdStr = claims.get("userId", String.class);
|
||||
if (userIdStr == null) {
|
||||
userIdStr = claims.getSubject();
|
||||
}
|
||||
|
||||
Long userId = Long.parseLong(userIdStr);
|
||||
|
||||
// 4. 调用 user-service 获取用户信息
|
||||
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
|
||||
if (userResult == null || userResult.getData() == null) {
|
||||
log.error("获取用户信息失败: userId={}", userId);
|
||||
throw new OAuth2Exception("server_error", "获取用户信息失败", 500);
|
||||
}
|
||||
|
||||
UserAuthRespDTO user = userResult.getData();
|
||||
|
||||
// 5. 构造 UserInfo 响应
|
||||
// 注意:根据 scope 返回不同的字段,这里简化处理返回所有字段
|
||||
UserInfoResponse response = UserInfoResponse.builder()
|
||||
.sub(String.valueOf(user.getId())) // Subject(用户唯一标识)
|
||||
.name(user.getUserName()) // 用户全名
|
||||
.preferredUsername(user.getUserAccount()) // 用户名
|
||||
.email(null) // TODO: 从用户信息中获取邮箱
|
||||
.emailVerified(false) // TODO: 从用户信息中获取邮箱验证状态
|
||||
.picture(user.getUserAvatar()) // 用户头像
|
||||
.role(user.getUserRole()) // 用户角色
|
||||
.build();
|
||||
|
||||
log.info("返回 UserInfo: userId={}, username={}", userId, user.getUserAccount());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Authorization Header 中提取 Bearer Token
|
||||
* @param authorization Authorization Header
|
||||
* @return Access Token
|
||||
*/
|
||||
private String extractBearerToken(String authorization) {
|
||||
if (StrUtil.isBlank(authorization)) {
|
||||
throw new OAuth2Exception("invalid_request", "缺少 Authorization Header", 401);
|
||||
}
|
||||
|
||||
if (!authorization.startsWith("Bearer ")) {
|
||||
throw new OAuth2Exception("invalid_request", "Authorization Header 格式错误,应为 'Bearer {token}'", 401);
|
||||
}
|
||||
|
||||
return authorization.substring(7);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OAuth2 Well-Known 配置端点(OIDC Discovery) 返回 OAuth2/OIDC 服务器的元数据配置
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/.well-known")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "OAuth2 Well-Known 端点", description = "OIDC Discovery 相关接口")
|
||||
public class OAuth2WellKnownController {
|
||||
|
||||
// TODO: 从配置文件读取这些值
|
||||
private static final String ISSUER = "http://localhost:10011/api";
|
||||
|
||||
/**
|
||||
* OIDC Discovery 端点
|
||||
* @return OIDC 配置元数据
|
||||
*/
|
||||
@GetMapping("/openid-configuration")
|
||||
@Operation(summary = "OIDC Discovery", description = "返回 OpenID Connect 配置元数据")
|
||||
public Map<String, Object> openidConfiguration() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
|
||||
// 1. 基本信息
|
||||
config.put("issuer", ISSUER);
|
||||
|
||||
// 2. 端点 URL
|
||||
config.put("authorization_endpoint", ISSUER + "/oauth2/authorize");
|
||||
config.put("token_endpoint", ISSUER + "/oauth2/token");
|
||||
config.put("userinfo_endpoint", ISSUER + "/oauth2/userinfo");
|
||||
config.put("end_session_endpoint", ISSUER + "/oauth2/logout");
|
||||
config.put("jwks_uri", ISSUER + "/oauth2/jwks"); // TODO: 实现 JWKS 端点
|
||||
|
||||
// 3. 支持的响应类型
|
||||
config.put("response_types_supported", List.of("code"));
|
||||
|
||||
// 4. 支持的授权类型
|
||||
config.put("grant_types_supported", List.of("authorization_code", "refresh_token"));
|
||||
|
||||
// 5. 支持的 Subject 类型
|
||||
config.put("subject_types_supported", List.of("public"));
|
||||
|
||||
// 6. 支持的 ID Token 签名算法
|
||||
config.put("id_token_signing_alg_values_supported", List.of("HS256"));
|
||||
|
||||
// 7. 支持的作用域
|
||||
config.put("scopes_supported", List.of("openid", "profile", "email"));
|
||||
|
||||
// 8. 支持的 Token 端点认证方法
|
||||
config.put("token_endpoint_auth_methods_supported", List.of("client_secret_post", "client_secret_basic"));
|
||||
|
||||
// 9. 支持的 PKCE 方法
|
||||
config.put("code_challenge_methods_supported", List.of("S256", "plain"));
|
||||
|
||||
// 10. 支持的 Claims
|
||||
config.put("claims_supported",
|
||||
List.of("sub", "name", "preferred_username", "email", "email_verified", "picture", "role"));
|
||||
|
||||
// 11. 其他配置
|
||||
config.put("response_modes_supported", List.of("query", "fragment"));
|
||||
config.put("request_parameter_supported", false);
|
||||
config.put("request_uri_parameter_supported", false);
|
||||
config.put("require_request_uri_registration", false);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* OAuth2 授权请求 DTO 对应 /oauth2/authorize 端点的请求参数
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Data
|
||||
public class OAuth2AuthorizeRequest {
|
||||
|
||||
/**
|
||||
* 响应类型(固定为 "code")
|
||||
*/
|
||||
private String responseType;
|
||||
|
||||
/**
|
||||
* 客户端 ID
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 重定向 URI
|
||||
*/
|
||||
private String redirectUri;
|
||||
|
||||
/**
|
||||
* 授权范围(空格分隔,如 "openid profile email")
|
||||
*/
|
||||
private String scope;
|
||||
|
||||
/**
|
||||
* 状态参数(用于 CSRF 防护)
|
||||
*/
|
||||
private String state;
|
||||
|
||||
/**
|
||||
* PKCE Code Challenge
|
||||
*/
|
||||
private String codeChallenge;
|
||||
|
||||
/**
|
||||
* PKCE Code Challenge Method(默认 S256)
|
||||
*/
|
||||
private String codeChallengeMethod;
|
||||
|
||||
/**
|
||||
* Nonce 参数(用于 ID Token 防重放)
|
||||
*/
|
||||
private String nonce;
|
||||
|
||||
/**
|
||||
* 用户 ID(授权后由服务器设置)
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* OAuth2 Token 请求 DTO 对应 /oauth2/token 端点的请求参数
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Data
|
||||
public class OAuth2TokenRequest {
|
||||
|
||||
/**
|
||||
* 授权类型(authorization_code 或 refresh_token)
|
||||
*/
|
||||
private String grantType;
|
||||
|
||||
/**
|
||||
* 授权码(grant_type=authorization_code 时必需)
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 重定向 URI(必须与授权请求时一致)
|
||||
*/
|
||||
private String redirectUri;
|
||||
|
||||
/**
|
||||
* 客户端 ID
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 客户端密钥(机密客户端必需)
|
||||
*/
|
||||
private String clientSecret;
|
||||
|
||||
/**
|
||||
* PKCE Code Verifier
|
||||
*/
|
||||
private String codeVerifier;
|
||||
|
||||
/**
|
||||
* Refresh Token(grant_type=refresh_token 时必需)
|
||||
*/
|
||||
private String refreshToken;
|
||||
|
||||
/**
|
||||
* 授权范围(refresh_token 时可选,不能超出原范围)
|
||||
*/
|
||||
private String scope;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* OAuth2 Token 响应 DTO 对应 /oauth2/token 端点的响应
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class OAuth2TokenResponse {
|
||||
|
||||
/**
|
||||
* 访问令牌
|
||||
*/
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
/**
|
||||
* Token 类型(固定为 "Bearer")
|
||||
*/
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
|
||||
/**
|
||||
* 过期时间(秒)
|
||||
*/
|
||||
@JsonProperty("expires_in")
|
||||
private Integer expiresIn;
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*/
|
||||
@JsonProperty("refresh_token")
|
||||
private String refreshToken;
|
||||
|
||||
/**
|
||||
* 授权范围(空格分隔)
|
||||
*/
|
||||
private String scope;
|
||||
|
||||
/**
|
||||
* ID Token(OIDC)
|
||||
*/
|
||||
@JsonProperty("id_token")
|
||||
private String idToken;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* OIDC UserInfo 响应 DTO 对应 /oauth2/userinfo 端点的响应
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class UserInfoResponse {
|
||||
|
||||
/**
|
||||
* Subject - 用户唯一标识符
|
||||
*/
|
||||
private String sub;
|
||||
|
||||
/**
|
||||
* 用户全名
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 首选用户名
|
||||
*/
|
||||
@JsonProperty("preferred_username")
|
||||
private String preferredUsername;
|
||||
|
||||
/**
|
||||
* 电子邮件
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 电子邮件是否已验证
|
||||
*/
|
||||
@JsonProperty("email_verified")
|
||||
private Boolean emailVerified;
|
||||
|
||||
/**
|
||||
* 头像图片 URL
|
||||
*/
|
||||
private String picture;
|
||||
|
||||
/**
|
||||
* 用户角色
|
||||
*/
|
||||
private String role;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* OAuth2 客户端实体
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Data
|
||||
@TableName("oauth2_client")
|
||||
public class OAuth2Client {
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 客户端 ID
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 客户端密钥(BCrypt 加密,公共客户端为 NULL)
|
||||
*/
|
||||
private String clientSecret;
|
||||
|
||||
/**
|
||||
* 客户端名称
|
||||
*/
|
||||
private String clientName;
|
||||
|
||||
/**
|
||||
* 客户端类型:confidential(机密)/ public(公共)
|
||||
*/
|
||||
private String clientType;
|
||||
|
||||
/**
|
||||
* 重定向 URI 列表(JSON 数组格式)
|
||||
*/
|
||||
private String redirectUris;
|
||||
|
||||
/**
|
||||
* 登出后重定向 URI 列表(JSON 数组格式)
|
||||
*/
|
||||
private String postLogoutRedirectUris;
|
||||
|
||||
/**
|
||||
* 允许的作用域(逗号分隔)
|
||||
*/
|
||||
private String allowedScopes;
|
||||
|
||||
/**
|
||||
* 允许的授权类型(逗号分隔)
|
||||
*/
|
||||
private String allowedGrantTypes;
|
||||
|
||||
/**
|
||||
* Access Token 有效期(秒)
|
||||
*/
|
||||
private Integer accessTokenTtl;
|
||||
|
||||
/**
|
||||
* Refresh Token 有效期(秒)
|
||||
*/
|
||||
private Integer refreshTokenTtl;
|
||||
|
||||
/**
|
||||
* 是否要求 PKCE
|
||||
*/
|
||||
private Boolean requirePkce;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
private Boolean isEnabled;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.exception;
|
||||
|
||||
/**
|
||||
* 无效客户端异常 当客户端 ID 不存在、客户端密钥错误或客户端被禁用时抛出
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
public class InvalidClientException extends OAuth2Exception {
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param errorDescription 错误描述
|
||||
*/
|
||||
public InvalidClientException(String errorDescription) {
|
||||
super("invalid_client", errorDescription, 401); // 401 Unauthorized
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认构造函数
|
||||
*/
|
||||
public InvalidClientException() {
|
||||
this("客户端认证失败");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.exception;
|
||||
|
||||
/**
|
||||
* 无效授权异常 当授权码无效、过期或 PKCE 验证失败时抛出
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
public class InvalidGrantException extends OAuth2Exception {
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param errorDescription 错误描述
|
||||
*/
|
||||
public InvalidGrantException(String errorDescription) {
|
||||
super("invalid_grant", errorDescription, 400); // 400 Bad Request
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认构造函数
|
||||
*/
|
||||
public InvalidGrantException() {
|
||||
this("授权码无效或已过期");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.exception;
|
||||
|
||||
/**
|
||||
* OAuth2 异常基类 用于统一处理 OAuth2 相关的异常
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
public class OAuth2Exception extends RuntimeException {
|
||||
|
||||
private final String error;
|
||||
|
||||
private final String errorDescription;
|
||||
|
||||
private final int httpStatus;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param error 错误代码(符合 OAuth2 规范)
|
||||
* @param errorDescription 错误描述
|
||||
*/
|
||||
public OAuth2Exception(String error, String errorDescription) {
|
||||
super(errorDescription);
|
||||
this.error = error;
|
||||
this.errorDescription = errorDescription;
|
||||
this.httpStatus = 400; // 默认 400 Bad Request
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造函数(带 HTTP 状态码)
|
||||
* @param error 错误代码
|
||||
* @param errorDescription 错误描述
|
||||
* @param httpStatus HTTP 状态码
|
||||
*/
|
||||
public OAuth2Exception(String error, String errorDescription, int httpStatus) {
|
||||
super(errorDescription);
|
||||
this.error = error;
|
||||
this.errorDescription = errorDescription;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化构造函数(仅错误描述)
|
||||
* @param errorDescription 错误描述
|
||||
*/
|
||||
public OAuth2Exception(String errorDescription) {
|
||||
this("invalid_request", errorDescription);
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public String getErrorDescription() {
|
||||
return errorDescription;
|
||||
}
|
||||
|
||||
public int getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.mapper;
|
||||
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* OAuth2 客户端 Mapper
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Mapper
|
||||
public interface OAuth2ClientMapper extends BaseMapper<OAuth2Client> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.service;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2AuthorizeRequest;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidGrantException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* OAuth2 授权码管理服务 负责授权码的生成、存储、验证和消费
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2AuthorizationService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
/**
|
||||
* 授权码有效期(秒)
|
||||
*/
|
||||
private static final int AUTH_CODE_TTL = 600; // 10 分钟
|
||||
|
||||
/**
|
||||
* 授权码长度
|
||||
*/
|
||||
private static final int AUTH_CODE_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* 生成授权码
|
||||
* @param request 授权请求
|
||||
* @return 授权码
|
||||
*/
|
||||
public String generateAuthorizationCode(OAuth2AuthorizeRequest request) {
|
||||
// 1. 生成 32 字符随机授权码
|
||||
String code = RandomUtil.randomString(AUTH_CODE_LENGTH);
|
||||
|
||||
// 2. 构造授权码数据
|
||||
Map<String, Object> codeData = new HashMap<>();
|
||||
codeData.put("code", code);
|
||||
codeData.put("clientId", request.getClientId());
|
||||
codeData.put("userId", request.getUserId());
|
||||
codeData.put("redirectUri", request.getRedirectUri());
|
||||
codeData.put("scope", request.getScope());
|
||||
codeData.put("codeChallenge", request.getCodeChallenge());
|
||||
codeData.put("codeChallengeMethod", request.getCodeChallengeMethod());
|
||||
codeData.put("nonce", request.getNonce());
|
||||
codeData.put("expiresAt", System.currentTimeMillis() + AUTH_CODE_TTL * 1000);
|
||||
|
||||
// 3. 存储到 Redis
|
||||
String key = String.format(RedisKeyConstants.OAUTH2_AUTH_CODE_PREFIX, code);
|
||||
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(codeData), AUTH_CODE_TTL, TimeUnit.SECONDS);
|
||||
|
||||
log.info("生成授权码: code={}, clientId={}, userId={}", code, request.getClientId(), request.getUserId());
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并消费授权码(一次性使用)
|
||||
* @param code 授权码
|
||||
* @return 授权码数据
|
||||
* @throws InvalidGrantException 授权码无效或已过期
|
||||
*/
|
||||
public Map<String, Object> validateAndConsumeCode(String code) {
|
||||
String key = String.format(RedisKeyConstants.OAUTH2_AUTH_CODE_PREFIX, code);
|
||||
|
||||
// 1. 从 Redis 获取授权码数据
|
||||
String data = redisTemplate.opsForValue().get(key);
|
||||
|
||||
if (data == null) {
|
||||
log.warn("授权码无效或已过期: {}", code);
|
||||
throw new InvalidGrantException("授权码无效或已过期");
|
||||
}
|
||||
|
||||
// 2. 立即删除授权码(一次性使用)
|
||||
redisTemplate.delete(key);
|
||||
|
||||
// 3. 解析数据
|
||||
Map<String, Object> codeData = JSONUtil.toBean(data, Map.class);
|
||||
|
||||
// 4. 检查是否过期
|
||||
long expiresAt = ((Number) codeData.get("expiresAt")).longValue();
|
||||
if (System.currentTimeMillis() > expiresAt) {
|
||||
log.warn("授权码已过期: {}", code);
|
||||
throw new InvalidGrantException("授权码已过期");
|
||||
}
|
||||
|
||||
log.info("授权码验证成功并已消费: code={}, clientId={}, userId={}", code, codeData.get("clientId"),
|
||||
codeData.get("userId"));
|
||||
|
||||
return codeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证授权码绑定的参数
|
||||
* @param codeData 授权码数据
|
||||
* @param clientId 客户端ID
|
||||
* @param redirectUri 重定向URI
|
||||
* @throws InvalidGrantException 验证失败
|
||||
*/
|
||||
public void validateCodeBinding(Map<String, Object> codeData, String clientId, String redirectUri) {
|
||||
// 验证 client_id
|
||||
if (!clientId.equals(codeData.get("clientId"))) {
|
||||
log.warn("授权码绑定的 client_id 不匹配: expected={}, actual={}", codeData.get("clientId"), clientId);
|
||||
throw new InvalidGrantException("授权码与客户端不匹配");
|
||||
}
|
||||
|
||||
// 验证 redirect_uri
|
||||
if (!redirectUri.equals(codeData.get("redirectUri"))) {
|
||||
log.warn("授权码绑定的 redirect_uri 不匹配: expected={}, actual={}", codeData.get("redirectUri"), redirectUri);
|
||||
throw new InvalidGrantException("授权码与重定向URI不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidClientException;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.mapper.OAuth2ClientMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OAuth2 客户端服务 负责客户端验证和管理
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2ClientService {
|
||||
|
||||
private final OAuth2ClientMapper clientMapper;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* 根据 client_id 获取客户端
|
||||
* @param clientId 客户端 ID
|
||||
* @return 客户端信息
|
||||
* @throws InvalidClientException 客户端不存在或被禁用
|
||||
*/
|
||||
public OAuth2Client getClientByClientId(String clientId) {
|
||||
if (StrUtil.isBlank(clientId)) {
|
||||
throw new InvalidClientException("client_id 不能为空");
|
||||
}
|
||||
|
||||
OAuth2Client client = clientMapper
|
||||
.selectOne(new LambdaQueryWrapper<OAuth2Client>().eq(OAuth2Client::getClientId, clientId));
|
||||
|
||||
if (client == null) {
|
||||
log.warn("客户端不存在: {}", clientId);
|
||||
throw new InvalidClientException("客户端不存在");
|
||||
}
|
||||
|
||||
if (!client.getIsEnabled()) {
|
||||
log.warn("客户端已被禁用: {}", clientId);
|
||||
throw new InvalidClientException("客户端已被禁用");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客户端密钥(机密客户端)
|
||||
* @param client 客户端信息
|
||||
* @param clientSecret 客户端密钥
|
||||
* @throws InvalidClientException 验证失败
|
||||
*/
|
||||
public void validateClientSecret(OAuth2Client client, String clientSecret) {
|
||||
// 公共客户端不需要验证密钥
|
||||
if ("public".equals(client.getClientType())) {
|
||||
if (StrUtil.isNotBlank(clientSecret)) {
|
||||
log.warn("公共客户端不应该提供 client_secret: {}", client.getClientId());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 机密客户端必须提供密钥
|
||||
if (StrUtil.isBlank(clientSecret)) {
|
||||
throw new InvalidClientException("机密客户端必须提供 client_secret");
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if (!passwordEncoder.matches(clientSecret, client.getClientSecret())) {
|
||||
log.warn("客户端密钥错误: {}", client.getClientId());
|
||||
throw new InvalidClientException("客户端认证失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重定向 URI
|
||||
* @param client 客户端信息
|
||||
* @param redirectUri 重定向 URI
|
||||
* @throws OAuth2Exception 验证失败
|
||||
*/
|
||||
public void validateRedirectUri(OAuth2Client client, String redirectUri) {
|
||||
if (StrUtil.isBlank(redirectUri)) {
|
||||
throw new OAuth2Exception("redirect_uri 不能为空");
|
||||
}
|
||||
|
||||
// 解析客户端配置的重定向 URI 列表
|
||||
List<String> allowedUris = parseJsonArray(client.getRedirectUris());
|
||||
|
||||
if (!allowedUris.contains(redirectUri)) {
|
||||
log.warn("无效的 redirect_uri: {} for client: {}", redirectUri, client.getClientId());
|
||||
throw new OAuth2Exception("无效的 redirect_uri");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证登出重定向 URI
|
||||
* @param client 客户端信息
|
||||
* @param postLogoutRedirectUri 登出重定向 URI
|
||||
* @throws OAuth2Exception 验证失败
|
||||
*/
|
||||
public void validatePostLogoutRedirectUri(OAuth2Client client, String postLogoutRedirectUri) {
|
||||
if (StrUtil.isBlank(postLogoutRedirectUri)) {
|
||||
// 登出重定向 URI 可选
|
||||
return;
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(client.getPostLogoutRedirectUris())) {
|
||||
throw new OAuth2Exception("客户端未配置 post_logout_redirect_uri");
|
||||
}
|
||||
|
||||
List<String> allowedUris = parseJsonArray(client.getPostLogoutRedirectUris());
|
||||
|
||||
if (!allowedUris.contains(postLogoutRedirectUri)) {
|
||||
log.warn("无效的 post_logout_redirect_uri: {} for client: {}", postLogoutRedirectUri, client.getClientId());
|
||||
throw new OAuth2Exception("无效的 post_logout_redirect_uri");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证授权类型
|
||||
* @param client 客户端信息
|
||||
* @param grantType 授权类型
|
||||
* @throws OAuth2Exception 验证失败
|
||||
*/
|
||||
public void validateGrantType(OAuth2Client client, String grantType) {
|
||||
if (StrUtil.isBlank(grantType)) {
|
||||
throw new OAuth2Exception("grant_type 不能为空");
|
||||
}
|
||||
|
||||
List<String> allowedGrantTypes = Arrays.asList(client.getAllowedGrantTypes().split(","));
|
||||
|
||||
if (!allowedGrantTypes.contains(grantType)) {
|
||||
log.warn("客户端 {} 不支持授权类型: {}", client.getClientId(), grantType);
|
||||
throw new OAuth2Exception("不支持的 grant_type");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证作用域
|
||||
* @param client 客户端信息
|
||||
* @param requestedScope 请求的作用域(空格分隔)
|
||||
* @return 验证后的作用域(如果为空则返回默认作用域)
|
||||
* @throws OAuth2Exception 验证失败
|
||||
*/
|
||||
public String validateScope(OAuth2Client client, String requestedScope) {
|
||||
// 如果未指定作用域,返回默认作用域
|
||||
if (StrUtil.isBlank(requestedScope)) {
|
||||
return client.getAllowedScopes().replace(",", " ");
|
||||
}
|
||||
|
||||
// 解析允许的作用域
|
||||
List<String> allowedScopes = Arrays.asList(client.getAllowedScopes().split(","));
|
||||
|
||||
// 解析请求的作用域
|
||||
String[] requestedScopes = requestedScope.split(" ");
|
||||
|
||||
// 验证每个请求的作用域
|
||||
for (String scope : requestedScopes) {
|
||||
if (!allowedScopes.contains(scope.trim())) {
|
||||
log.warn("客户端 {} 不支持作用域: {}", client.getClientId(), scope);
|
||||
throw new OAuth2Exception("不支持的 scope: " + scope);
|
||||
}
|
||||
}
|
||||
|
||||
return requestedScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 数组字符串
|
||||
* @param jsonArray JSON 数组字符串
|
||||
* @return 列表
|
||||
*/
|
||||
private List<String> parseJsonArray(String jsonArray) {
|
||||
try {
|
||||
return JSONUtil.toList(jsonArray, String.class);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("解析 JSON 数组失败: {}", jsonArray, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.service;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
|
||||
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* OAuth2 会话管理服务 负责会话管理和 Token 黑名单(用于单点登出)
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2SessionService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/**
|
||||
* 会话有效期(秒,7天)
|
||||
*/
|
||||
private static final long SESSION_TTL = 604800L;
|
||||
|
||||
/**
|
||||
* 创建会话
|
||||
* @param userId 用户ID
|
||||
* @param clientId 客户端ID
|
||||
* @param accessToken 访问令牌
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 会话ID
|
||||
*/
|
||||
public String createSession(Long userId, String clientId, String accessToken, String refreshToken) {
|
||||
// 1. 生成会话ID
|
||||
String sessionId = RandomUtil.randomString(32);
|
||||
|
||||
// 2. 构造会话数据
|
||||
Map<String, Object> sessionData = new HashMap<>();
|
||||
sessionData.put("sessionId", sessionId);
|
||||
sessionData.put("userId", userId);
|
||||
sessionData.put("createdAt", System.currentTimeMillis());
|
||||
|
||||
// 3. 构造客户端Token信息
|
||||
Map<String, String> clientTokens = new HashMap<>();
|
||||
clientTokens.put("clientId", clientId);
|
||||
clientTokens.put("accessToken", accessToken);
|
||||
clientTokens.put("refreshToken", refreshToken);
|
||||
clientTokens.put("issuedAt", String.valueOf(System.currentTimeMillis()));
|
||||
|
||||
// 将客户端Token列表添加到会话
|
||||
List<Map<String, String>> clients = new ArrayList<>();
|
||||
clients.add(clientTokens);
|
||||
sessionData.put("clients", clients);
|
||||
|
||||
// 4. 存储会话到 Redis
|
||||
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
|
||||
redisTemplate.opsForValue().set(sessionKey, JSONUtil.toJsonStr(sessionData), SESSION_TTL, TimeUnit.SECONDS);
|
||||
|
||||
// 5. 维护用户->会话映射(用于查询用户的所有会话)
|
||||
String userSessionsKey = String.format(RedisKeyConstants.OAUTH2_USER_SESSIONS_PREFIX, userId);
|
||||
redisTemplate.opsForSet().add(userSessionsKey, sessionId);
|
||||
redisTemplate.expire(userSessionsKey, SESSION_TTL, TimeUnit.SECONDS);
|
||||
|
||||
log.info("创建会话: sessionId={}, userId={}, clientId={}", sessionId, userId, clientId);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加客户端到现有会话
|
||||
* @param sessionId 会话ID
|
||||
* @param clientId 客户端ID
|
||||
* @param accessToken 访问令牌
|
||||
* @param refreshToken 刷新令牌
|
||||
*/
|
||||
public void addClientToSession(String sessionId, String clientId, String accessToken, String refreshToken) {
|
||||
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
|
||||
String sessionDataStr = redisTemplate.opsForValue().get(sessionKey);
|
||||
|
||||
if (sessionDataStr == null) {
|
||||
log.warn("会话不存在: {}", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析会话数据
|
||||
Map<String, Object> sessionData = JSONUtil.toBean(sessionDataStr, Map.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, String>> clients = (List<Map<String, String>>) sessionData.get("clients");
|
||||
|
||||
if (clients == null) {
|
||||
clients = new ArrayList<>();
|
||||
}
|
||||
|
||||
// 添加新的客户端Token
|
||||
Map<String, String> clientTokens = new HashMap<>();
|
||||
clientTokens.put("clientId", clientId);
|
||||
clientTokens.put("accessToken", accessToken);
|
||||
clientTokens.put("refreshToken", refreshToken);
|
||||
clientTokens.put("issuedAt", String.valueOf(System.currentTimeMillis()));
|
||||
|
||||
clients.add(clientTokens);
|
||||
sessionData.put("clients", clients);
|
||||
|
||||
// 更新会话
|
||||
redisTemplate.opsForValue().set(sessionKey, JSONUtil.toJsonStr(sessionData), SESSION_TTL, TimeUnit.SECONDS);
|
||||
|
||||
log.info("添加客户端到会话: sessionId={}, clientId={}", sessionId, clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销用户的所有会话(单点登出)
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
public void revokeAllUserSessions(Long userId) {
|
||||
log.info("撤销用户所有会话: userId={}", userId);
|
||||
|
||||
// 1. 获取用户的所有会话ID
|
||||
String userSessionsKey = String.format(RedisKeyConstants.OAUTH2_USER_SESSIONS_PREFIX, userId);
|
||||
Set<String> sessionIds = redisTemplate.opsForSet().members(userSessionsKey);
|
||||
|
||||
if (sessionIds == null || sessionIds.isEmpty()) {
|
||||
log.info("用户没有活跃会话: userId={}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
int revokedTokenCount = 0;
|
||||
|
||||
// 2. 遍历每个会话,提取所有Token并加入黑名单
|
||||
for (String sessionId : sessionIds) {
|
||||
String sessionKey = String.format(RedisKeyConstants.OAUTH2_SESSION_PREFIX, sessionId);
|
||||
String sessionDataStr = redisTemplate.opsForValue().get(sessionKey);
|
||||
|
||||
if (sessionDataStr != null) {
|
||||
Map<String, Object> sessionData = JSONUtil.toBean(sessionDataStr, Map.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, String>> clients = (List<Map<String, String>>) sessionData.get("clients");
|
||||
|
||||
if (clients != null) {
|
||||
for (Map<String, String> client : clients) {
|
||||
String accessToken = client.get("accessToken");
|
||||
String refreshToken = client.get("refreshToken");
|
||||
|
||||
// 将Token加入黑名单
|
||||
if (accessToken != null) {
|
||||
blacklistToken(accessToken);
|
||||
revokedTokenCount++;
|
||||
}
|
||||
if (refreshToken != null) {
|
||||
blacklistToken(refreshToken);
|
||||
revokedTokenCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
redisTemplate.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清空用户会话映射
|
||||
redisTemplate.delete(userSessionsKey);
|
||||
|
||||
// 4. 删除用户的 Refresh Token
|
||||
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
|
||||
redisTemplate.delete(refreshTokenKey);
|
||||
|
||||
log.info("撤销用户会话完成: userId={}, sessionCount={}, tokenCount={}", userId, sessionIds.size(), revokedTokenCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否在黑名单中
|
||||
* @param token Token
|
||||
* @return 是否在黑名单
|
||||
*/
|
||||
public boolean isTokenBlacklisted(String token) {
|
||||
String tokenHash = DigestUtil.sha256Hex(token);
|
||||
String key = String.format(RedisKeyConstants.OAUTH2_TOKEN_BLACKLIST_PREFIX, tokenHash);
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Token 加入黑名单
|
||||
* @param token Token
|
||||
*/
|
||||
private void blacklistToken(String token) {
|
||||
try {
|
||||
// 1. 计算Token的SHA256哈希
|
||||
String tokenHash = DigestUtil.sha256Hex(token);
|
||||
String key = String.format(RedisKeyConstants.OAUTH2_TOKEN_BLACKLIST_PREFIX, tokenHash);
|
||||
|
||||
// 2. 解析Token获取过期时间
|
||||
Claims claims = jwtUtil.parseClaims(token);
|
||||
long expiresAt = claims.getExpiration().getTime();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
// 3. 计算Token剩余有效期
|
||||
long ttl = (expiresAt - now) / 1000;
|
||||
|
||||
if (ttl > 0) {
|
||||
// 4. 加入黑名单,TTL设置为Token的剩余有效期
|
||||
redisTemplate.opsForValue().set(key, "1", ttl, TimeUnit.SECONDS);
|
||||
log.debug("Token加入黑名单: tokenHash={}, ttl={}秒", tokenHash, ttl);
|
||||
}
|
||||
else {
|
||||
log.debug("Token已过期,无需加入黑名单: tokenHash={}", tokenHash);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("Token加入黑名单失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.service;
|
||||
|
||||
import cn.meowrain.aioj.backend.auth.clients.UserClient;
|
||||
import cn.meowrain.aioj.backend.auth.common.constants.RedisKeyConstants;
|
||||
import cn.meowrain.aioj.backend.auth.dto.resp.UserAuthRespDTO;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.dto.OAuth2TokenResponse;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.entity.OAuth2Client;
|
||||
import cn.meowrain.aioj.backend.auth.utils.JwtUtil;
|
||||
|
||||
import cn.meowrain.aioj.backend.framework.core.web.Result;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* OAuth2 Token 服务 负责 Token 的生成、刷新和验证
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2TokenService {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
private final UserClient userClient;
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
private final OAuth2SessionService sessionService;
|
||||
|
||||
/**
|
||||
* 生成 Token 响应(授权码流程)
|
||||
* @param client 客户端信息
|
||||
* @param userId 用户ID
|
||||
* @param scope 授权范围
|
||||
* @param nonce Nonce参数(用于ID Token)
|
||||
* @return Token响应
|
||||
*/
|
||||
public OAuth2TokenResponse generateTokenResponse(OAuth2Client client, Long userId, String scope, String nonce) {
|
||||
// 1. 调用 user-service 获取用户信息
|
||||
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
|
||||
if (userResult == null || userResult.getData() == null) {
|
||||
throw new RuntimeException("获取用户信息失败");
|
||||
}
|
||||
|
||||
UserAuthRespDTO user = userResult.getData();
|
||||
|
||||
// 2. 生成 Access Token
|
||||
String accessToken = jwtUtil.generateAccessToken(user);
|
||||
|
||||
// 3. 生成 Refresh Token
|
||||
String refreshToken = jwtUtil.generateRefreshToken(userId);
|
||||
|
||||
// 4. 生成 ID Token(OIDC)
|
||||
String idToken = null;
|
||||
if (scope != null && scope.contains("openid")) {
|
||||
idToken = jwtUtil.generateIdToken(user, client.getClientId(), nonce);
|
||||
}
|
||||
|
||||
// 5. 存储 Refresh Token 到 Redis
|
||||
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS);
|
||||
|
||||
// 6. 创建会话(用于单点登出)
|
||||
sessionService.createSession(userId, client.getClientId(), accessToken, refreshToken);
|
||||
|
||||
log.info("签发Token: clientId={}, userId={}, scope={}", client.getClientId(), userId, scope);
|
||||
|
||||
// 7. 构造响应
|
||||
return OAuth2TokenResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.tokenType("Bearer")
|
||||
.expiresIn(client.getAccessTokenTtl())
|
||||
.refreshToken(refreshToken)
|
||||
.scope(scope)
|
||||
.idToken(idToken)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
* @param client 客户端信息
|
||||
* @param refreshToken Refresh Token
|
||||
* @param scope 授权范围(可选,不能超出原范围)
|
||||
* @return Token响应
|
||||
*/
|
||||
public OAuth2TokenResponse refreshToken(OAuth2Client client, String refreshToken, String scope) {
|
||||
// 1. 验证 Refresh Token
|
||||
if (!jwtUtil.isTokenValid(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 无效或已过期");
|
||||
}
|
||||
|
||||
// 2. 解析 Refresh Token 获取用户ID
|
||||
Long userId = Long.parseLong(jwtUtil.parseClaims(refreshToken).getSubject());
|
||||
|
||||
// 3. 从 Redis 验证 Refresh Token
|
||||
String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId);
|
||||
String storedToken = redisTemplate.opsForValue().get(refreshTokenKey);
|
||||
|
||||
if (!refreshToken.equals(storedToken)) {
|
||||
log.warn("Refresh Token 不匹配: userId={}", userId);
|
||||
throw new RuntimeException("Refresh Token 无效");
|
||||
}
|
||||
|
||||
// 4. 调用 user-service 获取最新用户信息
|
||||
Result<UserAuthRespDTO> userResult = userClient.getUserById(String.valueOf(userId));
|
||||
if (userResult == null || userResult.getData() == null) {
|
||||
throw new RuntimeException("获取用户信息失败");
|
||||
}
|
||||
|
||||
UserAuthRespDTO user = userResult.getData();
|
||||
|
||||
// 5. 生成新的 Access Token
|
||||
String newAccessToken = jwtUtil.generateAccessToken(user);
|
||||
|
||||
// 6. 生成新的 Refresh Token(Refresh Token Rotation)
|
||||
String newRefreshToken = jwtUtil.generateRefreshToken(userId);
|
||||
|
||||
// 7. 更新 Redis 中的 Refresh Token
|
||||
redisTemplate.opsForValue()
|
||||
.set(refreshTokenKey, newRefreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS);
|
||||
|
||||
log.info("刷新Token: clientId={}, userId={}", client.getClientId(), userId);
|
||||
|
||||
// 8. 构造响应(刷新时不返回 ID Token)
|
||||
return OAuth2TokenResponse.builder()
|
||||
.accessToken(newAccessToken)
|
||||
.tokenType("Bearer")
|
||||
.expiresIn(client.getAccessTokenTtl())
|
||||
.refreshToken(newRefreshToken)
|
||||
.scope(scope)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从授权码数据中提取信息
|
||||
* @param codeData 授权码数据
|
||||
* @param key 键名
|
||||
* @return 字符串值
|
||||
*/
|
||||
public String extractFromCodeData(Map<String, Object> codeData, String key) {
|
||||
Object value = codeData.get(key);
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从授权码数据中提取用户ID
|
||||
* @param codeData 授权码数据
|
||||
* @return 用户ID
|
||||
*/
|
||||
public Long extractUserIdFromCodeData(Map<String, Object> codeData) {
|
||||
Object userId = codeData.get("userId");
|
||||
if (userId instanceof Number) {
|
||||
return ((Number) userId).longValue();
|
||||
}
|
||||
return Long.parseLong(userId.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cn.meowrain.aioj.backend.auth.oauth2.service;
|
||||
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.InvalidGrantException;
|
||||
import cn.meowrain.aioj.backend.auth.oauth2.exception.OAuth2Exception;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) 验证服务 实现 RFC 7636 规范
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2025-12-14
|
||||
*/
|
||||
@Service
|
||||
public class PKCEService {
|
||||
|
||||
private static final String S256_METHOD = "S256";
|
||||
|
||||
private static final String PLAIN_METHOD = "plain";
|
||||
|
||||
/**
|
||||
* 验证 PKCE
|
||||
* @param codeVerifier Code Verifier(客户端生成的随机字符串,43-128字符)
|
||||
* @param codeChallenge Code Challenge(授权请求时发送的挑战值)
|
||||
* @param codeChallengeMethod 挑战方法(S256 或 plain)
|
||||
* @throws InvalidGrantException 验证失败时抛出
|
||||
*/
|
||||
public void validatePKCE(String codeVerifier, String codeChallenge, String codeChallengeMethod) {
|
||||
// 1. 验证参数
|
||||
if (StrUtil.isBlank(codeVerifier)) {
|
||||
throw new InvalidGrantException("code_verifier 不能为空");
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(codeChallenge)) {
|
||||
throw new InvalidGrantException("code_challenge 不能为空");
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(codeChallengeMethod)) {
|
||||
throw new InvalidGrantException("code_challenge_method 不能为空");
|
||||
}
|
||||
|
||||
// 2. 验证 code_verifier 长度(RFC 7636: 43-128 字符)
|
||||
if (codeVerifier.length() < 43 || codeVerifier.length() > 128) {
|
||||
throw new InvalidGrantException("code_verifier 长度必须在 43-128 字符之间");
|
||||
}
|
||||
|
||||
// 3. 根据方法验证
|
||||
String computedChallenge;
|
||||
switch (codeChallengeMethod) {
|
||||
case S256_METHOD:
|
||||
computedChallenge = computeS256Challenge(codeVerifier);
|
||||
break;
|
||||
case PLAIN_METHOD:
|
||||
// plain 方法:challenge = verifier(不推荐使用)
|
||||
computedChallenge = codeVerifier;
|
||||
break;
|
||||
default:
|
||||
throw new OAuth2Exception("不支持的 code_challenge_method: " + codeChallengeMethod);
|
||||
}
|
||||
|
||||
// 4. 比对 challenge
|
||||
if (!computedChallenge.equals(codeChallenge)) {
|
||||
throw new InvalidGrantException("code_verifier 验证失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 S256 方法的 Code Challenge challenge = BASE64URL(SHA256(verifier))
|
||||
* @param codeVerifier Code Verifier
|
||||
* @return Code Challenge
|
||||
*/
|
||||
private String computeS256Challenge(String codeVerifier) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
|
||||
}
|
||||
catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 算法不可用", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 PKCE 参数(在授权请求阶段)
|
||||
* @param codeChallenge Code Challenge
|
||||
* @param codeChallengeMethod 挑战方法
|
||||
* @param requirePkce 是否强制要求 PKCE
|
||||
* @throws OAuth2Exception 验证失败时抛出
|
||||
*/
|
||||
public void validatePKCERequest(String codeChallenge, String codeChallengeMethod, boolean requirePkce) {
|
||||
// 如果强制要求 PKCE
|
||||
if (requirePkce) {
|
||||
if (StrUtil.isBlank(codeChallenge)) {
|
||||
throw new OAuth2Exception("该客户端必须使用 PKCE,请提供 code_challenge");
|
||||
}
|
||||
if (StrUtil.isBlank(codeChallengeMethod)) {
|
||||
throw new OAuth2Exception("该客户端必须使用 PKCE,请提供 code_challenge_method");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了 code_challenge,验证 method
|
||||
if (StrUtil.isNotBlank(codeChallenge)) {
|
||||
if (StrUtil.isBlank(codeChallengeMethod)) {
|
||||
throw new OAuth2Exception("提供了 code_challenge 必须同时提供 code_challenge_method");
|
||||
}
|
||||
|
||||
// 只支持 S256 方法(更安全)
|
||||
if (!S256_METHOD.equals(codeChallengeMethod) && !PLAIN_METHOD.equals(codeChallengeMethod)) {
|
||||
throw new OAuth2Exception("不支持的 code_challenge_method,仅支持 S256 或 plain");
|
||||
}
|
||||
|
||||
// 推荐使用 S256
|
||||
if (PLAIN_METHOD.equals(codeChallengeMethod)) {
|
||||
// 可以记录警告日志:使用了不安全的 plain 方法
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
public UserLoginResponseDTO refreshToken(String refreshToken) {
|
||||
UserLoginResponseDTO userLoginResponseDTO = new UserLoginResponseDTO();
|
||||
if (!jwtUtil.isTokenValid(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 已过期");
|
||||
throw new ServiceException("Refresh Token 已过期");
|
||||
}
|
||||
|
||||
Long userId = Long.valueOf(jwtUtil.parseClaims(refreshToken).getSubject());
|
||||
@@ -103,7 +103,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cacheValue == null || !cacheValue.equals(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 已失效");
|
||||
throw new ServiceException("Refresh Token 已失效");
|
||||
}
|
||||
|
||||
// 再次签发新的 Access Token
|
||||
@@ -158,7 +158,8 @@ public class AuthServiceImpl implements AuthService {
|
||||
|
||||
log.debug("Access token validation successful for user: {}", userId);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("Error validating access token", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -69,4 +69,34 @@ public class JwtUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OIDC ID Token
|
||||
* @param user 用户信息
|
||||
* @param clientId 客户端ID(aud)
|
||||
* @param nonce 防重放参数
|
||||
* @return ID Token
|
||||
*/
|
||||
public String generateIdToken(UserAuthRespDTO user, String clientId, String nonce) {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", String.valueOf(user.getId())); // Subject - 用户ID
|
||||
claims.put("aud", clientId); // Audience - 客户端ID
|
||||
claims.put("name", user.getUserName());
|
||||
claims.put("preferred_username", user.getUserAccount());
|
||||
|
||||
if (nonce != null) {
|
||||
claims.put("nonce", nonce); // 防重放
|
||||
}
|
||||
|
||||
return Jwts.builder()
|
||||
.issuer("http://localhost:10011/api") // TODO: 从配置读取
|
||||
.subject(String.valueOf(user.getId()))
|
||||
.issuedAt(new Date(now))
|
||||
.expiration(new Date(now + jwtConfig.getAccessExpire()))
|
||||
.claims(claims)
|
||||
.signWith(getSigningKey(), Jwts.SIG.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ spring:
|
||||
host: 10.0.0.10
|
||||
port: 6379
|
||||
password: 123456
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://10.0.0.10/aioj_dev
|
||||
username: root
|
||||
password: root
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
|
||||
167
db/oauth2_schema.sql
Normal file
167
db/oauth2_schema.sql
Normal file
@@ -0,0 +1,167 @@
|
||||
-- OAuth2/OIDC 单点登录 数据库表结构
|
||||
-- 创建时间: 2025-12-14
|
||||
|
||||
-- =============================================
|
||||
-- OAuth2 客户端表
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS `oauth2_client` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`client_id` VARCHAR(100) NOT NULL COMMENT '客户端ID',
|
||||
`client_secret` VARCHAR(255) NULL COMMENT '客户端密钥(BCrypt加密,公共客户端为NULL)',
|
||||
`client_name` VARCHAR(100) NOT NULL COMMENT '客户端名称',
|
||||
`client_type` VARCHAR(20) NOT NULL COMMENT '客户端类型:confidential(机密)/ public(公共)',
|
||||
`redirect_uris` TEXT NOT NULL COMMENT '重定向URI列表(JSON数组格式)',
|
||||
`post_logout_redirect_uris` TEXT COMMENT '登出后重定向URI列表(JSON数组格式)',
|
||||
`allowed_scopes` VARCHAR(500) NOT NULL COMMENT '允许的作用域(逗号分隔,如:openid,profile,email)',
|
||||
`allowed_grant_types` VARCHAR(200) NOT NULL COMMENT '允许的授权类型(逗号分隔,如:authorization_code,refresh_token)',
|
||||
`access_token_ttl` INT DEFAULT 900 COMMENT 'Access Token有效期(秒,默认15分钟)',
|
||||
`refresh_token_ttl` INT DEFAULT 604800 COMMENT 'Refresh Token有效期(秒,默认7天)',
|
||||
`require_pkce` TINYINT(1) DEFAULT 1 COMMENT '是否要求PKCE(1=要求,0=不要求)',
|
||||
`is_enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用(1=启用,0=禁用)',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_client_id` (`client_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OAuth2客户端表';
|
||||
|
||||
-- =============================================
|
||||
-- 插入测试客户端数据
|
||||
-- =============================================
|
||||
|
||||
-- 1. Web 应用(机密客户端)
|
||||
-- client_secret 明文: web_app_secret_2024
|
||||
-- BCrypt 加密后: $2a$10$XQw.gH7qKvYJ8pqXk7YRNe8xZYKZJ.3kZQH5.3zV4YXjQH5.3zV4Y
|
||||
INSERT INTO `oauth2_client`
|
||||
(`client_id`, `client_secret`, `client_name`, `client_type`,
|
||||
`redirect_uris`, `post_logout_redirect_uris`,
|
||||
`allowed_scopes`, `allowed_grant_types`,
|
||||
`access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`)
|
||||
VALUES
|
||||
('web-app',
|
||||
'$2a$10$XQw.gH7qKvYJ8pqXk7YRNe8xZYKZJ.3kZQH5.3zV4YXjQH5.3zV4Y',
|
||||
'Web应用',
|
||||
'confidential',
|
||||
'["http://localhost:3000/callback","http://localhost:3000/silent-renew","http://localhost:8080/callback"]',
|
||||
'["http://localhost:3000/logout-callback","http://localhost:8080/logout"]',
|
||||
'openid,profile,email',
|
||||
'authorization_code,refresh_token',
|
||||
900,
|
||||
604800,
|
||||
1,
|
||||
1);
|
||||
|
||||
-- 2. 移动应用(公共客户端)
|
||||
INSERT INTO `oauth2_client`
|
||||
(`client_id`, `client_secret`, `client_name`, `client_type`,
|
||||
`redirect_uris`, `post_logout_redirect_uris`,
|
||||
`allowed_scopes`, `allowed_grant_types`,
|
||||
`access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`)
|
||||
VALUES
|
||||
('mobile-app',
|
||||
NULL,
|
||||
'移动应用',
|
||||
'public',
|
||||
'["acgoj://callback","acgoj://oauth/callback"]',
|
||||
'["acgoj://logout","acgoj://oauth/logout"]',
|
||||
'openid,profile',
|
||||
'authorization_code,refresh_token',
|
||||
900,
|
||||
2592000, -- 30天
|
||||
1, -- 强制要求PKCE
|
||||
1);
|
||||
|
||||
-- 3. 管理后台(机密客户端)
|
||||
-- client_secret 明文: admin_secret_2024
|
||||
-- BCrypt 加密后: $2a$10$YRx.hI8rLwZK9qrYl8ZSOe9yAZLAK.4lARG6.4aW5ZYkRG6.4aW5A
|
||||
INSERT INTO `oauth2_client`
|
||||
(`client_id`, `client_secret`, `client_name`, `client_type`,
|
||||
`redirect_uris`, `post_logout_redirect_uris`,
|
||||
`allowed_scopes`, `allowed_grant_types`,
|
||||
`access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`)
|
||||
VALUES
|
||||
('admin-app',
|
||||
'$2a$10$YRx.hI8rLwZK9qrYl8ZSOe9yAZLAK.4lARG6.4aW5ZYkRG6.4aW5A',
|
||||
'管理后台',
|
||||
'confidential',
|
||||
'["http://localhost:3001/admin/callback","http://admin.acgoj.com/callback"]',
|
||||
'["http://localhost:3001/admin/logout","http://admin.acgoj.com/logout"]',
|
||||
'openid,profile,email,admin',
|
||||
'authorization_code,refresh_token',
|
||||
900,
|
||||
604800,
|
||||
1,
|
||||
1);
|
||||
|
||||
-- =============================================
|
||||
-- Redis 数据结构说明(仅供参考,Redis中存储)
|
||||
-- =============================================
|
||||
|
||||
-- 1. 授权码存储
|
||||
-- Key: oauth2:auth_code:{code}
|
||||
-- Value: JSON {
|
||||
-- "code": "授权码",
|
||||
-- "clientId": "客户端ID",
|
||||
-- "userId": "用户ID",
|
||||
-- "redirectUri": "重定向URI",
|
||||
-- "scope": "授权范围",
|
||||
-- "codeChallenge": "PKCE challenge",
|
||||
-- "codeChallengeMethod": "S256",
|
||||
-- "nonce": "防重放参数",
|
||||
-- "expiresAt": 时间戳
|
||||
-- }
|
||||
-- TTL: 600秒(10分钟)
|
||||
|
||||
-- 2. 用户会话存储
|
||||
-- Key: oauth2:session:{sessionId}
|
||||
-- Value: JSON {
|
||||
-- "sessionId": "会话ID",
|
||||
-- "userId": "用户ID",
|
||||
-- "clients": [
|
||||
-- {
|
||||
-- "clientId": "客户端ID",
|
||||
-- "accessToken": "访问令牌",
|
||||
-- "refreshToken": "刷新令牌",
|
||||
-- "issuedAt": 时间戳
|
||||
-- }
|
||||
-- ],
|
||||
-- "createdAt": 时间戳
|
||||
-- }
|
||||
-- TTL: 604800秒(7天)
|
||||
|
||||
-- 3. 用户会话索引
|
||||
-- Key: oauth2:user_sessions:{userId}
|
||||
-- Value: Set<sessionId>
|
||||
-- TTL: 604800秒(7天)
|
||||
|
||||
-- 4. Token黑名单(用于单点登出)
|
||||
-- Key: oauth2:token_blacklist:{SHA256(token)}
|
||||
-- Value: "1"
|
||||
-- TTL: Token的剩余有效期(动态计算)
|
||||
|
||||
-- 5. Refresh Token存储(现有)
|
||||
-- Key: refresh_token:{userId}
|
||||
-- Value: refresh_token字符串
|
||||
-- TTL: 604800秒(7天)
|
||||
|
||||
-- =============================================
|
||||
-- 索引说明
|
||||
-- =============================================
|
||||
-- 1. uk_client_id: 唯一索引,确保client_id不重复
|
||||
-- 2. 如需按client_name查询,可添加: KEY `idx_client_name` (`client_name`)
|
||||
-- 3. 如需按is_enabled查询,可添加: KEY `idx_is_enabled` (`is_enabled`)
|
||||
|
||||
-- =============================================
|
||||
-- 查询示例
|
||||
-- =============================================
|
||||
|
||||
-- 查询所有启用的客户端
|
||||
-- SELECT * FROM oauth2_client WHERE is_enabled = 1;
|
||||
|
||||
-- 根据client_id查询客户端
|
||||
-- SELECT * FROM oauth2_client WHERE client_id = 'web-app';
|
||||
|
||||
-- 查询所有公共客户端
|
||||
-- SELECT * FROM oauth2_client WHERE client_type = 'public';
|
||||
|
||||
-- 查询所有需要PKCE的客户端
|
||||
-- SELECT * FROM oauth2_client WHERE require_pkce = 1;
|
||||
Reference in New Issue
Block a user