Spring Security实战:从过滤器链到JWT登录与权限控制 📅 发布时间:2026/9/8 8:26:34 👁 浏览次数: 想搞懂Spring Security的新手十有八九都是被它那套“过滤器链”“认证管理器”“SecurityContext”这些名词劝退的。我当年也一样对着网上零散的教程东拼西凑光把登录流程跑通就折腾了整整两天后来踩的坑多了才慢慢摸清楚这套框架的脾气。这篇文章我准备从零开始把手写项目时真正用得上的Spring Security知识捋一遍不管是刚接触Spring Boot安全开发还是项目里需要接入登录和权限控制的读者都能照着一步步跑起来并且知道每一步背后的原因。开头先把丑话说在前头Spring Security不是一个能靠死记配置背会的框架它本质上是一套围绕“认证”和“授权”展开的过滤器链机制。你只要理解了一个HTTP请求从进来到返回中间经过哪些环节、每个环节在干什么剩下的配置基本都是往这条链路上挂组件。这篇文章就像一次完整的项目实操复盘我会带着你走完从空项目到集成数据库用户、再到接口权限控制和JWT无状态登录的整个过程过程中遇到的莫名其妙的问题也会一并给你列出来。1. 为什么项目里要选Spring Security而不是自己写拦截器1.1 传统做法的痛点Session和拦截器为什么不够用很多老项目在做登录校验时习惯用拦截器HandlerInterceptor手动判断用户是否登录再往Session里塞一个用户对象。这套写法在小项目里跑得挺好但一旦遇到多角色权限、接口级细粒度控制、第三方OAuth登录、前后端分离这些需求你就会发现代码开始失控每个接口都要重复写权限判断漏一个就等于裸奔Session超时、并发踢人、密码加密这些安全细节更是要自己一个个去补。Spring Security做的事情就是把上面这一整套通用安全逻辑沉淀成了标准组件。它默认帮你处理了登录表单生成、Session管理、密码加密、CSRF防护、记住我、方法级权限拦截这些高频需求你用的时候只需要告诉它“我的用户从哪里来”“我有哪些权限规则”剩下的链路细节由框架接管。我见过不少团队早期为了“轻量”选择自己写安全层到了后期光是一个密码重置流程就改到崩溃最后又老老实实换回Spring Security。1.2 选型时和Shiro、自研过滤器相比它赢在哪里如果你搜过Java安全框架肯定绕不开Shiro和Spring Security这两大阵营。Shiro胜在轻量、上手快文档也简单适合不需要复杂权限模型的传统单体项目。但Shiro和Spring Boot的整合总隔着一层尤其是注册自定义过滤器、适配Spring的国际化、处理异常等方面要额外写不少胶水代码。Spring Security则完全长在Spring生态里Spring Boot的自动配置会把所有安全组件替你装配好。它最大的优势在于那个高度可扩展的过滤器链你可以随时在链路上插入自己的过滤器实现token校验、验证码校验、接口签名校验等定制需求。虽然学习曲线比Shiro陡一些但一旦理解了它的核心模型后续做任何复杂场景都会顺手得多。我在实际选型时的判断标准很简单如果项目已经深度拥抱Spring生态优先考虑Spring Security如果是遗留非Spring项目或者只想做极简登录再考虑Shiro也不迟。1.3 快速理解Spring Security的三个核心抽象接触Spring Security时你一定会反复看到这几个概念SecurityFilterChain、AuthenticationManager、SecurityContext。不要被名字吓到我用大白话解释一遍。SecurityFilterChain是一条由多个过滤器组成的链子HTTP请求一进应用就会沿着这条链依次经过每个过滤器。有的过滤器负责把用户信息放进上下文有的负责判断当前请求能不能访问有的负责处理登录提交这些组合在一起就是Spring Security的“安检通道”。AuthenticationManager是认证管理器负责验明用户身份。它接收一个“还没认证的凭证”返回一个“认证成功的凭证”如果验不过去就抛异常。它的下面还有AuthenticationProvider真正干活的其实是provider manager相当于一个调度中心。SecurityContext是安全上下文你可以把它理解成“当前线程的登录状态储物柜”。认证成功之后用户信息就被放进这个上下文里你在业务代码中通过SecurityContextHolder.getContext().getAuthentication()就能随时拿到当前登录用户。这三个概念搞懂了Spring Security的骨架也就立起来了。2. 10分钟跑通第一个Spring Security登录Demo2.1 环境准备和依赖引入这部分我们用一个全新的Spring Boot项目来做演示。我使用的是Spring Boot 3.x Spring Security 6.x的组合JDK要求17以上建议直接用IDEA的Spring Initializr创建项目。需要引入的核心依赖其实只有两个一个是Web另一个就是Spring Security。创建一个Maven工程在pom.xml里加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency依赖加完后启动项目你会立刻从控制台日志里看到类似这么一行Using generated security password: 6a2b3c4d-xxxx-xxxx-xxxx-xxxxxxxxxxxx这时候Spring Security已经默认接管了所有请求默认用户名是user密码是控制台随机生成的那一串。直接用浏览器访问任何接口都会被重定向到一个默认的登录页输入user和随机密码就能通过认证。这第一步能跑通说明框架本身没问题后面我们要做的就是把默认行为替换成自己的逻辑。2.2 自定义用户和密码从内存用户开始默认生成的随机密码每次重启都会变化肯定不能用在真实项目中。最快速的自定义方式是在配置类里声明一个UserDetailsService把用户信息放到内存中。我建议初学者先走这一步不是因为内存用户实用而是因为它能让你用最少的代码观察认证流程。Configuration EnableWebSecurity public class SecurityConfig { Bean public UserDetailsService userDetailsService() { UserDetails user User.withUsername(admin) .password({noop}123456) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); } }注意上面密码那里写的{noop}123456{noop}表示使用明文密码。这里要重点提醒明文密码仅限本地测试真实项目中绝对不能这么干。至于为什么要用UserDetailsService这个抽象而不是直接在配置里写死用户是因为Spring Security的认证流程只认UserDetailsService内存用户、数据库用户、甚至外部接口读取的用户最后都要包装成它来提供给框架。2.3 最简单的安全过滤链配置自定义用户后还差一条过滤链配置告诉Spring Security放行哪些路径、拦截哪些路径。下面是一份最基础的配置Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/public/**, /login).permitAll() .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .permitAll() ) .logout(logout - logout.permitAll()); return http.build(); }这份配置做了几件事允许匿名访问/public/**和登录页其他所有请求都需要认证开启表单登录登录页路径指定为/login。跑起来以后访问任意受保护路径都会跳到登录页登录成功后跳回原路径。整个过程没写一行业务代码框架就把跳转、校验、Session写入全部处理完了。2.4 登录表单页面长什么样如果你没有指定loginPageSpring Security会使用内置的默认登录页。实际项目中我们往往需要自己的登录页面最简单的方式是提供一个ControllerController public class LoginController { GetMapping(/login) public String loginPage() { return login; } }然后在templates/login.html里写一个表单。表单字段名必须是username和password提交地址默认是/login方式为POST。这些字段名是Spring Security表单登录的约定如果改成别的名字就得在配置里通过usernameParameter(account)之类的方法重新指定。新手在这里容易踩坑表单提交后一直报403或者跳回登录页多半就是字段名和约定不一致导致的。3. 真正理解认证流程从请求到Session的完整链路3.1 一次登录请求在过滤器链上经历了什么很多初学者看完上面的Demo依然不明白“用户名密码到底是怎么被校验的”。这一节我带你顺着请求路径从头捋一遍。用户提交登录请求后请求会先经过UsernamePasswordAuthenticationFilter。这个过滤器专门处理POST/login请求它会把表单里的username和password提取出来封装成一个UsernamePasswordAuthenticationToken对象。这个token对象的状态是“未认证”的也就是只有principal和credentials没有权限列表。接着这个token被交给AuthenticationManager。manager拿到token后遍历自己管理的AuthenticationProvider列表找到支持这个token类型的provider来处理。在表单登录场景里最终干活的是DaoAuthenticationProvider它做三件事调用UserDetailsService.loadUserByUsername(username)数据库里没有这个用户就抛UsernameNotFoundException。调用PasswordEncoder.matches(rawPassword, encodedPassword)校验密码。校验通过后生成一个带有权限信息且“已认证”的Authentication对象。这个认证成功的对象最终被放进了SecurityContext同时创建Session之后同一个会话里的所有请求都会从Session中恢复出这个上下文不再需要重新登录。3.2 为什么一定要用PasswordEncoder而不是直接比对密码在刚才的流程中DaoAuthenticationProvider校验密码时并不是直接把表单密码和数据库里的密码用equals比较而是通过PasswordEncoder的matches方法。这里面的原理值得展开讲一下。如果数据库直接存明文密码一旦数据泄露所有账号密码直接暴露。Spring Security推荐使用BCryptPasswordEncoder它是一种自带随机盐的哈希算法同一个密码每次加密出来的结果都不同但matches方法依然能校验通过。加盐的好处是即使两个用户设置相同密码库里的密文也不一样有效对抗彩虹表攻击。在配置类里声明这个Bean即可Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }声明之后注册新用户时对原始密码做一次passwordEncoder.encode(rawPassword)再入库登录时框架会自动完成比对。之前的{noop}明文写法就是绕过了这一步所以要特别强调仅限学习。3.3 从内存用户切换到数据库用户内存用户只是开胃菜真实项目必然要用数据库存放用户和角色。做法很简单实现一个自己的UserDetailsService从Mapper或Repository里查询用户把查出来的用户信息包装成UserDetails返回。先建表最简版用户表可以这样CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, enabled TINYINT DEFAULT 1 ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL );然后自定义UserDetailsServiceService public class CustomUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public CustomUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListGrantedAuthority authorities userMapper.findRolesByUserId(user.getId()) .stream() .map(role - new SimpleGrantedAuthority(role.getRoleCode())) .toList(); return new User(user.getUsername(), user.getPassword(), user.getEnabled(), true, true, true, authorities); } }这个Service一旦声明为Spring BeanSpring Security会自动使用它来代替默认的InMemoryUserDetailsManager。这也是框架“约定优于配置”的体现你只需要关注业务查询逻辑框架负责整合进生命周期。3.4 认证成功后如何获取当前用户信息用户通过认证后后续请求里你想拿到当前登录人的信息最常见的姿势是Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName();如果你在Controller里还可以直接通过方法参数注入AuthenticationGetMapping(/me) public String me(Authentication authentication) { return authentication.getName(); }如果只需要用户名用AuthenticationPrincipal UserDetails userDetails也是常见做法。这里要注意的是SecurityContextHolder默认使用MODE_THREADLOCAL也就是每个线程持有一份安全上下文这也意味着如果你在异步线程里使用SecurityContextHolder默认是拿不到登录信息的需要额外配置SecurityContextHolderStrategy或者手动传递上下文。4. 授权配置实战角色、权限、方法级安全的三层玩法4.1 基于URL的粗粒度权限控制认证解决的是“你是谁”的问题授权解决的是“你能干什么”的问题。最简单的授权方式是在过滤链配置里直接对URL做限制。比如.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/public/**).permitAll() .anyRequest().authenticated() )hasRole(ADMIN)有个隐含细节它实际校验的权限名是ROLE_ADMIN。如果你的数据库里角色编码存的是ADMIN那么在UserDetails里构建权限时应该写入ROLE_ADMIN或者用SimpleGrantedAuthority(ROLE_ADMIN)。这里的ROLE_前缀是框架的约定我刚学的时候就在这里卡了半天数据库里明明存了ADMIN却始终403。4.2 基于注解的方法级精确控制URL级别的控制适合粗粒度的模块拦截但很多时候我们要精确到某个操作方法。比如同一个订单接口管理员可以查看所有订单普通用户只能查看自己的订单。这种场景适合用方法级安全。首先在配置类上开启方法安全Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { }然后在方法上使用注解PreAuthorize(hasRole(ADMIN)) GetMapping(/admin/orders) public ListOrder listAllOrders() { return orderService.listAll(); } PreAuthorize(hasAuthority(order:delete)) DeleteMapping(/orders/{id}) public void deleteOrder(PathVariable Long id) { orderService.delete(id); }EnableMethodSecurity是Spring Security 6.x里替代旧版EnableGlobalMethodSecurity的注解。开启后PreAuthorize、PostAuthorize、Secured、RolesAllowed这些都可用。其中PreAuthorize支持SpEL表达式可以做非常灵活的判断比如PreAuthorize(hasRole(ADMIN) or #id authentication.principal.id)这种直接判断当前访问的资源是否属于本人。4.3 细粒度权限模型RBAC的落地思路做了几个Demo项目之后你会发现把角色写死在注解里只适合小项目。正常一点的系统角色和权限应该分开管理角色是一组权限的集合用户关联角色角色关联权限。把这个模型落到Spring Security里核心就是把权限点比如“订单删除”作为authority字符串写入UserDetails。此时hasRole(ADMIN)这种判断相对少见取而代之的是hasAuthority(order:delete)这种方式。这样设计的好处是权限点可以下放到数据库由管理后台动态配置角色的权限列表代码里不需要因为权限调整而重新发布。后端只用PreAuthorize(hasAuthority(order:delete))声明这个接口需要什么权限点至于哪个角色拥有这个权限点由数据来决定。我在实际项目中偏向于把URL权限也做成动态加载在配置requestMatchers时从数据库读取“URL-权限”映射这样能实现接口权限的运行时变更不过这种玩法对事务和缓存要求较高新手还是先从注解方式入手更稳妥。5. 前后端分离下的无状态登录改造Spring Security JWT5.1 为什么前后端分离项目不适合用Session登录前面讲的都是经典的服务端渲染场景前后端共享同一个Session。但现在的项目大多是前后端分离前端是Vue或React后端只提供接口。这种情况下Session机制有两个明显问题跨域和跨站点场景下Cookie和Session的传递比较麻烦需要额外处理CORS和credentials。后端服务水平扩展时Session默认存在单机内存里多实例部署就要引入Session共享方案比如Redis增加了架构复杂度。JWT的方案思路是认证成功后后端签发一个自包含的token字符串给前端前端每次请求把它放在Authorization: Bearer token头里后端通过过滤器解析并校验token校验通过就把用户信息放入SecurityContext。整个过程服务端不保存会话状态天然支持横向扩展。5.2 集成JWT的完整步骤第一步引入JWT相关依赖以jjwt为例dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency第二步写一个JWT工具类负责生成token和解析token。核心代码大致如下Component public class JwtUtils { private final SecretKey key Keys.hmacShaKeyFor(your-256-bit-secret-your-256-bit-secret.getBytes()); private final long expiration 3600_000L; public String generateToken(String username, ListString roles) { return Jwts.builder() .setSubject(username) .claim(roles, roles) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration)) .signWith(key) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }这里要特别注意密钥长度HS256算法要求密钥至少256bit也就是32字节以上太短会直接启动报错。生产环境不要硬编码在代码里最好放在配置中心或环境变量中。第三步写一个JwtAuthenticationFilter继承OncePerRequestFilter。这个过滤器的作用是每次请求进来后从请求头里取出token解析成功就构建Authentication对象并放入SecurityContext这样后续的URL授权和方法级授权才能识别当前用户。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; public JwtAuthenticationFilter(JwtUtils jwtUtils) { this.jwtUtils jwtUtils; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims jwtUtils.parseToken(token); String username claims.getSubject(); ListGrantedAuthority authorities new ArrayList(); // 读取roles并转为GrantedAuthority略 UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(username, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token无效不设置上下文后续会被拦截 } } filterChain.doFilter(request, response); } }第四步把这个过滤器注册进过滤链同时把登录接口设置为允许匿名访问http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);关闭CSRF是因为无状态接口不需要CookieCSRF攻击失去意义STATELESS告诉框架不要创建Session。5.3 登录接口怎么签发Token最后补一个登录接口把认证和token签发串起来。思路是先手动调用AuthenticationManager完成认证认证成功就用当前用户名和权限生成token返回给前端。PostMapping(/api/auth/login) public ResponseEntity? login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); ListString roles authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList(); String token jwtUtils.generateToken(authentication.getName(), roles); return ResponseEntity.ok(new LoginResponse(token)); }这里我们需要手动注入AuthenticationManager需要在配置里暴露这个BeanBean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }前端拿到token后存储到localStorage或者内存中之后每次请求在拦截器里统一添加Authorization: Bearer xxxx头即可。整体改造完成后你就拥有了一套完全无状态的认证体系接口服务可以随意横向扩容不依赖共享Session存储。6. 常见问题与排查技巧实录6.1 一个请求到不了Controller到底卡在哪一环新手遇到最多的问题就是接口始终进不去要么一直跳登录页要么直接403。我的排查习惯是分三步走先看控制台有没有异常日志Spring Security抛出的异常通常会包含AuthenticationException或AccessDeniedException的字样再检查请求头有没有. Authorization如果你用了JWT这一项为空基本就是前端没带token最后看过滤链是否干扰了请求比如自定义过滤器放行条件写错导致那么请求在permitAll()之前就被拦下来了。如果用的是表单登录还有一种经典情况前端提交的是JSON格式比如{username:admin,password:123}而UsernamePasswordAuthenticationFilter只认表单格式的application/x-www-form-urlencoded。这种情况下后端一直无法认证成功需要在安全配置里指定自定义认证过滤器或者改用JSON解析。6.2 登录成功却拿不到用户信息或者异步线程里信息丢失SecurityContextHolder默认使用ThreadLocal来保存登录信息主线程里的数据在子线程中不可见。我遇到过用Async发送通知邮件时子线程里拿不到当前用户名的问题。解决方案有三种一是把用户信息作为方法参数显式传递二是在创建线程池时配置DelegatingSecurityContextExecutor让任务执行时把父线程的上下文带过去三是用SecurityContextHolder.setContext()在新线程中手动设置。如果只是登录成功后接口里拿不到信息那就要检查是不是在过滤器链里给请求设置了STATELESS策略并且过滤器中又没有正确解析token放入上下文。请求到了Controller层如果发现Authentication为null优先检查自定义过滤器有没有被正确注册进链里。6.3 密码加密换算法后老数据怎么平滑过渡如果在项目中途从明文改成BCrypt已经存在的用户密码很可能是明文无法通过matches校验。这里有个实用技巧实现一个自定义的AuthenticationProvider在密码校验失败时判断数据库里的历史密码是否等于明文密码如果相等则自动把加密后的新密码回写数据库。这样用户下次登录后密码就完成了平滑升级不需要强制所有人修改密码。具体做法是继承DaoAuthenticationProvider重写additionalAuthenticationChecks方法。这里要注意并发线程安全回写操作尽量做成幂等。6.4 常见错误速查表现象可能原因解决方法登录一直回到登录页用户名或密码错误或者UserDetailsService未生效检查用户是否存在确认UserDetailsService是Spring Bean返回403CSRF未关闭或权限不足无状态接口关闭CSRF检查角色前缀ROLE_返回401token未传、过期或无效检查请求头确认token过期时间配置BCrypt报错“There is no PasswordEncoder mapped”密码存储格式和解码器不匹配统一使用BCryptPasswordEncoder或简单的迁移逻辑注册的过滤器不生效过滤器未被加入SecurityFilterChain使用addFilterBefore显式注册方法注解权限不生效未开启EnableMethodSecurity在配置类加上该注解循环依赖启动失败自定义过滤器依赖了SecurityConfig中的Bean调整注入方式避免循环依赖或用懒加载7. 把这个项目继续扩展下去的几点思路Spring Security能做的事远不止登录认证这么简单项目里如果时间允许还可以继续往这几个方向打磨。一个是把动态权限做成配置化。接口权限点从数据库读取支持运营后台实时调整某个角色能访问哪些接口这需要你在过滤链里动态构造requestMatchers规则同时要考虑规则变更后安全上下文的刷新问题。一个是集成OAuth2客户端。现在很多系统需要支持微信扫码登录、GitHub登录或者对接企业内部统一认证中心Spring Security对OAuth2和OIDC协议的支持非常完善基于oauth2Login配置就能快速接入。再一个是把安全审计做起来。使用Spring Security自带的AuthenticationSuccessEvent和AuthenticationFailureEvent等事件可以在用户登录成功或者失败时发消息给审计系统。我在项目中就是通过监听认证成功事件把登录时间、IP、用户代理写入日志表这对安全回溯很有价值。我在实际做的过程中发现Spring Security最忌讳的就是只看不练。照着文章里的代码自己敲一遍再故意把配置改错几次去观察现象比你刷十遍教程都管用。尤其是过滤器的注册顺序这个只有自己调试过才能真正理解它的含义。希望这篇实战梳理能帮你少踩几个我当年踩过的坑顺利把Spring Security用起来。