This commit is contained in:
wangli 2026-01-21 22:12:08 +08:00
parent 6a0b7409a6
commit f6d25af9b9
19 changed files with 1062 additions and 16 deletions

View File

@ -40,14 +40,4 @@ application-local.yml
rebel.xml rebel.xml
.flattened-pom.xml .flattened-pom.xml
.DS_Store .DS_Store
/.idea/.gitignore logs/
/logs/backend-java.log
/.idea/compiler.xml
/.idea/encodings.xml
/.idea/jarRepositories.xml
/.idea/misc.xml
/.idea/modules.xml
/.idea/inspectionProfiles/Project_Default.xml
/.idea/vcs.xml
/xianyu-core/xianyu-core.iml
/xianyu-server/xianyu-server.iml

19
pom.xml
View File

@ -15,6 +15,7 @@
<description>Xianyu Management System</description> <description>Xianyu Management System</description>
<modules> <modules>
<module>xianyu-api</module> <module>xianyu-api</module>
<module>xianyu-common</module>
<module>xianyu-core</module> <module>xianyu-core</module>
<module>xianyu-server</module> <module>xianyu-server</module>
<module>xianyu-goofish</module> <module>xianyu-goofish</module>
@ -31,6 +32,7 @@
<playwright.version>1.49.0</playwright.version> <playwright.version>1.49.0</playwright.version>
<sqlite-jdbc.version>3.44.1.0</sqlite-jdbc.version> <sqlite-jdbc.version>3.44.1.0</sqlite-jdbc.version>
<hibernate.version>6.4.1.Final</hibernate.version> <hibernate.version>6.4.1.Final</hibernate.version>
<jjwt.version>0.12.3</jjwt.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
@ -98,6 +100,23 @@
<version>${zxing.version}</version> <version>${zxing.version}</version>
</dependency> </dependency>
<!-- JWT 相关依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@ -12,9 +12,9 @@
<artifactId>xianyu-api</artifactId> <artifactId>xianyu-api</artifactId>
<properties> <properties>
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
</project> </project>

View File

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>top.biwin</groupId> <groupId>top.biwin</groupId>
<artifactId>xianyu-freedom</artifactId> <artifactId>xianyu-freedom</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>${revision}</version>
</parent> </parent>
<artifactId>xianyu-common</artifactId> <artifactId>xianyu-common</artifactId>

View File

@ -26,6 +26,28 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</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>
</dependencies> </dependencies>
</project> </project>

View File

@ -2,14 +2,22 @@ package top.biwin.xinayu.server;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** /**
* TODO * Xianyu Freedom 应用启动类
*
* 配置说明
* - @EntityScan: 扫描 xianyu-core 模块中的实体类AdminUser
* - @EnableJpaRepositories: 启用 JPA Repository 支持
* *
* @author wangli * @author wangli
* @since 2026-01-20 23:51 * @since 2026-01-20 23:51
*/ */
@SpringBootApplication @SpringBootApplication
@EntityScan(basePackages = "top.biwin.xianyu.core.entity")
@EnableJpaRepositories(basePackages = "top.biwin.xinayu.server.repository")
public class XianyuFreedomApplication { public class XianyuFreedomApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(XianyuFreedomApplication.class, args); SpringApplication.run(XianyuFreedomApplication.class, args);

View File

@ -0,0 +1,137 @@
package top.biwin.xinayu.server.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import top.biwin.xinayu.server.security.JwtAuthenticationFilter;
/**
* Spring Security 配置类
* 配置认证和授权相关的核心组件
*
* 配置内容
* 1. SecurityFilterChain - 定义安全过滤器链
* 2. PasswordEncoder - BCrypt 密码编码器强度 12
* 3. AuthenticationManager - 认证管理器
* 4. DaoAuthenticationProvider - DAO 认证提供者
*
* 安全策略
* - 无状态会话管理JWT 场景
* - 禁用 CSRFREST API 场景
* - 白名单/auth/** 接口
* - 其他所有接口需要认证
*
* @author wangli
* @since 2026-01-21
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private UserDetailsService userDetailsService;
/**
* 配置安全过滤器链
*
* 配置项
* 1. 禁用 CSRFJWT 无状态认证不需要
* 2. 配置授权规则
* - /auth/** 接口允许匿名访问登录刷新令牌
* - 其他所有接口需要认证
* 3. 配置无状态会话管理
* 4. 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前
*
* @param http HttpSecurity 对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRFJWT 场景下不需要
.csrf(AbstractHttpConfigurer::disable)
// 配置授权规则
.authorizeHttpRequests(auth -> auth
// 白名单允许 /auth/** 接口匿名访问
.requestMatchers("/auth/**").permitAll()
// 其他所有接口都需要认证
.anyRequest().authenticated()
)
// 配置无状态会话管理JWT 无状态认证
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 密码编码器 Bean
* 使用 BCrypt 算法强度为 122^12 = 4096 次哈希
*
* BCrypt 优势
* - 自动加盐
* - 计算密集防暴力破解
* - 业界标准
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* 认证管理器 Bean
* Spring Security 用于处理认证请求
*
* @param authenticationConfiguration 认证配置
* @return AuthenticationManager
* @throws Exception 配置异常
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* DAO 认证提供者 Bean
* 连接 UserDetailsService PasswordEncoder
*
* 工作流程
* 1. UserDetailsService 加载用户信息
* 2. 使用 PasswordEncoder 验证密码
*
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}

View File

@ -0,0 +1,94 @@
package top.biwin.xinayu.server.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xinayu.server.dto.LoginRequest;
import top.biwin.xinayu.server.dto.LoginResponse;
import top.biwin.xinayu.server.dto.RefreshRequest;
import top.biwin.xinayu.server.dto.RefreshResponse;
import top.biwin.xinayu.server.service.AuthService;
/**
* 认证控制器
* 提供登录刷新令牌等认证相关的 REST API
*
* 接口列表
* - POST /auth/login - 用户登录
* - POST /auth/refresh - 刷新访问令牌
*
* 注意这些接口在 SecurityConfig 中已配置为白名单无需认证即可访问
*
* @author wangli
* @since 2026-01-21
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthService authService;
/**
* 用户登录接口
*
* 请求示例
* POST /auth/login
* {
* "username": "admin",
* "password": "password123"
* }
*
* 响应示例
* {
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
* "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
* "expiresIn": 900,
* "tokenType": "Bearer"
* }
*
* 错误响应
* - 401 Unauthorized: 用户名或密码错误
* - 500 Internal Server Error: 服务器内部错误
*
* @param request 登录请求包含 username password
* @return ResponseEntity<LoginResponse> 登录响应
*/
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
LoginResponse response = authService.login(request.getUsername(), request.getPassword());
return ResponseEntity.ok(response);
}
/**
* 刷新访问令牌接口
*
* 请求示例
* POST /auth/refresh
* {
* "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
* }
*
* 响应示例
* {
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
* "expiresIn": 900,
* "tokenType": "Bearer"
* }
*
* 错误响应
* - 401 Unauthorized: 刷新令牌无效或已过期
* - 500 Internal Server Error: 服务器内部错误
*
* @param request 刷新令牌请求包含 refreshToken
* @return ResponseEntity<RefreshResponse> 刷新令牌响应
*/
@PostMapping("/refresh")
public ResponseEntity<RefreshResponse> refresh(@RequestBody RefreshRequest request) {
RefreshResponse response = authService.refresh(request.getRefreshToken());
return ResponseEntity.ok(response);
}
}

View File

@ -0,0 +1,28 @@
package top.biwin.xinayu.server.dto;
import lombok.Data;
/**
* 登录请求 DTO
* 用于接收客户端的登录请求参数
*
* @author wangli
* @since 2026-01-21
*/
@Data
public class LoginRequest {
/**
* 用户名
*/
private String username;
/**
* 邮箱
*/
private String email;
/**
* 密码
*/
private String password;
}

View File

@ -0,0 +1,37 @@
package top.biwin.xinayu.server.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应 DTO
* 返回给客户端的登录成功响应
*
* @author wangli
* @since 2026-01-21
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* 访问令牌短期有效用于访问受保护资源
*/
private String accessToken;
/**
* 刷新令牌长期有效用于获取新的访问令牌
*/
private String refreshToken;
/**
* 访问令牌过期时间
*/
private Long expiresIn;
/**
* 令牌类型固定为 "Bearer"
*/
private String tokenType = "Bearer";
}

View File

@ -0,0 +1,18 @@
package top.biwin.xinayu.server.dto;
import lombok.Data;
/**
* 刷新令牌请求 DTO
* 用于接收客户端的刷新令牌请求参数
*
* @author wangli
* @since 2026-01-21
*/
@Data
public class RefreshRequest {
/**
* 刷新令牌必填
*/
private String refreshToken;
}

View File

@ -0,0 +1,32 @@
package top.biwin.xinayu.server.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 刷新令牌响应 DTO
* 返回给客户端的刷新令牌成功响应
*
* @author wangli
* @since 2026-01-21
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RefreshResponse {
/**
* 新的访问令牌
*/
private String accessToken;
/**
* 访问令牌过期时间
*/
private Long expiresIn;
/**
* 令牌类型固定为 "Bearer"
*/
private String tokenType = "Bearer";
}

View File

@ -0,0 +1,128 @@
package top.biwin.xinayu.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 全局异常处理器
* 统一处理认证授权相关的异常返回友好的 JSON 错误响应
*
* 处理的异常类型
* - BadCredentialsException: 认证失败用户名或密码错误
* - UsernameNotFoundException: 用户不存在
* - RuntimeException: 运行时异常
* - Exception: 其他未知异常
*
* 响应格式
* {
* "timestamp": "2026-01-21T21:50:00",
* "status": 401,
* "error": "Unauthorized",
* "message": "用户名或密码错误",
* "path": "/auth/login"
* }
*
* @author wangli
* @since 2026-01-21
*/
@RestControllerAdvice
public class AuthenticationExceptionHandler {
/**
* 处理认证失败异常
*
* 触发场景
* - 用户名或密码错误
* - JWT 令牌无效或过期
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Map<String, Object>> handleBadCredentialsException(BadCredentialsException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* 处理用户不存在异常
*
* 触发场景
* - 根据用户名查询用户时用户不存在
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleUsernameNotFoundException(UsernameNotFoundException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* 处理运行时异常
*
* 触发场景
* - 用户被禁用
* - 业务逻辑异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.BAD_REQUEST.value());
errorResponse.put("error", "Bad Request");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* 处理未知异常
*
* 触发场景
* - 其他未被捕获的异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleException(Exception ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
errorResponse.put("error", "Internal Server Error");
errorResponse.put("message", "服务器内部错误: " + ex.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
}

View File

@ -0,0 +1,34 @@
package top.biwin.xinayu.server.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import top.biwin.xianyu.core.entity.AdminUser;
import java.util.Optional;
/**
* AdminUser 数据访问层
* 提供用户数据的 CRUD 操作
*
* @author wangli
* @since 2026-01-21
*/
@Repository
public interface AdminUserRepository extends JpaRepository<AdminUser, Long> {
/**
* 根据用户名查询用户
*
* @param username 用户名
* @return Optional<AdminUser>
*/
Optional<AdminUser> findByUsername(String username);
/**
* 根据邮箱查询用户
*
* @param email 邮箱
* @return Optional<AdminUser>
*/
Optional<AdminUser> findByEmail(String email);
}

View File

@ -0,0 +1,115 @@
package top.biwin.xinayu.server.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT 认证过滤器
* 从请求头中提取 JWT 令牌验证并设置 Spring Security 上下文
*
* 工作流程
* 1. 从请求头 "Authorization" 中提取 JWT格式Bearer <token>
* 2. 验证 JWT 的有效性
* 3. JWT 中提取用户名加载用户信息
* 4. 将认证信息设置到 Spring Security Context
*
* 安全性说明
* - 使用 OncePerRequestFilter 确保每个请求只执行一次
* - 异常情况下不会阻断请求由后续的认证检查处理
*
* @author wangli
* @since 2026-01-21
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
/**
* JWT 过滤逻辑
*
* 处理流程
* 1. 提取请求头中的 Authorization
* 2. 检查是否以 "Bearer " 开头
* 3. 提取 JWT 令牌
* 4. 验证令牌并提取用户名
* 5. 加载用户详情并设置到 Security Context
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头中获取 Authorization
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// 2. 检查 Authorization 头是否存在且以 "Bearer " 开头
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
// 3. 提取 JWT 令牌去掉 "Bearer " 前缀
jwt = authorizationHeader.substring(7);
try {
// 4. JWT 中提取用户名
username = jwtUtil.getUsernameFromToken(jwt);
} catch (Exception e) {
// JWT 解析失败记录日志可选
logger.warn("JWT 解析失败: " + e.getMessage());
}
}
// 5. 如果提取到用户名且当前 Security Context 中没有认证信息
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 6. 加载用户详情
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 7. 验证 JWT 是否有效
if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
// 8. 创建认证令牌
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 9. 设置请求详情
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 10. 将认证信息设置到 Security Context
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
// 11. 继续执行过滤器链
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,177 @@
package top.biwin.xinayu.server.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT 工具类
* 负责 JWT 令牌的生成解析和验证
*
* 安全性说明
* - 使用 HS256 算法签名
* - 密钥从环境变量读取避免硬编码
* - Access Token 短期有效15分钟
* - Refresh Token 长期有效7天
*
* @author wangli
* @since 2026-01-21
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private Long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private Long refreshTokenExpiration;
/**
* 生成访问令牌Access Token
* 有效期15分钟
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateAccessToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "access");
return createToken(claims, username, accessTokenExpiration);
}
/**
* 生成刷新令牌Refresh Token
* 有效期7天
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateRefreshToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "refresh");
return createToken(claims, username, refreshTokenExpiration);
}
/**
* 从令牌中提取用户名
*
* @param token JWT 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
return extractAllClaims(token).getSubject();
}
/**
* 验证令牌有效性
* 检查令牌是否过期以及用户名是否匹配
*
* @param token JWT 令牌
* @param username 用户名
* @return 是否有效
*/
public boolean validateToken(String token, String username) {
final String tokenUsername = getUsernameFromToken(token);
return (tokenUsername.equals(username) && !isTokenExpired(token));
}
/**
* 验证令牌是否有效不检查用户名
*
* @param token JWT 令牌
* @return 是否有效
*/
public boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 检查令牌是否过期
*
* @param token JWT 令牌
* @return 是否过期
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/**
* 从令牌中提取过期时间
*
* @param token JWT 令牌
* @return 过期时间
*/
private Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
/**
* 从令牌中提取所有声明Claims
*
* @param token JWT 令牌
* @return Claims 对象
*/
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 创建 JWT 令牌
*
* @param claims 自定义声明
* @param subject 主题用户名
* @param expiration 过期时间毫秒
* @return JWT 令牌字符串
*/
private String createToken(Map<String, Object> claims, String subject, Long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
/**
* 获取签名密钥
* 使用 HMAC-SHA 算法密钥长度至少 256
*
* @return SecretKey
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 获取访问令牌过期时间
*
* @return 过期时间
*/
public Long getAccessTokenExpirationInSeconds() {
return accessTokenExpiration / 1000;
}
}

View File

@ -0,0 +1,73 @@
package top.biwin.xinayu.server.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import top.biwin.xianyu.core.entity.AdminUser;
import top.biwin.xinayu.server.repository.AdminUserRepository;
import java.util.ArrayList;
/**
* AdminUser 用户详情服务
* 实现 Spring Security UserDetailsService 接口
*
* 这是主人实现自定义用户查询逻辑的核心位置
*
* 主要职责
* 1. 根据用户名从数据库查询 AdminUser
* 2. 检查用户是否存在和是否激活
* 3. AdminUser 转换为 Spring Security UserDetails
*
* @author wangli
* @since 2026-01-21
*/
@Service
public class AdminUserDetailsService implements UserDetailsService {
@Autowired
private AdminUserRepository adminUserRepository;
/**
* 根据用户名加载用户信息
*
* 主人的自定义逻辑实现位置 #1
*
* TODO: 主人请在这里实现用户查询逻辑具体步骤如下
* 1. 调用 adminUserRepository.findByUsername(username) 查询数据库
* 2. 如果用户不存在抛出 UsernameNotFoundException 异常格式"用户不存在: " + username
* 3. 检查用户的 isActive 字段如果为 false抛出异常格式"用户已被禁用: " + username
* 4. 使用 User.builder() 创建 UserDetails 对象
* - username: adminUser.getUsername()
* - password: adminUser.getPasswordHash()
* - authorities: new ArrayList<>()空权限列表主人可以后续扩展角色权限
* 5. 返回构建好的 UserDetails 对象
*
* 💡 扩展提示
* - 可以在这里添加登录失败次数限制需要在 AdminUser 实体中添加 failedLoginAttempts 字段
* - 可以添加 IP 白名单验证
* - 可以添加账号锁定逻辑
* - 如果后续需要角色权限可以在 AdminUser 中添加 roles 字段并在这里设置 authorities
*
* @param username 用户名
* @return UserDetails Spring Security 的用户详情对象
* @throws UsernameNotFoundException 用户不存在时抛出
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO: 主人请实现这里的逻辑
// 1. AdminUser adminUser = adminUserRepository.findByUsername(username)
// .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
// 2. if (!adminUser.getIsActive()) { throw new RuntimeException("用户已被禁用: " + username); }
// 3. return User.builder()
// .username(adminUser.getUsername())
// .password(adminUser.getPasswordHash())
// .authorities(new ArrayList<>())
// .build();
throw new UnsupportedOperationException("主人,请实现 loadUserByUsername 方法!");
}
}

View File

@ -0,0 +1,116 @@
package top.biwin.xinayu.server.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import top.biwin.xinayu.server.dto.LoginResponse;
import top.biwin.xinayu.server.dto.RefreshResponse;
import top.biwin.xinayu.server.security.JwtUtil;
/**
* 认证服务
* 提供登录刷新令牌等认证相关功能
*
* 这是主人实现登录和刷新令牌逻辑的核心位置
*
* @author wangli
* @since 2026-01-21
*/
@Service
public class AuthService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
/**
* 用户登录
*
* 主人的自定义逻辑实现位置 #2
*
* TODO: 主人请在这里实现登录逻辑具体步骤如下
* 1. 创建认证令牌
* UsernamePasswordAuthenticationToken authToken =
* new UsernamePasswordAuthenticationToken(username, password);
* 2. 使用 AuthenticationManager 进行认证
* Authentication authentication = authenticationManager.authenticate(authToken);
* 注意如果认证失败会自动抛出 BadCredentialsException 异常
* 3. 认证成功后生成 JWT 令牌
* String accessToken = jwtUtil.generateAccessToken(username);
* String refreshToken = jwtUtil.generateRefreshToken(username);
* 4. 获取过期时间
* Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
* 5. 构建并返回 LoginResponse 对象
* return new LoginResponse(accessToken, refreshToken, expiresIn, "Bearer");
*
* 💡 扩展提示
* - 可以在这里记录登录日志时间IP设备信息等
* - 可以重置登录失败次数
* - 可以实现多因素认证2FA逻辑
* - 可以返回用户的基本信息如昵称头像等
*
* @param username 用户名
* @param password 密码明文
* @return LoginResponse 登录响应包含 accessToken refreshToken
* @throws BadCredentialsException 认证失败时抛出
*/
public LoginResponse login(String username, String password) {
// TODO: 主人请实现这里的逻辑
// 1. UsernamePasswordAuthenticationToken authToken =
// new UsernamePasswordAuthenticationToken(username, password);
// 2. Authentication authentication = authenticationManager.authenticate(authToken);
// 3. String accessToken = jwtUtil.generateAccessToken(username);
// 4. String refreshToken = jwtUtil.generateRefreshToken(username);
// 5. Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
// 6. return new LoginResponse(accessToken, refreshToken, expiresIn, "Bearer");
throw new UnsupportedOperationException("主人,请实现 login 方法!");
}
/**
* 刷新访问令牌
*
* 主人的自定义逻辑实现位置 #3
*
* TODO: 主人请在这里实现刷新令牌逻辑具体步骤如下
* 1. 验证 refreshToken 的有效性
* if (!jwtUtil.validateToken(refreshToken)) {
* throw new BadCredentialsException("刷新令牌无效或已过期");
* }
* 2. refreshToken 中提取用户名
* String username = jwtUtil.getUsernameFromToken(refreshToken);
* 3. 生成新的 Access Token
* String newAccessToken = jwtUtil.generateAccessToken(username);
* 4. 获取过期时间
* Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
* 5. 构建并返回 RefreshResponse 对象
* return new RefreshResponse(newAccessToken, expiresIn, "Bearer");
*
* 💡 扩展提示
* - 可以实现 Refresh Token 轮换机制每次刷新都返回新的 refreshToken
* - 可以实现 Refresh Token 黑名单用户登出时将 refreshToken 加入黑名单
* - 可以限制单个 refreshToken 的使用次数
* - 可以检查用户是否仍然处于激活状态调用 AdminUserRepository 再次验证
*
* @param refreshToken 刷新令牌
* @return RefreshResponse 刷新响应包含新的 accessToken
* @throws BadCredentialsException 刷新令牌无效时抛出
*/
public RefreshResponse refresh(String refreshToken) {
// TODO: 主人请实现这里的逻辑
// 1. if (!jwtUtil.validateToken(refreshToken)) {
// throw new BadCredentialsException("刷新令牌无效或已过期");
// }
// 2. String username = jwtUtil.getUsernameFromToken(refreshToken);
// 3. String newAccessToken = jwtUtil.generateAccessToken(username);
// 4. Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
// 5. return new RefreshResponse(newAccessToken, expiresIn, "Bearer");
throw new UnsupportedOperationException("主人,请实现 refresh 方法!");
}
}

View File

@ -23,6 +23,7 @@ spring:
hibernate: hibernate:
ddl-auto: ${app.ddl-auto:update} ddl-auto: ${app.ddl-auto:update}
show-sql: true # Set to false to disable SQL logging show-sql: true # Set to false to disable SQL logging
open-in-view: false # 生产环境最佳实践,避免懒加载问题
properties: properties:
hibernate: hibernate:
format_sql: true # Set to false to disable SQL formatting format_sql: true # Set to false to disable SQL formatting
@ -36,7 +37,24 @@ logging:
level: level:
root: INFO root: INFO
com.xianyu.autoreply: DEBUG com.xianyu.autoreply: DEBUG
top.biwin.xinayu: DEBUG
org.springframework.security: DEBUG
org.hibernate.SQL: INFO # Ensure Hibernate SQL logging is not set to DEBUG/TRACE org.hibernate.SQL: INFO # Ensure Hibernate SQL logging is not set to DEBUG/TRACE
org.hibernate.type.descriptor.sql: INFO # Ensure Hibernate parameter logging is not set to DEBUG/TRACE org.hibernate.type.descriptor.sql: INFO # Ensure Hibernate parameter logging is not set to DEBUG/TRACE
file: file:
name: logs/backend-java.log name: logs/backend-java.log
# JWT 配置
jwt:
# JWT 密钥(生产环境请使用环境变量 JWT_SECRET
# 建议使用至少 256 位的随机字符串
secret: ${JWT_SECRET:xianyu-freedom-default-secret-key-please-change-in-production-environment-2026}
# Access Token 过期时间(毫秒)
# 15 分钟 = 900,000 毫秒
access-token-expiration: 900000
# Refresh Token 过期时间(毫秒)
# 7 天 = 604,800,000 毫秒
refresh-token-expiration: 604800000