Spring Security 从零到精通验收清单
这页不是简单罗列 API,而是用来验收你是否真的能把 Spring Security 从零基础学到能面试、能设计权限系统、能排查线上问题。
学完后你应该能回答清楚:
- Spring Security 为什么基于 Filter,而不是写在 Controller 里。
- 一个请求从进入系统到进入 Controller,中间经过哪些认证、授权、安全上下文处理。
- Session、JWT、OAuth2 分别解决什么问题,为什么没有绝对最优方案。
- 密码为什么不能明文、不能 MD5,BCrypt 为什么要加盐并故意变慢。
- 角色、权限码、菜单按钮、接口权限、数据权限为什么必须分层。
- 401、403、CSRF、CORS、XSS、Token 泄露分别怎么排查。
- 医疗数据采集、资产平台、后台管理系统中权限模型怎么落地。
总学习路线
flowchart TD
A["阶段1:理解 Web 安全问题"] --> B["阶段2:Servlet Filter 和安全过滤器链"]
B --> C["阶段3:认证:你是谁"]
C --> D["阶段4:SecurityContext 保存当前用户"]
D --> E["阶段5:授权:你能访问什么"]
E --> F["阶段6:Session、JWT、OAuth2 登录方案"]
F --> G["阶段7:密码、CSRF、CORS、XSS 等安全基础"]
G --> H["阶段8:RBAC、数据权限、多租户权限模型"]
H --> I["阶段9:商业项目落地和生产排查"]
I --> J["阶段10:面试标准回答和追问"]这条路线的核心思想是:先理解请求入口为什么要统一拦截,再理解身份怎么被确认,最后理解权限为什么不能只靠一个注解。
阶段1:为什么需要安全框架
一个后台系统没有安全框架时,常见写法是每个接口自己判断登录状态:
@GetMapping("/assets/{id}")
public AssetVO getAsset(@PathVariable Long id, HttpServletRequest request) {
Object user = request.getSession().getAttribute("user");
if (user == null) {
throw new RuntimeException("not login");
}
return assetService.getAsset(id);
}这类写法的问题不是“代码难看”这么简单,而是安全边界会被打散:
| 问题 | 后果 |
|---|---|
| 每个接口自己判断登录 | 容易漏接口,形成未授权访问 |
| 登录失败返回不统一 | 前端难处理,审计日志也混乱 |
| Token 解析散落各处 | 逻辑重复,撤销、续期、黑名单很难统一 |
| 权限判断写在 Controller | 业务接口和安全规则耦合,后续改权限风险大 |
| 数据权限靠前端隐藏按钮 | 用户直接改 URL、改 ID 就可能越权 |
Spring Security 的价值是把这些共性安全逻辑放到统一入口:
flowchart TD
A["HTTP 请求"] --> B["统一安全入口"]
B --> C["认证:确认用户是谁"]
C --> D["授权:判断能访问什么"]
D --> E["安全上下文:让业务代码拿到当前用户"]
E --> F["统一异常:401 或 403"]
F --> G["Controller 只处理业务"]如果不这样做,大项目里最容易出现的问题就是:某些接口忘记鉴权、某些接口只鉴权不做数据权限、某些异常返回 HTML 登录页、JWT 过期和无权限混成同一个错误。
阶段2:Servlet Filter 和 SecurityFilterChain
Spring Security 的底层基于 Servlet Filter。Filter 的特点是:它位于请求进入 Servlet、Controller 之前,可以决定是否继续放行。
public class TraceFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
System.out.println("before controller");
chain.doFilter(request, response);
System.out.println("after controller");
}
}Spring Security 不是一个过滤器,而是一组过滤器组成的链。每个过滤器只负责一类安全工作:
| 环节 | 负责的问题 |
|---|---|
| 上下文加载 | 从 Session 或请求中恢复当前用户 |
| 登录认证 | 解析用户名密码、Basic、JWT 等凭证 |
| 异常处理 | 把认证失败转 401,把授权失败转 403 |
| 授权判断 | 判断 URL 或方法是否允许访问 |
| 登出 | 清理 Session、Cookie、SecurityContext |
| CSRF | 校验跨站请求伪造防护 Token |
核心链路:
flowchart TD
A["HTTP 请求"] --> B["DelegatingFilterProxy"]
B --> C["FilterChainProxy"]
C --> D["匹配一个 SecurityFilterChain"]
D --> E["按顺序执行多个 Security Filter"]
E --> F{"认证和授权是否通过"}
F -- "通过" --> G["进入 DispatcherServlet 和 Controller"]
F -- "失败" --> H["异常处理器返回 401 或 403"]为什么顺序重要:
| 顺序错误 | 可能现象 |
|---|---|
| JWT 过滤器太靠后 | 授权过滤器执行时还没有用户信息,导致 403 |
| 异常处理器位置不对 | 抛出的认证异常没有被统一转成 JSON |
| CORS 没先处理预检请求 | 浏览器 OPTIONS 请求被拦截 |
| SecurityContext 未清理 | 线程复用场景下可能出现上下文污染风险 |
阶段3:认证到底做了什么
认证不是“查一下用户表”这么简单,它的目标是把一个不可信的请求凭证,转换成一个可信的 Authentication 对象。
账号密码认证流程:
flowchart TD
A["用户提交用户名和密码"] --> B["认证过滤器读取请求参数"]
B --> C["构造未认证 Authentication"]
C --> D["AuthenticationManager"]
D --> E["AuthenticationProvider"]
E --> F["UserDetailsService 查询用户"]
F --> G["PasswordEncoder 校验密码"]
G --> H{"是否通过"}
H -- "通过" --> I["返回已认证 Authentication"]
H -- "失败" --> J["抛出 AuthenticationException"]
I --> K["写入 SecurityContext"]核心对象必须能说清楚:
| 对象 | 本质作用 | 面试说法 |
|---|---|---|
Authentication | 表示一次认证结果 | 包含用户身份、凭证、权限、是否认证 |
AuthenticationManager | 认证入口 | 负责调度具体认证器 |
AuthenticationProvider | 认证执行者 | 不同登录方式对应不同 Provider |
UserDetailsService | 加载用户 | 从数据库查账号、密码密文、权限 |
PasswordEncoder | 密码哈希校验 | 不保存明文,通过 matches 比对 |
最小用户加载 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("账号不存在");
}
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
.authorities(account.getPermissionCodes().toArray(String[]::new))
.disabled(!account.isEnabled())
.accountLocked(account.isLocked())
.build();
}
}如果 UserDetailsService 只返回用户名密码,不返回权限,登录也许能成功,但后续接口授权会失败,因为 Authentication 里没有可用于判断的 GrantedAuthority。
阶段4:SecurityContext 为什么重要
认证成功以后,业务代码还要知道“当前用户是谁”。Spring Security 使用 SecurityContext 保存当前认证信息。
flowchart TD
A["认证成功 Authentication"] --> B["SecurityContext"]
B --> C["SecurityContextHolder"]
C --> D["Controller 或 Service 获取当前用户"]常见获取方式:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();原理上,SecurityContextHolder 默认使用 ThreadLocal。一次 Web 请求通常由一个线程处理,所以这个线程内的业务代码都能拿到当前用户。
这也解释了一个常见线上问题:异步线程拿不到登录用户。
@Async
public void exportAsync() {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
// 这里可能是 null,因为换了线程
}解决方向:
| 场景 | 做法 |
|---|---|
| 简单异步任务 | 显式传入 userId、tenantId、permissionScope |
| 需要安全上下文传递 | 使用 DelegatingSecurityContextExecutor |
| MQ 消费任务 | 不依赖 Web 登录态,用任务创建人和业务权限重新校验 |
不要在复杂业务里过度依赖静态上下文。商业系统更推荐把 CurrentUser 解析后传入业务服务,便于测试、审计和异步处理。
阶段5:密码安全为什么不能用 MD5
密码保存的目标不是“加密后存数据库”,而是“即使数据库泄露,攻击者也不能快速还原用户密码”。
| 做法 | 问题 |
|---|---|
| 明文保存 | 数据库泄露等于全量账号泄露 |
| MD5(password) | 太快,彩虹表和撞库成本低 |
| MD5(password + salt) | 比纯 MD5 好,但仍然太快 |
| BCrypt | 自动加盐,成本可调,故意慢 |
BCrypt Demo:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
public void register(String rawPassword) {
String hash = passwordEncoder.encode(rawPassword);
userMapper.insertPasswordHash(hash);
}
public boolean login(String rawPassword, String hash) {
return passwordEncoder.matches(rawPassword, hash);
}为什么 BCrypt 每次生成的密文不同也能校验成功?
BCrypt 的密文里包含算法版本、成本因子、盐和哈希结果。校验时会从密文中取出盐和成本因子,用同样规则计算一次,再比较结果。
$2a$12$盐和哈希结果如果用普通 MD5,攻击者可以每秒尝试大量密码;BCrypt 故意让计算变慢,使暴力破解成本上升。生产中成本因子不能盲目调太高,否则登录高峰会把 CPU 打满。
阶段6:Session 登录
Session 登录的本质是:服务端保存登录态,客户端只保存一个 SessionId。
flowchart TD
A["登录成功"] --> B["服务端创建 Session"]
B --> C["SecurityContext 放入 Session"]
C --> D["响应 Set-Cookie: JSESSIONID"]
D --> E["浏览器后续请求自动携带 Cookie"]
E --> F["服务端根据 SessionId 找回 SecurityContext"]
F --> G["识别为已登录用户"]适合场景:
| 场景 | 原因 |
|---|---|
| 传统后台管理系统 | 浏览器同域访问,Cookie 管理方便 |
| 权限变化要求较快生效 | 服务端可删除 Session 或更新权限缓存 |
| 需要主动踢人下线 | 服务端掌握会话状态 |
多实例部署问题:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Spring Session + Redis | 常用,实例无状态化 | 依赖 Redis |
| Nginx 粘性会话 | 改造少 | 实例故障体验差 |
| Session 复制 | 小集群可用 | 节点多时代价高 |
| 改 JWT | 易扩展 | 撤销和权限变更复杂 |
Session 不是落后方案。是否使用 Session 要看系统形态和控制需求,而不是只看“分布式”三个字。
阶段7:JWT 登录
JWT 的本质是带签名的声明集合。普通 JWT 默认不是加密,只是 Base64Url 编码加签名。
header.payload.signatureJWT 登录链路:
flowchart TD
A["用户提交账号密码"] --> B["认证成功"]
B --> C["生成 access token"]
C --> D["客户端保存 Token"]
D --> E["请求头携带 Authorization"]
E --> F["JWT Filter 截取请求"]
F --> G["解析 Token 并验签"]
G --> H["校验过期、issuer、audience、黑名单"]
H --> I["构造 Authentication"]
I --> J["写入 SecurityContext"]
J --> K["进入授权判断"]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 chain)
throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = authorization.substring(7);
JwtUser user = jwtService.parseAndVerify(token);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
user.userId(),
null,
user.authorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}配置过滤器顺序:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter)
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();
}JWT 常见风险:
| 风险 | 原因 | 处理 |
|---|---|---|
| Token 泄露 | 前端 XSS、日志打印、网络劫持 | HTTPS、不要打印 Token、短有效期 |
| 退出不立即生效 | JWT 无状态,服务端默认不保存 | Redis 黑名单、tokenVersion |
| 权限变更不生效 | Token 中权限是旧快照 | 缩短有效期、权限版本号 |
| payload 暴露 | 普通 JWT 不是加密 | 不放身份证、手机号、密码等敏感数据 |
| Token 过大 | 权限太多写进 Token | 只放 userId、tenantId、version |
阶段8:OAuth2 和单点登录
OAuth2 解决的是授权委托,OIDC 在 OAuth2 之上补充身份认证。面试中不能把 OAuth2 简单说成“第三方登录协议”。
授权码模式:
flowchart TD
A["用户访问业务系统"] --> B["重定向到认证中心"]
B --> C["用户在认证中心登录"]
C --> D["认证中心回调 code"]
D --> E["业务后端用 code 换 token"]
E --> F["校验 token 或 id_token"]
F --> G["查询或绑定本地用户"]
G --> H["建立本系统登录态"]为什么要有 code,而不是直接把 token 放回浏览器地址栏?
因为浏览器环境更容易泄露。授权码模式让后端使用 client_secret 和 code 换 token,减少 token 暴露在前端的风险。生产中还会使用 state 防 CSRF,使用 PKCE 保护公共客户端。
商业接入关注点:
| 关注点 | 说明 |
|---|---|
| 用户绑定 | 第三方用户和本系统用户如何映射 |
| 权限来源 | 权限由统一身份平台提供,还是本系统维护 |
| 退出联动 | 单点退出是否要通知各业务系统 |
| 回调安全 | redirect_uri 白名单,state 校验 |
| Token 管理 | 刷新、撤销、过期、审计 |
阶段9:授权、角色、权限码和数据权限
授权不只是“有没有 admin 角色”。商业系统至少要分四层:
| 层次 | 判断问题 | 例子 |
|---|---|---|
| 菜单权限 | 前端能不能看到入口 | 是否显示“资产管理”菜单 |
| 按钮权限 | 页面上能不能点按钮 | 是否显示“新增资产”按钮 |
| 接口权限 | 能不能调用后端接口 | 是否允许 POST /assets |
| 数据权限 | 能不能操作这条数据 | 是否能看某医院资产 |
RBAC 基础模型:
flowchart TD
A["用户 User"] --> B["用户角色 UserRole"]
B --> C["角色 Role"]
C --> D["角色权限 RolePermission"]
D --> E["权限 Permission"]
A --> F["组织范围 Department 或 Hospital"]
F --> G["数据范围 DataScope"]权限码建议设计成稳定动作:
asset:read
asset:create
asset:update
asset:delete
collect:task:start
collect:task:stop
user:role:assign方法权限 Demo:
@PreAuthorize("hasAuthority('asset:create')")
@PostMapping("/assets")
public AssetVO create(@RequestBody AssetCreateRequest request) {
return assetService.create(request);
}数据权限 Demo:
public AssetVO getAsset(Long assetId, CurrentUser user) {
Asset asset = assetRepository.findById(assetId);
if (!dataScopeService.canReadAsset(user, asset)) {
throw new AccessDeniedException("无权访问该资产");
}
return AssetVO.from(asset);
}为什么只靠 @PreAuthorize 不够?
@PreAuthorize("hasAuthority('asset:read')") 只能说明用户具备“读取资产”的动作权限,但不能说明他能读取“这条资产”。如果用户把 URL 从 /assets/100 改成 /assets/101,接口权限仍然通过,但如果 101 属于别的医院,就形成水平越权。
阶段10:CSRF、CORS、XSS 必须分清
CSRF
CSRF 是跨站请求伪造。关键点是浏览器会自动携带 Cookie。
flowchart TD
A["用户已登录系统"] --> B["浏览器保存 Cookie"]
C["用户访问恶意页面"] --> D["恶意页面提交表单到业务系统"]
D --> E["浏览器自动带上 Cookie"]
E --> F["服务端误认为是用户本人操作"]防护方式:
| 方式 | 原理 |
|---|---|
| CSRF Token | 请求必须带攻击者拿不到的随机值 |
| SameSite Cookie | 限制跨站携带 Cookie |
| 关键操作二次确认 | 降低误操作和伪造请求风险 |
CORS
CORS 是浏览器跨域访问控制,不是服务端之间调用限制。服务端接口用 Postman 或 Java HTTP Client 调用不会被浏览器 CORS 策略限制。
带凭证时不能这样配置:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueXSS
XSS 是脚本注入。它可以窃取 Token,也可以冒充用户发请求。
防护方向:
- 输出转义。
- 富文本白名单过滤。
- Cookie 设置
HttpOnly。 - 不把 Token 打到日志和 URL。
- 配置 Content Security Policy。
阶段11:统一异常和前后端分离
前后端分离项目不要让 Spring Security 返回默认 HTML 登录页,而要统一 JSON。
@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();
}401 和 403 区别:
| 状态码 | 含义 | 前端处理 |
|---|---|---|
| 401 | 未登录、Token 过期、Token 无效 | 跳登录、刷新 Token |
| 403 | 已登录但权限不足 | 提示无权限、隐藏入口 |
如果项目把所有安全错误都返回 200,前端和监控会很难区分真实失败原因;如果所有错误都返回 401,用户已经登录但无权限时会被错误踢回登录页。
阶段12:商业系统权限设计
以医疗数据采集与资产平台为例,权限模型不能只做用户和角色。
flowchart TD
A["用户"] --> B["角色"]
B --> C["权限码"]
C --> D["菜单和按钮"]
C --> E["接口"]
A --> F["租户、医院、部门"]
F --> G["数据范围"]
G --> H["资产、采集任务、接口配置、报表"]
H --> I["审计日志"]典型表设计:
| 表 | 作用 |
|---|---|
sys_user | 用户账号、状态、密码哈希 |
sys_role | 角色,例如平台管理员、医院管理员、科室操作员 |
sys_permission | 权限码、菜单、按钮、接口资源 |
sys_user_role | 用户和角色关系 |
sys_role_permission | 角色和权限关系 |
sys_dept | 组织部门 |
sys_user_scope | 用户可访问的数据范围 |
sys_audit_log | 登录、授权失败、敏感操作审计 |
查询列表时要追加数据范围:
select *
from data_asset
where deleted = 0
and hospital_id in (:allowedHospitalIds)
and asset_status = :status
order by updated_at desc
limit :offset, :size;详情和修改时仍要二次校验:
public void updateAsset(Long id, AssetUpdateRequest request, CurrentUser user) {
Asset asset = assetRepository.findById(id);
if (!dataScopeService.canWriteAsset(user, asset)) {
throw new AccessDeniedException("无权修改该资产");
}
asset.update(request);
}不能只在列表 SQL 加数据权限,因为用户可以绕过列表直接请求详情或修改接口。
阶段13:生产排查清单
登录失败
flowchart TD
A["登录失败"] --> B["账号是否存在"]
B --> C["账号是否禁用或锁定"]
C --> D["密码哈希是否由同一个 PasswordEncoder 生成"]
D --> E["matches 是否返回 true"]
E --> F["AuthenticationProvider 是否被注册"]
F --> G["失败处理器返回的错误是否正确"]JWT 无效
flowchart TD
A["JWT 无效"] --> B["请求头是否是 Bearer Token"]
B --> C["签名密钥是否一致"]
C --> D["算法是否一致"]
D --> E["Token 是否过期"]
E --> F["issuer 和 audience 是否匹配"]
F --> G["是否命中黑名单或 tokenVersion 失效"]
G --> H["JWT Filter 顺序是否正确"]明明登录了却 403
flowchart TD
A["出现 403"] --> B["SecurityContext 是否有 Authentication"]
B --> C["authenticated 是否为 true"]
C --> D["authorities 是否包含目标权限"]
D --> E["hasRole 是否被 ROLE_ 前缀影响"]
E --> F["@EnableMethodSecurity 是否开启"]
F --> G["数据权限是否拒绝了资源"]跨域失败
flowchart TD
A["浏览器 CORS 报错"] --> B["OPTIONS 预检是否放行"]
B --> C["Allow-Origin 是否匹配实际域名"]
C --> D["Allow-Methods 是否包含请求方法"]
D --> E["Allow-Headers 是否包含 Authorization"]
E --> F["带 Cookie 时 Credentials 配置是否正确"]阶段14:常见坑和不这样做的后果
| 坑 | 为什么错 | 后果 |
|---|---|---|
| 密码明文或 MD5 | 破解成本太低 | 数据库泄露后用户账号大面积风险 |
| JWT 放身份证、手机号 | JWT 默认不是加密 | Token 泄露后敏感信息直接暴露 |
| Token 永不过期 | 泄露后长期有效 | 难以及时止损 |
| 权限只存在前端 | 后端接口可直接调用 | 垂直越权 |
| 只做接口权限不做数据权限 | 无法判断资源归属 | 水平越权 |
hasRole 和 hasAuthority 混用 | hasRole 默认拼 ROLE_ | 明明有权限却 403 |
| CORS 全放开 | 信任边界扩大 | 跨站风险增加 |
| 异步任务依赖 SecurityContext | 换线程后上下文丢失 | 任务执行用户为空 |
| 日志打印 Token | 日志系统可泄露凭证 | 被复制后可冒充用户 |
阶段15:最小可运行骨架
依赖方向:Spring Boot 3 使用 Spring Security 6,推荐组件式配置,不再继承 WebSecurityConfigurerAdapter。
@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())
.cors(Customizer.withDefaults())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/health").permitAll()
.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler()))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}登录接口:
@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 TokenResponse login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
String token = jwtService.createAccessToken(authentication);
return new TokenResponse(token);
}
}接口权限和数据权限结合:
@PreAuthorize("hasAuthority('asset:update')")
@PutMapping("/assets/{id}")
public AssetVO update(@PathVariable Long id,
@RequestBody AssetUpdateRequest request) {
CurrentUser user = CurrentUserHolder.get();
return assetService.update(id, request, user);
}public AssetVO update(Long id, AssetUpdateRequest request, CurrentUser user) {
Asset asset = assetRepository.findById(id);
if (!dataScopeService.canWriteAsset(user, asset)) {
throw new AccessDeniedException("无权修改该资产");
}
asset.update(request);
assetRepository.save(asset);
auditLogService.record(user.id(), "asset:update", id);
return AssetVO.from(asset);
}阶段16:面试闭环
问:Spring Security 请求链路怎么走?
标准回答:
请求进入应用后先经过 Spring Security Filter Chain。认证阶段会解析 Session、JWT 或用户名密码等凭证,认证成功后构造
Authentication,放入SecurityContext。授权阶段根据 URL 规则、方法注解或权限码判断是否能访问资源。未认证返回 401,已认证但权限不足返回 403。商业系统还要在业务层做数据权限,防止用户改 ID 访问别人的数据。
问:JWT 和 Session 怎么选?
标准回答:
Session 是服务端保存登录态,客户端只保存 SessionId,适合传统后台和需要服务端主动控制会话的系统。JWT 是客户端携带签名 Token,服务端验签解析,适合前后端分离、移动端和网关统一认证。但 JWT 撤销、权限变更实时生效和泄露处理更复杂,通常要短有效期、refresh token、黑名单或 tokenVersion。
问:为什么权限注解不能解决所有权限?
标准回答:
权限注解只能判断用户是否能调用某个接口或方法,例如是否有
asset:read。但它无法天然判断这条资产属于哪个医院、部门或租户。商业系统必须在 Service 或 SQL 层追加数据范围校验,否则用户改 URL 里的 ID 就可能水平越权。
问:BCrypt 为什么比 MD5 更适合存密码?
标准回答:
MD5 计算太快,数据库泄露后攻击者可以用字典和 GPU 快速撞库。BCrypt 会自动加盐,并通过成本因子故意增加计算时间,使暴力破解成本更高。BCrypt 密文中包含盐和成本因子,所以每次生成结果不同,但仍然可以用
matches校验。
最终验收题
如果下面问题答不清楚,说明还没有真正学懂:
- 为什么 Spring Security 必须在 Controller 前处理认证和授权?
AuthenticationManager、AuthenticationProvider、UserDetailsService的职责边界是什么?SecurityContextHolder为什么在异步线程中可能拿不到用户?hasRole("ADMIN")为什么可能匹配的是ROLE_ADMIN?- JWT 为什么不能放敏感信息?如果 Token 泄露怎么办?
- Session 多实例部署为什么要 Redis Session 或粘性会话?
- CSRF 和 CORS 分别解决什么问题?为什么它们不是一回事?
- 医疗数据平台如何设计医院、部门、资产的数据权限?
- 登录成功但接口 403,你会按什么顺序排查?
- 权限改了但用户仍然能访问旧接口,可能是哪些缓存导致?
关联知识点跳转
- Spring Security 总览
- Spring Security 从零到生产级掌握
- 核心架构
- 登录认证
- JWT 认证
- 权限控制
- CSRF 与 CORS
- 过滤器链实战
- Spring Security 面试题
- JavaEE Filter 原理
- 信息安全
- 加密基础
本章小结
Spring Security 的学习主线不是背配置,而是理解请求在过滤器链中如何被认证、如何被写入上下文、如何被授权、如何在异常时返回正确状态。Session、JWT、OAuth2 是不同登录态方案;角色、权限码、数据权限是不同授权层次;CSRF、CORS、XSS 是不同安全风险。真正能上线的权限系统,一定要把接口权限、数据权限、密码安全、Token 生命周期、审计日志和生产排查一起设计。
