ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Security + JWT 权限认证实战:从入门到避坑指南

Spring Security + JWT 权限认证实战:从入门到避坑指南 做后端接口开发的朋友迟早会在权限认证这一关卡住。Spring Security加JWT这套组合我前后在好几个项目里落地过不敢说有多深的造诣但踩过的坑和绕过的弯绝对够写一篇长文。这篇文章我不想照着官方文档给你念一遍而是从实际项目出发讲清楚为什么要选这套方案、核心代码怎么写、以及真正上线后你才会遇到的那些问题比如token续签、安全漏洞修复、跨域配置之类。无论你是刚接触Spring Security的新手还是准备重构老项目认证模块的开发者这篇文章应该都能给你一些可以直接抄作业的参考。整个权限认证功能拆开来看无非就是两件事你是谁认证你能干什么授权。Spring Security负责解决后者JWT负责解决前者。两者结合之后系统就变成了无状态会话架构服务器不存用户登录状态每次请求带上一个合法token就行。这种模式下水平扩容变得特别轻松因为任何一台节点都能独立完成认证不用依赖集中式session存储。1. 内容整体设计与思路拆解1.1 为什么偏偏是Spring Security加JWT说实话市面上的认证方案不少。Shiro、Sa-Token、自研拦截器、Spring Security我都用过。Shiro的上手门槛确实低配置简单中文资料也多但一旦涉及复杂的权限模型、方法级安全控制它就得靠你自己造轮子了。自研拦截器看着最灵活但安全框架这东西自己造的轮子往往在某个边界场景下漏风等出问题的时候付出的代价远大于当初省下的学习成本。Spring Security的优势在于它是Spring家族的亲儿子和Spring Boot的整合顺畅到几乎零配置而且它已经把认证、授权、会话管理、CSRF防护、CORS配置这些统统帮你封装好了。你今天用JWT做无状态认证明天想换成OAuth2.0客户端模式后天想接OIDC单点登录Spring Security都有现成的模块支撑。这套框架的学习曲线确实陡峭一些但越过那个坎之后你会发现它其实设计得很优雅。JWT这边的选择逻辑也简单。传统的Session方案在分布式环境下要靠粘性会话或者额外引入Redis做session共享麻烦不说还增加了架构的复杂度。JWT把用户身份信息直接放进token里服务器无状态验证签名即完成认证。前端拿到token存起来每次请求带上就行。这正好契合现在前后端分离、多端共存的开发模式。1.2 认证授权整体流程与方案选型我习惯把整个流程画成一条直线来理解。用户发起登录请求带着用户名密码过来。后端先走Spring Security的AuthenticationManager做身份校验校验通过之后用JWT工具类生成一个带签名和过期时间的token返回给前端。前端拿到token存到本地通常是localStorage或者内存里后续每个需要鉴权的接口请求都在Header里带上Authorization: Bearer 。后端这边有一个自定义的JWT过滤器拦截所有请求提取token、验签、解析用户信息和权限列表把认证信息塞进SecurityContextHolder里。这样一来Spring Security的授权机制就能正常工作了比如接口上的PreAuthorize(hasRole(ADMIN))注解或者自定义的权限校验逻辑。选型上有个细节值得说下。JWT的Java库主要是jjwt和java-jwtauth0出的两个选择后来Hutool也封装了自己的JWT工具。我项目里用得最多的是jjwt因为它API设计稳定对JWT标准的支持最完整。不过值得一提的是如果项目中已经引入了Hutool直接用它自带的JWTUtil也能完成token的生成和解析Hutool还支持自定义过期时间代码量更少适合工具类喜欢集中管理的团队。关键看团队的技术栈习惯核心原理都是同一套。2. 核心细节解析与实操要点2.1 项目依赖与用户体系设计先看依赖。以Spring Boot 3.x为例Spring Security 6.x的写法跟5.x有比较大的变化如果你还在用继承WebSecurityConfigurerAdapter的老写法在Spring Boot 3.x里是直接跑不起来的。我的做法是全部用组件注册的方式也就是定义SecurityFilterChain的Bean这样既清晰又跟得上版本演进。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency 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这里有个小提示jjwt从0.10.x开始把api、impl、jackson三个模块拆开了impl和jackson必须是runtime作用域。很多人抄依赖的时候漏掉jackson那个结果运行时解析token直接报ClassNotFoundException排查半天发现是序列化器没引进来。用户体系的设计上我建议你不要把一堆字段堆在UserDetails实现类里。Spring Security需要的是用户的身份标识、密码、权限集合、账号状态这几个核心属性。业务上的手机号、邮箱、昵称这类的放到自己定义的User实体里然后让这个User实现UserDetails接口或者做一个适配器类来桥接。我倾向于后者因为业务实体跟安全框架解耦以后换认证方式不至于动业务代码。2.2 JWT工具类的实现细节JWT的结构分三段Header、Payload、Signature。Header里面是签名算法和token类型Payload里面是业务需要的声明比如用户名、权限列表、过期时间Signature是用密钥对前两段做的签名。这里有个新手特别容易误解的点JWT的Payload只是做了Base64URL编码不是加密。你把token贴到jwt在线解析的网站上payload里的内容一眼就能看到明文。所以密码、身份证号、手机号这类敏感信息绝对不要往JWT里塞。我见过有人把用户密码摘要放进去的这跟把钥匙挂在门上没区别。JWT工具类我习惯封装四个方法生成token、解析token、校验token、获取用户名。核心代码如下Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; private SecretKey getSigningKey() { byte[] keyBytes secret.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(Authentication authentication) { String username authentication.getName(); String authorities authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(,)); Date now new Date(); Date expiryDate new Date(now.getTime() expiration * 1000); return Jwts.builder() .setSubject(username) .claim(authorities, authorities) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }注意两个细节。第一密钥的长度HS256算法要求密钥至少256位也就是32个字节。如果你配置的secret字符串太短jjwt会直接抛WeakKeyException。第二expiration单位问题我在配置文件里定义expiration的时候用的是秒所以生成过期时间的时候乘以了1000转成毫秒。实际操作中很多人会忽略单位换算导致token刚发出来就过期了或者过期时间比预期长了1000倍。2.3 认证过滤器与Security配置自定义过滤器是整条链路的核心。这个过滤器要做的事很简单从请求头里拿到token验签解析出用户信息构建Authentication对象放进SecurityContextHolder。但有几个坑必须提前说。第一过滤器要继承OncePerRequestFilter保证一次请求只执行一次避免转发场景下重复认证。第二不要在这个过滤器里把异常吞掉。token过期、签名错误、token格式不对这些情况要区分处理至少要给到合适的HTTP状态码。第三过滤器只负责认证不负责拦截。哪些接口要放行、哪些要认证这些规则写在SecurityConfig里面过滤器只负责有没有token、token合不合法这个层面的事。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { String token header.substring(7); if (jwtUtil.validateToken(token)) { String username jwtUtil.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }这过滤器里的逻辑虽然直白但有一个细节值得展开解析出username之后为什么还要调一次loadUserByUsername去数据库查用户有些实现偷懒直接从token的claims里拿权限信息构造Authentication对象省掉数据库查询。这个做法能省一丢丢性能但代价是用户在token签发之后如果被删号、被禁用、权限被改都要等token过期才能生效。而调用loadUserByUsername每次实时查库虽然多了一次IO却能保证权限变更即时生效。我更倾向于查库毕竟权限认证系统里及时性往往比性能更重要。SecurityConfig这边的配置Spring Security 6.x的关键是定义SecurityFilterChainConfiguration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Autowired private AuthenticationEntryPoint unauthorizedHandler; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .requestMatchers(HttpMethod.GET, /api/public/**).permitAll() .anyRequest().authenticated()) .exceptionHandling(ex - ex.authenticationEntryPoint(unauthorizedHandler)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }几个关键点。csrf要关掉因为我们是无状态token认证CSRF防护本来就是防cookie攻击的跟JWT的方案相悖。session策略要设为STATELESS告诉Spring Security别创建HttpSession否则它默认还会往响应里写JSESSIONID容易让人误以为session还在生效。密码编码器必须用BCryptPasswordEncoder或者更现代的DelegatingPasswordEncoder绝对不能明文存密码。3. 实操过程与核心环节实现3.1 登录接口与认证流程串起来有了上面的底层配置登录接口的写法可以很优雅。Spring Security已经把用户名密码校验封装在AuthenticationManager里了我们只需要调一下让它去匹配UserDetailsService返回的用户信息和前端传来的密码成功了就生成token返回。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest loginRequest) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); String token jwtUtil.generateToken(authentication); return Result.success(Collections.singletonMap(token, token)); } }这里有个容易被忽略的体验问题当前后端的login接口如果和前端是跨域部署的比如前端在8080端口后端的login接口返回token就必须在SecurityConfig里把CORS配置加上否则浏览器直接就把请求拦了。CORS配置不复杂但漏了的话会让你在联调阶段莫名其妙的浪费大量时间。建议在SecurityConfig里加一个CorsConfigurationSource的Bean允许指定域名跨域。登录接口本身的校验逻辑也值得一提。很多项目在登录前要求先输入图形验证码验证码的校验放哪一层我一般建议放在AuthenticationManager之前先校验验证码再走用户名密码认证。验证码本身不涉及Spring Security就是一个独立的Controller接口生成验证码图片返回给前端同时把答案存到Redis设置5分钟过期。登录时前端把验证码和用户名密码一起提交后端先比对Redis里的验证码过了再走认证。这套流程几乎每个项目都会用能有效挡住一部分恶意刷登录的请求。3.2 权限控制与接口保护认证解决的是你是谁的问题授权解决的是你能做什么的问题。Spring Security的授权主要分两种基于URL的授权和基于方法注解的授权。URL级别的授权写在configure方法里通过requestMatchers来配置。比如GET /api/admin/要求ROLE_ADMIN角色才能访问/api/public/完全放行。方法级别的授权则靠EnableMethodSecurity开启然后在接口上标注PreAuthorize(ss.hasPermi(system:user:add))这类表达式。我项目里更倾向于方法注解因为权限粒度更细而且权限规则跟接口放一起可读性更好。基于方法注解实现权限控制需要在登录或者token解析的时候把权限列表塞进Authentication对象。JWT的claims里带上authorities字段就算不用查库SecurityContextHolder也能拿到完整的权限集合。更稳妥的做法是前面提到的每次实时查库把用户的权限从数据库里捞出来这样权限变更能即时生效。有种场景比较麻烦用户被踢下线、强制退出、密码被重置你希望这个token立刻失效。但JWT的特性决定了它签发之后是自己给自己背书服务端没法主动让它失效。网上的方案有很多比较经典的是用Redis维护一个token黑名单每次请求的时候查一下。但这就引入了Redis依赖跟无状态的设计初衷有点矛盾。我的取舍是核心业务才加黑名单校验普通项目直接等token自然过期毕竟安全和复杂度之间总得有个平衡。3.3 跑通一次完整请求到这里整个链路已经闭环了。我们来一步一步走一遍完整的请求流程我用实际经验说明每个环节的预期效果。第一步用户调用/api/auth/login提交用户名和密码。AuthenticationManager会去调UserDetailsService.loadUserByUsername拿到用户信息再用PasswordEncoder比对密码。匹配成功则返回Authentication对象里面带着用户的权限列表。如果密码不匹配会抛BadCredentialsException这个异常Spring Security会自动转成401。第二步拿到Authentication对象之后调用JwtUtil.generateToken生成token。这个token里面包含了用户名、权限列表、签发时间、过期时间。返回给前端格式通常是token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjoiUk9MRV9BRE1JTixTWVNURU06VVNFUjoxMTEiLCJpYXQiOjE3MjAwMDAwMDAsImV4cCI6MTcyMDAwMzYwMH0.xxxxx。第三步前端拿到token存起来后续请求在Header里带Authorization: Bearer 。第四步JwtAuthenticationFilter拦截到这个请求提取token调用validateToken验证签名和过期时间解析出用户名再通过UserDetailsService查库拿到最新的用户信息和权限列表构造Authentication对象设置到SecurityContextHolder。第五步DispatcherServlet把请求分发给对应的Controller方法。Spring Security在进入方法之前会检查这个接口的授权规则。如果有PreAuthorize(hasAuthority(SYSTEM:USER:ADD))这样的注解它会拿SecurityContextHolder里的权限列表来比对允许或者拒绝。第六步如果一切顺利Controller正常返回业务数据如果token过期过滤器里抛出的ExpiredJwtException会触发AuthenticationEntryPoint返回401和自定义的错误信息给前端。前端收到401就知道token失效了引导用户重新登录或走刷新token的逻辑。3.4 配置项的补充说明实际项目中还需要在application.yml里配置JWT相关的参数jwt: secret: your-256-bit-secret-key-here-please-change-in-production expiration: 7200这里有两个建议。第一secret千万不要写死到代码里。我之前有个项目因为密钥硬编码在配置文件里然后代码仓库被员工误传到公开仓库结果整个token体系就相当于裸奔了最后不得不临时切换密钥并且强制所有用户重新登录那次教训挺深刻。正确的做法是放到环境变量或者配置中心按环境区分至少保证生产环境的密钥跟开发环境不一样。第二expiration的值按业务需要来。普通的后台管理系统建议1-2小时App的接口token可以放长到7天但一旦长了token泄露的风险就高了所以接口层面最好能配合操作审计日志一起使用。4. 常见问题与排查技巧实录4.1 token过期与续签方案JWT最经典的痛点就是过期时间怎么定。定短了用户用着用着token就失效了体验很差定长了token泄露的风险就大了。这里我推荐一个在项目中验证过多次的方案滑动续期。所谓滑动续期就是每次请求进来的时候判断token剩余的有效期。如果剩余时间少于某个阈值比如总有效期的四分之一就签发一个新token放到响应头里返回给前端。前端只要检测到响应头里有新token就替换掉本地存储的旧token。这样一来用户只要一直在用系统token就会不停地续期不需要重新登录而如果用户真的离开了一个多月token自然过期重新登录也是合理的。实现思路是这样的在JwtAuthenticationFilter里解析token后顺便取一下过期时间。如果剩余时间少于阈值就用同样的用户信息重新生成token写到response的header里比如Access-Control-Expose-Headers: New-Token或者自定义一个Authorization头。前端配合调整一下即可。这里要提醒的是续签逻辑别放在登录接口里做登录接口是每次用户输密码换token的地方而续签是每次请求自动换token的地方两者职责不同。如果你把续签和登录混在一起代码会乱成一锅粥。4.2 JWT安全漏洞与修复建议JWT开发中最容易犯的错是把敏感信息放进payload然后以为别人解不开。实际上payload只是base64编码任何人拿到token用jwt在线解析工具一键就能看到内容。所以token里最多放用户名、用户ID、角色编码这类不敏感的信息。如果要展示昵称、头像、页面权限列表还是通过接口去查询别一股脑塞token里。另一个安全性极强的坑是密钥泄露。网上搜JWT密钥能搜出来一堆测试库里的通用密钥比如secret、123456这类。如果项目上线后密钥还是这种弱口令攻击者完全可以伪造一个管理员token直接打穿整个系统。密钥必须是一段足够长的随机字符串长度至少32字节而且要定期轮换。轮换的时候要注意兼容性最好在解析token的时候支持新旧两个密钥给旧token留一段缓冲过期时间。还有一个比较隐蔽的漏洞是算法混淆攻击。JWT支持多种签名算法比如HS256对称加密和RS256非对称加密。如果服务端没有限制算法类型攻击者把token头部里的alg从RS256改成HS256然后用公钥作为HMAC的密钥来签名某些库就会用公钥去验签结果就验签通过了。这个漏洞的修复很简单使用jjwt解析时强制指定签名算法或者校验算法必须是你期望的算法不要接受任意alg。在JwtUtil的validateToken方法里解析前先看header的alg字段如果不是HS256直接拒绝。4.3 无状态会话下的登出与踢人因为JWT是无状态的服务端根本不知道这个token是否还在有效期内所以传统Session方案的登出逻辑也就是销毁服务端session在JWT方案里走不通。网上有几种做法。第一种是纯前端方案登出的时候前端把本地存的token删掉就行。这个方案最简单但注意如果token被泄露出去持有token的人照样能访问接口直到token过期。第二种是Redis黑名单方案。登出的时候把token的jtiJWT ID或者token本身塞进Redis设置过期时间等于token的剩余有效期。每次请求进来的时候JwtAuthenticationFilter校验完token后再查一下Redis看看token在不在黑名单里在就直接拒绝。这个方案能实现真正的登出即失效代价是每次请求多一次Redis查询。第三种是针对强制下线的场景给每个用户的token加一个版本号比如Redis里存一个userId到version的映射token里带version字段每次校验的时候比对一下版本号一致才算有效。管理端要踢人的时候把版本号加一该用户的所有token立刻全部失效。我一般根据业务的重要性来选择普通系统用第一种涉及资金或敏感数据的系统用第二种需要管理端踢人功能的系统用第三种或二三种结合。4.4 几个容易踩的坑第一个坑是Spring Security的过滤器顺序。自定义的JwtAuthenticationFilter必须加在UsernamePasswordAuthenticationFilter之前。如果顺序放错了请求还没被JWT过滤器认证就往后面走了SecurityContextHolder里一直是空的后面哪怕你权限配置写的都对结果全是401。第二个坑是自定义AuthenticationEntryPoint。默认情况下token过期或认证失败的时候返回的是Spring Security标准错误页或者纯文本提示前端拿到这种非JSON格式的响应往往一脸懵。正确的做法是自定义一个AuthenticationEntryPoint重写commence方法往response里写入JSON格式的提示信息。同理权限不足的时候AccessDeniedHandler也要自定义。我习惯统一在返回值里用Result对象做包装反正要把成功、失败、未登录、无权限这几类状态码做成一套规范前后端才好配合。第三个坑是PermitAll不等于不经过过滤器链。很多新人以为接口标记了permitAll过滤器和鉴权逻辑就不执行了然后在这个接口里拿不到SecurityContextHolder里的用户信息。实际上permitAll只是放行了授权规则JwtAuthenticationFilter还是会正常执行。想要在permitAll的接口里获取当前登录用户通常还是要靠JWT过滤器解析token后把用户信息放进去。所以在设计放行接口的时候如果接口逻辑里需要获取操作人就点一下getAuthentication拿一下如果没有就说明没传token或者传了无效token。第四个坑是跨域加认证的组合问题。浏览器发起预检请求OPTIONS的时候不会带Authorization头如果SecurityConfig把OPTIONS请求拦截了前端会莫名其妙地报CORS错误。所以跨域配置的时候要么在SecurityConfig里放行OPTIONS请求要么保证CORS配置加在Security过滤器链的最前面让预检请求直接通过。最后一个坑日志别打token。我遇到过排查线上问题的时候开发同学把整个请求对象打到了日志里里面带了完整的token。随着日志文件被多系统采集、汇聚token等于变相泄露了。这个习惯要改日志里涉及token、密码、支付信息的一律脱敏。5. 一些实际项目中的补充建议这个标题下的内容展开能写很久。最后我再按个人经验补几个方向你后续做扩展的时候可以参考。如果项目是SPA端加移动端共用一套接口token的存放位置需要区分对待。浏览器端建议放在内存变量里而不是localStorage因为localStorage存在XSS的风险。有人为了省事把token放sessionStorage刷新页面就没了又得重新登录体验不太好。折中的方案是前端把token放内存刷新后用一个静默刷新接口重新换取这个要配合第四节的续签方案来做。如果你看到jwt在线解析这类工具别只在调试的时候用也要拿它审视一下自己发的token里到底装了哪些信息。凡是你不希望对外暴露的数据都不要出现在token里。token的设计原则是最小化原则只放能唯一定位到用户和能完成权限判定的必要信息。关于jwt实现token续签网上还有用refresh_token双token的方案。个人体验单token滑动续期对大多数项目已经够用了双token的复杂度在于刷新接口也得做安全控制否则等于多了一个攻击面。真要上双token建议刷新token设置短期有效比如2小时且只能调用刷新接口不能直接访问业务接口同时刷新token的存储要求比access_token更严格。实现Spring Security加JWT权限认证功能整套流程本身不算特别复杂真正复杂的是上线运行之后遇到的各种边界情况。我上面的这些内容都是一次次线上告警和加班排查换来的总结希望对你能有实际的帮助。项目跑起来之后的日常维护中如果遇到了教科书上没写的怪问题回来翻翻这篇文章多少能提供一点排查思路。
返回列表