实现oauth2

This commit is contained in:
2025-12-14 17:47:08 +08:00
parent d04440f0b1
commit d353735d1b
31 changed files with 2355 additions and 70 deletions

View File

@@ -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
View File

@@ -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>

View File

@@ -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&#10;3:0:UserServiceApplication&#10;----------------------------------------&#10;2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490&#10;4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207&#10;" />
<option name="data" value="1:0:AIOJAdminApplication&#10;3:0:UserServiceApplication&#10;5:0:AIOJAuthApplication&#10;----------------------------------------&#10;2:1:43cc61de-66e1-44cc-b4a2-b24d7e03b490&#10;4:3:903d03c4-df11-4cf8-939a-3e5fba0ab207&#10;6:5:2fd8684a-b9aa-4507-abb0-f7c259d91286&#10;" />
</component>
</project>

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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())

View File

@@ -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);
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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 TokenID 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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 HeaderBearer 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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 Tokengrant_type=refresh_token 时必需)
*/
private String refreshToken;
/**
* 授权范围refresh_token 时可选,不能超出原范围)
*/
private String scope;
}

View File

@@ -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 TokenOIDC
*/
@JsonProperty("id_token")
private String idToken;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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("客户端认证失败");
}
}

View File

@@ -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("授权码无效或已过期");
}
}

View File

@@ -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;
}
}

View File

@@ -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> {
}

View File

@@ -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不匹配");
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 TokenOIDC
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 TokenRefresh 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());
}
}

View File

@@ -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 方法
}
}
}
}

View File

@@ -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;
}

View File

@@ -69,4 +69,34 @@ public class JwtUtil {
}
}
/**
* 生成 OIDC ID Token
* @param user 用户信息
* @param clientId 客户端IDaud
* @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();
}
}

View File

@@ -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
View 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 '是否要求PKCE1=要求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;