Spring Seucrity 相关重要知识

Spring Security 到底是什么?过滤器链是怎么串起来的?

Spring Security 本质上就是一组 Servlet 过滤器,为 Java 应用提供认证、授权,以及一系列安全防护

  • 它不创造新的运行容器,而是挂到 Servlet 容器的 FilterChain 上,所以它管不到过滤器链之前的东西
  • 它的真正威力在于扩展力,几乎每个环节都留了扩展点。

对于过滤器链,首先先看三个类

  • DelegatingFilterProxy:它只是一个“壳”,由 Servlet 容器注册;作用是把请求委托给 FilterChainProxy 这个 Bean,这样就能让容器拿到 Spring 容器管理的 Filter
  • FilterChainProxy:Spring Security 的核心 Bean,内部持有一个或多个 SecurityFilterChain,通过 RequestMatcher 选择匹配的那一条链,而且还会对请求前后进行处理
  • SecurityFilterChain:一条具体的过滤器序列。由 HttpSecurity.build() 构建,就是我们配置类中 http.xxx() 写出来的东西。多个 SecurityFilterChain 可以共存,由 @Order 决定匹配优先级。

为什么需要 DelegatingFilterProxy 而不直接把 FilterChainProxy 注册到容器?

因为 Web 容器启动时 Spring 容器可能还没初始化完,且 Filter 由容器实例化无法注入依赖;延迟委托解决初始化顺序问题。

自定义 JWT 过滤器应该插在哪?

  • 一般是放在http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class),因为只要保证在 AuthorizationFilter 之前把 SecurityContext 填好即可;插在 UsernamePasswordAuthenticationFilter 之前是社区最通行的写法

Spring Security 的认证整体流程是怎么样的? 认证成功后,用户信息存在哪里?如何在 Spring Security 中自定义认证逻辑?什么时候自定义 AuthenticationProvider?为什么需要匿名认证过滤器?

按顺序从上到下是这样:

  • 用户提交凭证
  • UsernamePasswordAuthenticationFilter 拦截POST /login,把请求参数封装成未认证的 Authentication
  • 交给 AuthenticationManager(默认实现 ProviderManager)
  • ProviderManager 遍历 AuthenticationProvider 列表,找到支持该 token 类型的 Provider
  • DaoAuthenticationProvider,用 UserDetailsService.loadUserByUsername(username)加载用户,用 PasswordEncoder.matches(rawPassword, encodedPassword)校验密码,而且一般情况下,UserDetailsService是需要自己实现这个接口的,一般数据库读取用户
  • 校验通过会创建已认证的 Authentication
  • 然后把 Authentication 放进 SecurityContext,再放进 SecurityContextHolder,并触发 AuthenticationSuccessHandler(或返回 JSON)

认证相关的核心类如下

  • Authentication:认证信息的载体,认证前后是同一个接口的两个状态

  • AuthenticationManager:认证入口,只有一个方法 authenticate(),默认实现 ProviderManager

  • AuthenticationProvider:具体认证逻辑的接口,一般自定义登录方式就实现它,是校验策略问题。

    一般自定义登录方式就需要实现这个接口,一搬是非用户名密码的认证方式时用

  • UserDetailsService:loadUserByUsername,只负责加载用户,不校验密码,是数据来源问题

  • UserDetails:用户模型(用户名、密码、权限、四个状态位)

    自己实现 UserDetails(或写一个包装类)是为了带上业务字段(userId、tenantId、deptId),这样 Controller 里可以直接从 Authentication.getPrincipal() 取到,不用再查一次库。

  • PasswordEncoder:编码和校验密码

  • SecurityContextHolder:存放 SecurityContext 的 ThreadLocal 门面

认证成功后,当前请求线程中,用户信息存在 SecurityContextHolder 的 ThreadLocal 中(默认策略 MODE_THREADLOCAL),内容是一个 SecurityContext,里面是 Authentication。

关于异步/子线程:默认 MODE_THREADLOCAL 下,@Async 方法、CompletableFuture、手动 new 的线程都拿不到父线程的 SecurityContext。解决方案:

  • 用 DelegatingSecurityContextExecutor 包装线程池。
  • 用 SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)(仅对子线程继承有效,线程池复用场景不可靠)。
  • 在异步方法开头手动 SecurityContextHolder.setContext()。

如果是请求间,处于无状态的 JWT 下,那就没有持久化,每请求从 JWT 解析,如果是 Session 有状态,就从HttpSession 的 SPRING_SECURITY_CONTEXT 属性可以拿到

默认策略下,ThreadLocal 只在当前请求线程可见;异步/子线程需要额外处理。

跨请求的持久化(Session 或 JWT)是另一件事,Session 靠 HttpSessionSecurityContextRepository,无状态靠每请求重新解析令牌。

自定义 AuthenticationProvider 的时候一把按是你的凭证类型或者认证策略变的时候进行重写

场景 该用什么
换数据库/换用户表 UserDetailsService
加业务字段到 principal UserDetails
换密码加密算法 PasswordEncoder
短信/扫码/OTP 登录 AuthenticationProvider

自定义认证逻辑的几种方式如下

  • 只换数据源

    实现 UserDetailsService,返回自己的 UserDetails。密码校验仍走 DaoAuthenticationProvider + PasswordEncoder。这是最常见的场景。

  • 换用户模型

    实现 UserDetails(或继承 org.springframework.security.core.userdetails.User),带上业务字段。

  • 换认证方式:实现 AuthenticationProvider,supports() 返回你的自定义 token 类型。

那么,为什么需要匿名认证过滤器,因为需要让让”未登录”和”已登录但无权限”在代码层面统一处理,同时避免 null 判断。

没有匿名认真过滤器的时候

1
2
3
4
5
6
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
// 已登录逻辑
} else {
// 未登录逻辑,每个需要判断的地方都要写 null 检查
}

有它之后:

  • 过滤器链走到 AnonymousAuthenticationFilter 时,如果 SecurityContextHolder 里没有 Authentication,就创建一个 AnonymousAuthenticationToken,principal 是字符串 "anonymousUser",authority 是 ROLE_ANONYMOUS。
  • 这样 SecurityContextHolder.getContext().getAuthentication() 永远不为 null。
  • 授权时,ROLE_ANONYMOUS 与普通角色一样参与判断,isAnonymous() 表达式也能正常工作。

Spring Security 的权限控制方式有哪几种?认证通过但访问接口 403,怎么排查?

维度 方式 适用
URL 级 http.authorizeHttpRequests(...) + requestMatchers 粗粒度、路由级、统一入口
方法级 @PreAuthorize / @PostAuthorize / @Secured / @PreFilter / @PostFilter 细粒度、业务接口级
数据级 在 Service 里校验“这条数据属不属于当前用户/租户” 防水平越权(IDOR)
域对象级 PermissionEvaluator + hasPermission(#id, 'Course', 'delete') 权限规则复杂的业务

那么这个故障比较常见,认证通过依旧foribiden因此重心放到授权上

  1. 角色前缀:数据库存的是 ADMIN,却用 hasRole("ADMIN"),但是因为自动前缀实际要求的是ROLE_ADMIN;要么存 ROLE_ADMIN,要么统一用 hasAuthority("ADMIN")。

    hasRole() 和 hasAuthority() 的区别?

    • hasAuthority("ROLE_ADMIN"):完全字符串匹配。
    • hasRole("ADMIN"):会自动补前缀,等价于 hasAuthority("ROLE_ADMIN")(前缀默认 ROLE_,可自定义 GrantedAuthorityDefaults)。
  2. 权限没加载进来:UserDetails#getAuthorities() 返回空,或 JWT 过滤器只设置了 principal 没设置 authorities。

  3. SecurityContext 没传到当前线程:异步接口、@Async、CompletableFuture、线程池任务里取不到用户,导致匿名身份被拒。

  4. 匹配顺序问题:requestMatchers 先写的规则先生效,permitAll 写在了 authenticated 后面也可能被吞;多个 SecurityFilterChain 的 @Order 冲突。

  5. 方法级注解没生效:没加 @EnableMethodSecurity;或同类内部方法自调用(AOP 代理不生效);或注解写在 private 方法上。

  6. 被 @PreAuthorize 表达式写错:hasPermission 没注册 PermissionEvaluator,Spring 会抛异常或直接拒绝。

permitAll() / anonymous() / authenticated() / fullyAuthenticated() 的区别。

表达式 含义
permitAll() 无条件放行,不要求任何身份
anonymous() 仅允许匿名用户访问(已登录用户反而被拒,常用于“登录页只给没登录的人看”)
authenticated() 只要已认证即可(包括匿名身份也算“已认证”,所以通常要配合 fullyAuthenticated)
fullyAuthenticated() 必须是真实认证的用户(排除匿名和 Remember-Me 自动登录)
rememberMe() 通过 Remember-Me 自动登录的用户
denyAll() 一律拒绝(兜底推荐,白名单思维)
hasIpAddress("10.0.0.0/24") 按来源 IP 限制(注意反向代理后要取 X-Forwarded-For)

坑点:authenticated() 会放行匿名用户(因为 AnonymousAuthenticationToken.isAuthenticated() 返回 true),所以想“必须登录”要用 fullyAuthenticated() 或同时排除匿名。

@PreAuthorize / @PostAuthorize 的区别?

@PreAuthorize 和 @PostAuthorize 都是 Spring Security 中基于方法的安全注解

维度 @PreAuthorize @PostAuthorize
执行时机 方法调用前校验 方法执行后校验
能否访问返回值 不能 能,通过 returnObject
能否访问方法参数 能,通过参数名或 #p0 等 能
方法是否执行 校验失败则不执行 无论校验结果,方法都会执行
典型用途 拦截无权访问 一般控制只读操作

在方法执行之前进行校验,失败直接抛 AccessDeniedException,方法体不会执行。

1
2
3
4
5
6
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }

// 使用参数
@PreAuthorize("#userId == authentication.principal.id")
public User getUser(Long userId) { ... }

方法先执行完,拿到返回值后再校验。失败同样抛异常,但方法已经执行了

1
2
3
4
5
@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(Long id) {
// 方法会先执行,查出文档
return documentRepository.findById(id).orElseThrow();
}

了解SecurityContextHolder 吗?底层怎么实现的?异步/线程池里丢失用户上下文该怎么办?SecurityContext 的持久化能说一说吗?

SecurityContextHolder 是获取 SecurityContext 的门面类,默认用 ThreadLocal 保存上下文,同一个请求线程内随处可取。这样,让应用在不显式传递用户信息的情况下,也能在同一线程中访问用户身份。

设置系统属性 spring.security.strategy可以发现,它有三种策略

策略 作用域 用途
MODE_THREADLOCAL(默认) 当前线程 使用 ThreadLocal 保存每个线程的安全上下文,适合 Web 应用。
MODE_INHERITABLETHREADLOCAL 当前线程 + 它创建的子线程 子线程可继承父线程的安全上下文,适用于自行创建线程的场景。
MODE_GLOBAL 全局静态变量 所有线程共享同一个安全上下文,不适合服务器环境

可以通过SecurityContext获取当前用户,而@AuthenticationPrincipal 是一个非常便捷的注解。它的核心原理可以概括为:通过 Spring MVC 的参数解析器(HandlerMethodArgumentResolver),从当前请求的安全上下文(SecurityContext)中自动提取用户信息,并注入到控制器方法的参数中。

异步/线程池里丢失用户上下文一般用这几种办法

  • 让 Spring 帮你包装 Executor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    public class AsyncSecurityConfig {

    /** 关键:用 DelegatingSecurityContextAsyncTaskExecutor 包装,任务提交时自动复制上下文 */
    @Bean
    public AsyncTaskExecutor securityAwareExecutor(ThreadPoolTaskExecutor delegate) {
    return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Configuration
    @EnableAsync
    public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(32);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("async-sec-");
    executor.initialize();
    // @Async 方法里也能拿到 SecurityContext
    return new DelegatingSecurityContextAsyncTaskExecutor(executor);
    }
    }
  • 不传SecurityContext,只传必要的参数

    在跨线程前把 userId / tenantId等需要的参数提取成方法参数或上下文 DTO,异步任务不依赖 SecurityContext。这样安全上下文只用于 Web 线程,异步链路显式传 userId,避免隐式依赖。

在跨请求的情况下,为了在多个请求之间识别出同一个已认证用户,会涉及到SecurityContext 的持久化

持久化工作由 SecurityContextRepository 接口统一抽象,而 Spring Security 默认使用 HttpSessionSecurityContextRepository,它将 SecurityContext 存储在 HttpSession 中,默认属性名为 SPRING_SECURITY_CONTEXT,下次请求由 SecurityContextHolderFilter 恢复,这是 Session 模式。

SecurityContextHolderFilter 默认只读取不保存,如果要在 Session 里持久化,需要显式配置 securityContext().requireExplicitSave(false) 或手动 saveContext。

用户上下文还涉及到一个安全的跨服务透传的问题

  1. 绝不信任外部传入的身份:请求头里的 X-User-Id 只能在内网可信链路中使用。
  2. 优先用签名令牌:跨服务传递带签名的内部 JWT,下游验签即可,无需共享 Session。
  3. 无状态传递和幂等校验:内部调用携带 traceId/requestId,配合接口幂等键。

Spring Security与网络安全

如何防止跨站请求伪造 CSRF 攻击?而 Spring Security 默认开启 CSRF 防护,它的原理是什么?在什么情况下你会建议关闭它

CSRF 的核心是利用浏览器自动携带 Cookie 的特性,冒充用户发送恶意请求。攻击者盗用了你的身份,以你的名义做了某件事,而目标网站会误以为这个请求是你主动发起的。

常用的防御手段包括:

  • 使用 CSRF Token:服务端生成一个随机且唯一的 Token 传给客户端,客户端在发送非 GET 请求时必须在 Header 或表单中带回该 Token 。
  • 同步令牌模式:访问受保护的页面的时候,向用户 Session 中存入一个随机生成的 Token,请求提交时,对比请求中的 Token 与服务器保存的 Token 是否一致。
  • 检查 Referer/Origin 头部: 服务器验证请求来源是否为可信域名
  • 双重 Cookie 验证:客户端在请求参数和 Cookie 中各带一个相同的 Token,服务端对比二者。
  • SameSite Cookie 属性: 将 Cookie 设置为 SameSite=Strict 或 Lax,限制第三方网站携带 Cookie。

Spring Security 默认开启此防护,其核心组件是 CsrfFilter

  • 生成 Token: 当用户访问应用时,CsrfTokenRepository(默认存入 HttpSession)会生成一个 CsrfToken。
  • 注入 Token:
    • 后端渲染: 在表单中自动添加一个隐藏域 <input type="hidden" name="_csrf" value="...">。
    • 前后端分离:或者要求前端从 Cookie 中读取并放入 HTTP 请求头。
  • 拦截:
    • CsrfFilter 拦截所有可能改变状态的请求,它从请求头或请求参数中提取 Token,并与服务器端存储的 Token 进行比对。一致则放行

虽然安全性很重要,但在以下场景中,关闭 CSRF 是合理且常规的

  • 非浏览器客户端: 如果你的后端只服务于移动端或者桌面端,这些客户端不依赖浏览器的 Cookie 机制,免疫 CSRF 攻击
  • 使用无状态认证:JWT,由于 JWT 通常存储在浏览器的 localStorage 或 sessionStorage 中,且必须手动放入请求头,浏览器不会像 Cookie 那样自动发送它,因此不需要额外的 CSRF Token

在审计时,重点检查那些显式调用了 .csrf().disable() 的配置类。如果该项目是传统的 JSP/Thymeleaf 开发或者依赖 Session 认证,关闭 CSRF 将是极大的安全隐患。

如何防止跨站脚本 XSS 攻击?在 Spring Security 中你如何进行实践的?如何检测防御的效果?

XSS 跨站脚本攻击,是攻击者将恶意脚本注入到网页中,当用户访问该页面时,脚本在用户浏览器中执行,从而窃取信息、伪造操作等

无论使用什么框架,以下通用手段是防御 XSS 的基础

  • 输入验证 / 过滤:对用户输入的内容按规则校验,拒绝类似脚本的非法输入;
  • 输出编码:将输出到 HTML/JS/CSS 中的特殊字符(如</>/&/")转义,使其失去脚本执行能力;
  • 使用安全的 API:不用 innerHTML、document.write、eval 这类把字符串当代码执行的 API,改用 textContent、createElement;后端拼 HTML 时依赖模板引擎的自动转义,不要手工拼接字符串;
  • 设置安全响应头:如Content-Security-Policy (CSP)限制脚本加载源,X-XSS-Protection开启浏览器 XSS 防护;

Spring Security 本身不直接处理 XSS,但是我们可以基于 Spring Security 的过滤器链机制,进行 XSS 过滤,核心是替换和移除类似脚本的恶意字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

// 注册为Spring组件,自动加入过滤器链
@Component
public class XssFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 包装请求,重写参数获取方法
XssHttpServletRequestWrapper wrappedRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
chain.doFilter(wrappedRequest, response);
}

// 自定义RequestWrapper,过滤参数
private static class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}

// 重写获取参数的核心方法
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
return value != null ? cleanXss(value) : null;
}

@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) return null;
for (int i = 0; i < values.length; i++) {
values[i] = cleanXss(values[i]);
}
return values;
}

// XSS清理核心方法:转义特殊字符
private String cleanXss(String value) {
if (value == null) return null;
// 替换常见XSS攻击字符
value = value.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("\\(", "&\#40;")
.replaceAll("\\)", "&#41;")
.replaceAll("eval\\((.*)\\)", "") // 移除eval脚本
.replaceAll("[\\\"\\\'][\\s]*javascript:(.*)[\\\"\\\']", "\"\""); // 移除javascript:伪协议
return value;
}
}
}

这里推荐使用成熟的 XSS 过滤库,替代自定义过滤规则

也需要配置 Spring Security 的 XSS 安全响应头,强化防御:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter;

@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 开启头部安全配置
.headers(headers -> headers
// 开启浏览器XSS防护(IE/Chrome等)
.xssProtection(xss -> xss.block(true))
// 禁止页面嵌入到iframe(防点击劫持,间接降低XSS风险)
.frameOptions(frame -> frame.deny())
// 配置CSP策略(核心:限制脚本加载源)
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'")
)
// 禁止浏览器嗅探MIME类型,防止脚本伪装成其他类型执行
.contentTypeOptions(content -> content.disableSniffing())
)
// 其他Security配置(如授权、认证)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}

一般情况下,也需要在 Spring Boot 中配置 Cookie 的 HttpOnly 属性,虽然是默认开启的,但是如果你的 Cookie 是你自定义的,那么你就需要手动配置一下了

1
2
3
4
5
6
7
# application.yml
server:
servlet:
session:
cookie:
http-only: true # 会话Cookie无法被JS读取
secure: true # 仅在HTTPS下传输(生产环境建议开启)

对于检测,除了你自己向系统输入 XSS 测试用例,验证是否被过滤,也可以使用自动化工具,例如 OWASP ZAP 开源安全扫描工具,可自动扫描 XSS 漏洞,模拟攻击者注入恶意脚本,验证防御是否生效;

你在防御层实现了 XSS 防护 ,是靠框架自带功能还是自定义拦截?如果审计一个 Vue3 前端项目 ,哪些指令可能引发 XSS?

两边都用了,但职责不一样

  • 框架自带的部分:Spring Security 只负责安全响应头,也就是 CSP、X-Content-Type-Options、X-Frame-Options 这些,它不解析也不转义业务数据,所以只能算辅助防线。
  • 自定义拦截的部分:就是我上面写的 XssFilter,在过滤器链里包装 HttpServletRequest,对参数做转义和危险字符清理,属于输入侧的过滤。

补充一点,X-XSS-Protection 现在已经被主流浏览器废弃了(它自身存在绕过问题,还可能被利用做侧信道攻击),我在配置里保留它只是为了兼容老浏览器,真正起作用的是 CSP 和输出编码。

对接口返回的 JSON 还有一层:在序列化出口做统一的转义和脱敏,用的是 Jackson 自定义序列化器配合声明式注解,这样不至于每写一个 DTO 就手工处理一遍。

审计 Vue3 项目时,风险都集中在绕开 Vue 转义的地方

  • v-html:头号高危指令,等价于 innerHTML,只要内容来自用户就必然存在存储型或 DOM 型 XSS。必须用 DOMPurify.sanitize() 净化后再渲染,或者干脆改用插值 {{ }}。
  • v-bind:href / :src / :xlink:href:属性值里可以塞 javascript:、data:text/html;base64,... 这类伪协议,点击后就执行脚本。要对协议做白名单校验,只放行 http、https 和相对路径。
  • 动态组件 <component :is="xxx">:如果 xxx 直接来自路由参数或接口,就可能加载到非预期组件。
  • eval()、new Function()、setTimeout("字符串"):把字符串当代码执行,审计时直接全局搜这几个关键字。
  • Markdown / 富文本渲染:marked 这类渲染器如果没开净化,或者第三方富文本编辑器版本过旧有已知漏洞,都会变成注入点。
  • innerHTML、outerHTML、document.write、insertAdjacentHTML:这些是在 ref 上直接操作 DOM 的写法,绕过了 Vue 的转义,审计时和 v-html 一起搜。

Vue 的模板插值 {{ }} 本身会做 HTML 转义,所以普通文本展示不用太担心;真正的问题都出在不想让它转义的写法上。

审计完还要有验证手段:自己向系统输入 XSS 测试用例看是否被过滤,再用 OWASP ZAP 这类自动化工具模拟注入,确认防御是否真的生效。

你在项目中用了 Hibernate ,它能完全防止 SQL 注入吗?你是如何对你的平台做注入防御的?在代码审计时,你会重点检查哪些地方?

虽然 Hibernate 通过 HQL 和 Criteria API,输入的不是原生 SQL,而是使用了预编译参数绑定机制,在一定程度上降低了风险,但是也没法保证问题不会发生

只要开发者在构建 HQL 或原生 SQL 时使用了字符串拼接,注入漏洞依然存在。

防御它的思路如下:

  • 持久层规范:不要使用任何字符串拼接,严格要求团队使用 ? 或 :name 形式的参数占位符。而且使用了 Spring Data JPA 在默认情况下会引导开发者使用参数化查询 。
  • 防御层加固: 在项目中通过 Spring Security 配合拦截器,在防御层实现了对 SQL 注入的过滤监控 。
  • 不要信任任何用户输入: 任何被用作 SQL 查询的用户输入都有 SQL 注入攻击的风险。网站应用不应该在代码中直接使用用户输入,应该去做校验,移除潜在的恶意代码因素。
  • 使用白名单,而不是黑名单:不要基于黑名单过滤用户输入。应尽可能只使用严格的白名单,对用户输入进行验证和过滤。
  • 周期性扫描:SQL 注入漏洞可能因为开发者的失误,也可能被外部库、模块或软件引入。每次构建都进行扫描,比如使用 Acunetix

重点检查哪些地方?

  • HQL/SQL 拼接:搜索代码中所有调用 session.createQuery()、session.createNativeQuery() 或 JPA 的 @Query 注解处,查看是否存在 + 拼接字符串或 String.format()。
  • Like 模糊查询:检查 LIKE 子句中是否手动拼写了 %。正确做法应该是 where name like :name 并在传入参数时构造 %keyword%。
  • MyBatis 动态标签:要重点看 XML 映射文件,寻找 ${} 这种直接替换字符串的写法,而非安全的 #{} 预编译写法。
  • Order By/Group By:检查排序字段是否由前端传入,未经过滤直接拼接在 SQL 后。由于预编译无法对关键字(DESC/ASC)生效,这里极易产生注入。

你设计过基于 Spring Security 的多层角色权限系统 ,在审计时,如何判断一个接口是否存在权限绕过漏洞?

从入口往里查、从功能权限查到数据权限

  1. 先找全入口:搜 @RestController、@RequestMapping,逐个确认这个接口有没有权限注解、有没有落在受保护的路径范围内。最怕的是新写的接口既没加注解、也没被规则覆盖。
  2. 再查白名单:permitAll() 里放行了哪些路径?很多项目的导出、下载、回调、健康检查接口被顺手放行,而这些恰恰是泄露数据的高危入口。
  3. 查规则顺序:requestMatchers 是从上到下第一条匹配生效,所以 permitAll 写在 authenticated 后面就会被吞掉,导致本该保护的路由实际是放行的。
  4. 查水平越权:这是最容易被忽略的一类。重点对比列表接口和详情接口——如果列表按当前用户过滤了,详情却直接用 findById(id) 查,那就是典型漏洞;还要检查写操作(改、删)有没有校验资源归属。
  5. 查身份来源:接口是否信任了请求头或请求体里的 userId、role、tenantId。只要身份来自客户端可改的地方,就等于没有鉴权,这是参数篡改提权的入口。
  6. 查方法级注解是否失效:没加 @EnableMethodSecurity、注解写在 private 方法上、或者同类内部方法自调用导致 AOP 代理被绕过。
  7. 查路径绕过:/admin/../user、URL 编码(%2e%2e)、分号、双斜杠、尾部斜杠、大小写,这些在部分匹配器实现下能绕过前缀规则。
  8. 查多租户隔离:是不是所有查询都带上了租户条件。人工看代码一定会漏,所以更好的做法是用 Hibernate @Filter 或 MyBatis 拦截器从机制上自动注入 tenant_id。

Java 原生反序列化或 Fastjson 等工具类常出现漏洞,审计时如何排查?

反序列化漏洞的本质是把数据当成了代码:ObjectInputStream 在反序列化时会回调类的 readObject、readResolve 等方法,攻击者构造一条利用链(gadget chain),就能从一段字节流走到命令执行。

高危场景主要有这几类

  • 原生反序列化:直接 new ObjectInputStream(inputStream) 读取外部数据,比如 Cookie、RMI/JMX 通信、JMS 消息体。
  • 第三方组件:Fastjson(autoType)、Jackson(开启 enableDefaultTyping)、XStream、Hessian、Kryo,以及 Shiro 的 rememberMe Cookie。
  • 缓存与消息队列:Redis 里存的是 Java 序列化对象,或者 MQ 的消息体走原生序列化,读取时同样没有校验。

审计排查的步骤

  1. 搜入口:全局搜 ObjectInputStream、readObject()、readUnshared()、XMLDecoder、XStream.fromXML、JSON.parseObject(...) 带 Feature.SupportAutoType、enableDefaultTyping,以及各序列化框架的配置。
  2. 跟数据来源:这些入口的入参到底来自哪里。来自 HTTP 请求体、Cookie、MQ、缓存、RPC 入参的才是真风险,来自内部常量池的基本可以排除。
  3. 查依赖版本:重点看 commons-collections 3.x、fastjson、xstream、shiro 这些的版本,对照公开漏洞确认是否在受影响区间。
  4. 看有没有白名单:JDK 9+ 的 ObjectInputFilter(或 jdk.serialFilter 参数)、Fastjson 的 safeMode、Jackson 的多态类型白名单。
  5. 验证:在授权范围内用 ysoserial、marshalsec 构造 payload 验证,或者用 CodeQL、Semgrep 这类工具做污点分析辅助。

修复手段按优先级排

  • 根治:不反序列化不可信数据。接口用 JSON 加明确的 DTO,禁用多态类型,不要用原生序列化当传输格式。
  • 收敛:Fastjson 升级到安全版本并开启 safeMode,或者直接迁到 Jackson;Shiro 更换 rememberMe 密钥,或改用 PersistentTokenBasedRememberMeServices(服务端存 token)。
  • 兜底:用 ObjectInputFilter 给允许反序列化的类做白名单,JDK 自带的机制,成本低效果直接。
  • 隔离:RMI、JMX 端口不对外暴露。

一句话总结:能不用原生序列化就不用;必须用时,只允许白名单类,同时对依赖版本做持续扫描。

什么是文件上传漏洞?如何绕过文件上传限制?

文件上传漏洞是攻击者上传恶意文件(如 Webshell、病毒、木马)到服务器,然后访问该文件的时候,其中的恶意代码就会被执行,获取服务器控制权。

文件上传漏洞的危害极大,可以直接获取 Webshell,进而控制整个服务器。

文件上传漏洞产生的原因是应用程序对上传的文件没有严格的验证和限制。常见的不安全实现:

  • 只在前端验证文件类型(可以绕过)
  • 只检查文件扩展名(可以伪造)
  • 只检查 Content-Type(可以修改)
  • 上传文件的目录可以执行脚本

防御方法:

  • 扩展名白名单:只允许业务真正需要的后缀(jpg、png、pdf、docx),不要用黑名单(黑名单永远列不全)。并且服务端强制重命名为 UUID + 白名单后缀,不保留用户原始文件名(原始名只用于展示),同时过滤 ../、/、\ 这类路径穿越字符。
  • 校验真实内容:读文件头的魔数(Magic Number)判断真实类型,图片再用 ImageIO 解析一次确认能正常解码。因为 Content-Type 和文件后缀都是客户端可控的,只有内容本身骗不了人。
  • 限制大小和数量:在配置里限制单文件大小(spring.servlet.multipart.max-file-size),业务上再限制单用户配额和上传并发,防止大文件或海量上传把磁盘和带宽打满。
  • 存储位置与访问方式:文件保存到 Web 根目录之外(独立文件服务器或 MinIO),通过带鉴权的下载接口读取,绝不落在可执行目录里。
  • 禁止执行:Nginx / Apache 层对上传目录关闭脚本解析,容器里的应用以非 root 运行,从环境上切断“上传即执行”。
  • 病毒扫描与格式净化:接入 ClamAV 之类的引擎做扫描,Office 文档做宏剥离。
  • 下载也要鉴权:用短期预签名 URL 或带权限校验的下载接口,不要用可枚举的固定路径(否则文件上传没出问题,文件下载反而泄露了)。

如何绕过文件上传限制,常见的手法有这些

  • 修改 Content-Type:把 text/html 改成 image/png,骗过只检查 MIME 的代码。
  • 双扩展名与大小写:a.jpg.jsp、a.JSP、a.Jsp,绕过基于字符串的黑名单匹配。
  • 截断与编码:a.jsp%00.jpg(老环境)、Unicode 或 URL 编码绕过关键字符过滤。
  • 图片马:文件头是合法的 JPG,末尾拼接脚本代码,绕过图片校验,再配合文件包含或解析漏洞执行。
  • 覆盖配置文件:上传 .htaccess、web.config 改变目录的解析规则,把自己变成可执行脚本。
  • 利用解析漏洞:某些中间件会把 a.jpg/x.php 之类的路径当作 PHP 解析。

所以防御的核心思路是白名单 + 重命名 + 内容校验 + 存储隔离,任何一个单点都不够看;前端那层校验只能算体验优化,因为攻击者可以完全绕开前端直接调接口。

Spring Security 实践

数据库动态权限怎么设计?为什么这样设计?那你 JWT 里放什么?缓存和数据库不一致怎么办?

RBAC 需要三层的表设计和关联表

1
2
3
4
5
sys_user            (id, username, password, status, tenant_id, ...)
sys_role (id, code, name, tenant_id, ...)
sys_permission (id, code, name, type, parent_id, ...) -- type: 目录/菜单/按钮/接口
sys_user_role (user_id, role_id)
sys_role_permission (role_id, permission_id)

权限点命名规范一般是模块:资源:操作,例如research:project:create,research:project:delete

实现方式是

  1. URL 级粗粒度:登录后把权限点写进 Authentication.authorities,requestMatchers 只做是否登录/是否属于某后端域的粗校验。

  2. 方法级细粒度:业务接口上用 @PreAuthorize("@ps.has('research:project:delete')"),其中 @ps 是自定义 Bean(PermissionService),每次校验时按需从缓存(Redis/Caffeine)查用户权限,而不是全部塞进 JWT。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Component("ps")
    public class PermissionService {

    private final PermissionCache permissionCache; // Redis + Caffeine 二级缓存

    /** 判断当前登录用户是否拥有某个权限点 */
    public boolean has(String permissionCode) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !auth.isAuthenticated()) {
    return false;
    }
    // 超级管理员直通
    if (auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))) {
    return true;
    }
    return permissionCache.getPermissions(currentUserId(auth)).contains(permissionCode);
    }
    }

    // 使用:注意 SpEL 里是字符串字面量,不是方法名
    @PreAuthorize("@ps.has('research:project:delete')")
    @DeleteMapping("/projects/{id}")
    public void deleteProject(@PathVariable Long id) { ... }

为什么这样设计

  • 权限变更不需要重新签发 JWT:因为权限不写在令牌里,而是每次查缓存,改权限后清缓存即可秒级生效。
  • 能让令牌体积缩小,几十个权限点全塞进 JWT 会让每个请求多带几百字节
  • 支持运行时改权限、临时授权等很多动态的功能

那你 JWT 里放什么?只放身份标识和稳定信息:userId、jti、签发/过期时间、tenantId(多租户必需)、tokenVersion(用于强制失效)。权限不放。

缓存和数据库不一致怎么办?所以说,改权限需要先改 DB,再删缓存。缓存降级时回源 DB 并短 TTL 兜底。极端情况下以 DB 为准,权限校验是宁可失败不可放过的。

微服务项目里是如何做鉴权的?内部服务调用怎么保证身份可信?多租户的鉴权和数据隔离是如何做的?多租户平台里,怎么防止 A 租户看到 B 租户的数据?

一般是网关统一鉴权:令牌只解析一次、业务服务不依赖安全框架、鉴权策略集中在一处,服务内不再各自验签。

不过我更倾向网关做认证和粗粒度授权,服务内做细粒度授权的分层方式

  • 网关:统一校验令牌(验签、过期、黑名单),剥离客户端伪造的身份头,注入可信的 X-User-Id、X-Tenant-Id、X-Roles,再做路由级白名单和全局限流。
  • 业务服务:从可信头还原 SecurityContext,做方法级权限(@PreAuthorize)和数据级权限(租户、归属校验)。
  • 认证中心:只负责签发、刷新和吊销令牌,业务服务里不含签发密钥。

代价是网关成为关键路径,所以网关必须多副本部署;而好处是密钥的分发面很小,私钥只有认证中心有,公钥只有网关和需要验签的服务有。

内部服务调用怎么保证身份可信

  1. 网络层隔离:服务只在私网暴露,安全组限制来源。
  2. 网关强制覆盖身份头:先删掉外部传进来的同名头,再用 set 覆盖,而不是 add 追加;客户端伪造的 X-User-Id 到不了业务服务。
  3. 内部签名令牌:调用方携带短时效的内部 JWT(限定 aud=internal),下游验签即可,比裸请求头更安全,而且本地验签不产生 IO。
  4. 关键操作二次校验:内网也不是绝对可信,涉及权限变更、资金的操作仍然要查库确认资源归属,这就是零信任的思路。

多租户的鉴权和数据隔离中,隔离模式一般有三种,中小型 SaaS 最常用第三种

模式 隔离级别 成本 适用
独立数据库 最高 最高 大客户、强合规要求
共享库 + 独立 Schema 中 中 中等规模 SaaS
共享库 + 共享表 + tenant_id 字段 逻辑隔离 最低 中小 SaaS(最常见)

防止 A 租户看到 B 租户的数据,关键在这几点

  • 租户 ID 只能来自令牌:从 claim 或网关注入的可信头取,绝不接受请求参数里的 tenantId。
  • 数据层自动注入:用 Hibernate 的 @TenantId / @Filter,或者 MyBatis-Plus 的 TenantLineInnerInterceptor,从机制上保证每条 SQL 都带上租户条件,而不是靠开发者记得写 where。
  • 唯一索引带上租户:写成 unique(tenant_id, code),而不是 unique(code)。
  • 缓存的 key 也要带租户:project:{tenantId}:{id},否则不同租户会读到同一份缓存,这是最隐蔽的越权点。
  • 异步和定时任务显式传租户:线程池里没有请求上下文,必须显式传递。
  • 用测试兜底:两个租户互访对方资源的用例放进 CI。

如果有超级管理员需要跨租户查数据,我会给它一条独立的、显式绕过的查询通道,配单独的权限点和全量审计,而不是在业务代码里到处都是 if (isSuperAdmin)。 一般是网关统一鉴权,令牌只解析一次、业务服务不依赖安全框架、策略集中

权限数据什么时候加载?改权限要重启或重新登录吗?

接口频控和动态 IP 黑名单怎么做的?

接口频控我用的是“注解 + AOP + Redis”的声明式方案,在业务方法上标注阈值即可

  • 维度组合:用户 + 接口 + IP。只按 IP 限会被 NAT 误伤,只按用户限挡不住未登录的刷接口,所以要组合。
  • 计数实现:Redis 的 INCR + EXPIRE 做固定窗口计数,用 Lua 脚本保证“自增”和“设置过期”两步的原子性,避免并发下 key 永不过期。
  • 超限响应:抛业务异常并返回 429(Too Many Requests),同时给前端一个可重试的提示,不要把流量异常暴露成 500。
  • 重点接口额外加固:登录、短信、导出这类高价值接口用多窗口(秒级 + 小时级)叠加限制,既拦突发也拦慢速刷取。

动态 IP 黑名单的流程是:风控判定(短时间内大量认证失败、异常地域、明显的爬虫特征)→ 写 Redis 的 ip:blacklist(带 TTL)→ 过滤器在最前面拦截并返回 403,同时在网关层同步一份做全局限流。

这里有两个坑一定要提前说明。一是 NAT 出口 IP 会被大量正常用户共享,所以 IP 维度的阈值要放宽、并且支持白名单,主要还是依赖用户维度;二是 IPv6 需要先做归一化,否则同一个地址的多种写法会绕过黑名单。

另外,Redis 故障时不能因为限流组件挂掉就把全站封死,所以我会降级成本地限流(RateLimiter 之类的单机令牌桶)加放行,限流本身就是防滥用的手段,不能反过来变成可用性故障。

JWT 结构是什么样的?JWT 已经签发了,你怎么让某个用户立刻下线?令牌注销与即时失效你是如何做的?

1
header.payload.signature          三段用 '.' 连接,各自 Base64Url 编码
段 内容 例子
Header 算法与类型 {"alg":"RS256","typ":"JWT","kid":"key-2026-01"}
Payload(Claims) 标准声明 + 自定义声明 iss、sub、aud、exp、nbf、iat、jti + userId、tenantId、tokenVersion
Signature 对前两段的签名 RS256(base64(header) + "." + base64(payload), privateKey)

因为 JWT 无状态,签发后服务端不认识它,无法单方面作废。

所以说,需要使用jti 黑名单:把该用户当前令牌的 jti 写入 Redis,TTL = 剩余有效期,鉴权时多一次 EXISTS 查询。这样就能精确踢单个会话。

tokenVersion 版本号是用户表存 token 版本号,令牌携带签发时的版本;改密码或者强制下线时 tokenVersion + 1 + 缓存更新,这样该用户所有令牌一次性失效。适合全端下线。

会话注册表是把 refresh token 的入库,吊销时标记 REVOKED,阻断刷新链路。

一般情况下使用 Redis 做JTI黑名单,配合会话注册表和tokenVersion 版本号

  • 先在 DB 会话注册表中标记

    表中把用户的refreshToken 记录标记为 REVOKED,并递增 token_version。这样就不能刷新了

  • Redis 拦截 access token

    把当前 access token 的 jti 写入 Redis 黑名单,TTL 设为令牌剩余有效期,同时更新 Redis 中的 tokenVersion 缓存,这样校验过滤器在读缓存的时候就能立即拒绝。

  • 兜底

    若第二步写 Redis 失败,把这次吊销动作落到补偿任务表,由 @Scheduled 指数退避重试,直到成功

你的第三方登录是如何做的?OAuth2 的授权模式清楚吗?扫码登录怎么实现?SSO 了解吗?

我用 OAuth2 的授权码模式拿到第三方授权,用 OIDC 的 id_token 做身份认证,id_token 和 access_token 的具体编码格式就是 JWT。

概念 是什么 不是什么
OAuth2 授权框架:让第三方在不拿到用户密码的前提下,获得有限、可撤销的授权 不是认证协议(虽然常被拿来登录)
OIDC 建立在 OAuth2 之上的认证层,多了 id_token(JWT)和 /userinfo 端点 不是 OAuth2 的替代品
JWT 一种令牌格式,自包含、可验签 不是协议,也不是加密
SSO 单点登录这个目标本身 是目标,不是协议

授权模式一共四种

模式 流程概要 适用 备注
授权码模式 前端拿 code,后端用 code + client_secret 换 token Web 服务端应用 最标准,code 只在前端过一手
授权码 + PKCE 在授权码基础上加 code_verifier / code_challenge SPA、原生 App 没有 client_secret 的公开客户端必须用
简化模式 直接在前端返回 token 已淘汰 令牌会暴露在 URL 和浏览器历史里
密码模式 客户端直接拿用户名密码换 token 遗留系统 OAuth2.1 已经移除,第一方应用也不建议用
客户端模式 服务端对服务端,无用户参与 内部服务调用、开放平台 机器身份场景

OAuth2 的真正价值在第三方接入和授权标准化。

实现时有四个安全点必须做到

  • state:随机值,和用户会话绑定,回调时校验,防 CSRF 和授权码注入。
  • redirect_uri:授权服务器必须精确匹配预注册地址,防开放重定向把 code 劫持走。
  • code:一次性、短时效(一般不超过 10 分钟),并且只能由后端去换 token。
  • PKCE:公开客户端没有 client_secret,必须用 PKCE 防止 code 被截获。

扫码登录怎么实现(核心思路是二维码只承载一次性标识,确认身份的是手机端已登录的 App)

  • 生成:PC 端请求二维码接口,服务端生成全局唯一的 qrId 写入 Redis(状态 WAITING,TTL 120 秒),返回二维码内容。
  • 感知:PC 端轮询查询状态,或者用 SSE / WebSocket 推送,体验更好。
  • 确认:手机 App 扫码后带上自己的有效令牌调确认接口,服务端校验 qrId 存在且状态为 WAITING,用 Lua 脚本原子地把状态改成 CONFIRMED 并绑定扫码用户。
  • 兑换:PC 端发现状态变化后调兑换接口,服务端用 GETDEL 原子地取出并删除,签发正式令牌。

SSO 我了解的是这几种方案:OIDC(现代标准)、CAS(高校和企业内网常见)、共享 Session(同主域下最简单)、SAML(企业级对接,实现复杂)。

我们项目是高校场景,通常会对接学校的统一身份认证。做法是认证中心完成对外部协议的对接,把外部身份映射到本地用户(首次登录自动建号或绑定),再签发我们自己的 JWT,后续所有服务只认内部令牌,这样外部协议不会渗透到业务服务里。

JWT 的令牌续期、防重放是怎么做的?CORS 和 Spring Security 怎么配合?什么是会话固定攻击?

令牌续期走的是双令牌方案,这也是目前的主流做法

维度 access token refresh token
时效 短(15 分钟 ~ 2 小时) 长(7 ~ 30 天)
存放 前端内存或 sessionStorage HttpOnly + Secure 的 Cookie,或服务端注册表
用途 访问业务接口 换取新的 access token
能否吊销 靠黑名单、版本号 必须能吊销,服务端存注册表

刷新流程是:access token 过期后接口返回 401 和可识别的错误码,前端拿 refresh token 调刷新接口,服务端校验签名、注册表未吊销、未过期之后签发新的 access token,并**轮换出新的 refresh token*,此时旧的立即失效,前端再用新令牌重放原请求。

这里有个并发刷新的坑:多个请求同时过期会同时触发刷新,而 refresh token 是一次性轮换的,后到的请求拿着已经作废的令牌就会失败。解决办法有两个——前端加单飞(single-flight)锁只发一次刷新,或者服务端对同一个 refresh token 给一个短暂的幂等宽限窗口。更严格的做法是记录“令牌家族”,发现已用过的令牌被再次使用就判定为盗用,直接吊销整个家族。

防重放分四层来看

  • 全站 HTTPS:从传输层防止令牌被截获,这是最基础的一层。
  • 短时效 + jti:令牌没有长期复用的空间,配合黑名单可以精确吊销。
  • 一次性 requestId / nonce:对支付、审批、批量删除这类高危写操作,要求客户端带请求 ID,服务端用 Redis 的 SETNX 原子占位,TTL 取业务幂等窗口(比如 5 分钟),重复请求直接拒绝。
  • 时间戳 + 签名:开放平台或内部服务调用场景,把 timestamp、nonce、请求体摘要一起参与 HMAC 签名,服务端校验时间窗(一般 ±5 分钟)并确认 nonce 未被使用过。

SETNX 占位之后的业务失败要处理干净:要么把 key 删掉允许重试(有并发风险),要么用幂等表加状态机——先落库“处理中”,成功改“成功”,失败改“失败”并按业务规则决定能否重试。后者更稳,也是我项目里用的方式。

CORS 和 Spring Security 的配合,主要是位置和凭证两个问题

  • 位置:CorsFilter 必须排在认证、授权过滤器之前,否则预检请求(OPTIONS)会因为未认证被拦成 401,浏览器就报跨域错误。用 http.cors(Customizer.withDefaults()) 让 Spring Security 把它插到正确位置,比自己 addFilterBefore 更可靠。
  • 凭证与通配符冲突:allowCredentials(true) 时 allowedOrigins 不能写成 *,要么用 allowedOriginPatterns,要么明确白名单。
  • 不要反射 Origin 头直接回写,那等于放开了所有来源。
1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("https://*.example.com")); // 白名单,不用 *
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN"));
config.setAllowCredentials(true); // 带 Cookie 必须开启
config.setMaxAge(3600L); // 预检结果缓存,减少 OPTIONS 请求

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}

生产上更省事的做法是用 Nginx 同域反向代理,从根上就没有跨域,CORS 配置只需要留给真正的外部调用方。

会话固定攻击(Session Fixation):攻击者先自己访问站点拿到一个合法的 JSESSIONID,再诱导受害者用这个 ID 去登录(比如通过 URL 参数或者预先设置 Cookie)。如果登录成功后 Session ID 不变,攻击者就能用同一个 ID 直接进入受害者的会话。

Spring Security 的防护是在认证成功时更换 session ID,也就是 changeSessionId()

策略 行为
changeSessionId(默认) 复用 Session 对象,只换 ID,保留属性
migrateSession 新建 Session 并复制全部属性(Servlet 3.0 及更早的默认)
newSession 新建 Session,只复制 Spring Security 需要的属性
none 不处理,禁止使用
1
http.sessionManagement(s -> s.sessionFixation(fix -> fix.changeSessionId()));

顺带说一句,这也是为什么登录接口不要用 GET、也不要把 sessionId 放在 URL 里——URL 会被记进浏览器历史、Referer 和服务器日志,等于把会话凭证往外送。

如果让你从零设计一个登录鉴权模块,你会怎么做?

我会按“先澄清需求,再定选型,最后补加固”的顺序来做

第一步,澄清需求:几端(Web、App、小程序)?是否需要第三方登录?是否需要踢人下线?是否多租户?并发量级?

第二步,选型:有强管控需求(踢人、权限实时生效)就用网关 + 认证中心 + JWT,配令牌注册表或黑名单;服务间调用用 client_credentials 或内部签名的 JWT。

第三步,定表:用户、角色、权限、会话/令牌注册表、登录日志。

第四步,认证流程:密码用 BCrypt 存储 → 做风险评估(异地、新设备)→ 需要就上 2FA → 签发短时效 access token 和长时效 refresh token。

第五步,授权分层:路由(网关)→ 功能(@PreAuthorize)→ 数据(租户、归属)→ 字段(脱敏),四层缺一层都会漏。

第六步,安全加固:频控、失败锁定、验证码、CSRF/XSS/注入防护、CSP、HTTPS 和 HSTS、Cookie 属性、审计日志。

第七步,可观测与运维:登录成功率、401/403 比例、黑名单命中数、补偿任务积压,这些都要有指标和告警。

第八步,测试:未授权、越权、令牌过期、篡改、重放这些用例进 CI。

我的判断标准可以总结成一句话:关键路径要有权威数据源,性能路径要有缓存,异常路径要有补偿。