refactor: 重构安全架构,提取通用安全模块到common-security

- 将JwtAuthenticationFilter、JwtUtil、JwtProperties从auth服务移至common-security模块
- 新增common-security通用安全模块,提供JWT认证、权限验证等核心安全功能
- 重命名SecurityConfiguration为AuthSecurityConfiguration,使用common-security的filter
- 新增JacksonConfiguration配置类,统一JSON序列化配置
- 新增头像更新功能AvatarUpdateRequestDTO
- 移除冗余的UserLoginResponseDTO类
- 更新各服务模块的依赖配置以引入common-security模块
- 新增README.md项目说明文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-12 01:54:21 +08:00
parent 8bd56a6001
commit a4575cebd4
47 changed files with 704 additions and 317 deletions

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain.aioj</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>aioj-backend-common-security</artifactId>
<version>1.0.0</version>
<description>AIOJ 公共安全模块 - JWT 认证和 Spring Security 配置</description>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.meowrain.aioj</groupId>
<artifactId>aioj-backend-common-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.meowrain.aioj</groupId>
<artifactId>aioj-backend-common</artifactId>
<version>${revision}</version>
</parent>
<artifactId>aioj-backend-common-security</artifactId>
<packaging>jar</packaging>
<description>AIOJ 公共安全模块 - JWT 认证和 Spring Security 配置</description>
<dependencies>
<!-- ==================== Spring Security ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- ==================== JWT ==================== -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ==================== Lombok ==================== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- ==================== 内部模块 ==================== -->
<dependency>
<groupId>cn.meowrain.aioj</groupId>
<artifactId>aioj-backend-common-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,44 @@
package cn.meowrain.aioj.backend.framework.security.autoconfigure;
import cn.meowrain.aioj.backend.framework.security.filter.JwtAuthenticationFilter;
import cn.meowrain.aioj.backend.framework.security.properties.JwtPropertiesConfiguration;
import cn.meowrain.aioj.backend.framework.security.utils.JwtUtil;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* Spring Security 自动配置类
*
* 引入 aioj-backend-common-security 依赖后,会自动启用:
* - JWT 认证过滤器
* - Spring Security 基本配置
*
* 配置项application.yml
* jwt:
* enabled: true # 是否启用 JWT 认证(默认 true
* secret: your-secret # JWT 密钥
* access-expire: 7200000 # Access Token 过期时间
* refresh-expire: 604800000 # Refresh Token 过期时间
*/
@AutoConfiguration
@EnableConfigurationProperties(JwtPropertiesConfiguration.class)
public class SecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "jwt", name = "enabled", havingValue = "true", matchIfMissing = true)
public JwtUtil jwtUtil(JwtPropertiesConfiguration jwtConfig) {
return new JwtUtil(jwtConfig);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "jwt", name = "enabled", havingValue = "true", matchIfMissing = true)
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtil jwtUtil) {
return new JwtAuthenticationFilter(jwtUtil);
}
}

View File

@@ -0,0 +1,76 @@
package cn.meowrain.aioj.backend.framework.security.config;
import cn.meowrain.aioj.backend.framework.security.autoconfigure.SecurityAutoConfiguration;
import cn.meowrain.aioj.backend.framework.security.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@AutoConfiguration(after = SecurityAutoConfiguration.class)
@EnableWebSecurity
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "jwt", name = "enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnBean(JwtAuthenticationFilter.class)
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
@ConditionalOnMissingBean(SecurityFilterChain.class)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Swagger 文档路径
.requestMatchers("/doc.html", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**",
"/v3/api-docs/**", "/v3/api-docs", "/favicon.ico")
.permitAll()
// 用户注册、发送验证码等公开接口
.requestMatchers("/v1/user/register", "/v1/user/email/send-code",
"/api/v1/user/register", "/api/v1/user/email/send-code")
.permitAll()
// 文件访问接口(公开,用于访问图片等静态资源)
.requestMatchers("/file/**", "/api/file/**")
.permitAll()
// 内部服务调用路径Feign- 使用具体路径匹配
.requestMatchers("/v1/user/inner/**", "/v1/*/inner/**",
"/api/v1/user/inner/**", "/api/v1/*/inner/**")
.permitAll()
// 其他请求需要认证(可由子类覆盖此配置)
.anyRequest()
.authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@ConditionalOnMissingBean(CorsConfigurationSource.class)
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -0,0 +1,99 @@
package cn.meowrain.aioj.backend.framework.security.filter;
import cn.meowrain.aioj.backend.framework.security.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
/**
* JWT 认证过滤器
* 拦截所有请求,验证 JWT Token 并设置 SecurityContext
*/
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private static final String TOKEN_PREFIX = "Bearer ";
private static final String HEADER_NAME = "Authorization";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil.isTokenValid(token)) {
Claims claims = jwtUtil.parseClaims(token);
Authentication authentication = createAuthentication(claims);
SecurityContextHolder.getContext().setAuthentication(authentication);
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();
}
filterChain.doFilter(request, response);
}
/**
* 从请求中提取 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;
}
/**
* 根据 JWT Claims 创建 Authentication 对象
*/
private Authentication createAuthentication(Claims claims) {
String userId = claims.getSubject();
String role = claims.get("role", String.class);
// 创建权限列表
List<SimpleGrantedAuthority> authorities = Collections
.singletonList(new SimpleGrantedAuthority("ROLE_" + (role != null ? role : "USER")));
// 创建认证对象principal 为 userId 字符串)
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("/v3/api-docs") || path.equals("/favicon.ico") || path.contains("/swagger");
}
}

View File

@@ -0,0 +1,31 @@
package cn.meowrain.aioj.backend.framework.security.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(value = JwtPropertiesConfiguration.PREFIX)
public class JwtPropertiesConfiguration {
public static final String PREFIX = "jwt";
/***
* 开启
*/
private Boolean enabled;
/**
* JWT 密钥(必须 32 字节以上)
*/
private String secret;
/**
* 过期时间(单位:毫秒)
*/
private long accessExpire; // access token TTL
/**
* 刷新令牌时间
*/
private long refreshExpire; // refresh token TTL
}

View File

@@ -0,0 +1,82 @@
package cn.meowrain.aioj.backend.framework.security.utils;
import cn.meowrain.aioj.backend.framework.security.properties.JwtPropertiesConfiguration;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@RequiredArgsConstructor
public class JwtUtil {
private final JwtPropertiesConfiguration jwtConfig;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes());
}
/**
* 生成 Access Token
* @param userId 用户ID
* @param additionalClaims 额外的声明(如 userName, role 等)
*/
public String generateAccessToken(Long userId, Map<String, Object> additionalClaims) {
long now = System.currentTimeMillis();
Map<String, Object> claims = new HashMap<>();
if (additionalClaims != null) {
claims.putAll(additionalClaims);
}
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(new Date(now))
.expiration(new Date(now + jwtConfig.getAccessExpire()))
.claims(claims)
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
/** 生成 Refresh Token只含 userId */
public String generateRefreshToken(Long userId) {
long now = System.currentTimeMillis();
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(new Date(now))
.expiration(new Date(now + jwtConfig.getRefreshExpire()))
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
/** 解析 Token */
public Claims parseClaims(String token) {
return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload();
}
/** 校验 Token 是否过期 */
public boolean isTokenValid(String token) {
try {
Claims claims = parseClaims(token);
return claims.getExpiration().after(new Date());
}
catch (Exception ignored) {
return false;
}
}
/**
* 从 Token 中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = parseClaims(token);
return Long.parseLong(claims.getSubject());
}
}

View File

@@ -0,0 +1,2 @@
cn.meowrain.aioj.backend.framework.security.autoconfigure.SecurityAutoConfiguration
cn.meowrain.aioj.backend.framework.security.config.SecurityConfiguration