维基框架 (Wiki FW) v1.1.1 | 企业级微服务开发框架
Release Notes 版本修复日志 【修复】修复wiki-all-jpa包命名空间错误问题 【修复】修改日志输出目录至项目根目录 【修复】wiki-all,wiki-all-jpa 启动类型注解配置 新增 wiki-oauth2 组件,支持OAuth2授权 1.【安全】OAuth2权限体系强化 新增标准OAuth2授权码(authorization_code)获取流程,支持/oauth2/authorize端点动态生成权限code 扩展令牌端点/oauth2/access_token,支持通过授权码交换访问令牌(access_token) 新增令牌刷新机制,支持grant_type=refresh_token动态刷新访问凭证 集成RBAC权限模型,实现细粒度Scope权限控制 2.【优化】Spring Security深度整合 重构认证过滤器链,支持OAuth2与原生表单登录无缝切换 新增@OAuth2ResourceServer注解,一键开启资源服务器配置 优化JWT令牌校验性能,支持HS256/RS256双签名算法 3.【安全】令牌管理增强 令牌存储支持数据库模式,持久化访问凭证 新增令牌自动回收机制,闲置令牌15分钟强制失效 令牌绑定客户端IP,防止凭证劫持(CVE-2025-8821修复) 4.【优化】配置简化 spring: custom: oauth2: # 刷新令牌有效期,单位秒,默认30天(默认值) refresh-token-time-to-live: 2592000 # 访问令牌有效期,单位秒,默认7天(默认值) access-token-time-to-live: 604800 # 需要授权接口 api-path-patterns: - /api/** # 需要放行接口 allow-list: - /oauth2/** OAuth2 接入示例 1. 引入依赖 <dependencies> <groupId>com.framewiki</groupId> <artifactId>wiki-oauth2</artifactId> <version>1.1.1version> </dependencies> 2. 请求示例 1. 获取授权码 GET /oauth2/authorize?response_type=code &client_id=client123 &scope=read 2. 兑换访问令牌 GET /oauth2/access_token grant_type=authorization_code &code=MmUuMrt &client_id=client123 ×tamp=1756625626 &signature=817a2bcb5720fec967ba936fdfc64fc5 3. 刷新访问令牌 GET /oauth2/access_token grant_type=refresh_token &refresh_token=eyJhbGciOiJIUzUxMiJ9.eyJkaXNwbGF5TmFtZSI6ImNsaWVudDEyMyIsImxvZ2luTmFtZSI6ImNsaWVudDEyMyIsInRpbWUiOjE3NTY3MTIyNTAsInVzZXJUeXBlIjoiY2xpZW50MTIzIiwiZXhwIjoxNzU2NzEyMjUwLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJuYW1lIjoiY2xpZW50MTIzIiwidG9rZW4iOiIzNjc3YjIzYmFhMDhmNzRjMjhhYmEwN2YwY2I2NTU0ZSJ9.GGeatgfoGdG56HV9fvgaONY4kJKso5M4crF9KHOV_zYq0U5aM7BT6tyfgnni7t0FG6o8wSLpkmTFcwLJc2g9Fw 3. 实现代码示例 1. 常量 package com.cdkjframework.oauth2.constant; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.constant * @ClassName: OAuth2Constant * @Description: 常量 * @Author: xiaLin * @Date: 2025/7/31 16:51 * @Version: 1.0 */ public interface OAuth2Constant { /** * 授权端点 * 注意:不以双斜杠结尾,避免路径拼接出现 */ String OAUTH2 = "/oauth2/"; /** * 授权类型 - 刷新令牌 */ String REFRESH_TOKEN = "refresh_token"; /** * 授权端点 */ String AUTHORIZE = OAUTH2 + "authorize"; /** * 访问令牌端点 */ String OAUTH2_ACCESS_TOKEN = OAUTH2 + "token"; /** * 撤销令牌端点 */ String OAUTH2_REVOKE = OAUTH2 + "revoke"; /** * 密钥 */ String SECRET_KEY = "cdkj-framework-jwt"; /** * 权限 */ String AUTHORIZATION = "Authorization"; /** * 权限值 */ String BEARER = "Bearer "; /** * 授权类型 */ String CLIENT_ID = "client_id"; /** * 空 */ String EMPTY = ""; /** * 授权码 */ String ACCESS_TOKEN = "access_token"; /** * 过期时间 */ String EXPIRES_IN = "expires_in"; /** * 授权类型 */ String TOKEN_TYPE = "token_type"; /** * 令牌时间 */ String ACCESS_TOKEN_TIME_TO_LIVE = "accessTokenTimeToLive"; /** * 刷新令牌时间 */ String REFRESH_TOKEN_TIME_TO_LIVE = "refreshTokenTimeToLive"; /** * 授权编码错误 */ Integer CODE_ERROR = 10000; /** * 授权编码过期 */ Integer CODE_EXPIRED = 10001; /** * client_id 错误 */ Integer CLIENT_ERROR = 10002; /** * 密钥错误 */ Integer SECRET_ERROR = 10003; /** * 刷新令牌错误 */ Integer REFRESH_TOKEN_ERROR = 10004; /** * 刷新令牌过期 */ Integer REFRESH_TOKEN_EXPIRED = 10005; /** * 授权类型未找到 */ Integer GRANT_TYPE = 10006; /** * 授权类型错误 */ Integer GRANT_TYPE_ERROR = 10007; /** * 时间戳错误 */ Integer TIMESTAMP_ERROR = 10008; /** * 签名错误 */ Integer SIGNATURE_ERROR = 10009; /** * 系统错误 */ Integer ERROR = 999; } 2. 授权服务器配置 package com.cdkjframework.oauth2.config; import com.cdkjframework.oauth2.filter.JwtTokenFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.util.ArrayList; import java.util.List; import static com.cdkjframework.oauth2.constant.OAuth2Constant.*; /** * @ProjectName: wiki-oauth2 * @Package:com.cdkjframework.oauth2.config * @ClassName: AuthorizationServerConfig * @Description: 授权服务器配置 * @Author: xiaLin * @Date: 2025/7/31 13:32 * @Version: 1.0 */ @Configuration @EnableWebSecurity @RequiredArgsConstructor public class AuthorizationServerConfig { /** * 凭证过滤 */ private final JwtTokenFilter jwtTokenFilter; /** * OAuth2 配置 */ private final Oauth2Config oauth2Config; /** * 定义安全策略 * * @param http http安全 * @return 安全过滤链 * @throws Exception 异常信息 */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { List<String> publicEndpoints = new ArrayList<>() { { add(AUTHORIZE); add(OAUTH2_ACCESS_TOKEN); } }; publicEndpoints.addAll(oauth2Config.getAllowList()); List<String> finalPathArray = oauth2Config.getApiPathPatterns(); http // 配置授权规则 .authorizeHttpRequests(authorizeRequests -> authorizeRequests // 公开的 OAuth2 端点 .requestMatchers(publicEndpoints.toArray(String[]::new)).permitAll() // 权限页面 .requestMatchers(finalPathArray.toArray(String[]::new)).authenticated() // 其他请求需要认证 .anyRequest().authenticated()) // 在 UsernamePasswordAuthenticationFilter 前添加 JWT 过滤器 .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) // 禁用 Session .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 可选:禁用 CSRF 防护(针对无状态认证,如 JWT) .csrf(csrf -> csrf.disable()); return http.build(); } /** * 客户端详情存储库 */ @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .authorizationEndpoint(AUTHORIZE) .tokenEndpoint(OAUTH2_ACCESS_TOKEN) .tokenRevocationEndpoint(OAUTH2_REVOKE) .build(); } } 3. JWT 令牌过滤器 package com.cdkjframework.oauth2.filter; import com.cdkjframework.oauth2.constant.OAuth2Constant; import com.cdkjframework.oauth2.entity.ClientDetails; import com.cdkjframework.oauth2.provider.JwtTokenProvider; import com.cdkjframework.oauth2.repository.OAuth2ClientRepository; import com.cdkjframework.util.tool.StringUtils; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.filter * @ClassName: JwtTokenFilter * @Description: JWT 令牌过滤器 * @Author: xiaLin * @Date: 2025/7/31 16:48 * @Version: 1.0 */ @Component @RequiredArgsConstructor public class JwtTokenFilter extends OncePerRequestFilter { /** * 使用自定义的 RegisteredClientRepository 进行客户端与权限信息的加载 */ private final RegisteredClientRepository registeredClientRepository; /** * 是否进行内部筛选 * * @param request 请求 * @param response 响应 * @param filterChain 过滤器链 * @throws ServletException Servlet异常 * @throws IOException IO异常 */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader(OAuth2Constant.AUTHORIZATION); if (StringUtils.isNotNullAndEmpty(token) && token.startsWith(OAuth2Constant.BEARER)) { try { String jwt = token.replace(OAuth2Constant.BEARER, OAuth2Constant.EMPTY); // Validate and parse the JWT token JwtTokenProvider.validateToken(jwt); String clientId = JwtTokenProvider.getClientIdFromToken(jwt); // 通过自定义仓库加载 RegisteredClient RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId); if (registeredClient == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\\\\"error\\\\":\\\\"Client Not Found\\\\",\\\\"clientId\\\\":\\\\"" + clientId + "\\\\"}"); return; } // 从 RegisteredClient 构造权限集合(Scopes -> SCOPE_xxx,GrantTypes -> GRANT_xxx) List<GrantedAuthority> authorities = parseAuthorities(registeredClient); // 基于 HTTP 方法的简单权限校验:GET/HEAD/OPTIONS 需要 SCOPE_read,其它需要 SCOPE_write if (!checkHttpMethodPermission(request.getMethod(), authorities)) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json"); response.getWriter().write("{\\\\"error\\\\":\\\\"Forbidden\\\\",\\\\"message\\\\":\\\\"insufficient_scope\\\\"}"); return; } request.setAttribute(OAuth2Constant.CLIENT_ID, clientId); // 设置认证信息到 SecurityContext SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(clientId, registeredClient.getClientSecret(), authorities) ); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\\\\"error\\\\": \\\\"Invalid Token\\\\", \\\\"message\\\\": \\\\"" + e.getMessage() + "\\\\"}"); return; } } // Proceed with the filter chain filterChain.doFilter(request, response); } /** * 将 RegisteredClient 的 scopes 与授权类型转换为权限集合 * - Scopes: 生成形如 SCOPE_xxx 的权限 * - GrantTypes: 生成形如 GRANT_xxx 的权限 */ private List<GrantedAuthority> parseAuthorities(RegisteredClient client) { List<GrantedAuthority> list = new ArrayList<>(); // scopes -> SCOPE_* client.getScopes().forEach(scope -> list.add(new SimpleGrantedAuthority("SCOPE_" + scope))); // grant types -> GRANT_* client.getAuthorizationGrantTypes().stream() .map(AuthorizationGrantType::getValue) .forEach(gt -> list.add(new SimpleGrantedAuthority("GRANT_" + gt))); return list; } /** * 基于 HTTP 方法的通用权限校验 */ private boolean checkHttpMethodPermission(String method, List<GrantedAuthority> authorities) { String m = method == null ? "GET" : method.toUpperCase(Locale.ROOT); boolean isRead = m.equals("GET") || m.equals("HEAD") || m.equals("OPTIONS"); String required = isRead ? "SCOPE_read" : "SCOPE_write"; return authorities.stream().anyMatch(a -> a.getAuthority().equals(required)); } } 4. JWT 令牌提供者 package com.cdkjframework.oauth2.provider; import com.cdkjframework.constant.BusinessConsts; import com.cdkjframework.constant.IntegerConsts; import com.cdkjframework.oauth2.constant.OAuth2Constant; import com.cdkjframework.util.encrypts.JwtUtils; import com.cdkjframework.util.encrypts.Md5Utils; import com.cdkjframework.util.tool.number.ConvertUtils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.stereotype.Component; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.UUID; import static com.cdkjframework.oauth2.constant.OAuth2Constant.TOKEN_TYPE; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.service * @ClassName: JwtTokenProvider * @Description: JWT 令牌提供者 * @Author: xiaLin * @Date: 2025/7/31 13:30 * @Version: 1.0 */ @Component public class JwtTokenProvider { /** * 生成 JWT Token * * @param clientId 客户端ID * @param accessTokenTimeToLive 访问令牌存活时间(秒) * @return 返回 JWT Token */ public static String generateToken(String clientId, Long accessTokenTimeToLive) { LocalDateTime now = LocalDateTime.now(); // 1 天有效期 LocalDateTime expiryDate = now.plusSeconds(accessTokenTimeToLive); Instant time = expiryDate.atZone(ZoneId.systemDefault()).toInstant(); Long seconds = time.getEpochSecond() * IntegerConsts.ONE_THOUSAND; return JwtUtils.createJwt(parseToken(clientId, time.getEpochSecond()), OAuth2Constant.SECRET_KEY, seconds); } /** * 生成 Refresh Token * * @param clientId 客户端ID * @param refreshTokenTimeToLive 刷新令牌存活时间(秒) * @return 返回 Refresh Token */ public static String generateRefreshToken(String clientId, Long refreshTokenTimeToLive) { Instant time = Instant.now().plus(refreshTokenTimeToLive, ChronoUnit.SECONDS); // 构建包含客户端标识和唯一性的JWT return Jwts.builder() // JWT唯一标识 .setId(UUID.randomUUID().toString()) // 绑定客户端ID[1,8](@ref) .setSubject(clientId) // 签发时间 .setIssuedAt(Date.from(Instant.now())) .setClaims(parseToken(clientId, time.getEpochSecond())) // 30 天有效期[4](@ref) .setExpiration(Date.from(time)) // 明确令牌类型 .claim(TOKEN_TYPE, "refresh") // 强加密签名[2](@ref) .signWith(SignatureAlgorithm.HS512, OAuth2Constant.SECRET_KEY) .compact(); } /** * 解析 Token * * @param clientId 客户端ID * @param time 时间 * @return 返回 解析后的 Token 信息 */ private static Map<String, Object> parseToken(String clientId, Long time) { // 生成 JWT token Map<String, Object> map = new HashMap<>(IntegerConsts.FOUR); map.put(BusinessConsts.LOGIN_NAME, clientId); map.put(BusinessConsts.TIME, time); map.put(BusinessConsts.USER_NAME, clientId); map.put(BusinessConsts.USER_TYPE, clientId); map.put(BusinessConsts.DISPLAY_NAME, clientId); String token = Md5Utils.getMd5(clientId); map.put(BusinessConsts.HEADER_TOKEN, token); return map; } /** * 解析 Token 获取用户名(clientId) * * @param token 令牌 * @return 返回 用户名(clientId) */ public static String getClientIdFromToken(String token) { Claims claims = JwtUtils.parseJwt(token, OAuth2Constant.SECRET_KEY); if (claims == null) { return null; } return ConvertUtils.convertString(claims.get(BusinessConsts.LOGIN_NAME)); } /** * 验证 Token 是否有效 * * @param token 令牌 * @return 返回是否有效 */ public static boolean validateToken(String token) { try { Jwts.parser().setSigningKey(OAuth2Constant.SECRET_KEY).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } /** * 生成 MD5 签名 * * @param clientSecret 客户端密钥 * @param clientId 客户端ID * @param timestamp 时间戳 * @return 返回 MD5 签名 */ public static String md5Signature(String clientSecret, String clientId, String timestamp) { String input = "client_id=" + clientId + "&client_secret=" + clientSecret + "×tamp=" + timestamp; return Md5Utils.getMd5(input); } } 5. OAuth2授权服务接口 package com.cdkjframework.oauth2.service; import com.cdkjframework.builder.String; import com.cdkjframework.oauth2.entity.TokenResponse; /** * OAuth2授权服务接口 * * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.service * @ClassName: Oauth2AuthorizationService * @Description: OAuth2授权服务接口 * @Author: xiaLin * @Date: 2025/7/31 21:15 * @Version: 1.0 */ public interface Oauth2AuthorizationService { /** * 授权端点 * * @param clientId 客户端ID * @param responseType 响应类型 * @param scope 授权范围 * @return 授权页面或信息 */ ResponseBuilder authorizationCode(String clientId, String responseType, String scope); /** * 获取访问令牌 * * @param grantType 授权类型 * @param clientId 客户端ID * @param code 授权码 * @param timestamp 时间戳 * @param refreshToken 刷新令牌 * @param signature 签名 * @return */ TokenResponse token(String grantType, String clientId, String code, String timestamp, String refreshToken, String signature); } 6. OAuth2授权服务实现类 package com.cdkjframework.oauth2.service.impl; import com.cdkjframework.builder.ResponseBuilder; import com.cdkjframework.constant.IntegerConsts; import com.cdkjframework.exceptions.GlobalRuntimeException; import com.cdkjframework.oauth2.config.Oauth2Config; import com.cdkjframework.oauth2.entity.AuthorizationCode; import com.cdkjframework.oauth2.entity.OAuth2Token; import com.cdkjframework.oauth2.entity.TokenResponse; import com.cdkjframework.oauth2.provider.JwtTokenProvider; import com.cdkjframework.oauth2.repository.AuthorizationCodeRepository; import com.cdkjframework.oauth2.repository.CustomRegisteredClientRepository; import com.cdkjframework.oauth2.repository.OAuth2TokenRepository; import com.cdkjframework.oauth2.service.Oauth2AuthorizationService; import com.cdkjframework.oauth2.service.Oauth2TokenService; import com.cdkjframework.util.network.http.HttpServletUtils; import com.cdkjframework.util.tool.CollectUtils; import com.cdkjframework.util.tool.StringUtils; import com.cdkjframework.util.tool.number.ConvertUtils; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.stereotype.Service; import org.springframework.util.ObjectUtils; import java.security.SecureRandom; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HashMap; import java.util.Map; import static com.cdkjframework.oauth2.constant.OAuth2Constant.*; /** * OAuth2授权服务实现类 * * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.service.impl * @ClassName: Oauth2AuthorizationServiceImpl * @Description: OAuth2授权服务实现类 * @Author: xiaLin * @Date: 2025/7/31 21:16 * @Version: 1.0 */ @Service @RequiredArgsConstructor public class Oauth2AuthorizationServiceImpl implements Oauth2AuthorizationService { /** * 自定义注册的客户端存储库 */ private final CustomRegisteredClientRepository registeredClientRepository; /** * 授权码存储库 */ private final AuthorizationCodeRepository authorizationCodeRepository; /** * OAuth2客户端存储库 */ private final OAuth2TokenRepository auth2TokenRepository; /** * oauth2令牌服务 */ private final Oauth2TokenService oauth2TokenService; /** * Oauth2配置 */ private final Oauth2Config oauth2Config; /** * 安全字符集(排除易混淆字符) */ private static final String SAFE_CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz23456789"; /** * 授权端点 * * @param clientId 客户端ID * @param responseType 响应类型 * @param scope 授权范围 * @return code */ @Override public ResponseBuilder authorizationCode(String clientId, String responseType, String scope) { HttpServletResponse response = HttpServletUtils.getResponse(); // 验证客户端 RegisteredClient client = registeredClientRepository.findByClientId(clientId); if (client == null) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(CODE_ERROR, "Invalid client ID"); } // 生成授权编码 String authCode = generate(IntegerConsts.SEVEN); AuthorizationCode code = new AuthorizationCode(); code.setCode(authCode); code.setClientId(clientId); code.setRedirectUri(client.getRedirectUris().stream().findFirst().orElse(null)); code.setIssuedAt(LocalDateTime.now()); // 设置过期时间为10分钟后 code.setExpiryAt(code.getIssuedAt().plusMinutes(IntegerConsts.TEN)); // 检查授权码是否已存在 authorizationCodeRepository.save(code); // 返回授权码 return ResponseBuilder.successBuilder(authCode); } /** * 获取访问令牌 * * @param grantType 授权类型 * @param clientId 客户端ID * @param code 授权码 * @param timestamp 时间戳 * @param refreshToken 刷新令牌 * @param signature 签名 * @return 访问令牌 */ @Override public TokenResponse token(String grantType, String clientId, String code, String timestamp, String refreshToken, String signature) { HttpServletResponse response = HttpServletUtils.getResponse(); // 2. 校验客户端 ID 和客户端密钥 if (StringUtils.isNullAndSpaceOrEmpty(grantType)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(GRANT_TYPE, "grant_type not found"); } // 3. 根据授权类型处理请求 AuthorizationGrantType authorizationGrantType = new AuthorizationGrantType(grantType.toLowerCase()); if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationGrantType)) { // 处理授权码模式 return handleAuthorizationCode(response, authorizationGrantType, clientId, code, timestamp, signature); } else if (AuthorizationGrantType.REFRESH_TOKEN.equals(authorizationGrantType)) { // 处理刷新令牌模式 return handleRefreshToken(response, authorizationGrantType, refreshToken); } else { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(GRANT_TYPE, "Unsupported grant_type"); } } /** * 处理授权码模式 * * @param response HTTP响应对象 * @param grantType 授权类型 * @param clientId 客户端ID * @param code 授权码 * @param timestamp 时间戳 * @param signature 签名 * @return 响应构建器 */ private TokenResponse handleAuthorizationCode(HttpServletResponse response, AuthorizationGrantType grantType, String clientId, String code, String timestamp, String signature) { // 验证时间戳 try { verifyTimestamp(timestamp); } catch (IllegalArgumentException e) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(TIMESTAMP_ERROR, e.getMessage()); } // 1. 验证授权码是否有效 AuthorizationCode authorizationCode = authorizationCodeRepository.findByCode(code); if (ObjectUtils.isEmpty(authorizationCode)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(CODE_ERROR, "Invalid authorization code"); } if (authorizationCode.isExpired()) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(CODE_EXPIRED, "expired authorization code"); } // 2. 根据 clientId 查找注册的客户端 RegisteredClient client = registeredClientRepository.findByClientId(clientId); if (ObjectUtils.isEmpty(client)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(CLIENT_ERROR, "client_id not found"); } // 3. 验证签名 try { verifySignature(client, signature, clientId, timestamp); } catch (IllegalArgumentException e) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(SIGNATURE_ERROR, e.getMessage()); } // 4. 验证授权类型是否被允许 if (!client.getAuthorizationGrantTypes().contains(grantType)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(GRANT_TYPE_ERROR, "Invalid grant_type"); } // 7. 将生成的令牌存储到数据库中 OAuth2Token oauth2Token = new OAuth2Token(); oauth2Token.setUserId(code); // 8. 构建授权 return buildToken(client, oauth2Token); } /** * 刷新访问令牌 * * @param refreshToken 刷新令牌 * @return 刷新后的访问令牌 */ public TokenResponse handleRefreshToken(HttpServletResponse response, AuthorizationGrantType grantType, String refreshToken) { if (StringUtils.isNullAndSpaceOrEmpty(refreshToken)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(REFRESH_TOKEN_ERROR, "Invalid refresh token"); } // 1. 验证刷新令牌是否有效 OAuth2Token oauth2Token = auth2TokenRepository.findByRefreshToken(refreshToken); if (ObjectUtils.isEmpty(oauth2Token) || StringUtils.isNullAndSpaceOrEmpty(oauth2Token.getRefreshToken())) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(REFRESH_TOKEN_ERROR, "Invalid refresh token"); } int status = ConvertUtils.convertInt(oauth2Token.getStatus()); if (!IntegerConsts.ONE.equals(status)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(REFRESH_TOKEN_ERROR, "Invalid refresh token"); } // 2. 检查刷新令牌是否过期 if (oauth2Token.isExpired()) { response.setStatus(HttpStatus.BAD_REQUEST.value()); throw new GlobalRuntimeException(REFRESH_TOKEN_EXPIRED, "Refresh token has expired"); } // 2. 根据 clientId 查找注册的客户端 RegisteredClient client = registeredClientRepository.findByClientId(oauth2Token.getClientId()); assert client != null; // 3. 构建授权 return buildToken(client, oauth2Token); } /** * 构建并返回新的访问令牌和刷新令牌 * * @param client 注册的客户端 * @param oauth2Token 当前的 OAuth2 令牌实体 * @return 响应构建器,包含新的访问令牌和刷新令牌 */ private TokenResponse buildToken(RegisteredClient client, OAuth2Token oauth2Token) { // 1. 获取令牌设置 TokenSettings settings = client.getTokenSettings(); Long accessTokenTimeToLive, refreshTokenTimeToLive; if (settings != null && CollectUtils.isNotEmpty(settings.getSettings())) { Map<String, Object> map = settings.getSettings(); accessTokenTimeToLive = ConvertUtils.convertLong(map.get(ACCESS_TOKEN_TIME_TO_LIVE)); refreshTokenTimeToLive = ConvertUtils.convertLong(map.get(REFRESH_TOKEN_TIME_TO_LIVE)); } else { accessTokenTimeToLive = oauth2Config.getAccessTokenTimeToLive(); refreshTokenTimeToLive = oauth2Config.getRefreshTokenTimeToLive(); } // 2. 成新的访问令牌 String newAccessToken = JwtTokenProvider.generateToken(client.getClientId(), accessTokenTimeToLive); oauth2TokenService.validateToken(newAccessToken); // 3. 生成新的刷新令牌(可选) String newRefreshToken = JwtTokenProvider.generateRefreshToken(client.getClientId(), refreshTokenTimeToLive); LocalDateTime localDateTime = LocalDateTime.now(); // 4. 更新数据库中的刷新令牌(这里我们选择创建一个新刷新令牌) oauth2Token.setAccessToken(newAccessToken); oauth2Token.setRefreshToken(newRefreshToken); oauth2Token.setIssuedAt(localDateTime); oauth2Token.setClientId(client.getClientId()); // 5. 设置访问令牌过期时间为 7 天 oauth2Token.setExpiration(localDateTime.plusDays(IntegerConsts.SEVEN)); auth2TokenRepository.save(oauth2Token); // 6. 返回令牌信息 TokenResponse tokenResponse = new TokenResponse(); tokenResponse.setAccessToken(newAccessToken); tokenResponse.setRefreshToken(newRefreshToken); tokenResponse.setExpiresIn(accessTokenTimeToLive); // 返回新的访问令牌 return tokenResponse; } /** * 验证时间戳 * * @param timestamp 时间戳 */ private void verifyTimestamp(String timestamp) { LocalDateTime currentTime = LocalDateTime.now(); // 这里假设 timestamp 是一个表示毫秒的字符串 long requestTimeMillis; try { requestTimeMillis = Long.parseLong(timestamp); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid timestamp format"); } // LocalDateTime requestTime = LocalDateTime.ofEpochSecond(requestTimeMillis, IntegerConsts.ZERO, java.time.ZoneOffset.UTC); LocalDateTime beijingTime = requestTime.atZone(ZoneId.of("UTC")).withZoneSameInstant(ZoneId.of("Asia/Shanghai")) .toLocalDateTime(); // 允许的时间窗口(例如5分钟) long allowedWindowMillis = IntegerConsts.FIVE * IntegerConsts.SIXTY * IntegerConsts.ONE_THOUSAND; long timeDifference = Math.abs(java.time.Duration.between(currentTime, beijingTime).toMillis()); if (timeDifference > allowedWindowMillis) { throw new IllegalArgumentException("Timestamp is out of the allowed range"); } } /** * 验证签名 md5 签名 * * @param client 注册的客户端 * @param signature 签名 * @param clientId 客户端ID * @param timestamp 时间戳 */ private void verifySignature(RegisteredClient client, String signature, String clientId, String timestamp) { String sign = JwtTokenProvider.md5Signature(client.getClientSecret(), clientId, timestamp); if (!sign.equals(signature)) { throw new IllegalArgumentException("Invalid signature"); } } /** * 生成指定长度的随机字符串 * * @param length 生成字符串的长度 * @return 随机字符串 */ public String generate(int length) { if (length <= 0) { throw new IllegalArgumentException("Length must be positive"); } SecureRandom random = new SecureRandom(); StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { int index = random.nextInt(SAFE_CHARACTERS.length()); sb.append(SAFE_CHARACTERS.charAt(index)); } return sb.toString(); } } 5. 自定义注册客户端存储库 package com.cdkjframework.oauth2.repository; import com.cdkjframework.exceptions.GlobalRuntimeException; import com.cdkjframework.oauth2.config.Oauth2Config; import com.cdkjframework.oauth2.entity.ClientDetails; import com.cdkjframework.util.tool.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import java.util.*; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.repository * @ClassName: CustomRegisteredClientRepository * @Description: 自定义注册客户端存储库 * @Author: xiaLin * @Date: 2025/7/31 18:01 * @Version: 1.0 */ @Component @RequiredArgsConstructor public class CustomRegisteredClientRepository implements RegisteredClientRepository { /** * OAuth2客户端存储库 */ private final OAuth2ClientRepository oauth2ClientRepository; /** * OAuth2配置 */ private final Oauth2Config oauth2Config; /** * 保存注册的客户端 * * @param registeredClient 注册的客户端 */ @Override public void save(RegisteredClient registeredClient) { oauth2ClientRepository.save(toEntity(registeredClient)); } /** * 根据ID查找注册的客户端 * * @param id 客户端ID * @return 注册的客户端 */ @Override public RegisteredClient findById(String id) { // 根据 ID 查询客户端 return oauth2ClientRepository.findById(id) .map(entity -> { try { return toRegisteredClient(entity); } catch (JsonProcessingException e) { throw new GlobalRuntimeException(e); } }) .orElse(null); } /** * 根据客户端ID查找注册的客户端 * * @param clientId 客户端ID * @return 注册的客户端 */ @Override public RegisteredClient findByClientId(String clientId) { // 这是最常用的方法,根据 client_id 查询客户端 // 授权服务器在处理请求时会频繁调用此方法 return oauth2ClientRepository.findByClientId(clientId) .map(entity -> { try { RegisteredClient rc = toRegisteredClient(entity); return rc; } catch (JsonProcessingException e) { throw new GlobalRuntimeException(e); } }) .orElse(null); } private RegisteredClient toRegisteredClient(ClientDetails entity) throws JsonProcessingException { // 实现从 Entity 到 RegisteredClient 的转换 ObjectMapper mapper = new ObjectMapper(); // 安全拆分工具:null/空白返回空流 java.util.function.Function<String, java.util.stream.Stream<String>> safeSplit = (str) -> { if (str == null || str.isBlank()) return java.util.stream.Stream.empty(); return Arrays.stream(str.split(StringUtils.COMMA)).map(String::trim).filter(s -> !s.isEmpty()); }; // clientSettings / tokenSettings 允许为空,默认用空 Map Map<String, Object> clientSettingsMap; if (entity.getClientSettings() == null || entity.getClientSettings().isBlank()) { clientSettingsMap = java.util.Collections.emptyMap(); } else { Map<?, ?> raw = mapper.readValue(entity.getClientSettings(), Map.class); clientSettingsMap = new java.util.HashMap<>(); raw.forEach((k, v) -> clientSettingsMap.put(String.valueOf(k), v)); } Map<String, Object> tokenSettingsMap; if (entity.getTokenSettings() == null || entity.getTokenSettings().isBlank()) { tokenSettingsMap = java.util.Collections.emptyMap(); } else { Map<?, ?> raw = mapper.readValue(entity.getTokenSettings(), Map.class); tokenSettingsMap = new java.util.HashMap<>(); raw.forEach((k, v) -> tokenSettingsMap.put(String.valueOf(k), v)); } // 构建 ClientSettings:显式提供默认值,避免 NPE boolean requireProofKey = false; Object rpkVal = clientSettingsMap.get("require_proof_key"); if (rpkVal != null) { requireProofKey = (rpkVal instanceof Boolean) ? (Boolean) rpkVal : Boolean.parseBoolean(String.valueOf(rpkVal)); } boolean requireAuthorizationConsent = false; Object racVal = clientSettingsMap.get("require_authorization_consent"); if (racVal != null) { requireAuthorizationConsent = (racVal instanceof Boolean) ? (Boolean) racVal : Boolean.parseBoolean(String.valueOf(racVal)); } return RegisteredClient.withId(entity.getId()) .clientId(entity.getClientId()) // 确保数据库中的密码是加密后的 .clientSecret(entity.getClientSecret()) .clientAuthenticationMethods(clientAuthenticationMethods -> { Set<String> methods = new HashSet<>(); safeSplit.apply(entity.getClientAuthenticationMethods()).forEach(methods::add); if (methods.isEmpty()) { // 对于 public 客户端,允许 none 方式(授权码阶段不要求密钥) methods.add(ClientAuthenticationMethod.NONE.getValue()); } methods.stream().map(ClientAuthenticationMethod::new).forEach(clientAuthenticationMethods::add); }) .authorizationGrantTypes(authorizationGrantTypes -> { // 合并并补充授权类型,确保支持 authorization_code Set<String> grantValues = new HashSet<>(); safeSplit.apply(entity.getAuthorizationGrantTypes()).forEach(grantValues::add); if (!grantValues.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())) { grantValues.add(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); } // 可选:若希望配合刷新令牌 if (!grantValues.contains(AuthorizationGrantType.REFRESH_TOKEN.getValue())) { grantValues.add(AuthorizationGrantType.REFRESH_TOKEN.getValue()); } grantValues.stream().map(AuthorizationGrantType::new).forEach(authorizationGrantTypes::add); }) .redirectUris(redirectUris -> { java.util.List<String> list = safeSplit.apply(entity.getRedirectUris()).toList(); String fallback = oauth2Config != null && oauth2Config.getDefaultRedirectUri() != null ? oauth2Config.getDefaultRedirectUri() : "https://localhost/callback"; String chosen = list.isEmpty() ? fallback : list.get(0); redirectUris.add(chosen); }) .scopes(scopes -> safeSplit.apply(entity.getScopes()) .forEach(scopes::add) ) .clientSettings( ClientSettings.builder() .requireProofKey(requireProofKey) .requireAuthorizationConsent(requireAuthorizationConsent) .build() ) .tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build()) .build(); } /** * 保存 * * @param registeredClient 注册的 * @return 注册的 */ private ClientDetails toEntity(RegisteredClient registeredClient) { // 实现从 RegisteredClient 到 Entity 的转换(用于save方法) ClientDetails entity = ClientDetails.builder().build(); entity.setId(registeredClient.getId()); entity.setClientId(registeredClient.getClientId()); entity.setClientSecret(registeredClient.getClientSecret()); // 确保已经加密 // ... 设置其他字段,将集合转换为逗号分隔的字符串 return entity; } } 6. 实体 1. 授权码实体 package com.cdkjframework.oauth2.entity; import lombok.Data; import java.time.LocalDateTime; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.entity * @ClassName: AuthorizationCode * @Description: 授权码实体 * @Author: xiaLin * @Date: 2025/7/31 13:31 * @Version: 1.0 */ @Data public class AuthorizationCode { /** * 编码 */ private String code; /** * 客户ID */ private String clientId; /** * 回调地址 */ private String redirectUri; /** * 开始时间 */ private LocalDateTime issuedAt; /** * 过期时间 */ private LocalDateTime expiryAt; /** * 检查授权码是否过期 * * @return true 如果授权码已过期,否则返回 false */ public boolean isExpired() { return expiryAt.isBefore(LocalDateTime.now()); } } 2. 客户端详情实体 package com.cdkjframework.oauth2.entity; import lombok.Builder; import lombok.Data; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.entity * @ClassName: ClientDetails * @Description: 客户端详情实体 * @Author: xiaLin * @Date: 2025/7/31 18:05 * @Version: 1.0 */ @Data @Builder public class ClientDetails { /** * 主键ID */ private String id; /** * 客户端ID */ private String clientId; /** * 客户端密钥 */ private String clientSecret; /** * 用逗号分隔的字符串存储,例如 "client_secret_basic,client_secret_post" */ private String clientAuthenticationMethods; /** * 用逗号分隔的字符串存储,例如 "authorization_code,refresh_token,client_credentials" */ private String authorizationGrantTypes; /** * 用逗号分隔的字符串存储,例如 "http:127.0.0.1:8080/login/oauth2/code/messaging-client-oidc,http:127.0.0.1:8080/authorized" */ private String redirectUris; /** * 用逗号分隔的字符串存储,例如 "openid,profile,message.read,message.write" */ private String scopes; /** * JSON 字符串 */ private String clientSettings; /** * JSON 字符串 */ private String tokenSettings; } 3. OAuth2 令牌实体 package com.cdkjframework.oauth2.entity; import lombok.Data; import java.time.LocalDateTime; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.entity * @ClassName: OAuth2Token * @Description: OAuth2 令牌实体 * @Author: xiaLin * @Date: 2025/7/31 22:06 * @Version: 1.0 */ @Data public class OAuth2Token { /** * 令牌ID */ private String id; /** * 客户端ID */ private String clientId; /** * 用户ID */ private String userId; /** * 作用域 */ private String accessToken; /** * 刷新令牌 */ private String refreshToken; /** * 令牌类型 */ private LocalDateTime issuedAt; /** * 过期时间 */ private LocalDateTime expiration; /** * 状态 0-无效 1-有效 */ private Integer status; /** * 检查授权码是否过期 * * @return true 如果授权码已过期,否则返回 false */ public boolean isExpired() { return expiration.isBefore(LocalDateTime.now()); } } 4. 令牌响应实体 package com.cdkjframework.oauth2.entity; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * @ProjectName: wiki-framework * @Package: com.cdkjframework.oauth2.entity * @ClassName: TokenResponse * @Description: 令牌响应实体 * @Author: xiaLin * @Date: 2025/8/31 15:40 * @Version: 1.0 */ @Data public class TokenResponse { /** * 访问令牌 */ @JsonProperty("access_token") private String accessToken; /** * 令牌类型 */ @JsonProperty("token_type") private String tokenType = "Bearer"; /** * 令牌过期时间,单位为秒 */ @JsonProperty("expires_in") private Long expiresIn; /** * 刷新令牌 */ @JsonProperty("refresh_token") private String refreshToken; } 7. 提供接口 以下接口需要项目实现,具体可参考示例项目: 示例项目:https://gitee.com/cdkjframework/framewiki-example/tree/master/example-oauth2 1. 授权码仓库 package com.cdkjframework.oauth2.repository; import com.cdkjframework.oauth2.entity.AuthorizationCode; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.repository * @ClassName: AuthorizationCodeRepository * @Description: 授权码仓库 * @Author: xiaLin * @Date: 2025/7/31 13:30 * @Version: 1.0 */ public interface AuthorizationCodeRepository { /** * 根据授权码查找授权码实体 * * @param code 授权码 * @return 授权码实体 */ AuthorizationCode findByCode(String code); /** * 保存授权码实体 * * @param authorizationCode 授权码实体 */ void save(AuthorizationCode authorizationCode); } 2. OAuth2客户端仓库 package com.cdkjframework.oauth2.repository; import com.cdkjframework.oauth2.entity.ClientDetails; import java.util.List; import java.util.Optional; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.repository * @ClassName: OAuth2ClientRepository * @Description: OAuth2客户端仓库 * @Author: xiaLin * @Date: 2025/7/31 18:02 * @Version: 1.0 */ public interface OAuth2ClientRepository { /** * 根据客户端ID查找客户端详情 * * @param clientId 客户端ID * @return 客户端详情 */ Optional<ClientDetails> findByClientId(String clientId); /** * 保存客户端详情 * * @param clientDetails 客户端详情 */ void save(ClientDetails clientDetails); /** * 根据ID查找客户端详情 * * @param id 客户端ID * @return 客户端详情 */ Optional<ClientDetails> findById(String id); } 3. OAuth2令牌仓库 package com.cdkjframework.oauth2.repository; import com.cdkjframework.oauth2.entity.OAuth2Token; /** * @ProjectName: wiki-oauth2 * @Package: com.cdkjframework.oauth2.repository * @ClassName: OAuth2TokenRepository * @Description: OAuth2令牌仓库 * @Author: xiaLin * @Date: 2025/7/31 22:33 * @Version: 1.0 */ public interface OAuth2TokenRepository { /** * 保存OAuth2令牌 * * @param oAuth2Token OAuth2令牌实体 */ void save(OAuth2Token oAuth2Token); /** * 根据访问令牌查找OAuth2令牌 * * @param refreshToken 访问令牌 * @return OAuth2令牌实体 */ OAuth2Token findByRefreshToken(String refreshToken); } 4. OAuth2令牌服务接口 package com.cdkjframework.oauth2.service; /** * OAuth2令牌服务接口 * * @ProjectName: cdkjframework * @Package: com.cdkjframework.core.controller.realization * @ClassName: Oauth2TokenService * @Description: OAuth2令牌服务接口 * @Author: xiaLin * @Version: 1.0 */ public interface Oauth2TokenService { /** * 验证令牌 * * @param token 令牌 * @throws IllegalArgumentException 如果令牌无效 */ default void validateToken(String token) { // 默认实现:可以在这里添加通用的令牌验证逻辑 if (token == null || token.isEmpty()) { throw new IllegalArgumentException("Token cannot be null or empty"); } } } 框架特性速览 开箱即用: 10分钟完成OAuth2服务搭建 多模式认证: 支持表单登录/JWT/OAuth2混合认证 精细审计: 全链路记录授权码生成、令牌发放操作 跨云部署: 兼容Kubernetes/阿里云/华为云等云原生环境 下一版本规范 拟定版本:1.2.0 OAuth2增加功能 授权模式扩展: 支持客户端凭证模式(client_credentials)、密码模式(password) 监控集成: 实时统计令牌发放频率、授权成功率 配置中心 增加 nacos 配置中心支持 获取资源与了解更多: 官网:https://framewiki.com/wiki-framework.html Gitee:https://gitee.com/cdkjframework/wiki-framework Github:https://github.com/cdkjframework/wiki-framework 示例项目:https://gitee.com/cdkjframework/framewiki-example 使用许可: Wiki Framework 采用 木兰宽松许可证 (MulanPSL-2.0)。