文章

Spring Security 学习笔记:认证与授权

牛耕田

暂存笔记,持续补充中。后端系统只要涉及「登录 + 权限」,就绕不开 Spring Security。

一、本质:一条过滤器链

Spring Security 的一切都发生在 Servlet Filter Chain 上。核心是一个名为 springSecurityFilterChainFilterChainProxy,内部又串联了十几个过滤器:

请求 → SecurityContextPersistenceFilter
     → UsernamePasswordAuthenticationFilter(表单登录)
     → BearerTokenAuthenticationFilter(JWT)
     → FilterSecurityInterceptor / AuthorizationFilter(鉴权)
     → 到达 Controller

每个过滤器只做一件事,按顺序执行、可插拔

二、两个核心概念

概念 含义 存放位置
Authentication(认证) 「你是谁」 SecurityContextHolder
Authorization(授权) 「你能干什么」 AuthorizationFilter 校验
  • SecurityContextHolder 默认用 ThreadLocal 存储,因此请求结束后必须清理(框架已自动处理)。这也是为什么异步线程里拿不到登录用户——ThreadLocal 不跨线程传递。

三、Spring Security 6 配置方式

Spring Security 6 起全面推荐基于组件的 Lambda 配置WebSecurityConfigurerAdapter 已废弃。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // 开启 @PreAuthorize
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)          // 前后端分离可关
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> e
                .authenticationEntryPoint(restAuthEntryPoint)   // 未登录 401
                .accessDeniedHandler(restAccessDeniedHandler)); // 无权限 403
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();     // 不要用 MD5/SHA1
    }
}

注意hasRole("ADMIN") 会自动补 ROLE_ 前缀,即要求权限为 ROLE_ADMIN;而 hasAuthority("user:add") 不补前缀,做细粒度权限时更灵活。

四、认证流程(表单登录版)

  1. UsernamePasswordAuthenticationFilter 拦截 /login 请求。
  2. 封装成 UsernamePasswordAuthenticationToken,交给 AuthenticationManager
  3. AuthenticationManager 委派给 DaoAuthenticationProvider
  4. UserDetailsService.loadUserByUsername() 查库,返回 UserDetails
  5. PasswordEncoder.matches() 比对密码。
  6. 成功后将 Authentication 写入 SecurityContextHolder
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        User u = userMapper.findByUsername(username);
        if (u == null) throw new UsernameNotFoundException("用户不存在");
        return org.springframework.security.core.userdetails.User
                .withUsername(u.getUsername())
                .password(u.getPassword())          // 存的是 BCrypt 密文
                .authorities(u.getRoles().toArray(new String[0]))
                .build();
    }
}

五、JWT 无状态登录(前后端分离主流方案)

思路:服务端不存 Session,登录成功后签发一个自包含的 Token,客户端每次请求带上,服务端验签即可。

@Component
public class JwtFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            try {
                Claims claims = jwtUtil.parse(token);      // 验签 + 过期校验
                String username = claims.getSubject();
                var auth = new UsernamePasswordAuthenticationToken(
                        username, null,
                        jwtUtil.getAuthorities(claims));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                // 令牌无效:不设置认证信息,后续会被 401 拦截
            }
        }
        chain.doFilter(req, resp);
    }
}

JWT 三部分Header.Payload.Signature

  • Header:算法类型(HS256/RS256)。
  • Payload:claims(subexp、自定义字段),Base64 可解,绝不能放密码等敏感信息
  • Signature:用密钥对前两部分签名,防篡改。

实践要点

  • 密钥(secret)必须足够长(HS256 建议 ≥ 256 bit)且存环境变量,不硬编码。
  • 设置合理 exp(如 2 小时),配合 Refresh Token 做续期。
  • 退出登录无法真正让 JWT 失效(服务端无状态),需依赖 Redis 黑名单或短过期时间。

六、方法级授权

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal")
    public Order getOrder(Long userId, Long orderId) { ... }

    @PreAuthorize("@ss.hasPermi('order:delete')")   // 自定义 Bean 做细粒度校验
    public void delete(Long orderId) { ... }

    @PostAuthorize("returnObject.ownerId == authentication.principal")
    public Order load(Long id) { ... }              // 返回后校验
}

越权防护重点:水平越权(改别人的 id)必须靠业务层校验资源归属,光靠 @PreAuthorize 的角色判断不够。

七、常见攻击与防护

攻击 原理 防护
CSRF 借用户 Cookie 发伪造请求 开 CSRF Token;前后端分离用 JWT 天然免疫
XSS 注入脚本窃取 Token 输出转义、CSP;Token 别存 localStorage,优先 HttpOnly Cookie
暴力破解 撞库 登录失败次数限制 + 验证码 + 账号锁定
会话固定 登录后不换 SessionId 框架默认已处理
密码泄露 存明文 BCrypt / Argon2 加盐哈希

八、小结

  • Spring Security = 过滤器链 + 认证/授权两件事
  • 前后端分离推荐:JWT 无状态 + BCrypt 加密 + @PreAuthorize 方法级鉴权
  • 三条红线:密钥不入代码库、密码必须加盐哈希、越权校验必须落到业务层。