CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
Spring Security 基础 过滤器链、认证与授权概念。
Spring Security 基础
这一课你将能做到
解释过滤器链、认证(Authentication)与授权(Authorization)三个核心概念的关系
自定义 SecurityFilterChain:放行公开接口、拦截受保护接口、按角色控制访问
使用 BCryptPasswordEncoder 存储密码,并解释「为什么不能明文存密码」
为什么要学
到目前为止你写的接口是「裸奔」的:任何人知道 URL 就能查、能删。真实系统必须有门卫——你是谁(认证)、你能干什么(授权) 。本站的登录、按用户隔离的刷题记录、管理后台权限,全部建立在这套机制上。
Spring Security 是 Servlet 过滤器链 + Spring 容器的结合体:一组按序执行的过滤器,每个管一件事(解析 token、检查权限、处理异常……)。它以「默认全锁死」著称——第一次加上依赖,所有接口突然 401,很多人当场懵掉。这一课的目标就是把「默认行为」和「自定义方法」都拆开看清 。
先建立图像
HTTP 请求
↓
┌─ Security 过滤器链(按序执行,任何一个不放行就到此为止)─┐
│ SecurityContextPersistenceFilter 恢复/保存登录状态 │
│ UsernamePasswordAuthenticationFilter 处理表单登录 │
│ ExceptionTranslationFilter 把后面的异常翻译成 401/403 │
│ FilterSecurityInterceptor ★ 授权裁决:这个 URL 你能进吗 │
└──────────────────────────────────────────────────────┘
↓ 全部通过
你的 Controller
两个核心概念第一次分清:
概念 回答的问题 例子 认证 Authentication 你是谁? 账号密码登录、JWT 校验 授权 Authorization 你能干什么? 普通用户不能删别人的任务,ADMIN 可以 主体 Principal 认证后的身份对象 用户名、用户 ID、角色集合
登录成功后,认证信息存进 SecurityContextHolder (本质是 ThreadLocal,请求线程内随处可取),后续过滤器据此裁决。
跟着做
步骤 1:先体验「默认全锁死」
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
只加依赖不加任何配置,重启后访问 /api/tasks:
{ "status": 401, "error": "Unauthorized" }
同时启动日志出现一行随机密码:
Using generated security password: 7a3f9e2b-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Boot 自动配置了一个用户名 user、密码为随机串的账号,并且默认拦截所有请求 、启用表单登录页。访问任意接口会先跳到登录表单——这就是「默认安全」:宁可全锁,不可漏放。接下来把它改成 API 服务的样子。
步骤 2:第一个自定义 SecurityFilterChain
Spring Security 6(Boot 3 配套)的写法是声明一个 SecurityFilterChain Bean:
package com.example.taskflow.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/hello", "/actuator/health").permitAll()
.anyRequest().authenticated()) // 其余都要登录
.httpBasic(Customizer.withDefaults()); // 先用 HTTP Basic 体验
return http.build();
}
}
curl http://localhost:8080/api/hello # 200,放行了
curl http://localhost:8080/api/tasks # 401,被拦了
curl -u user:启动日志里的随机密码 http://localhost:8080/api/tasks # 200
-u user:密码 是 HTTP Basic 认证:用户名密码以 Base64 放在 Authorization 头里。注意 authorizeHttpRequests 从上到下匹配,先写具体规则,anyRequest() 必须放最后 ——它兜底拦截一切。
步骤 3:定义用户与角色(内存版) @Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("alice")
.password(passwordEncoder().encode("alice123"))
.roles("USER") // 内部自动加前缀存为 ROLE_USER
.build();
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("admin123"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/hello").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/tasks/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
curl -u alice:alice123 http://localhost:8080/api/tasks # 200
curl -u alice:alice123 http://localhost:8080/api/admin/x # 403 Forbidden(登录了但没权限)
curl -u admin:admin123 http://localhost:8080/api/admin/x # 200
401(未认证)和 403(已认证但无权限)从此可以亲眼区分 。
步骤 4:密码必须加密——BCryptPasswordEncoder 上面的 .encode("alice123") 就是加密入口。声明 Encoder Bean:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
为什么不能明文存?数据库被拖库(安全事件里最常见的一种)时,明文密码直接泄露,且用户往往多平台共用密码,等于泄露了他的全部账号。BCrypt 的特性:
单向散列 + 自动加盐 :同一密码每次加密结果都不同,彩虹表失效;
可调强度 :默认强度 10,算力提升后调大参数即可,「暴力破解成本」随硬件增长;
验证用 matches 而不是比对字符串 :
boolean ok = passwordEncoder.matches("alice123", "$2a$10$存储在库里的哈希串");
本站后端的用户表密码列就是 BCrypt 串($2a$10$... 开头)。公司里若发现同事在代码或日志里打印明文密码,属于必须立即叫停的问题。
步骤 5:调整成前后端分离形态(API 服务版) 浏览器表单登录、重定向、CSRF 页面防护都是「服务端渲染」时代的机制,对纯 API 服务需要显式调整:
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // API 无 cookie 会话,CSRF 不适用
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 不创建会话
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()); // 下一课换成 JWT 过滤器
return http.build();
}
三条调整的因果:API 用 token 认证 → 不依赖 cookie 会话 → 没有 cookie 就没有 CSRF 攻击面 → csrf 可以关。顺序不能倒过来背 ——没有无状态前提就关 CSRF 是错误的。
步骤 6:在业务代码里拿当前登录人 import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@GetMapping("/api/me")
public String me(Authentication authentication) { // 方式一:直接注入参数
return "当前用户: " + authentication.getName();
}
@GetMapping("/api/me2")
public String me2() { // 方式二:静态取(工具类场景)
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "当前用户: " + auth.getName();
}
SecurityContextHolder 基于 ThreadLocal,同一线程内 随时可取;异步线程、线程池复用场景要显式传递,直接取会拿到 null。
讲清楚要点
Security 的一切都在过滤器链里发生 :你的 Controller 执行之前,链上的过滤器已经完成了认证解析与授权裁决。排查「为什么 401/403」永远先看链的配置顺序。
规则从上到下,先具体后兜底 :requestMatchers("/api/admin/**") 必须写在 anyRequest() 之前;permitAll 的白名单是「门卫免检名单」,每加一个公开接口都要有意识确认它真的可以公开。
认证与授权是两道门 :401 = 第一道门没过(没登录/token 无效),403 = 第二道门没过(登录了但角色不够)。前端拿到这两种码的处理逻辑完全不同(401 去登录,403 提示无权限)。
密码存储的唯一正解是单向加盐散列 :BCrypt/SCrypt/Argon2。MD5/SHA-256 无盐可被彩虹表破解,明文存储是事故。验证走 matches,永远不存在「解密」。
UserDetailsService 是认证的扩展点 :Security 只问「给我这个用户名,我看看他的密码和角色」。内存版换成了查数据库的用户实现,就完成了从玩具到真项目的过渡——下一课 JWT 登录正是围绕它构建。
你可能会懵的地方
为什么加上依赖后所有接口都 401 了?
默认策略就是「全拦截 + 生成随机用户」。这是特性不是 bug:安全组件宁可先锁死。定义自己的 SecurityFilterChain 后即可按白名单放行。
ROLE_ 前缀怎么回事?hasRole 和 hasAuthority 有什么区别?
roles("USER") 内部自动补成 ROLE_USER 存储,hasRole("USER") 匹配时也自动补前缀;hasAuthority 用原始字符串精确匹配(如 hasAuthority("task:delete") 细粒度权限点)。团队实践:粗粒度用角色,细粒度用权限点。
CSRF 是什么?为什么 API 服务能关?
攻击者诱导已登录用户的浏览器带着 cookie 自动发起伪造请求。前提是「认证状态存 cookie 且浏览器自动携带」。纯 token 认证(放 header)不带自动凭证,攻击面不存在,所以可以关。
Session 和 JWT 的关系?下一课为什么要 STATELESS?
Session:状态存服务端,浏览器带 JSESSIONID cookie。JWT:状态编码进 token 由客户端持有,服务端只验签名。API 集群/多端场景 JWT 更轻,因此声明 STATELESS 不创建会话——下一课正式落地。
SecurityContextHolder 为什么在异步线程里取不到?
ThreadLocal 只在创建它的线程可见。@Async 方法、线程池任务里要用 DelegatingSecurityContextAsyncTaskProcessor 或显式传 Authentication,初学阶段记住「别在新线程裸取」即可。
错法对照 错法 问题 正解 anyRequest() 写在 requestMatchers 前面 后面的规则永远匹配不到 兜底规则放链尾 数据库存明文密码或 MD5 拖库即泄露;MD5 可彩虹表破解 BCryptPasswordEncoder + 盐 没开 STATELESS 却照常使用 session 集群下 session 不共享,行为诡异 API 服务显式 STATELESS 无状态前提不清就 disable CSRF 有 cookie 认证时留下 CSRF 攻击面 先确认认证机制,再决定 CSRF 把 BCrypt 串当可逆数据「解密」回来 BCrypt 是单向散列,不可能解密 验证用 encoder.matches()
卡点急救
自定义配置后依然全 401 :确认类上有 @Configuration + @EnableWebSecurity;确认 SecurityFilterChain Bean 没被写错方法名导致没注册;确认放行的 requestMatchers 路径与实际请求完全一致(含 /api 前缀)。
登录成功但一直 403 :roles 前缀问题(库里存的是 ROLE_USER 时用 hasAuthority("ROLE_USER") 或 hasRole("USER"),别混配);或用户没分配该角色。打印 Authentication 的 authorities 实查。
循环依赖或启动报 UserDetailsService 相关错误 :SecurityConfig 里注入 UserDetailsService 时,避免在同一个类里互相引用;密码 Encoder 单独声明为 Bean。
加了 Security 后静态资源/文档页也 401 :把 swagger、h2-console 等路径加入 permitAll(h2-console 还需允许 frameOptions),再逐个收敛。
面试常问
Spring Security 的核心组件和请求流程?
FilterChainProxy 统筹一组 SecurityFilterChain;请求依次经过认证相关过滤器(解析凭证)→ ExceptionTranslationFilter(翻译异常)→ 授权过滤器(URL/方法级裁决)→ 达到业务。认证成功后 Authentication 存入 SecurityContextHolder(ThreadLocal)。
认证和授权的区别?分别在哪里实现?
认证解决「你是谁」(UserDetailsService 校验凭证、生成 Authentication);授权解决「你能不能」(URL 级 authorizeHttpRequests、方法级 @PreAuthorize)。401/403 分别对应两道关卡。
密码加密为什么用 BCrypt 而不是 MD5+盐?
BCrypt 自带随机盐、内置强度因子可随算力增长而调高、单次计算刻意变慢(抗暴力破解);MD5 快且无盐机制(需手动加),已被认为不安全。Argon2 是更新的标准。
@PreAuthorize 怎么用?
方法级授权:@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name"),SpEL 可引用参数与认证信息;需开启 @EnableMethodSecurity。适合「同一 URL 不同数据权限」的场景。
自测三问
认证和授权分别回答什么问题?分别对应哪个 HTTP 状态码?
写出「/api/auth/** 公开、/api/admin/** 仅 ADMIN、其余需登录」的完整过滤器链配置。
为什么同一密码两次 BCrypt 加密结果不同?登录时怎么验证?
小结一句话 Spring Security 用过滤器链在请求到达业务前完成「认证定身份、授权定边界」,密码以 BCrypt 单向散列存储——先分清两道门,再谈自定义。
延伸