2026/10/14 23:35:04

Spring Security实战:认证授权、JWT与接口权限控制

Spring Security实战:认证授权、JWT与接口权限控制 上个月帮一个团队排查线上接口被刷的问题发现他们对 Spring Security 最大的误解是框架加上等于安全配置完等于完工。项目接入后只跑通了默认登录后续所有接口一律 permitAll这跟没装没区别。我们花了一整天把认证、授权、Token、权限模型重新梳理了一遍改完之后才敢说Spring Security 才算真正接入了项目。这篇教程就按我们当时整理的思路来写覆盖 Spring Boot 接入、内存用户登录、数据库用户扩展、JWT 无状态认证、接口级权限控制以及前后端分离场景下最容易踩的跨域、401、Token 过期这些坑。内容会偏实操每一步都给出可以复制的代码和取舍理由适合刚接触 Security 的小白也适合已经跑通登录但想完善权限模型的同学。1. 裸奔接口的代价先弄明白认证与授权各自的职责1.1 三个看似不会有问题的真实场景先说两个我实际遇到过的例子。某内部管理系统上线了大半年所有接口只靠网关拦了一层。运维同学觉得内网 IP 访问问题不大。直到有一天做安全巡检发现后台管理接口可以被任意登录用户直接调用。普通用户只要手动拼一下 URL就能看到全公司的组织架构和薪资配置。这就是典型的接口裸奔登录是登录了但没有任何授权控制。另一个项目更典型。角色判断全写在业务代码里到处是if (user.getRole().equals(ADMIN))这种散弹式逻辑。新来的同事接需求漏写一个判断某个操作就对所有角色开放了。代码评审很难发现测试也不容易覆盖到所有分支。还有一个最常见的密码直接明文存数据库或者只做一层 MD5。数据库一旦泄露所有账号就全交代了。MD5 撞库成本极低网上彩虹表一查一个准。这三个场景分别对应三个问题没有授权、授权散落、凭据存储不安全。Spring Security 不是解决其中某一件事而是把认证、授权、密码存储、会话管理这些事全部收敛到同一条链路上统一处理。1.2 认证与授权千万别混为一谈很多人在配置 Security 时分不清两个词authentication 和 authorization。前者是认证后者是授权。认证回答的问题是你是谁。登录、Token 校验、密码比对都属于认证范畴。授权回答的是你能做什么。判断当前用户是否有权限访问某个接口、执行某个操作属于授权范畴。可以拿门禁卡类比认证是刷卡进门证明你确实是公司员工授权是进哪一层、开哪扇门。楼下的闸机能确认你的身份但不会自动决定你能不能进财务室。Spring Security 的设计也明确区分这两层认证由 AuthenticationManager 负责授权由 AccessDecisionManager / AuthorizationManager 负责。后面配置时会看到认证逻辑主要围绕 UserDetailsService 和过滤器展开授权逻辑围绕 authorizeHttpRequests 和注解展开。理解这个分工后面配置才不会写成一锅粥。提示项目里如果只做了登录没做授权Security 的价值只发挥了一半。先想清楚哪些接口登录后就能访问哪些接口需要特定角色再动手配置。2. 所有安全控件的底层一条请求在过滤器链里经历了什么2.1 过滤器链本身并不神秘Spring Security 的核心机制是一条过滤器链。Java Web 里的 Servlet Filter 本身就是一组对请求的拦截器请求进来依次经过每个 Filter每个 Filter 决定放行或拦截。Spring Security 在这基础上封装了一层把几十个安全相关 Filter 串成一条链。默认配置下一条请求大致会经历这么几站安全上下文过滤器把当前线程的认证信息清理或初始化会话管理过滤器处理 Session 相关逻辑匿名认证过滤器没有登录时创建一个匿名 Authentication登录过滤器处理表单登录或 Token 登录授权过滤器判断当前请求能否访问目标资源这就是为什么说 Security 是一条链路管所有。你自定义的 JWT 过滤器最终也会插进这条链路的某个位置而不是单独跑在 Spring MVC 里。2.2 为什么前后端分离必须抛弃 HttpSession传统服务端渲染项目里登录成功后服务端把用户信息存进 Session通过 JSESSIONID Cookie 维持会话。浏览器每次请求自动带 Cookie服务端查 Session 就知道是谁。前后端分离后这套玩法就难受了。第一前端可能部署在完全不同的域名下跨域请求携带 Cookie 要处理 CORS 的 credentials 配置稍微没配对浏览器直接拒绝。第二移动端 App 里压根没有浏览器 Cookie 机制怎么带 Session ID第三项目横向扩容部署多个实例时Session 默认存单机内存用户第一次请求落在 A 实例第二次请求落到 B 实例B 不认识这个 Session用户就被踢下线了。所以前后端分离项目普遍的做法是服务端不保存会话状态客户端每次请求带一个凭证通常是 Token服务端验一下凭证就放行。这就是无状态认证也是本教程后面 JWT 方案的前提。2.3 SecurityContextHolder认证信息放在哪认证成功之后用户信息放在了 SecurityContextHolder 里。这是一个基于 ThreadLocal 的容器相当于每个请求线程自己的一个小抽屉。过滤器链里的认证过滤器把 Authentication 对象塞进这个小抽屉后续 Spring MVC 的 Controller 就能从中取当前用户。业务代码里经常这样拿登录用户Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName();理解了这一层你就明白为什么自定义过滤器里必须做解析 Token → 构建 Authentication → 放入 SecurityContextContextHolder这个动作。因为后续接口能不能通过授权校验全靠这个抽屉里有没有东西。注意SecurityContextHolder 默认策略是请求结束由过滤器清理避免线程池复用导致串号。无状态模式下每个请求都要重新走一遍 Token 解析所以过滤器里的解析逻辑必须高效可靠。3. 第一步实操Spring Boot 接入 Security 并跑通一套最基础的登录3.1 依赖引入与默认行为先把依赖加进 pom.xmldependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency只要引入这个依赖Spring Boot 自动配置会立刻生效。项目启动时日志里会出现一行随机密码Using generated security password: 8f9a0b1c2d3e4f5a默认用户名是 user默认所有接口都要求登录默认走表单登录跳转。这个状态下项目是没法正常给前后端分离前端用的但它是验证依赖是否生效的最快方法启动项目访问任意接口应该会跳到一个默认登录页或被 401 拦截。3.2 自定义用户来源把数据库用户交给框架默认的 user 用户没有实际意义真实项目用户肯定在数据库里。Spring Security 通过 UserDetailsService 接口加载用户我们只需要实现它的 loadUserByUsername 方法Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserMapper userMapper; public UserDetailsServiceImpl(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 user.getRoles().stream() .map(SimpleGrantedAuthority::new) .toList(); return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .authorities(authorities) .build(); } }loadUserByUsername 里需要注意返回的 UserDetails 中的 password 必须是加密后的密文绝对不能返回明文。UserDetails 对象的 authorities 就是后面授权判断的依据它可以是角色名也可以是权限字符串这个细节放到第 5 部分细说。写完实现类Spring Security 会优先使用这个 Bean 来加载用户。真正的校验逻辑由框架内部的 DaoAuthenticationProvider 完成它把前端传来的明文密码和 UserDetails 里的密文交给 PasswordEncoder 做比对我们不用自己写密码比对。3.3 密码存储BCrypt拒绝明文密码比对需要 PasswordEncoder。现代 Spring Security 默认推荐 BCrypt因为 BCrypt 内部带盐相同密码每次加密结果不同而且计算成本可以调高让暴力破解更困难。Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }注册用户时直接调用 passwordEncoder.encode(rawPassword) 存库就行。有一个坑必须提醒Spring Security 5 之后的 DelegatingPasswordEncoder 支持带前缀的密码格式例如{bcrypt}。如果你数据库里已经存了不带前缀的裸 BCrypt 字符串直接用默认 PasswordEncoder 比对会直接抛异常。解决办法就是显式声明一个 BCryptPasswordEncoder Bean绕开前缀解析。如果你的密码是从旧系统迁过来的 MD5建议迁移时统一重加密别混着存。基础配置类长这样Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .anyRequest().authenticated() ) .formLogin(form - form.disable()) .httpBasic(basic - basic.disable()); return http.build(); } }到这里一个最基础的从数据库加载用户 BCrypt 校验 登录接口放行的模型就出来了。跑通这一步后面接 JWT 会顺很多。经验先跑通 UserDetailsService 和 BCrypt再加 JWT 过滤器。一次只加一个变量出问题的时候你才知道是怎么来的。4. 前后端分离的核心改造用 JWT 替换 HttpSession 实现无状态认证4.1 为什么选 JWT 而不是简单 UUID Token无状态认证的 Token 方案有不少最简单的方案是登录后生成一个 UUID 存 Redis之后每次请求带 UUID服务端查 Redis 校验。这个方案也很好而且支持主动踢人、登录状态实时控制。JWT 和 UUID Token 的区别在于自包含。JWT 的 payload 里可以直接携带用户 ID、用户名、角色列表服务端验签通过后就知道是谁不需要再查 Redis 或数据库。代价是 JWT 一旦签发在过期之前无法主动作废。项目选型时可以这样判断如果系统有严格的用户封禁、强制踢下线需求优先 UUID Token 存 Redis如果追求接口鉴权性能、不想为每个请求都查一次缓存优先 JWT。我们这里用 JWT 做演示是因为它在前后端分离场景下最典型代码也最能体现过滤器链的工作方式。4.2 JWT 的结构与生成校验一段 JWT 长这样用点分成三段eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.签名部分Header 声明算法Payload 放用户信息Signature 用密钥对前两部分签名。服务端校验时用同一个密钥重算签名对得上说明 Token 没被篡改。引入 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工具类Component public class JwtTokenProvider { private final SecretKey key; private final long expirationMillis 60 * 60 * 1000L; public JwtTokenProvider(Value(${jwt.secret}) String secret) { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createToken(String username, ListString authorities) { Instant now Instant.now(); return Jwts.builder() .subject(username) .claim(authorities, authorities) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(expirationMillis))) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String getUsername(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody().getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }两点必须注意第一密钥长度至少 32 个字节配短了 jjwt 直接报错第二不要往 payload 里塞敏感信息比如手机号、身份证号JWT 的 payload 只做 Base64 编码不是加密谁拿到都能解出来。4.3 自定义过滤器接入过滤器链登录接口发出 Token 之后后续请求要能识别 Token。这个识别逻辑放在一个自定义 Filter 里插到 Spring Security 过滤器链的前端位置。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) { this.tokenProvider tokenProvider; this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token)) { String username tokenProvider.getUsername(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }过滤器做的事就三步从请求头拿 Token校验 Token通过用户名重新加载用户并放入 SecurityContext。注意这里用的是 loadUserByUsername意味着每次请求都会查一次数据库。如果对性能敏感可以把用户信息直接放进 JWT 的 claims 里构造 Authentication 时不再查库安全性上两者各有取舍项目里按需选择。配置里把过滤器挂到 UsernamePasswordAuthenticationFilter 之前http .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)因为这条链上原本的认证过滤器是处理表单登录和 Session 的我们要让 JWT 的认证逻辑先跑跑完直接把用户放进上下文后面的授权过滤器直接用它判断权限。4.4 登录接口与统一返回登录接口自己不处理密码比对它把用户名密码交给 AuthenticationManager由框架完成校验RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; private final JwtTokenProvider tokenProvider; PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest request) { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); UserDetails userDetails userDetailsService.loadUserByUsername(request.getUsername()); ListString authorities userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList(); String token tokenProvider.createToken(userDetails.getUsername(), authorities); return ResponseEntity.ok(new LoginResponse(token, userDetails.getUsername(), authorities)); } }如果用户名密码不对authenticate 会抛出 BadCredentialsException再由全局异常处理器转成 401 JSON。到这里前后端分离的认证闭环就已经打通了登录拿 Token请求带 Token过滤器验 Token。提示Session 默认还在创建。记得把配置加上.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)彻底关掉服务端会话否则响应头里还会出现 JSESSIONID有违无状态设计初衷。5. 权限管到接口粒度角色、权限字符串与方法级校验5.1 hasRole 与 hasAuthority 的细微差别很多初学者卡在授权配置上主要是不清楚 hasRole 和 hasAuthority 的关系。hasRole(ADMIN) 底层会转成 hasAuthority(ROLE_ADMIN)。也就是说使用 hasRole 时用户的权限集合里必须有带 ROLE_ 前缀的权限字符串。hasAuthority(ADMIN) 要求的则是精确匹配没有自动加前缀。实际项目里我更推荐用权限字符串而不是直接挂角色名。角色是权限的集合一个角色对应多个权限点。接口校验的是权限点而不是校验角色。例如删除用户这个接口要求 user:delete 权限而不关心调用者是管理员还是运营。给用户分配角色时再把一组权限点挂到角色上。配置示例.authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/user/**).hasAuthority(user:query) .anyRequest().authenticated() )5.2 从数据库加载动态权限前面 UserDetailsService 里我们把用户角色映射成 authority 传入 UserDetails。如果项目里角色是动态配置的通常的做法是登录时查出用户的角色列表再查出每个角色对应的权限列表两者合并作为 authorities。权限表设计不用复杂三张表就够用户表、角色表、用户角色中间表。权限点可以先写死在代码里用常量维护等规模大了再引入权限路由表也不迟。数据库加载权限的 loadUserByUsername 逻辑ListString roles roleMapper.findRolesByUsername(username); ListString permissions roleMapper.findPermissionsByRoles(roles); ListGrantedAuthority authorities new ArrayList(); roles.forEach(role - authorities.add(new SimpleGrantedAuthority(ROLE_ role))); permissions.forEach(permission - authorities.add(new SimpleGrantedAuthority(permission)));这里有两个坑。第一JWT 签发之后权限快照就固定在 Token 里用户权限改了要等 Token 过期才生效。如果你需要即时生效要么把 Token 有效期调短要么改成 UUID Token 存 Redis。第二authorities 列表里混着角色和权限点配置授权时千万不要手滑写错前缀。5.3 方法级安全注解精准锁定业务操作URL 级别的授权只能做到粗粒度控制真正精细的校验可以放到方法上Configuration EnableMethodSecurity public class SecurityConfig { ... }然后业务接口上加注解PreAuthorize(hasAuthority(user:delete)) public void deleteUser(Long userId) { userMapper.deleteById(userId); }方法级校验的一个常用场景是数据权限。比如只能删自己创建的用户可以结合 SpEL 表达式访问方法参数。Spring Security 提供 PreAuthorize、PostAuthorize、Secured、RolesAllowed 几个注解其中 PreAuthorize 表达式能力最强PreAuthorize(hasAuthority(user:delete) and #userId authentication.principal.id)这个表达式的意思是必须拥有 user:delete 权限且目标 userId 等于当前登录用户自己的 ID。用这种方式把细粒度数据权限也收编进安全框架业务代码里就不用到处写 if 判断了。6. 联调阶段踩过的坑跨域、认证失败、Token 过期6.1 CORS 配置的三个误区前后端分离联调时CORS 报错几乎人人都会遇到。第一个误区只在 Controller 方法上加了 CrossOrigin但请求被 Spring Security 的过滤器链拦截在到达 Controller 之前CORS 的预检 OPTIONS 请求进不来照样报跨域。正确做法是在 Security 配置里声明全局 CORS 配置源。第二个误区allowCredentials(true)与allowedOrigin(*)不能同时用。如果前端请求需要携带 Cookie 或凭证信息allowedOrigin 必须指定具体来源或使用 allowedOriginPatterns。前后端分离项目如果只依赖 Token 请求头其实不需要 allowCredentials直接 allowedOrigin(*) 就行。第三个误区预检请求 OPTIONS 被安全链拦了。记得在配置里对 OPTIONS 放行或者在 CORS 配置里把 OPTIONS 加到 allowedMethods。否则前端发 POST 请求前先发一个 OPTIONS 预检直接被 401 拒绝真实请求根本发不出去。一个可用的 CORS 配置Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList(http://localhost:3000, https://*.example.com)); config.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(Arrays.asList(*)); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }注意其中https://*.example.com是通配示例实际项目要把域名换成自己的。6.2 认证失败与拒绝访问的返回体处理默认配置下认证失败会返回 302 跳转登录页授权失败返回 403 页面。前后端分离项目的前端只认 JSON所以必须重写这两个行为。认证失败处理逻辑放到 AuthenticationEntryPointhttp.exceptionHandling(eh - eh .authenticationEntryPoint((request, response, authException) - { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\没有权限访问\}); }) )这段代码在真实项目里建议换成 ObjectMapper 输出统一结构体写死字符串只是演示。6.3 Token 过期、无感刷新与并发登录JWT Token 过期后前端会收到 401。体验好的做法是支持刷新 Token登录接口返回 accessToken 和 refreshTokenaccessToken 有效期短refreshToken 有效期长。accessToken 过期后前端拿 refreshToken 换新的 accessToken。实现上换取刷新 Token 的接口要单独放行.requestMatchers(/api/auth/refresh).permitAll()刷新逻辑就是校验 refreshToken 合法后重新签发一个 accessToken。要注意 refreshToken 被泄露的风险建议设置较短的 refresh 有效期并定期轮换密钥。无状态模式下并发登录检测是个麻烦事。因为服务端没有会话记录同一个账号在不同设备登录签发出去的 Token 都是合法的没法主动踢人。如果业务上有同一账号只允许一台设备登录的需求就得把 Token 或登录状态存 Redis登录时生成新的 sessionId 覆盖旧的同时维护一个当前有效 Token的缓存过滤器里每次校验时比对一下。这属于 JWT 方案的天然缺陷选型前就要想清楚。6.4 一些小而致命的问题最后提醒几个在实际项目里容易被忽视的点。登录接口虽然 permitAll但不代表可以放着不管。没有验证码、没有失败次数限制的登录接口很容易被暴力撞库。简单起见可以在登录接口加一个失败的尝试次数计数存 Redis连续失败几次后锁 IP 或锁账号一段时间。静态资源也要记得放行.requestMatchers(/css/**, /js/**, /images/**, /favicon.ico).permitAll()如果你也用 Swagger 类的接口文档要把对应路径放行否则文档页面被安全链挡住后端同学自己都看不到接口列表。还有一点自定义 Filter 里如果直接抛异常默认不会走 Controller 层的 RestControllerAdvice因为过滤器链在进入 Spring MVC 之前就把请求拦住了。所以 Filter 里的异常要自己 catch 并写回响应或者抛出后由 Security 的异常处理器统一处理。很多团队在这里卡很久表现就是明明配了全局异常处理器为什么 Filter 里的异常没有返回 JSON。最后一个容易被忽略的老项目升级 Spring Security 后WebSecurityConfigurerAdapter已经废弃了还按照旧教程写会拿到一堆过时示例。本文使用的是 SecurityFilterChain 组件式配置这是当前 Spring Boot 3 的主流写法可以放心照着搭。我在实际项目里的感受是Spring Security 这东西配置代码本身不复杂复杂的是理解每个配置项背后的过滤器行为。只要把过滤器链、认证上下文、授权模型这三件事吃透遇到任何诡异问题都能顺着请求链路一步步排查。每次业界有新版本、新写法出来万变不离其宗——链上每一站做什么由哪一个配置控制看准这两点就不会迷路。