Skip to content

Spring Security 从零到精通验收清单

这页不是简单罗列 API,而是用来验收你是否真的能把 Spring Security 从零基础学到能面试、能设计权限系统、能排查线上问题。

学完后你应该能回答清楚:

  1. Spring Security 为什么基于 Filter,而不是写在 Controller 里。
  2. 一个请求从进入系统到进入 Controller,中间经过哪些认证、授权、安全上下文处理。
  3. Session、JWT、OAuth2 分别解决什么问题,为什么没有绝对最优方案。
  4. 密码为什么不能明文、不能 MD5,BCrypt 为什么要加盐并故意变慢。
  5. 角色、权限码、菜单按钮、接口权限、数据权限为什么必须分层。
  6. 401、403、CSRF、CORS、XSS、Token 泄露分别怎么排查。
  7. 医疗数据采集、资产平台、后台管理系统中权限模型怎么落地。

总学习路线

mermaid
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:为什么需要安全框架

一个后台系统没有安全框架时,常见写法是每个接口自己判断登录状态:

java
@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 的价值是把这些共性安全逻辑放到统一入口:

mermaid
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 之前,可以决定是否继续放行。

java
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

核心链路:

mermaid
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 对象。

账号密码认证流程:

mermaid
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:

java
@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 保存当前认证信息。

mermaid
flowchart TD
    A["认证成功 Authentication"] --> B["SecurityContext"]
    B --> C["SecurityContextHolder"]
    C --> D["Controller 或 Service 获取当前用户"]

常见获取方式:

java
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

原理上,SecurityContextHolder 默认使用 ThreadLocal。一次 Web 请求通常由一个线程处理,所以这个线程内的业务代码都能拿到当前用户。

这也解释了一个常见线上问题:异步线程拿不到登录用户。

java
@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:

java
@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 的密文里包含算法版本、成本因子、盐和哈希结果。校验时会从密文中取出盐和成本因子,用同样规则计算一次,再比较结果。

text
$2a$12$盐和哈希结果

如果用普通 MD5,攻击者可以每秒尝试大量密码;BCrypt 故意让计算变慢,使暴力破解成本上升。生产中成本因子不能盲目调太高,否则登录高峰会把 CPU 打满。

阶段6:Session 登录

Session 登录的本质是:服务端保存登录态,客户端只保存一个 SessionId。

mermaid
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 编码加签名。

text
header.payload.signature

JWT 登录链路:

mermaid
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:

java
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);
    }
}

配置过滤器顺序:

java
@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 简单说成“第三方登录协议”。

授权码模式:

mermaid
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 基础模型:

mermaid
flowchart TD
    A["用户 User"] --> B["用户角色 UserRole"]
    B --> C["角色 Role"]
    C --> D["角色权限 RolePermission"]
    D --> E["权限 Permission"]
    A --> F["组织范围 Department 或 Hospital"]
    F --> G["数据范围 DataScope"]

权限码建议设计成稳定动作:

text
asset:read
asset:create
asset:update
asset:delete
collect:task:start
collect:task:stop
user:role:assign

方法权限 Demo:

java
@PreAuthorize("hasAuthority('asset:create')")
@PostMapping("/assets")
public AssetVO create(@RequestBody AssetCreateRequest request) {
    return assetService.create(request);
}

数据权限 Demo:

java
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。

mermaid
flowchart TD
    A["用户已登录系统"] --> B["浏览器保存 Cookie"]
    C["用户访问恶意页面"] --> D["恶意页面提交表单到业务系统"]
    D --> E["浏览器自动带上 Cookie"]
    E --> F["服务端误认为是用户本人操作"]

防护方式:

方式原理
CSRF Token请求必须带攻击者拿不到的随机值
SameSite Cookie限制跨站携带 Cookie
关键操作二次确认降低误操作和伪造请求风险

CORS

CORS 是浏览器跨域访问控制,不是服务端之间调用限制。服务端接口用 Postman 或 Java HTTP Client 调用不会被浏览器 CORS 策略限制。

带凭证时不能这样配置:

text
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

XSS

XSS 是脚本注入。它可以窃取 Token,也可以冒充用户发请求。

防护方向:

  1. 输出转义。
  2. 富文本白名单过滤。
  3. Cookie 设置 HttpOnly
  4. 不把 Token 打到日志和 URL。
  5. 配置 Content Security Policy。

阶段11:统一异常和前后端分离

前后端分离项目不要让 Spring Security 返回默认 HTML 登录页,而要统一 JSON。

java
@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:商业系统权限设计

以医疗数据采集与资产平台为例,权限模型不能只做用户和角色。

mermaid
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登录、授权失败、敏感操作审计

查询列表时要追加数据范围:

sql
select *
from data_asset
where deleted = 0
  and hospital_id in (:allowedHospitalIds)
  and asset_status = :status
order by updated_at desc
limit :offset, :size;

详情和修改时仍要二次校验:

java
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:生产排查清单

登录失败

mermaid
flowchart TD
    A["登录失败"] --> B["账号是否存在"]
    B --> C["账号是否禁用或锁定"]
    C --> D["密码哈希是否由同一个 PasswordEncoder 生成"]
    D --> E["matches 是否返回 true"]
    E --> F["AuthenticationProvider 是否被注册"]
    F --> G["失败处理器返回的错误是否正确"]

JWT 无效

mermaid
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

mermaid
flowchart TD
    A["出现 403"] --> B["SecurityContext 是否有 Authentication"]
    B --> C["authenticated 是否为 true"]
    C --> D["authorities 是否包含目标权限"]
    D --> E["hasRole 是否被 ROLE_ 前缀影响"]
    E --> F["@EnableMethodSecurity 是否开启"]
    F --> G["数据权限是否拒绝了资源"]

跨域失败

mermaid
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 永不过期泄露后长期有效难以及时止损
权限只存在前端后端接口可直接调用垂直越权
只做接口权限不做数据权限无法判断资源归属水平越权
hasRolehasAuthority 混用hasRole 默认拼 ROLE_明明有权限却 403
CORS 全放开信任边界扩大跨站风险增加
异步任务依赖 SecurityContext换线程后上下文丢失任务执行用户为空
日志打印 Token日志系统可泄露凭证被复制后可冒充用户

阶段15:最小可运行骨架

依赖方向:Spring Boot 3 使用 Spring Security 6,推荐组件式配置,不再继承 WebSecurityConfigurerAdapter

java
@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();
    }
}

登录接口:

java
@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);
    }
}

接口权限和数据权限结合:

java
@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);
}
java
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 校验。

最终验收题

如果下面问题答不清楚,说明还没有真正学懂:

  1. 为什么 Spring Security 必须在 Controller 前处理认证和授权?
  2. AuthenticationManagerAuthenticationProviderUserDetailsService 的职责边界是什么?
  3. SecurityContextHolder 为什么在异步线程中可能拿不到用户?
  4. hasRole("ADMIN") 为什么可能匹配的是 ROLE_ADMIN
  5. JWT 为什么不能放敏感信息?如果 Token 泄露怎么办?
  6. Session 多实例部署为什么要 Redis Session 或粘性会话?
  7. CSRF 和 CORS 分别解决什么问题?为什么它们不是一回事?
  8. 医疗数据平台如何设计医院、部门、资产的数据权限?
  9. 登录成功但接口 403,你会按什么顺序排查?
  10. 权限改了但用户仍然能访问旧接口,可能是哪些缓存导致?

关联知识点跳转

本章小结

Spring Security 的学习主线不是背配置,而是理解请求在过滤器链中如何被认证、如何被写入上下文、如何被授权、如何在异常时返回正确状态。Session、JWT、OAuth2 是不同登录态方案;角色、权限码、数据权限是不同授权层次;CSRF、CORS、XSS 是不同安全风险。真正能上线的权限系统,一定要把接口权限、数据权限、密码安全、Token 生命周期、审计日志和生产排查一起设计。