feat(auth): 添加用户认证和 JWT 令牌管理功能

- 新增用户注册和登录接口
- 实现 JWT 令牌生成和验证逻辑
- 添加用户权限管理相关实体和 mapper
- 更新安全配置,支持 JWT 认证
-移除 log4j2 相关配置,改为使用 logback
This commit is contained in:
chaos
2025-07-18 17:33:11 +08:00
parent 20c05c41f0
commit 6e5f735fcc
20 changed files with 499 additions and 58 deletions

View File

@@ -0,0 +1,9 @@
package cn.nopj.chaos_api.mapper;
import cn.nopj.chaos_api.domain.entity.Permission;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {
}

View File

@@ -0,0 +1,10 @@
package cn.nopj.chaos_api.mapper;
import cn.nopj.chaos_api.domain.entity.Role;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RoleMapper extends BaseMapper<Role> {
}

View File

@@ -0,0 +1,32 @@
package cn.nopj.chaos_api.mapper;
import cn.nopj.chaos_api.domain.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户的角色和权限信息
* @param username 用户名
* @return 权限编码列表 (包含角色和权限)
*/
@Select("""
SELECT p.code FROM t_permission p
LEFT JOIN t_role_permission rp ON p.id = rp.permission_id
LEFT JOIN t_role r ON r.id = rp.role_id
LEFT JOIN t_user_role ur ON r.id = ur.role_id
LEFT JOIN t_user u ON u.id = ur.user_id
WHERE u.username = #{username}
UNION
SELECT r.code FROM t_role r
LEFT JOIN t_user_role ur ON r.id = ur.role_id
LEFT JOIN t_user u ON u.id = ur.user_id
WHERE u.username = #{username}
""")
List<String> findAuthoritiesByUsername(@Param("username") String username);
}

View File

@@ -24,5 +24,10 @@
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,14 @@
package cn.nopj.chaos_api.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("t_permission")
public class Permission implements Serializable {
private Long id;
private String name;
private String code;
}

View File

@@ -0,0 +1,13 @@
package cn.nopj.chaos_api.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("t_role")
public class Role implements Serializable {
private Long id;
private String name;
private String code;
}

View File

@@ -0,0 +1,21 @@
package cn.nopj.chaos_api.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("t_user")
public class User implements Serializable {
private Long id;
private String username;
private String password;
private Boolean enabled;
private Boolean accountNonExpired;
private Boolean credentialsNonExpired;
private Boolean accountNonLocked;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,12 @@
package cn.nopj.chaos_api.dto;
import lombok.Data;
/**
* 登录请求参数
*/
@Data
public class LoginRequest {
private String username;
private String password;
}

View File

@@ -0,0 +1,12 @@
package cn.nopj.chaos_api.dto;
import lombok.Data;
/**
* 注册请求参数
*/
@Data
public class RegisterRequest {
private String username;
private String password;
}

View File

@@ -36,10 +36,15 @@
<artifactId>chaos_api_common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.nopj</groupId>
<artifactId>chaos_api_data</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -49,12 +54,15 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
</dependencies>
@@ -63,6 +71,7 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.7.0</version>
<executions>
<execution>
<goals>

View File

@@ -0,0 +1,57 @@
package cn.nopj.chaos_api.config.jwt;
import cn.nopj.chaos_api.util.JwtTokenUtil;
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.beans.factory.annotation.Value;
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 登录授权过滤器
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
String authToken = authHeader.substring(this.tokenHead.length());
String username = jwtTokenUtil.getUsernameFromToken(authToken);
// 如果 Token 中有用户名但上下文中没有,说明是首次登录
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 验证 Token 是否有效
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}

View File

@@ -1,11 +1,18 @@
package cn.nopj.chaos_api.config.sec;
import cn.nopj.chaos_api.config.jwt.JwtAuthenticationTokenFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@@ -14,18 +21,29 @@ public class SecurityConfig {
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
public SecurityConfig(RestAuthenticationEntryPoint restAuthenticationEntryPoint, RestfulAccessDeniedHandler restfulAccessDeniedHandler) {
public SecurityConfig(RestAuthenticationEntryPoint restAuthenticationEntryPoint, RestfulAccessDeniedHandler restfulAccessDeniedHandler, JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter) {
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
this.restfulAccessDeniedHandler = restfulAccessDeniedHandler;
this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 允许所有对 /api/public/** 的匿名访问
.requestMatchers("/api/public/**").permitAll()
// 其他所有请求都需要认证
.requestMatchers("/api/auth/login","/api/auth/register").permitAll()
.anyRequest().authenticated()
)
// 禁用 CSRF因为现代前后端分离项目通常使用 Token
@@ -34,6 +52,7 @@ public class SecurityConfig {
.authenticationEntryPoint(restAuthenticationEntryPoint)
.accessDeniedHandler(restfulAccessDeniedHandler))
;
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

@@ -0,0 +1,45 @@
package cn.nopj.chaos_api.controller;
import cn.nopj.chaos_api.dto.LoginRequest;
import cn.nopj.chaos_api.dto.RegisterRequest;
import cn.nopj.chaos_api.model.ApiResult;
import cn.nopj.chaos_api.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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 java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;
@Value("${jwt.tokenHead}")
private String tokenHead;
@PostMapping("/register")
public ApiResult<?> register(@RequestBody RegisterRequest registerRequest) {
if (authService.register(registerRequest) != null) {
return ApiResult.success("注册成功");
}
return ApiResult.failed("用户名已存在");
}
@PostMapping("/login")
public ApiResult<?> login(@RequestBody LoginRequest loginRequest) {
String token = authService.login(loginRequest.getUsername(), loginRequest.getPassword());
if (token == null) {
return ApiResult.failed("用户名或密码错误");
}
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return ApiResult.success(tokenMap);
}
}

View File

@@ -0,0 +1,17 @@
package cn.nopj.chaos_api.service;
import cn.nopj.chaos_api.domain.entity.User;
import cn.nopj.chaos_api.dto.RegisterRequest;
public interface AuthService {
/**
* 注册
*/
User register(RegisterRequest registerRequest);
/**
* 登录
* @return 生成的 JWT token
*/
String login(String username, String password);
}

View File

@@ -0,0 +1,59 @@
package cn.nopj.chaos_api.service.impl;
import cn.nopj.chaos_api.domain.entity.User;
import cn.nopj.chaos_api.dto.RegisterRequest;
import cn.nopj.chaos_api.mapper.UserMapper;
import cn.nopj.chaos_api.service.AuthService;
import cn.nopj.chaos_api.util.JwtTokenUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public User register(RegisterRequest registerRequest) {
// 检查用户名是否已存在
if (userMapper.selectOne(new QueryWrapper<User>().eq("username", registerRequest.getUsername())) != null) {
// 在实际项目中,这里应该抛出自定义异常
return null;
}
User user = new User();
user.setUsername(registerRequest.getUsername());
// 使用 BCrypt 加密密码
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
userMapper.insert(user);
// 你可以在这里为新用户分配默认角色
return user;
}
@Override
public String login(String username, String password) {
// 使用 Spring Security 的 AuthenticationManager 进行用户认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 生成 JWT
return jwtTokenUtil.generateToken(userDetails);
}
}

View File

@@ -0,0 +1,49 @@
package cn.nopj.chaos_api.service.impl;
import cn.nopj.chaos_api.domain.entity.User;
import cn.nopj.chaos_api.mapper.UserMapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 从数据库查询用户
User user = userMapper.selectOne(new QueryWrapper<User>().eq("username", username));
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 2. 查询该用户的权限信息 (角色 + 权限)
List<String> authorities = userMapper.findAuthoritiesByUsername(username);
// 3. 将权限字符串列表转换为 GrantedAuthority 集合
List<GrantedAuthority> grantedAuthorities = authorities.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 4. 构建并返回 Spring Security 的 User 对象
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getEnabled(),
user.getAccountNonExpired(),
user.getCredentialsNonExpired(),
user.getAccountNonLocked(),
grantedAuthorities
);
}
}

View File

@@ -0,0 +1,84 @@
package cn.nopj.chaos_api.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date;
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private Algorithm algorithm;
private JWTVerifier verifier;
@PostConstruct
public void init() {
// 在bean初始化后根据 secret 创建算法实例和验证器
this.algorithm = Algorithm.HMAC512(secret);
this.verifier = JWT.require(algorithm).build();
}
/**
* 从 Token 中获取用户名
*/
public String getUsernameFromToken(String token) {
try {
DecodedJWT jwt = verifier.verify(token);
return jwt.getSubject();
} catch (Exception e) {
return null;
}
}
/**
* 验证 Token 是否有效
*/
public boolean validateToken(String token, UserDetails userDetails) {
try {
String username = getUsernameFromToken(token);
return username != null && username.equals(userDetails.getUsername()) && !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 生成 Token
*/
public String generateToken(UserDetails userDetails) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration * 1000);
return JWT.create()
.withSubject(userDetails.getUsername())
.withIssuedAt(now)
.withExpiresAt(expiryDate)
.sign(algorithm);
}
private boolean isTokenExpired(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().before(new Date());
} catch (Exception e) {
// 如果解析失败,也认为它已过期或无效
return true;
}
}
}

View File

@@ -26,3 +26,8 @@ mybatis-plus:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
tokenHeader: Authorization
tokenHead: Chaos
secret: zHANgcHao@1995!20250506
expiration: 604800

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
<RollingFile name="RollingFile"
fileName="logs/chaos-api.log"
filePattern="logs/chaos-api-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout>
<Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern>
</PatternLayout>
<Policies>
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="10 MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
</Appenders>
<Loggers>
<Logger name="org.springframework" level="info" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/>
</Logger>
<Logger name="cn.nopj" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/>
</Logger>
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/>
</Root>
</Loggers>
</Configuration>

31
pom.xml
View File

@@ -51,18 +51,7 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.5.3</version>
</dependency>
<!-- &lt;!&ndash; https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-logging &ndash;&gt;-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-logging</artifactId>-->
<!-- <version>3.5.3</version>-->
<!-- </dependency>-->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>3.5.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -113,6 +102,24 @@
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.5.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.18</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>