From d353735d1bb63e81a72a93de8c88bd9f293cf579 Mon Sep 17 00:00:00 2001 From: meowrain Date: Sun, 14 Dec 2025 17:47:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0oauth2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- .idea/dataSources.xml | 8 + .idea/db-forest-config.xml | 2 +- aioj-backend-auth/pom.xml | 9 + .../common/constants/RedisKeyConstants.java | 22 ++ .../auth/config/SecurityConfiguration.java | 4 +- .../auth/controller/AuthController.java | 8 + .../auth/filter/JwtAuthenticationFilter.java | 125 +++++----- .../OAuth2AuthorizationController.java | 236 ++++++++++++++++++ .../controller/OAuth2LogoutController.java | 186 ++++++++++++++ .../controller/OAuth2TokenController.java | 135 ++++++++++ .../controller/OAuth2UserInfoController.java | 109 ++++++++ .../controller/OAuth2WellKnownController.java | 82 ++++++ .../oauth2/dto/OAuth2AuthorizeRequest.java | 59 +++++ .../auth/oauth2/dto/OAuth2TokenRequest.java | 54 ++++ .../auth/oauth2/dto/OAuth2TokenResponse.java | 54 ++++ .../auth/oauth2/dto/UserInfoResponse.java | 56 +++++ .../auth/oauth2/entity/OAuth2Client.java | 96 +++++++ .../exception/InvalidClientException.java | 26 ++ .../exception/InvalidGrantException.java | 26 ++ .../oauth2/exception/OAuth2Exception.java | 62 +++++ .../oauth2/mapper/OAuth2ClientMapper.java | 16 ++ .../service/OAuth2AuthorizationService.java | 127 ++++++++++ .../oauth2/service/OAuth2ClientService.java | 195 +++++++++++++++ .../oauth2/service/OAuth2SessionService.java | 225 +++++++++++++++++ .../oauth2/service/OAuth2TokenService.java | 166 ++++++++++++ .../auth/oauth2/service/PKCEService.java | 124 +++++++++ .../auth/service/impl/AuthServiceImpl.java | 7 +- .../aioj/backend/auth/utils/JwtUtil.java | 30 +++ .../src/main/resources/application-dev.yml | 5 + db/oauth2_schema.sql | 167 +++++++++++++ 31 files changed, 2355 insertions(+), 70 deletions(-) create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2AuthorizationController.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2LogoutController.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2TokenController.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2UserInfoController.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/controller/OAuth2WellKnownController.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2AuthorizeRequest.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenRequest.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/OAuth2TokenResponse.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/dto/UserInfoResponse.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/entity/OAuth2Client.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidClientException.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/InvalidGrantException.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/exception/OAuth2Exception.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/mapper/OAuth2ClientMapper.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2AuthorizationService.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2ClientService.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2SessionService.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2TokenService.java create mode 100644 aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/PKCEService.java create mode 100644 db/oauth2_schema.sql 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> 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 sessionData = JSONUtil.toBean(sessionDataStr, Map.class); + @SuppressWarnings("unchecked") + List> clients = (List>) sessionData.get("clients"); + + if (clients == null) { + clients = new ArrayList<>(); + } + + // 添加新的客户端Token + Map 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 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 sessionData = JSONUtil.toBean(sessionDataStr, Map.class); + @SuppressWarnings("unchecked") + List> clients = (List>) sessionData.get("clients"); + + if (clients != null) { + for (Map 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); + } + } + +} diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2TokenService.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2TokenService.java new file mode 100644 index 0000000..c80ede4 --- /dev/null +++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/OAuth2TokenService.java @@ -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 userResult = userClient.getUserById(String.valueOf(userId)); + if (userResult == null || userResult.getData() == null) { + throw new RuntimeException("获取用户信息失败"); + } + + UserAuthRespDTO user = userResult.getData(); + + // 2. 生成 Access Token + String accessToken = jwtUtil.generateAccessToken(user); + + // 3. 生成 Refresh Token + String refreshToken = jwtUtil.generateRefreshToken(userId); + + // 4. 生成 ID Token(OIDC) + String idToken = null; + if (scope != null && scope.contains("openid")) { + idToken = jwtUtil.generateIdToken(user, client.getClientId(), nonce); + } + + // 5. 存储 Refresh Token 到 Redis + String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId); + redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS); + + // 6. 创建会话(用于单点登出) + sessionService.createSession(userId, client.getClientId(), accessToken, refreshToken); + + log.info("签发Token: clientId={}, userId={}, scope={}", client.getClientId(), userId, scope); + + // 7. 构造响应 + return OAuth2TokenResponse.builder() + .accessToken(accessToken) + .tokenType("Bearer") + .expiresIn(client.getAccessTokenTtl()) + .refreshToken(refreshToken) + .scope(scope) + .idToken(idToken) + .build(); + } + + /** + * 刷新 Token + * @param client 客户端信息 + * @param refreshToken Refresh Token + * @param scope 授权范围(可选,不能超出原范围) + * @return Token响应 + */ + public OAuth2TokenResponse refreshToken(OAuth2Client client, String refreshToken, String scope) { + // 1. 验证 Refresh Token + if (!jwtUtil.isTokenValid(refreshToken)) { + throw new RuntimeException("Refresh Token 无效或已过期"); + } + + // 2. 解析 Refresh Token 获取用户ID + Long userId = Long.parseLong(jwtUtil.parseClaims(refreshToken).getSubject()); + + // 3. 从 Redis 验证 Refresh Token + String refreshTokenKey = String.format(RedisKeyConstants.REFRESH_TOKEN_KEY_PREFIX, userId); + String storedToken = redisTemplate.opsForValue().get(refreshTokenKey); + + if (!refreshToken.equals(storedToken)) { + log.warn("Refresh Token 不匹配: userId={}", userId); + throw new RuntimeException("Refresh Token 无效"); + } + + // 4. 调用 user-service 获取最新用户信息 + Result userResult = userClient.getUserById(String.valueOf(userId)); + if (userResult == null || userResult.getData() == null) { + throw new RuntimeException("获取用户信息失败"); + } + + UserAuthRespDTO user = userResult.getData(); + + // 5. 生成新的 Access Token + String newAccessToken = jwtUtil.generateAccessToken(user); + + // 6. 生成新的 Refresh Token(Refresh Token Rotation) + String newRefreshToken = jwtUtil.generateRefreshToken(userId); + + // 7. 更新 Redis 中的 Refresh Token + redisTemplate.opsForValue() + .set(refreshTokenKey, newRefreshToken, client.getRefreshTokenTtl(), TimeUnit.SECONDS); + + log.info("刷新Token: clientId={}, userId={}", client.getClientId(), userId); + + // 8. 构造响应(刷新时不返回 ID Token) + return OAuth2TokenResponse.builder() + .accessToken(newAccessToken) + .tokenType("Bearer") + .expiresIn(client.getAccessTokenTtl()) + .refreshToken(newRefreshToken) + .scope(scope) + .build(); + } + + /** + * 从授权码数据中提取信息 + * @param codeData 授权码数据 + * @param key 键名 + * @return 字符串值 + */ + public String extractFromCodeData(Map codeData, String key) { + Object value = codeData.get(key); + return value != null ? value.toString() : null; + } + + /** + * 从授权码数据中提取用户ID + * @param codeData 授权码数据 + * @return 用户ID + */ + public Long extractUserIdFromCodeData(Map codeData) { + Object userId = codeData.get("userId"); + if (userId instanceof Number) { + return ((Number) userId).longValue(); + } + return Long.parseLong(userId.toString()); + } + +} diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/PKCEService.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/PKCEService.java new file mode 100644 index 0000000..81c5c1e --- /dev/null +++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/oauth2/service/PKCEService.java @@ -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 方法 + } + } + } + +} diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/service/impl/AuthServiceImpl.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/service/impl/AuthServiceImpl.java index 221ba53..2d04005 100644 --- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/service/impl/AuthServiceImpl.java +++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/service/impl/AuthServiceImpl.java @@ -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; } diff --git a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/utils/JwtUtil.java b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/utils/JwtUtil.java index a39ca55..b7138e6 100644 --- a/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/utils/JwtUtil.java +++ b/aioj-backend-auth/src/main/java/cn/meowrain/aioj/backend/auth/utils/JwtUtil.java @@ -69,4 +69,34 @@ public class JwtUtil { } } + /** + * 生成 OIDC ID Token + * @param user 用户信息 + * @param clientId 客户端ID(aud) + * @param nonce 防重放参数 + * @return ID Token + */ + public String generateIdToken(UserAuthRespDTO user, String clientId, String nonce) { + long now = System.currentTimeMillis(); + + Map 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(); + } + } diff --git a/aioj-backend-auth/src/main/resources/application-dev.yml b/aioj-backend-auth/src/main/resources/application-dev.yml index a8d32c1..4880e1b 100644 --- a/aioj-backend-auth/src/main/resources/application-dev.yml +++ b/aioj-backend-auth/src/main/resources/application-dev.yml @@ -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: diff --git a/db/oauth2_schema.sql b/db/oauth2_schema.sql new file mode 100644 index 0000000..aa65770 --- /dev/null +++ b/db/oauth2_schema.sql @@ -0,0 +1,167 @@ +-- OAuth2/OIDC 单点登录 数据库表结构 +-- 创建时间: 2025-12-14 + +-- ============================================= +-- OAuth2 客户端表 +-- ============================================= +CREATE TABLE IF NOT EXISTS `oauth2_client` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `client_id` VARCHAR(100) NOT NULL COMMENT '客户端ID', + `client_secret` VARCHAR(255) NULL COMMENT '客户端密钥(BCrypt加密,公共客户端为NULL)', + `client_name` VARCHAR(100) NOT NULL COMMENT '客户端名称', + `client_type` VARCHAR(20) NOT NULL COMMENT '客户端类型:confidential(机密)/ public(公共)', + `redirect_uris` TEXT NOT NULL COMMENT '重定向URI列表(JSON数组格式)', + `post_logout_redirect_uris` TEXT COMMENT '登出后重定向URI列表(JSON数组格式)', + `allowed_scopes` VARCHAR(500) NOT NULL COMMENT '允许的作用域(逗号分隔,如:openid,profile,email)', + `allowed_grant_types` VARCHAR(200) NOT NULL COMMENT '允许的授权类型(逗号分隔,如:authorization_code,refresh_token)', + `access_token_ttl` INT DEFAULT 900 COMMENT 'Access Token有效期(秒,默认15分钟)', + `refresh_token_ttl` INT DEFAULT 604800 COMMENT 'Refresh Token有效期(秒,默认7天)', + `require_pkce` TINYINT(1) DEFAULT 1 COMMENT '是否要求PKCE(1=要求,0=不要求)', + `is_enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用(1=启用,0=禁用)', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_client_id` (`client_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OAuth2客户端表'; + +-- ============================================= +-- 插入测试客户端数据 +-- ============================================= + +-- 1. Web 应用(机密客户端) +-- client_secret 明文: web_app_secret_2024 +-- BCrypt 加密后: $2a$10$XQw.gH7qKvYJ8pqXk7YRNe8xZYKZJ.3kZQH5.3zV4YXjQH5.3zV4Y +INSERT INTO `oauth2_client` +(`client_id`, `client_secret`, `client_name`, `client_type`, + `redirect_uris`, `post_logout_redirect_uris`, + `allowed_scopes`, `allowed_grant_types`, + `access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`) +VALUES +('web-app', + '$2a$10$XQw.gH7qKvYJ8pqXk7YRNe8xZYKZJ.3kZQH5.3zV4YXjQH5.3zV4Y', + 'Web应用', + 'confidential', + '["http://localhost:3000/callback","http://localhost:3000/silent-renew","http://localhost:8080/callback"]', + '["http://localhost:3000/logout-callback","http://localhost:8080/logout"]', + 'openid,profile,email', + 'authorization_code,refresh_token', + 900, + 604800, + 1, + 1); + +-- 2. 移动应用(公共客户端) +INSERT INTO `oauth2_client` +(`client_id`, `client_secret`, `client_name`, `client_type`, + `redirect_uris`, `post_logout_redirect_uris`, + `allowed_scopes`, `allowed_grant_types`, + `access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`) +VALUES +('mobile-app', + NULL, + '移动应用', + 'public', + '["acgoj://callback","acgoj://oauth/callback"]', + '["acgoj://logout","acgoj://oauth/logout"]', + 'openid,profile', + 'authorization_code,refresh_token', + 900, + 2592000, -- 30天 + 1, -- 强制要求PKCE + 1); + +-- 3. 管理后台(机密客户端) +-- client_secret 明文: admin_secret_2024 +-- BCrypt 加密后: $2a$10$YRx.hI8rLwZK9qrYl8ZSOe9yAZLAK.4lARG6.4aW5ZYkRG6.4aW5A +INSERT INTO `oauth2_client` +(`client_id`, `client_secret`, `client_name`, `client_type`, + `redirect_uris`, `post_logout_redirect_uris`, + `allowed_scopes`, `allowed_grant_types`, + `access_token_ttl`, `refresh_token_ttl`, `require_pkce`, `is_enabled`) +VALUES +('admin-app', + '$2a$10$YRx.hI8rLwZK9qrYl8ZSOe9yAZLAK.4lARG6.4aW5ZYkRG6.4aW5A', + '管理后台', + 'confidential', + '["http://localhost:3001/admin/callback","http://admin.acgoj.com/callback"]', + '["http://localhost:3001/admin/logout","http://admin.acgoj.com/logout"]', + 'openid,profile,email,admin', + 'authorization_code,refresh_token', + 900, + 604800, + 1, + 1); + +-- ============================================= +-- Redis 数据结构说明(仅供参考,Redis中存储) +-- ============================================= + +-- 1. 授权码存储 +-- Key: oauth2:auth_code:{code} +-- Value: JSON { +-- "code": "授权码", +-- "clientId": "客户端ID", +-- "userId": "用户ID", +-- "redirectUri": "重定向URI", +-- "scope": "授权范围", +-- "codeChallenge": "PKCE challenge", +-- "codeChallengeMethod": "S256", +-- "nonce": "防重放参数", +-- "expiresAt": 时间戳 +-- } +-- TTL: 600秒(10分钟) + +-- 2. 用户会话存储 +-- Key: oauth2:session:{sessionId} +-- Value: JSON { +-- "sessionId": "会话ID", +-- "userId": "用户ID", +-- "clients": [ +-- { +-- "clientId": "客户端ID", +-- "accessToken": "访问令牌", +-- "refreshToken": "刷新令牌", +-- "issuedAt": 时间戳 +-- } +-- ], +-- "createdAt": 时间戳 +-- } +-- TTL: 604800秒(7天) + +-- 3. 用户会话索引 +-- Key: oauth2:user_sessions:{userId} +-- Value: Set +-- 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;