diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 31d5b2b..1762eef 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -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:*)"
]
}
}
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index 15acdd0..267fcc1 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -17,5 +17,13 @@
jdbc:mysql://10.0.0.10/aioj_dev
$ProjectFileDir$
+
+ mysql.8
+ true
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://10.0.0.10/aioj_dev
+ $ProjectFileDir$
+
\ No newline at end of file
diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml
index 16a0a56..de227fd 100644
--- a/.idea/db-forest-config.xml
+++ b/.idea/db-forest-config.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/aioj-backend-auth/pom.xml b/aioj-backend-auth/pom.xml
index 72da676..26da59f 100644
--- a/aioj-backend-auth/pom.xml
+++ b/aioj-backend-auth/pom.xml
@@ -30,12 +30,21 @@
aioj-backend-common-feign
1.0-SNAPSHOT
+
+ cn.meowrain
+ aioj-backend-common-mybatis
+ 1.0-SNAPSHOT
+
cn.hutool
hutool-crypto
+
+ cn.hutool
+ hutool-json
+
org.apache.commons
commons-lang3
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/common/constants/RedisKeyConstants.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/common/constants/RedisKeyConstants.java
index c1fb373..703642e 100644
--- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/common/constants/RedisKeyConstants.java
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/common/constants/RedisKeyConstants.java
@@ -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
+ */
+ 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";
+
}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/config/SecurityConfiguration.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/config/SecurityConfiguration.java
index 33aa51c..456f357 100644
--- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/config/SecurityConfiguration.java
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/config/SecurityConfiguration.java
@@ -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())
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/controller/AuthController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/controller/AuthController.java
index d90de77..dbb18ea 100644
--- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/controller/AuthController.java
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/controller/AuthController.java
@@ -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 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);
}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/filter/JwtAuthenticationFilter.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/filter/JwtAuthenticationFilter.java
index 8cb6ca0..e6fcf09 100644
--- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/filter/JwtAuthenticationFilter.java
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/filter/JwtAuthenticationFilter.java
@@ -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 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 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");
- }
}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2AuthorizationController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2AuthorizationController.java
new file mode 100644
index 0000000..589f6d0
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2AuthorizationController.java
@@ -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();
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2LogoutController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2LogoutController.java
new file mode 100644
index 0000000..a177b07
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2LogoutController.java
@@ -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();
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2TokenController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2TokenController.java
new file mode 100644
index 0000000..929b087
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2TokenController.java
@@ -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 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);
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2UserInfoController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2UserInfoController.java
new file mode 100644
index 0000000..77bd3b4
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2UserInfoController.java
@@ -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 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);
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2WellKnownController.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2WellKnownController.java
new file mode 100644
index 0000000..3279b95
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2WellKnownController.java
@@ -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 openidConfiguration() {
+ Map 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;
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2AuthorizeRequest.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2AuthorizeRequest.java
new file mode 100644
index 0000000..cc04f7d
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2AuthorizeRequest.java
@@ -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;
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenRequest.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenRequest.java
new file mode 100644
index 0000000..3c7950d
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenRequest.java
@@ -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;
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenResponse.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenResponse.java
new file mode 100644
index 0000000..c10ec4b
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenResponse.java
@@ -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;
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/UserInfoResponse.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/UserInfoResponse.java
new file mode 100644
index 0000000..93bd9f8
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/UserInfoResponse.java
@@ -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;
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/entity/OAuth2Client.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/entity/OAuth2Client.java
new file mode 100644
index 0000000..2e8b87c
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/entity/OAuth2Client.java
@@ -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;
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidClientException.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidClientException.java
new file mode 100644
index 0000000..0de65f9
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidClientException.java
@@ -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("客户端认证失败");
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidGrantException.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidGrantException.java
new file mode 100644
index 0000000..8d6d1cb
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidGrantException.java
@@ -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("授权码无效或已过期");
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/OAuth2Exception.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/OAuth2Exception.java
new file mode 100644
index 0000000..84af698
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/OAuth2Exception.java
@@ -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;
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/mapper/OAuth2ClientMapper.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/mapper/OAuth2ClientMapper.java
new file mode 100644
index 0000000..b0987ce
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/mapper/OAuth2ClientMapper.java
@@ -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 {
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2AuthorizationService.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2AuthorizationService.java
new file mode 100644
index 0000000..ba4fa0b
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2AuthorizationService.java
@@ -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 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 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 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 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不匹配");
+ }
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2ClientService.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2ClientService.java
new file mode 100644
index 0000000..f5a7898
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2ClientService.java
@@ -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().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 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 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 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 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 parseJsonArray(String jsonArray) {
+ try {
+ return JSONUtil.toList(jsonArray, String.class);
+ }
+ catch (Exception e) {
+ log.error("解析 JSON 数组失败: {}", jsonArray, e);
+ return List.of();
+ }
+ }
+
+}
diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2SessionService.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2SessionService.java
new file mode 100644
index 0000000..024c5bb
--- /dev/null
+++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2SessionService.java
@@ -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 sessionData = new HashMap<>();
+ sessionData.put("sessionId", sessionId);
+ sessionData.put("userId", userId);
+ sessionData.put("createdAt", System.currentTimeMillis());
+
+ // 3. 构造客户端Token信息
+ Map clientTokens = new HashMap<>();
+ clientTokens.put("clientId", clientId);
+ clientTokens.put("accessToken", accessToken);
+ clientTokens.put("refreshToken", refreshToken);
+ clientTokens.put("issuedAt", String.valueOf(System.currentTimeMillis()));
+
+ // 将客户端Token列表添加到会话
+ List