前言
本项目Security6登录设计参考CSDN博客(侵删)
SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_springsecurity redis管理token-CSDN博客
因为SpringBoot3与Security6更新后, 部分方法被标为弃用,
经过更改后, 已全部更改为最新用法
- 代码辅助: ChatGPT-4o/智谱清言GLM-4
- 使用数据库: Mysql/Redis
- 使用关键模块: MybatisPlus/java-jwt/lombok
- 项目基础: SpringBoot3.3.3/Security6
语雀原文 语雀 - Security6 开发文档
接口测试文档 测试Get请求 - 千寻轻语阁(Security-1)
下图为Security默认认证流程, 我们要重写部分方法, 用于实现自己的认证方式
Spring Security原始认证流程
与上图非对应关系
Spring Security的原始认证流程通常涉及以下步骤:
- 用户发送登录请求。
- 请求被一个认证过滤器(例如
UsernamePasswordAuthenticationFilter
)拦截。 - 认证过滤器使用
AuthenticationManager
进行认证。 AuthenticationManager
使用UserDetailsService
加载用户详情。- 密码验证。
- 如果认证成功,创建一个认证对象并设置到
SecurityContextHolder
。 - 用户被重定向到原始请求或登录成功页面。
数据库结构
用户账号密码表
全称t_user_account
create_time 默认为 NOW() 添加时间
create table chat_zen.t_user_account
(
id int unsigned auto_increment
primary key,
username varchar(255) not null comment '用户账号-唯一',
password_hash varchar(255) not null comment '用户密码-加密',
create_time datetime default CURRENT_TIMESTAMP not null comment '账号创建时间',
constraint t_user_account_pk_2
unique (username)
);
安全配置
yml配置文件
chat-zen:
security:
# Security认证白名单
permit-all-path:
- /http-test/**
- /auth/**
jwt:
# jwt加密key
key: chatzen-key
expiration: 172800000 # 48 hour - *ms
Security配置
- 配置类定义:
-
- 该类使用了
@Configuration
和@EnableWebSecurity
注解,表示这是一个Spring Security的配置类。 - 类中定义了多个
@Bean
方法,用于创建和配置Spring Security的相关组件。
- 该类使用了
- 认证异常处理器:
-
- 通过
@Resource
注入了一个AuthenticationExceptionHandler
对象,用于处理认证过程中的异常。
- 通过
- JWT认证过滤器:
-
- 同样通过
@Resource
注入了一个JWTAuthenticationFilter
对象,这个过滤器用于拦截请求,并验证请求中的JWT令牌。
- 同样通过
- 权限配置:
-
- 通过
security
对象获取了不需要认证的路径(白名单路径),并在securityFilterChain
方法中配置了这些路径。 - 配置了其他所有请求都需要认证。
- 通过
- CSRF保护:
-
- 关闭了CSRF保护,因为该系统不使用Session,而是使用JWT令牌。
- 会话管理:
-
- 配置了不通过Session获取SecurityContext,而是使用无状态的会话策略。
- 异常处理:
-
- 配置了异常处理,当认证过程中出现异常时,会调用
AuthenticationExceptionHandler
进行处理。
- 配置了异常处理,当认证过程中出现异常时,会调用
- 密码编码器:
-
- 配置了一个
BCryptPasswordEncoder
作为密码编码器,用于对密码进行哈希处理。
- 配置了一个
- 身份认证管理器:
-
- 通过
AuthenticationConfiguration
创建了一个AuthenticationManager
对象,用于处理身份认证。
- 通过
// 放行接口
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
List<String> permitAllPaths = security.getPermitAllPath();
// 配置不需要认证的请求(这里所有的路径可以写在配置文件上修改时就不用改代码)
if (!CollectionUtils.isEmpty(permitAllPaths)) {
permitAllPaths.forEach(path -> {
try {
// 放行白名单路径
http.authorizeHttpRequests(auth -> auth
.requestMatchers(path).permitAll()); // 放行白名单路径
} catch (Exception e) {
log.error("放行白名单路径失败", e);
}
});
}
http // 关闭csrf保护, 因为无需Session
.csrf(AbstractHttpConfigurer::disable)
// 其他所有请求都需要认证
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// 不通过Session获取SecurityContext
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)// 配置异常处理
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationExceptionHandler)
);
// 配置token过滤器
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// 配置密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证管理器,调用authenticate()方法完成认证
*/
@Bean
public AuthenticationManager authenticationManager
(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
认证流程
登录接口流程
- 客户端发起登录请求:
-
- 客户端通过HTTP POST请求发送用户名和密码到服务器的
/auth/login
端点。
- 客户端通过HTTP POST请求发送用户名和密码到服务器的
- 认证端点处理请求 (
AuthController.java
):
-
AuthController
的login
方法接收请求。- 方法从请求体中提取用户名和密码。
- 如果用户名或密码为空,返回错误响应。
@RestController
@RequestMapping("/auth")
public class AuthController {
@Resource
private IAuthService authService;
@PostMapping("/login")
public Result<String > login(@RequestBody JSONObject params){
String username = params.getString("username");
String password = params.getString("password");
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
return Result.fail(500, "用户名或密码为空!",null);
}
String token = authService.login(username, password); // 执行登录验证流程
return Result.succ(token);
}
}
- 执行认证服务 (
AuthServiceImpl.java
):
-
login
方法使用提取的用户名和密码创建一个UsernamePasswordAuthenticationToken
。- 调用
AuthenticationManager
的authenticate
方法进行认证。
@Override
public String login(String username, String password) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, password);
// 调用AuthenticationManager的authenticate方法进行认证
// 该方法会调用UserDetailsService的loadUserByUsername方法进行认证
Authentication authenticate =
authenticationManager.authenticate(authenticationToken);
// 认证完成后会返回Authentication对象, 该对象中包含了认证成功的用户信息
// 如果认证失败, 则authenticate方法会抛出AuthenticationException异常
// 我已经配置了处理异常的类
if (null == authenticate) {
log.error("认证失败 {username:{}, password:{}}", username, password);
return null;
}
/**
认证成功后续流程
**/
}
- 用户详情加载 (
UserDetailsServiceImpl.java
):
-
- 如果
AuthenticationManager
需要用户详情,它会调用UserDetailsService
的loadUserByUsername
方法。 UserDetailsServiceImpl
从数据库加载与用户名关联的用户详情。
- 如果
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
// 通过MybatisPlus查询用户信息
QueryWrapper<UserAccount> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(UserAccount::getUsername, username);
UserAccount userAccount = userAccountService.getOne(wrapper);
// 如果用户不存在则抛出异常
if (userAccount == null) {
throw new UsernameNotFoundException("用户名不存在");
}
// 返回封装UserDetails对象
return new SecurityUser(userAccount);
}
- 密码验证:
-
AuthenticationManager
使用UserDetailsService
提供的用户详情来验证密码。
- 生成JWT令牌 (
AuthServiceImpl.java
):
-
- 如果认证成功,
AuthServiceImpl
使用JwtUtil
生成一个JWT令牌。 - 令牌包含用户ID作为主题,并设置一个过期时间。
- 如果认证成功,
/**
* 生成JWT Token
* @param userId 用户ID
* @return 生成的JWT Token
*/
public String generateToken(String userId) {
return JWT.create()
.withSubject(userId) // 设置主题 - 用户id
.withIssuedAt(new Date()) // 设置签发时间 - 现在
// 设置过期时间 - 配置文件
.withExpiresAt(new Date(System.currentTimeMillis()+EXPIRATION_TIME))
.sign(algorithm); // 设置算法与签名秘钥 - 配置文件
}
- 存储令牌和用户信息到Redis (
AuthServiceImpl.java
):
-
- 生成令牌后,将其与用户信息一起存储在Redis中,并设置过期时间。
@Override
public String login(String username, String password) {
/**
认证流程
**/
// 获取从loadUserByUsername方法获取的用户对象
SecurityUser user = (SecurityUser) authenticate.getPrincipal();
UserAccount userEntity = user.getUserAccount();
// 生成token
String token = jwtUtil.generateToken(userEntity.getId().toString());
// 存入Redis
redisTemplate.opsForValue().set(
String.format(Const.REDIS_KEY, userEntity.getId()), // 设置Redis Key - 常量
JSON.toJSONString(user), // 存入Redis的值 - SecurityUser封装对象
jwtUtil.getEXPIRATION_TIME(), // 设置数据过期时间 - jwt过期时间
TimeUnit.MILLISECONDS); // 设置过期时间的单位 - ms
return token;
}
- 返回JWT令牌 (
AuthController.java
):
-
AuthServiceImpl
将生成的JWT令牌返回给AuthController
。AuthController
将令牌包装在成功响应中返回给客户端。
@RestController
@RequestMapping("/auth")
public class AuthController {
@Resource
private IAuthService authService;
@PostMapping("/login")
public Result<String > login(@RequestBody JSONObject params){
String username = params.getString("username");
String password = params.getString("password");
if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
return Result.fail(500, "用户名或密码为空!",null);
}
String token = authService.login(username, password);
return Result.succ(token); // 验证成功, 返回响应
}
}
- 客户端接收响应:
-
- 客户端接收到包含JWT令牌的响应,并可以将其用于随后的请求。
需要认证的接口流程
从登录端点获取到的JwtToken
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyIiwiZXhwIjoxNzI2Mjc2OTMwLCJpYXQiOjE3MjYxMDQxMzB9.YfdxPXnCl_GGwK08vy2umicr-p1l-yakW_BjwuLdSfE
有效期48小时, 用户test, 用户Id 2
发送需要鉴权的请求时, 需要在请求头中添加Authorization
Headers, 格式为Bearer JwtToken
- 客户端发起需要认证的请求:
-
- 客户端发送请求到一个需要认证的端点,并在请求头中携带JWT令牌。
- JWT认证过滤器拦截请求 (
JWTAuthenticationFilter.java
):
-
JWTAuthenticationFilter
拦截请求并从请求头中提取JWT令牌。
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 从请求头中获取Authorization信息
String header = request.getHeader("Authorization");
// 判断是否是放行请求
if (isFilterRequest(request)) {
// 如果是放行请求,则直接放行
logger.info("放行路径 {}", request.getRequestURI());
filterChain.doFilter(request, response);
return;
}
logger.info("拦截路径 {}", request.getRequestURI());
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
String token = header.substring(7);
/**
JwtToken 验证流程
**/
}
}
- 验证JWT令牌 (
JWTAuthenticationFilter.java
):
-
JWTAuthenticationFilter
使用JwtUtil
验证JWT令牌的有效性。- 如果令牌有效,
JWTAuthenticationFilter
从令牌中提取用户信息,并创建一个认证对象。 - 认证对象被设置到
SecurityContextHolder
中。
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
/**
拦截路径, 提取JwtToken流程
**/
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
String token = header.substring(7);
try{
// 验证JWTToken是否有效并在有效期
boolean tokenExpired = jwtUtil.isTokenExpired(token);
if (tokenExpired) {
// 验证失败
logger.error("Token已过期, 请重新登录");
fallback("Token失效, 请重新登录", response);
return;
}
} catch (Exception e){
logger.error(e.getMessage());
fallback("Token解析失败, 请重新获取", response);
return;
}
// 验证成功
String userId = jwtUtil.extractUserId(token);
String cache = (String) redisTemplate.opsForValue()
.get(String.format(Const.REDIS_KEY, userId));
SecurityUser securityUser = JSON.parseObject(cache, SecurityUser.class);
if (securityUser == null) {
fallback("用户信息解析失败, 请重新登录", response);
return;
}
logger.info("用户{}验证成功", securityUser.getUserAccount().getUsername());
// 将用户信息存入SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(securityUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}
- 访问受保护资源:
-
- 如果认证成功,客户端可以访问受保护的资源。
@GetMapping("auth-test")
public Result<SecurityUser> authTest() {
// 从SecurityContextHolder获取Authentication对象, 然后获取SecurityUser封装用户信息
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
SecurityUser user = (SecurityUser) authentication.getPrincipal();
// 获取用户信息, 直接返回用户实体类型会将所有信息都返回, 包括账号密码
return Result.succ(user);
}
- 客户端接收响应:
-
- 客户端接收到来自服务器的响应。