Spring Security 从零到生产级掌握
Spring Security 不能只学成“加个依赖就有登录页”或者“写个 JWT 过滤器”。真正的后台系统安全要能解释:请求为什么先进过滤器链,认证对象怎么流转,SecurityContext 保存在哪里,401 和 403 为什么不同,Session 和 JWT 怎么选,权限注解为什么不能解决数据权限,CSRF 和 CORS 分别防什么,Token 泄露、密码泄露、越权访问怎么处理。
一句话建立主线:
Spring Security 是基于 Servlet Filter 链实现的认证、授权、会话、安全上下文和 Web 攻击防护框架。
如果你想检查自己是否真的从零基础学懂到能面试、能落地、能排查,按 Spring Security 从零到精通验收清单 逐项验收。
学习目标
学完这一页,你要能做到:
- 解释 Spring Security 为什么把核心逻辑放在过滤器链里。
- 解释认证和授权的区别,以及 401、403 的区别。
- 解释
Authentication、SecurityContext、SecurityContextHolder、UserDetailsService、AuthenticationProvider的关系。 - 解释账号密码登录、Session 登录、JWT 登录的完整流程。
- 解释 JWT 是签名 Token,不是默认加密,为什么不能放敏感数据。
- 解释 URL 权限、方法权限、角色权限、权限码和数据权限怎么设计。
- 解释 CSRF、CORS、XSS、Token 泄露和密码哈希。
- 解释认证异常、授权异常如何统一返回 JSON。
- 设计一个后台管理系统的用户、角色、权限、部门、租户、数据范围模型。
- 排查登录失败、Token 无效、403、跨域、权限不生效等生产问题。
学习路线
flowchart TD
A["JavaEE Filter 基础"] --> B["Security Filter Chain"]
B --> C["认证<br/>你是谁"]
C --> D["SecurityContext<br/>保存当前用户"]
D --> E["授权<br/>你能访问什么"]
E --> F["Session/JWT/OAuth2"]
F --> G["CSRF/CORS/Web安全"]
G --> H["权限模型和数据权限"]
H --> I["异常处理和生产排查"]学 Spring Security 前,最好先理解 JavaEE Filter。Spring Security 的本质就是一组有顺序的 Filter。
第一步:为什么核心是过滤器链
安全逻辑必须发生在 Controller 之前。
如果请求已经进入 Controller 才判断是否登录,很多统一问题会变复杂:
- 未登录请求可能已经触发业务代码。
- 权限拒绝无法统一处理。
- Token 解析散落在各个接口。
- 日志、审计、上下文设置重复。
过滤器链可以在请求入口统一处理:
flowchart TD
A["HTTP 请求"] --> B["Spring Security Filter Chain"]
B --> C["解析凭证<br/>Session/JWT/Basic"]
C --> D["认证用户"]
D --> E["写入 SecurityContext"]
E --> F["授权判断"]
F --> G["进入 Controller"]
F --> H["拒绝并返回 401/403"]Spring Security 不是一个 Filter,而是一组 Filter。不同 Filter 负责不同安全步骤。
常见职责:
| 职责 | 说明 |
|---|---|
| 加载安全上下文 | 从 Session 或请求中恢复用户 |
| 认证 | 解析用户名密码、Token、Basic 等 |
| 异常处理 | 把认证失败、权限不足转成响应 |
| 授权 | 判断当前用户是否能访问资源 |
| 登出 | 清理上下文、Session、Cookie |
| CSRF | 校验防伪 Token |
第二步:认证和授权
认证 Authentication:确认你是谁。
授权 Authorization:确认你能做什么。
| 阶段 | 问题 | 例子 |
|---|---|---|
| 认证 | 用户身份是否可信 | 账号密码正确、JWT 签名有效 |
| 授权 | 是否有访问权限 | 是否有 asset:read 权限 |
| 数据权限 | 是否能访问这条数据 | 只能看本医院、本部门资产 |
401 和 403:
| 状态码 | 含义 | 场景 |
|---|---|---|
| 401 | 未认证或认证失效 | 没带 Token、Token 过期 |
| 403 | 已认证但权限不足 | 已登录但没有 admin 权限 |
前端看到 401 通常跳登录或刷新 Token;看到 403 通常提示无权限。
第三步:核心对象关系
flowchart TD
A["请求凭证<br/>用户名密码/JWT"] --> B["AuthenticationFilter"]
B --> C["AuthenticationManager"]
C --> D["AuthenticationProvider"]
D --> E["UserDetailsService"]
E --> F["UserDetails<br/>用户、密码、权限"]
D --> G["PasswordEncoder 或 Token 校验"]
G --> H["认证成功 Authentication"]
H --> I["SecurityContext"]
I --> J["SecurityContextHolder"]核心对象:
| 对象 | 作用 |
|---|---|
Authentication | 表示认证信息,包含 principal、credentials、authorities、authenticated |
SecurityContext | 保存当前请求的 Authentication |
SecurityContextHolder | 当前线程获取 SecurityContext 的工具 |
UserDetailsService | 根据用户名加载用户信息 |
UserDetails | Spring Security 识别的用户模型 |
AuthenticationManager | 认证入口调度器 |
AuthenticationProvider | 具体认证逻辑 |
PasswordEncoder | 密码哈希和校验 |
为什么 SecurityContextHolder 通常和线程有关:
Web 请求通常由一个线程处理,Spring Security 会把当前用户放到当前线程上下文里,业务代码才能这样获取:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();注意:如果开启异步线程,新线程默认拿不到原线程的安全上下文,需要显式传递或重新认证。
第四步:账号密码登录流程
典型流程:
flowchart TD
A["用户提交用户名密码"] --> B["认证过滤器读取参数"]
B --> C["构造未认证 Authentication"]
C --> D["AuthenticationManager"]
D --> E["DaoAuthenticationProvider"]
E --> F["UserDetailsService 查用户"]
F --> G["PasswordEncoder.matches 校验密码"]
G --> H{"是否成功"}
H -- "成功" --> I["生成已认证 Authentication"]
I --> J["写入 SecurityContext"]
J --> K["返回登录成功"]
H -- "失败" --> L["AuthenticationFailureHandler"]用户查询 Demo:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
public CustomUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) {
UserAccount account = userMapper.findByUsername(username);
if (account == null) {
throw new UsernameNotFoundException("user not found");
}
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
.authorities(account.getPermissions().toArray(String[]::new))
.disabled(!account.isEnabled())
.build();
}
}密码必须用安全哈希:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}保存密码:
String hash = passwordEncoder.encode(rawPassword);校验密码:
boolean ok = passwordEncoder.matches(rawPassword, hash);密码哈希不是加密。加密通常可以解密,哈希不可逆。BCrypt 会加盐并故意变慢,降低撞库和暴力破解风险。
第五步:Session 登录
Session 登录适合传统后台、服务端渲染或同域管理系统。
流程:
flowchart TD
A["登录成功"] --> B["SecurityContext 写入 Session"]
B --> C["响应 Set-Cookie JSESSIONID"]
C --> D["浏览器保存 Cookie"]
D --> E["后续请求带 JSESSIONID"]
E --> F["SecurityContextPersistenceFilter 恢复上下文"]
F --> G["当前请求识别为已登录"]优点:
- 服务端可主动失效 Session。
- 权限变更相对容易生效。
- 适合后台管理系统。
缺点:
- 多实例要共享 Session。
- 移动端和跨域场景要处理 Cookie。
- 服务端保存状态。
多实例方案:
| 方案 | 说明 |
|---|---|
| Redis Session | 常见,使用 Spring Session |
| 粘性会话 | 简单但实例故障体验差 |
| Session 复制 | 集群小可以用 |
| 改 JWT | 服务端无状态,但撤销复杂 |
第六步:JWT 登录
JWT 常用于前后端分离、移动端、网关统一认证。
JWT 结构:
header.payload.signature它默认是签名,不是加密。payload 是 Base64Url 编码,拿到 Token 的人可以解码看到内容,所以不要放手机号、身份证、密码、敏感权限等明文敏感信息。
JWT 登录流程:
flowchart TD
A["用户登录成功"] --> B["服务端生成 JWT"]
B --> C["返回 access_token"]
C --> D["前端保存 Token"]
D --> E["请求头 Authorization: Bearer token"]
E --> F["JWT Filter 解析和验签"]
F --> G{"是否有效"}
G -- "有效" --> H["构造 Authentication"]
H --> I["写入 SecurityContext"]
G -- "无效" --> J["返回 401"]JWT Filter 简化 Demo:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = header.substring(7);
JwtUser user = jwtService.parseAndVerify(token);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
user.userId(),
null,
user.authorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}JWT 生产问题:
| 问题 | 处理 |
|---|---|
| Token 泄露 | HTTPS、短有效期、刷新 Token、设备管理 |
| 无法主动退出 | Redis 黑名单、Token 版本号、短 access token |
| 权限变更不生效 | Token 短有效期、每次查权限版本 |
| payload 太大 | 只放 userId、tenantId、少量声明 |
| 前端存储风险 | 避免 XSS,谨慎 localStorage |
第七步:OAuth2 和单点登录思路
OAuth2 解决的是授权委托,不是简单的“登录协议”。OIDC 在 OAuth2 之上补充身份认证。
常见场景:
| 场景 | 说明 |
|---|---|
| 企业统一登录 | 接入统一身份平台 |
| 第三方登录 | 微信、GitHub、企业微信 |
| 开放平台授权 | 用户授权第三方应用访问资源 |
授权码模式简化流程:
flowchart TD
A["用户访问业务系统"] --> B["跳转认证中心"]
B --> C["用户登录并授权"]
C --> D["认证中心返回 code"]
D --> E["业务后端用 code 换 token"]
E --> F["拉取用户信息或校验 id_token"]
F --> G["建立本系统登录态"]商业系统里,接 OAuth2/OIDC 时要关注:
- 回调地址白名单。
- state 防 CSRF。
- token 保存和刷新。
- 用户绑定关系。
- 退出登录联动。
- 权限从统一身份平台同步还是本系统维护。
第八步:授权和权限模型
授权要分层理解:
| 层次 | 解决什么 | 例子 |
|---|---|---|
| URL 权限 | 能不能访问接口 | /admin/** 需要 admin |
| 方法权限 | 能不能调用方法 | @PreAuthorize |
| 按钮权限 | 前端是否显示操作 | asset:create |
| 数据权限 | 能不能看这条数据 | 只能看本部门资产 |
权限码建议稳定命名:
asset:read
asset:create
asset:update
asset:delete
collect:task:start
collect:task:stop典型 RBAC 模型:
flowchart TD
A["用户 User"] --> B["用户角色 UserRole"]
B --> C["角色 Role"]
C --> D["角色权限 RolePermission"]
D --> E["权限 Permission"]数据库表:
| 表 | 作用 |
|---|---|
sys_user | 用户 |
sys_role | 角色 |
sys_permission | 权限码和资源 |
sys_user_role | 用户和角色关系 |
sys_role_permission | 角色和权限关系 |
sys_department | 部门组织 |
sys_user_data_scope | 用户或角色的数据范围 |
方法权限示例:
@PreAuthorize("hasAuthority('asset:create')")
@PostMapping("/assets")
public AssetVO create(@RequestBody AssetCreateRequest request) {
return assetService.create(request);
}只靠注解不够,因为它只能判断“能不能调用创建资产接口”,不能判断“能不能操作这条资产数据”。
数据权限要结合业务字段:
public AssetVO getAsset(Long assetId, CurrentUser user) {
Asset asset = assetRepository.findById(assetId);
if (!dataScopeService.canReadAsset(user, asset)) {
throw new AccessDeniedException("no permission to read asset");
}
return AssetVO.from(asset);
}第九步:CSRF、CORS、XSS
CSRF
CSRF 是跨站请求伪造。攻击者诱导用户访问恶意页面,浏览器自动携带目标站点 Cookie 发请求。
flowchart TD
A["用户已登录系统"] --> B["浏览器保存 Cookie"]
C["恶意页面"] --> D["发起到系统的请求"]
D --> E["浏览器自动带 Cookie"]
E --> F["服务端误以为是用户操作"]防护:
- CSRF Token。
- SameSite Cookie。
- 关键操作二次确认。
- 前后端分离 Token 放 Authorization Header 时,CSRF 风险变化,但仍要防 XSS 和 CORS 过宽。
CORS
CORS 是浏览器跨域访问控制。它不是服务端之间调用限制,而是浏览器安全策略。
危险配置:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true带凭证时不能随便放开所有来源。
XSS
XSS 是脚本注入。攻击者让页面执行恶意 JS,可能窃取 Token、Cookie 或发起操作。
防护:
- 输出转义。
- 富文本白名单过滤。
- Cookie 设置 HttpOnly。
- Content Security Policy。
- 前端不要把不可信 HTML 直接渲染。
第十步:统一异常处理
认证失败和权限不足要区分。
| 异常 | 含义 | 响应 |
|---|---|---|
AuthenticationException | 未登录或认证失败 | 401 |
AccessDeniedException | 已登录但权限不足 | 403 |
配置 Demo:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}");
})
.accessDeniedHandler((request, response, e) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"无权限\"}");
})
);
return http.build();
}前后端分离项目不要使用默认登录页跳转,要统一返回 JSON。
第十一步:商业权限系统设计
以医疗数据采集与资产平台为例:
flowchart TD
A["用户"] --> B["角色"]
B --> C["菜单和按钮权限"]
B --> D["接口权限"]
A --> E["部门/医院/租户"]
E --> F["数据范围"]
F --> G["资产、采集任务、接口配置"]权限设计不要只停在角色:
| 权限类型 | 例子 |
|---|---|
| 菜单权限 | 是否能看到“资产管理”菜单 |
| 按钮权限 | 是否能点击“新增资产” |
| 接口权限 | 是否能调用 POST /assets |
| 数据权限 | 是否能看某医院资产 |
| 字段权限 | 是否能看敏感字段 |
| 操作审计 | 谁在什么时候做了什么 |
数据权限常见维度:
- 租户。
- 医院。
- 部门。
- 项目。
- 数据密级。
- 创建人。
- 负责范围。
SQL 层面常见做法:
select *
from asset
where hospital_id in (:allowedHospitalIds)
and deleted = 0但不要只靠前端隐藏按钮。前端隐藏只能改善体验,真正安全必须由后端校验。
第十二步:生产排查
登录失败
flowchart TD
A["登录失败"] --> B["用户名是否存在"]
B --> C["用户是否禁用/锁定"]
C --> D["密码是否 BCrypt matches"]
D --> E["UserDetailsService 是否返回权限"]
E --> F["失败处理器返回什么"]JWT 无效
flowchart TD
A["JWT 无效"] --> B["请求头是否带 Bearer"]
B --> C["签名密钥是否正确"]
C --> D["Token 是否过期"]
D --> E["issuer/audience 是否匹配"]
E --> F["是否在黑名单"]
F --> G["过滤器顺序是否正确"]明明有权限却 403
flowchart TD
A["出现 403"] --> B["是否已认证成功"]
B --> C["SecurityContext 是否有 Authentication"]
C --> D["authorities 是否包含权限码"]
D --> E["hasRole 和 hasAuthority 是否混用"]
E --> F["方法权限是否开启"]
F --> G["数据权限是否拒绝"]跨域失败
flowchart TD
A["跨域失败"] --> B["浏览器控制台看 CORS 报错"]
B --> C["OPTIONS 预检是否放行"]
C --> D["Allow-Origin 是否匹配"]
D --> E["是否带 Credentials"]
E --> F["Header 和 Method 是否允许"]常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 密码用 MD5 | 容易被撞库 | BCrypt/Argon2/PBKDF2 |
| JWT 放敏感信息 | Token 泄露后可直接读 | payload 只放必要声明 |
| Token 永不过期 | 泄露后长期有效 | 短 access token + refresh |
| 只做前端按钮控制 | 后端接口可被绕过 | 后端必须鉴权 |
| 只做接口权限 | 越权访问别人的数据 | 业务层做数据权限 |
| CORS 全放开 | 跨站风险扩大 | 白名单来源 |
| 过滤器顺序错 | Token 不生效或异常不统一 | 明确 addFilterBefore/After |
| 异步线程取不到用户 | SecurityContext 在线程间不自动传 | 显式传递或重新获取 |
最小完整 Demo:JWT 前后端分离骨架
配置:
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}登录接口:
@RestController
public class LoginController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public LoginController(AuthenticationManager authenticationManager, JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
String token = jwtService.createToken(authentication);
return Map.of("accessToken", token);
}
}权限接口:
@PreAuthorize("hasAuthority('asset:read')")
@GetMapping("/assets/{id}")
public AssetVO getAsset(@PathVariable Long id) {
return assetService.getAsset(id);
}数据权限仍要在 assetService.getAsset 中检查资源归属。
面试标准回答
Spring Security 请求链路怎么说
请求进入应用后先经过 Spring Security Filter Chain。认证过滤器会解析 Session、JWT 或用户名密码等凭证,认证成功后构造 Authentication 并放入 SecurityContext。授权阶段根据 URL、方法注解或权限规则判断是否能访问资源。未认证返回 401,已认证但权限不足返回 403。业务数据权限还需要在 Service 层结合租户、部门、资源归属继续校验。
JWT 和 Session 怎么选
Session 状态保存在服务端,适合传统后台和需要服务端主动控制会话的场景;JWT 状态主要在客户端 Token 中,适合前后端分离、移动端和网关统一认证。JWT 扩展性好但撤销、权限变更实时生效和泄露处理更复杂,通常要短有效期、refresh token、黑名单或 tokenVersion 机制。
为什么权限注解不能解决所有权限
权限注解只能判断当前用户是否能调用某个接口或方法,例如有没有 asset:read。但它不知道这条资产属于哪个医院、部门或租户。商业系统必须在业务层或数据访问层继续做数据权限校验,防止用户越权访问别人的数据。
关联知识点
- Spring Security 总览
- Spring Security 从零到精通验收清单
- Spring Security 商业场景训练营
- 核心架构
- 登录认证
- JWT 认证
- 权限控制
- CSRF 与 CORS
- 过滤器链实战
- Spring Security 面试题
- JavaEE Filter 原理
- 信息安全
- JWT 签名和密码哈希
本章小结
Spring Security 的学习核心是过滤器链。过滤器链负责在 Controller 前完成认证、上下文恢复、授权判断和异常处理。Session、JWT、OAuth2 只是不同凭证和会话方案;角色、权限码、数据范围才是商业系统的权限设计核心。真正上线时,必须同时处理密码安全、Token 风险、CSRF/CORS、统一异常、审计日志和数据权限。
