Spring security原理解析 + 实战
引言
本文是博主自己学习Spring security的学习笔记和一些心的分享。我将分析Spring security的底层执行流程,并带领你从0开始搭建一个基于常见的JWT无状态登录流程的spring security鉴权项目。
1.依赖版本
- jdk: 21
- springboot: 3.4.0
2. Spring security基本执行流程分析
由于网上的博客质量参差不齐,很容易就被带入错误的陷阱。所以博主推荐观看官方文档,不懂的部分结合AI进行提问。下面附上spring security的官方文档地址。我的文章也将基于官方文档就进行编写。
2.1 回顾传统的Filter
我们都知道在学习java web初期会了解到servlet的Filter过滤器,对于没有spring的年代,我们通常会使用它来进行接口的校验过滤等。简单回顾一下Filter的流程图,没错,所谓Filter,就是请求达到Servlet被处理前的一系列过滤器链。
2.2 Spring Security中的Filter(权限过滤)
Spring Security自己实现了一个Filter,将它称之为DelegatingFilterProxy
,可以将它看作一个代理类,它持有了FilterChanProxy
这个Bean, 这就是最关键所在,FilterChanProxy
可以根据各种情况执行不同的SecurityFilterChain(例如更具url匹配等),Spring Security的各种权限校验就在此实现(例如我们可以设置除了/login
接口之外的所有请求都要走JwtFilter,这也是我后文的代码demo中的配置思路)
2.3 权限验证后
在通过一系列的SecurityFilter后,我们就知道此次请求是否有权限访问这个接口了。但是spring security的工作可能并未完成 ,通过校验后它将会设置一系列的信息在上下文当中,称之为SecurityContextHolder
,它的结构如下:
需要注意的是这一工作并不是必须的
例如:在第一次使用账号密码访问数据库的登录时我们其实不需要这个上下文,具体原因如下:
- login请求的目的是发放
Token
,而不是在当前请求中继续执行需要认证状态的操作。认证信息已经通过 authenticationManager.authenticate() 的返回值获得了。 - 设置 SecurityContextHolder 的责任通常落在处理后续携带
Token
的请求的那个过滤器上(例如 JwtAuthorizationFilter)。
总的来说:我们需要分析具体的情况,而不是一昧的遵循标准!
3.实战部分
实战部分我会给出关键的代码,而不是全部的代码,本人小菜鸡一枚,如果有错误,请帮忙指出
标题提到我将搭建一个基于常见的JWT无状态登录流程的spring security鉴权项目,我会创建一个/login
接口用于登录这个流程非常简单,我们的项目基于这个接口展开,鉴权流程共分为两步:
- 第一次账号密码登录:此次登录将查询数据库中user信息以及匹配账号密码,登录成功后返回
JWT
字符串 - 后续携带JWT的登录:后续将会携带JWT在请求头中。结构是:
Authorization:Bearer xxxxxxx
, 通过JWT携带的信息进行权限校验以及上下文设置
3.1 UserDetails实体类定义
首先我们定义一些实体类,这部分较为简单,唯一需要注意的是: User需要继承 Spring Security的UserDetails类,这样我们的User对象才可以被框架所管理
java
package com.ljy.common.entity.dao;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Data
@ToString
@TableName("user")
public class User extends CommonDao implements UserDetails {
private String username;
private String email;
private String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
}
3.2 UserDetailsService接口实现
这个接口是Spring Security携带的,我们需要实现它并重写loadUserByUsername
方法用于框架查询用户做匹配。博主这里使用了mybatis-plus做了一个简单的查询
java
package com.ljy.main.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ljy.common.entity.dao.User;
import com.ljy.common.entity.dao.dto.UserRegisterDto;
import com.ljy.main.mapper.UserMapper;
import com.ljy.main.service.UserService;
import jakarta.annotation.Resource;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
@Resource
UserMapper userMapper;
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return getUserByUserName(username);
}
@Override
public User getUserByUserName(String userName) {
return userMapper.selectOne(new QueryWrapper<User>().eq("username", userName));
}
3.3 重点:Spring Security的配置
Security的一系列配置:
java
package com.ljy.main.config.webconfig;
import com.ljy.main.service.UserService;
import com.ljy.main.service.impl.UserServiceImpl;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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;
@Configuration
@EnableWebSecurity
public class MySecurityConfig {
@Resource
UserServiceImpl userService;
/**
* 其他请求直接被拒绝了
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager,JwtAuthorizationFilter jwtAuthorizationFilter) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**", "/login").permitAll() // 公开访问的资源
.anyRequest().authenticated() // 其他请求需要认证
)
// .addFilterBefore(new JwtAuthenticationFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class) // 登录过滤器
.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 解析过滤器
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authenticationManager(authenticationManager);
return http.build();
}
@Bean
DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(userService);
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(UserServiceImpl userService) {
return userService;
}
@Bean
public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
}
配置解释:
-
@EnableWebSecurity
: 启用 Web 安全功能。 -
securityFilterChain(HttpSecurity http)
: 定义核心的安全过滤器链 Bean。- authorizeHttpRequests: 配置请求的授权规则。/public/** 和 /login 不需要认证,其他都需要。
- addFilterBefore: 将我们的 JwtAuthorizationFilter 添加到 UsernamePasswordAuthenticationFilter 之前。这意味着对于携带 JWT 的请求,会先由我们的 Filter 处理。
- csrf(AbstractHttpConfigurer::disable): 禁用 CSRF 保护。因为 JWT 是无状态的,并且通常不依赖 Cookie,所以 CSRF 攻击的风险较低,可以禁用以简化配置(尤其对于 API)。
- sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)): 设置 Session 管理为无状态。这是 JWT 认证的核心,服务器不应创建或使用 HTTP Session 来存储安全上下文。
- httpBasic(AbstractHttpConfigurer::disable) 和 formLogin(AbstractHttpConfigurer::disable): 禁用默认的 HTTP Basic 和表单登录,因为我们用 JWT。
-
daoAuthenticationProvider
: 配置一个使用我们自定义的 UserDetailsService 和 PasswordEncoder 的 DaoAuthenticationProvider。这是处理用户名密码认证的核心组件。 -
passwordEncoder
: 定义密码加密器,必须配置,推荐 BCryptPasswordEncoder。 -
userDetailsService
: 将我们的 UserServiceImpl 注册为 UserDetailsService Bean。 -
authenticationManager
: 显式配置 AuthenticationManager。在 Spring Security 6+ 中,通常需要这样做。我们使用 ProviderManager,并将 daoAuthenticationProvider 注册进去。这个 AuthenticationManager 会被 /login 接口注入并使用。
JWT Filter:
java
package com.ljy.main.config.webconfig;
import cn.hutool.jwt.JWTUtil;
import com.ljy.main.service.impl.UserServiceImpl;
import com.ljy.main.utils.AppContext;
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.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* json的认证方式
*/
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private static final String AUTH_HEADER = "Authorization";
private static final String TOKEN_PREFIX = "Bearer ";
@Autowired
UserServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(AUTH_HEADER);
if (authHeader != null && authHeader.startsWith(TOKEN_PREFIX)) {
String token = authHeader.substring(TOKEN_PREFIX.length());
String username = (String) JWTUtil.parseToken(token).getPayload().getClaim("username"); // 解析 JWT 获取用户名
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 创建认证对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, null // 权限列表可以为空
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置认证信息到上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
AppContext.setUser(userDetailsService.getUserByUserName(username));
}
}
/**
* 无论请求是否成功,都需要释放thread local
*/
try {
filterChain.doFilter(request, response);
} finally {
AppContext.clearContext();
}
}
}
Controller, login接口:
java
package com.ljy.main.controller;
import cn.hutool.jwt.JWTUtil;
import com.ljy.common.R;
import com.ljy.common.entity.dao.User;
import com.ljy.common.entity.dao.dto.UserLoginDto;
import com.ljy.main.service.UserService;
import jakarta.annotation.Resource;
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.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class UserController {
@Resource
UserService userService;
@Resource
AuthenticationManager authenticationManager;
byte[] screct = new byte[256];
@PostMapping("/login")
public R login(@RequestBody UserLoginDto userLoginDto) {
//错误的做法,绕过了spring security的认证流程
// User user = userService.getUserByUserName(userLoginDto.getUserName());
// if (user != null) {
// Map<String, Object> jwtPayload = new HashMap<>();
// byte[] bytes = new byte[10];
// jwtPayload.put("username", user.getUsername());
// String token = JWTUtil.createToken(jwtPayload, bytes);
// return R.ok("login success", token);
// } else {
// return R.error("login error");
// }
try {
// 1. 创建包含用户名和密码的 AuthenticationToken
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userLoginDto.getUserName(), userLoginDto.getPassWord());
// 2. 调用 AuthenticationManager 进行认证
// 这会触发 DaoAuthenticationProvider -> UserDetailsService.loadUserByUsername -> PasswordEncoder.matches
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功,从 Authentication 对象获取用户信息
// 通常 principal 就是 UserDetails 对象,或者你自定义的对象
Object principal = authentication.getPrincipal();
String username;
if (principal instanceof UserDetails) {
username = ((UserDetails) principal).getUsername();
} else {
username = principal.toString(); // 或者根据你的 UserDetails 实现来获取
}
// 4. 生成 JWT
Map<String, Object> jwtPayload = new HashMap<>();
jwtPayload.put("username", username);
// 你可以添加更多信息到 payload,比如用户ID、角色等
// jwtPayload.put("roles", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
// 使用配置好的、安全的密钥生成 Token
// *** 确保你的 JWTUtil.createToken 使用了正确的密钥 ***
String token = JWTUtil.createToken(jwtPayload, screct); // 假设你的工具类接受 byte[] 密钥
// 5. 返回成功的响应和 Token
return R.ok("login success", token);
} catch (BadCredentialsException e) {
// 用户名或密码错误
return R.error("用户名或密码错误");
} catch (AuthenticationException e) {
// 其他认证异常 (账户锁定、过期等 - 取决于你的 UserDetails 实现和 Provider 配置)
// UsernameNotFoundException 也会被 AuthenticationManager 捕获并可能包装成 AuthenticationException
return R.error("认证失败: " + e.getMessage());
} catch (Exception e) {
// 其他未知错误
// Log the exception
return R.error("登录时发生内部错误");
}
}
}
到此为止:我们便实现了一个简单的基于spring security的登录后端项目