newuser1
newuser1
发布于 2026-07-12 / 1 阅读
0
0

两块“小饼干”守住一次登录:用 AgentLog 彻底搞懂 JSESSIONID、XSRF-TOKEN 与 CSRF

面向未来忘得一干二净的我:这篇不要求你先记住 Session、Cookie、CSRF 或 Spring Security。我们从“浏览器替你保管两张小纸条”开始,一步一步走完页面启动、获取 CSRF Token、登录、访问受保护接口、提交写请求、刷新页面、登出和后端重启的完整生命周期,并把每一步对应到 AgentLog 的真实前后端代码。

JSESSIONID 与 XSRF-TOKEN 的职责分工

一、先记住这个儿童版故事

想象你去游乐园:

  • JSESSIONID 是寄存柜号码牌。号码牌上没有你的全部资料,但工作人员能拿号码去柜台后台找到“你是谁、已经登录、有什么权限”。

  • XSRF-TOKEN 是一张需要你亲手抄写的随机口令。工作人员要求:浏览器自动递来的口令和你主动写在申请表上的口令必须一样。

为什么需要第二张纸?因为浏览器有一个既方便又危险的习惯:访问某个网站时,会自动携带属于该网站的 Cookie。坏网站虽然通常看不到 Cookie 内容,却可能诱导浏览器替它向好网站发请求。

于是后端说:

“光自动带来 Cookie 不够。写操作还必须把一个坏网站读不到的值,主动放进 X-XSRF-TOKEN 请求头。”

正常前端能读到本网站的 XSRF-TOKEN,坏网站受同源策略限制,通常读不到,也不能用普通 HTML 表单添加自定义请求头。这就是 CSRF 防护最核心的思路。

先把结论钉在墙上:

名称

放在哪里

谁保存

解决什么问题

JSESSIONID

Cookie

浏览器保存编号,服务端保存 Session 内容

“你是谁、是否已登录”

XSRF-TOKEN

Cookie

当前项目主要由浏览器保存

“浏览器自动带 Cookie 还不够,请求方还得知道 token 值”

X-XSRF-TOKEN

HTTP 请求头

前端每次写请求临时添加

把 token 通过不会自动携带的第二条通道交给后端

XSRF-TOKEN 不是用户身份证,也不是登录凭证。只有它而没有有效 JSESSIONID,依然不能冒充登录用户。


二、先认识整个项目中的参与者

浏览器端

浏览器负责四件事:

  1. 保存后端通过 Set-Cookie 下发的 Cookie。

  2. 请求同一目标域名时,按照 Domain、Path、Secure、SameSite 等规则自动携带 Cookie。

  3. 执行 Vue 和 axios 代码。

  4. 受同源策略和 CORS 规则约束,不允许其他网站随便读取本网站的数据。

注意:Cookie 按域名和路径隔离,不按端口隔离。开发环境中:

前端:http://localhost:5173
后端:http://localhost:8080

二者端口不同,但主机名都是 localhost。路径为 / 的 Cookie 可能被带给两个端口。生产环境应通过 HTTPS、正确的 Cookie 属性和反向代理统一规划,而不是照搬本地开发配置。

Vue 前端

当前 AgentLog 前端有四个关键位置:

web/src/main.ts
  页面启动后调用 refreshCsrfToken()

web/src/api/csrf.ts
  请求 /api/v1/web/csrf
  把响应中的 token 和 headerName 保存到 JS 内存

web/src/api/http.ts
  创建公共 axios 实例
  自动携带 Cookie
  为 POST/PUT/PATCH/DELETE 添加 X-XSRF-TOKEN

web/src/stores/session.ts
  编排登录、刷新登录态、登出以及登录/登出后的 CSRF 刷新

Vite 开发代理

web/vite.config.ts 将浏览器发往 /api 的请求转发给 localhost:8080

server: {
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,
    },
  },
}

所以浏览器代码只写:

GET /api/v1/web/csrf

Vite 在开发环境中负责把它转交给 Spring Boot。

Spring Security

后端最重要的安全配置位于:

backend/src/main/java/com/agentlog/shared/security/ApiSecurityConfiguration.java

这里启用了:

CookieCsrfTokenRepository csrfTokenRepository =
        CookieCsrfTokenRepository.withHttpOnlyFalse();

CsrfTokenRequestAttributeHandler csrfRequestHandler =
        new CsrfTokenRequestAttributeHandler();

并配置:

.csrf(csrf -> csrf
    .csrfTokenRepository(csrfTokenRepository)
    .csrfTokenRequestHandler(csrfRequestHandler)
    .ignoringRequestMatchers(...))

通俗翻译:

  • CookieCsrfTokenRepository:负责生成、读取、写入和清除 CSRF Token。

  • CsrfTokenRequestAttributeHandler:负责从当前请求中解析客户端提交的 token。

  • CsrfFilter:在请求到达 Controller 之前执行安全检查。

  • ignoringRequestMatchers:指定哪些路径完全跳过 CSRF 检查。

业务 Controller

两个 Controller 承担主要编排工作:

CsrfController
  GET /api/v1/web/csrf
  让前端获得当前 CSRF Token

WebAuthController
  POST /api/v1/web/auth/login
  POST /api/v1/web/auth/logout
  GET  /api/v1/web/me

三、生命周期 1:第一次打开页面,CSRF Token 怎样出生

第一次获取 CSRF Token

第 1 步:Vue 页面先挂载

web/src/main.ts 创建 Vue 应用:

createApp(App)
  .use(createPinia())
  .use(router)
  .use(ElementPlus)
  .mount('#app')

然后调用:

refreshCsrfToken().catch(() => {
  // 后端没启动时不阻塞纯前端页面
})

这里要注意:当前代码是“先挂页面,再异步获取 token”,不是等待 token 获取成功后才显示页面。因此后端刚启动很慢、用户又立刻点击写操作时,理论上存在 token 尚未准备好的短暂窗口。成熟实现可以在应用启动阶段统一等待,或者在写按钮上增加准备状态。

第 2 步:csrf.ts 请求后端

web/src/api/csrf.ts

let csrfToken = ''
let csrfHeaderName = 'X-XSRF-TOKEN'

export async function refreshCsrfToken(): Promise<void> {
  const { data } = await axios.get('/api/v1/web/csrf', {
    withCredentials: true,
  })
  csrfToken = data.token
  csrfHeaderName = data.headerName
}

这里有两个存储位置:

浏览器 Cookie 中:XSRF-TOKEN=abc
Vue 模块内存中:csrfToken='abc'

Cookie 中的一份用于让后端读取;JS 内存中的一份用于以后塞进请求头。

第 3 步:请求先经过 CsrfFilter

请求是:

GET /api/v1/web/csrf

GET 被认为是安全方法,正常情况下不修改服务端数据,所以 CsrfFilter 不要求请求头里带 CSRF Token。

“不要求校验”和“没有 CSRF Token 对象”不是一回事。过滤器仍然可以为当前请求准备一个延迟加载的 CsrfToken

第 4 步:Controller 访问 token,触发加载或生成

CsrfController

@GetMapping("/csrf")
public CsrfTokenResponse csrf(CsrfToken token) {
    return new CsrfTokenResponse(
            token.getHeaderName(),
            token.getToken());
}

Spring 把当前请求的 CsrfToken 注入参数。Controller 调用 token.getToken() 时,仓库必须给出真实 token:

Cookie 中已有 XSRF-TOKEN?
├─ 有:加载旧值
└─ 没有:随机生成新值,并写入响应 Cookie

第一次响应类似:

HTTP/1.1 200 OK
Set-Cookie: XSRF-TOKEN=9461d648-eb43-4b39-aa18-860522702cc1; Path=/
Content-Type: application/json

{
  "headerName": "X-XSRF-TOKEN",
  "token": "9461d648-eb43-4b39-aa18-860522702cc1"
}

第 5 步:浏览器和前端分别保存

浏览器看到 Set-Cookie,自动保存 XSRF-TOKEN

axios 读取 JSON 响应体,执行:

csrfToken = data.token
csrfHeaderName = data.headerName

到这里,前端已经为后续写请求准备完毕。


第二次请求时浏览器已经自动带上:

Cookie: XSRF-TOKEN=9461d648-...

仓库从 Cookie 加载旧 token,因此响应通常是:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "headerName": "X-XSRF-TOKEN",
  "token": "9461d648-..."
}

响应体仍有 token,但响应头不再需要重复发送 Set-Cookie

在 Chrome DevTools 中要区分:

Response / Preview
  看 JSON 里的 token

Headers → Response Headers
  看后端这一次有没有重新 Set-Cookie

Application → Cookies
  看浏览器当前实际保存的 Cookie

三个容易混淆的名字:

XSRF-TOKEN       Cookie 名
X-XSRF-TOKEN     请求头名
token             /csrf 响应 JSON 字段名

没有 X-XSRF-TOKEN 请求头不会导致 /csrf 生成新 token。真正决定“加载旧的还是生成新的”的是:请求 Cookie 中有没有 XSRF-TOKEN


五、生命周期 2:登录时 JSESSIONID 怎样出生

登录与 Session 建立

第 1 步:Pinia 发起登录

web/src/stores/session.ts

async login(email: string, password: string) {
  this.user = (
    await authApi.loginWeb({ email, password })
  ).data
  this.checked = true
  await refreshCsrfToken()
}

authApi 使用项目公共 httpClienthttpClient 配置了:

export const httpClient = axios.create({
  baseURL: '',
  withCredentials: true,
})

withCredentials: true 表示允许 axios 在符合浏览器规则时携带 Cookie。在当前同源代理开发方式下,它也让“登录后依靠 Cookie 维持身份”的意图非常明确。

第 2 步:当前项目对登录路径跳过 CSRF

安全配置中:

.ignoringRequestMatchers(
    "/api/v1/web/auth/register",
    "/api/v1/web/auth/login",
    "/api/v1/web/auth/send-code",
    "/api/v1/web/csrf",
    ...)

因此登录请求虽然是 POST,当前配置不会检查 CSRF Token。

这和 permitAll() 不是一回事:

配置

作用

permitAll()

不要求用户先登录,但 CSRF 过滤器仍可能执行

ignoringRequestMatchers()

对指定路径直接跳过 CSRF 校验

当前前端启动时本来就能匿名获取 CSRF Token,所以从更严格的设计看,登录接口不是绝对必须豁免。保护登录可以防“登录 CSRF”:攻击者诱导受害者登录攻击者自己的账号,受害者随后把自己的隐私数据误写进攻击者账号。

不过当前登录只接受 JSON,跨站 JavaScript 设置 application/json 会触发 CORS 预检,普通 HTML 表单也不能按相同格式提交,这又构成了一层现实防护。安全设计应综合 CSRF、CORS、Content-Type、SameSite、限流,而不是只看一个开关。

第 3 步:AuthenticationManager 验证账号密码

WebAuthController 构造一个尚未认证的凭据对象:

Authentication authentication =
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                email,
                request.password()));

然后 Spring Security 调用 AppUserDetailsService.loadUserByUsername()

虽然接口方法名叫 loadUserByUsername,当前参数实际放的是 email:

UserAccount account = userAccountMapper.selectOne(
    Wrappers.<UserAccount>lambdaQuery()
        .eq(UserAccount::getEmail, username));

查到用户后,返回:

return new User(
    String.valueOf(account.getId()),
    account.getPasswordHash(),
    List.of(new SimpleGrantedAuthority("ROLE_USER")));

PasswordEncoder 使用 BCrypt,把前端明文密码和数据库哈希进行安全比对。成功后得到已认证 Authentication

第 4 步:建立 SecurityContext

Controller 创建上下文并放入认证结果:

SecurityContext context =
        SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

可以把 SecurityContext 理解成当前请求线程身边的小档案袋:

当前用户 ID
是否已认证
角色 ROLE_USER

只放进 SecurityContextHolder 还不够,因为请求结束后线程会归还线程池。下次请求换一个线程,这份身份就丢了。

第 5 步:把身份保存进 HttpSession

项目显式调用:

securityContextRepository.saveContext(
    context,
    httpRequest,
    httpResponse);

这里使用 HttpSessionSecurityContextRepository。它会把 SecurityContext 放进 HttpSession

如果请求之前没有 Session,Servlet 容器创建一个,并通过响应告诉浏览器:

Set-Cookie: JSESSIONID=7A19F...; Path=/; HttpOnly

浏览器只保存 JSESSIONID 这个号码。真正的 SecurityContext 在服务端 Session 中。

当前项目没有配置 Spring Session Redis,因此默认嵌入式 Tomcat 通常把 Session 放在当前 JVM 内存管理结构中:

JSESSIONID=7A19F...
        ↓
Tomcat JVM 内存中的 HttpSession
        ↓
SPRING_SECURITY_CONTEXT
        ↓
Authentication(userId, ROLE_USER, authenticated=true)

后端重启后,默认内存 Session 消失,旧 JSESSIONID 即使仍在浏览器,也查不到登录状态。


六、生命周期 3:刷新页面为什么仍然登录

页面刷新后:

Vue / Pinia 内存全部清空
csrf.ts 中的 csrfToken 重新变成空字符串
浏览器 Cookie 通常还在
服务端 HttpSession 通常还在

接下来发生两条恢复链。

登录身份恢复链

前端调用:

GET /api/v1/web/me
Cookie: JSESSIONID=7A19F...

Spring Security 在 Controller 之前根据 JSESSIONID 找到 HttpSession,再从 Session 中恢复 SecurityContext,放回当前请求的 SecurityContextHolder

所以 CurrentUser.requireId() 能取到当前用户 ID。

CSRF 内存恢复链

前端重新调用:

GET /api/v1/web/csrf
Cookie: XSRF-TOKEN=9461d648-...

后端从 Cookie 加载旧 token,并通过 JSON 返回。csrf.ts 再把它放回 JS 内存。

这就是为什么页面刷新后不一定生成新 CSRF Token,但前端仍需要调用 /csrf

不是为了保证“换新”,而是为了把浏览器 Cookie 中现有的 token 重新同步进已经清空的 JavaScript 内存。


七、生命周期 4:一次受保护写请求怎样过五关

受保护写请求的双通道校验

假设用户修改头像:

PUT /api/v1/web/me/avatar
Content-Type: application/json
Cookie: JSESSIONID=7A19F...; XSRF-TOKEN=9461d648-...
X-XSRF-TOKEN: 9461d648-...

{"mediaId":"..."}

第 1 关:axios 请求拦截器添加请求头

web/src/api/http.ts

httpClient.interceptors.request.use((config) => {
  const method = (config.method ?? 'get').toLowerCase()
  if (['post', 'put', 'patch', 'delete'].includes(method)) {
    config.headers.set(
      getCsrfHeaderName(),
      getCsrfToken(),
    )
  }
  return config
})

它只给 unsafe 方法添加 CSRF 请求头:

GET/HEAD/OPTIONS/TRACE:按约定不得修改服务端数据,通常不校验
POST/PUT/PATCH/DELETE:可能修改数据,必须校验

这条约定很重要。如果后端写了一个“点击 GET 就删除数据”的接口,CSRF 框架会把它当安全请求跳过校验。因此 GET 必须保持只读,不能靠方法名自我安慰。

JavaScript 主动添加的是请求头;Cookie 则由浏览器根据规则自动携带:

主动通道:X-XSRF-TOKEN 请求头
自动通道:XSRF-TOKEN Cookie

这就是 Double Submit Cookie 中“双提交”的含义。

第 3 关:CSRF 校验

CsrfFilter 在 Controller 之前执行:

从 CookieCsrfTokenRepository 加载期望值 expected
从 X-XSRF-TOKEN 请求头解析实际值 actual
比较 expected 与 actual

结果:

缺少 Cookie       → 403
缺少请求头         → 403
两者不相同         → 403
两者相同           → 继续

第 4 关:Session 认证

Spring Security 根据 JSESSIONID 恢复身份:

没有有效 Session → 401,意思是“我不知道你是谁”
有有效 Session   → 继续授权判断

第 5 关:业务授权和 Controller

认证只回答“你是谁”,授权还要回答“你能否修改这份资源”。例如行级授权必须继续检查当前用户是否拥有目标内容。

CSRF Token 通过也不等于授权通过。它们负责不同层次:

JSESSIONID / Authentication:你是谁
CSRF:是不是可疑的跨站自动请求
Authorization:你能不能操作这条数据
Validation:你提交的数据是否合法

八、攻击者视角:为什么坏网站通常抄不到第二份口令

正常请求与 CSRF 攻击对比

假设用户登录了:

https://zakuku.top

随后打开:

https://evil.example

恶意页面可以尝试:

<form action="https://zakuku.top/api/delete" method="post">
  ...
</form>

浏览器可能自动携带属于 zakuku.top 的 Cookie,具体还受 SameSite 等规则影响。

但坏网站通常面临三堵墙:

  1. 同源策略evil.example 的 JavaScript 不能读取 zakuku.topdocument.cookie

  2. 自定义请求头限制:普通 HTML 表单不能添加 X-XSRF-TOKEN

  3. CORS 预检:跨站 fetch 想带自定义头,浏览器先发 OPTIONS;后端没有允许该 Origin 时,正式请求无法发送或响应不可被脚本使用。

于是攻击请求通常只能做到:

Cookie: JSESSIONID=...; XSRF-TOKEN=abc

却做不到:

X-XSRF-TOKEN: abc

后端返回 403 Forbidden

如果坏网站真的能读取 XSRF-TOKEN 呢

这通常意味着发生了更严重的问题:

  • 本站存在 XSS;

  • 浏览器安装了恶意扩展;

  • Cookie Domain 配得过宽,被不可信子域影响;

  • CORS 同时错误允许任意或恶意 Origin 携带凭据;

  • 用户设备或代理被攻陷。

尤其是 XSS:攻击脚本已经运行在本网站源下,它不但能读 XSRF-TOKEN,还可以直接调用本站接口。CSRF 不是用来防 XSS 的。

记忆口诀:

CSRF 防“借你的浏览器自动发请求”
XSS 防“坏代码已经住进你的网站”

九、CookieCsrfTokenRepository 到底把 token 存在哪里

当前项目不是把 CSRF Token 放进 JVM Map。

CookieCsrfTokenRepository 是无服务端状态的仓库实现。跨请求持久保存 token 的主体是浏览器 Cookie:

浏览器 Cookie:长期保存 XSRF-TOKEN
前端 JS 内存:当前页面期间保存一份副本
后端请求对象:本次请求期间临时持有 CsrfToken
后端全局 Map:没有为每个用户保存 CSRF Token

“Repository”只是统一抽象,不代表一定有数据库或 Map。它可以:

  • 从 Cookie 加载;

  • 向响应写 Cookie;

  • 随机生成 token;

  • 通过过期 Cookie 命令浏览器删除。

新 token 不是根据 Session ID 或用户 ID 计算出来的,思路近似:

UUID.randomUUID().toString()

它是不可预测的随机值,而不是:

hash(sessionId)

如果需要清除,服务端不会删除 Map 中的一条记录,而是响应:

Set-Cookie: XSRF-TOKEN=; Max-Age=0; Path=/

浏览器收到后删除 Cookie。

如果换成 HttpSessionCsrfTokenRepository

另一种实现会把 CSRF Token 保存在 HttpSession:

浏览器只提交 token
服务端 Session 保存期望 token
后端比较“Session 中的期望值”和“请求提交值”

这叫同步器 Token 模式。它更直接地绑定 Session,但服务端需要保存状态。Cookie Double Submit 则减少服务端 CSRF 状态,但更依赖 Cookie 域、跨站规则和 token 不被读取或注入。


十、登出、后端重启与 token 轮换

当前登出代码

WebAuthController

@PostMapping("/auth/logout")
public void logout(HttpServletRequest httpRequest) {
    httpRequest.getSession().invalidate();
    SecurityContextHolder.clearContext();
}

它明确做了两件事:

  1. 让当前 HttpSession 失效;

  2. 清空当前线程中的 SecurityContextHolder

但当前代码没有显式清除 XSRF-TOKEN Cookie。因此:

JSESSIONID 对应的服务端 Session:失效
XSRF-TOKEN Cookie:可能继续存在

前端登出后调用 refreshCsrfToken(),不代表后端必然生成新 token。只要旧 Cookie 还在,CookieCsrfTokenRepository 就可能重新加载旧值。

后端重启

默认内存 Session 的典型结果:

JVM 重启
├─ HttpSession 丢失 → 用户登录失效
├─ 浏览器 JSESSIONID 可能暂时还在,但服务端找不到
└─ 浏览器 XSRF-TOKEN 可能还在,/csrf 仍可返回它

CSRF Token 还在不会让用户保持登录,因为真正的登录身份在服务端 Session。

最佳实践:身份边界变化时轮换 CSRF Token

更严格的做法是在登录成功和登出时清除旧 CSRF Cookie:

csrfTokenRepository.saveToken(null, request, response);

然后前端重新请求 /csrf,拿到新 token。

Spring Security 的标准认证、登出流程可以通过 CsrfAuthenticationStrategyCsrfLogoutHandler 做这类清理。当前项目自己在 Controller 中认证并手工登出,需要显式接入相应逻辑。

所以 csrf.ts 注释里的“token 随会话变化”应理解成设计期望,而不是仅凭当前代码就能保证的事实。


十一、那条一直挂着的 101 请求是什么

开发者工具中可能看到:

ws://localhost:5173/?token=-1eq69xSHpTg
Status: 101 Switching Protocols

它是 Vite 开发服务器的热更新 WebSocket,不是 Spring Boot 的 /csrf 请求。

生命周期:

浏览器先发 HTTP Upgrade 请求
→ Vite 返回 101 Switching Protocols
→ HTTP 连接升级为 WebSocket
→ 页面打开期间持续保持
→ 文件变化时 Vite 推送热更新消息
→ 关闭页面或停止 Vite 后断开

101 不是“后端卡住了”,而是“升级成功,长连接正在正常工作”。查询参数里的 token 是 Vite 自己的 WebSocket 校验 token,与 JSESSIONIDXSRF-TOKENX-XSRF-TOKEN 都无关。

生产环境使用 vite build 后不会保留这条 HMR WebSocket。

如果未来开发的是业务 WebSocket,并且它使用 Cookie Session 认证,则要额外验证 Origin 或握手 token。普通 HTTP CSRF 校验不会自动保护每一条 WebSocket 消息。


十二、当前 AgentLog 实现的安全审视

已经做对的部分

  1. 使用 Session Cookie 保存 Web 登录态,没有把长期 JWT 暴露给普通页面脚本。

  2. JSESSIONIDXSRF-TOKEN 职责分离。

  3. 写请求由 axios 拦截器统一添加 CSRF 请求头,避免每个页面重复手写。

  4. GET 和写请求的语义分离。

  5. 登录后的 SecurityContext 被显式保存进 HttpSession。

  6. CLI 这类不依赖浏览器 Cookie 的接口与 Web Session 思路分开。

建议继续改进的部分

  1. 登录、登出后显式清除并轮换 CSRF Token。

  2. 重新评估登录、注册、发送验证码是否必须全部 CSRF 豁免。

  3. /csrf 本身是 GET,请求方法天然不做 CSRF 校验,配置在 ignore 列表里没有太大必要。

  4. 应用启动时等待 CSRF 初始化,或禁止 token 未就绪时发写请求。

  5. 生产环境明确设置 Cookie 的 SecureSameSite、Domain、Path、有效期策略。

  6. 为“缺 Cookie、缺 Header、Header 不一致、登出后旧 Session、后端重启”等场景增加贴近真实浏览器行为的集成测试。

  7. 如果未来多实例部署,应使用 Spring Session Redis 或其他共享 Session 存储,否则请求落到不同实例可能找不到登录态。


十三、一张表记住全部生命周期

时刻

浏览器 Cookie

Vue 内存

服务端内存

发生什么

首次打开页面前

无当前 Session

还是游客

第一次 /csrf

XSRF-TOKEN

有 token 副本

无 CSRF 用户 Map

为写请求做好准备

登录成功后

XSRF-TOKENJSESSIONID

有 token、user

HttpSession 中有 SecurityContext

成为登录用户

普通 GET

自动带 Cookie

不必加 CSRF 头

根据 Session 恢复身份

只读请求

写请求

自动带两个 Cookie

主动加 X-XSRF-TOKEN

校验 CSRF、认证、授权

通过后修改数据

刷新页面

Cookie 还在

全部清空

Session 还在

/me/csrf 恢复

当前实现登出后

XSRF 可能还在

user 清空,再拉 token

Session 失效

失去登录身份

默认后端重启后

Cookie 可能还在

取决于页面是否刷新

内存 Session 丢失

必须重新登录


十四、精华总结:只背这 12 句话

  1. Cookie 是浏览器保存并按规则自动携带的小数据,不等于 Session。

  2. Session 的真实内容在服务端,JSESSIONID 只是查找它的编号。

  3. JSESSIONID 回答“你是谁”,CSRF Token 不负责登录认证。

  4. CSRF 攻击利用的是浏览器自动携带 Cookie 的特点。

  5. XSRF-TOKEN 放在 Cookie,X-XSRF-TOKEN 放在请求头。

  6. 两者相等只能证明请求方能获得 token 并主动提交,不是数学上证明“同一个浏览器”。

  7. CookieCsrfTokenRepository 不为每个用户在 JVM Map 中保存 token,token 主要保存在浏览器 Cookie。

  8. /csrf 是否生成新 token,看请求 Cookie 中有没有旧 token,不看请求头有没有 token。

  9. 第二次 /csrf 通常仍返回 JSON token,但不会重复 Set-Cookie

  10. 页面刷新会清空 JS 内存,所以即使 Cookie 还在,也要再调 /csrf 把 token 同步回来。

  11. CSRF 防跨站请求伪造,不防已经进入本站执行的 XSS。

  12. 101 的 Vite WebSocket 是热更新长连接,与业务 Session、CSRF 无关。


十五、常见 QA(按实际遇到频率排序)

Q1:为什么 /csrf 请求没有 X-XSRF-TOKEN 请求头也能成功?

因为它是 GET,属于安全方法,不要求 CSRF 校验。这个接口的用途恰恰是让前端获得 token。

Q2:后续 /csrf 为什么没有返回 XSRF-TOKEN

先确认你看的是哪里。响应头可能没有新的 Set-Cookie,但响应 JSON 仍应有 token 字段。因为浏览器已有 Cookie,后端直接复用,不必重复设置。

Q3:每调用一次 /csrf 都会生成新 token 吗?

不会。Cookie 中已有 token 就加载旧值;没有才随机生成。

Q4:CSRF Token 存在 JVM Map 吗?

当前 CookieCsrfTokenRepository 不保存跨请求的服务端 token Map。浏览器 Cookie 保存 token。换成 HttpSessionCsrfTokenRepository 才会把期望值放进 Session。

Q5:XSRF-TOKEN 为什么不能设置 HttpOnly?

当前前端需要获得 token 值并放进自定义请求头,所以它必须能被同源前端代码获得。真正的登录 Cookie JSESSIONID 应保持 HttpOnly。

Q6:别的网站能让浏览器携带 Cookie,为什么不能直接攻击?

因为它通常读不到目标网站的 token,也不能通过普通表单设置 X-XSRF-TOKEN 自定义头。跨站 fetch 还受 CORS 预检限制。

Q7:如果攻击者知道 XSRF-TOKEN,是不是就能攻击?

还要看它能否让浏览器带上有效登录 Cookie、能否跨域发出带自定义头的请求。但能读取目标站点 Cookie 往往已经意味着 XSS、恶意扩展或严重配置问题,安全边界已明显失守。

Q8:为什么 GET 不检查 CSRF?

HTTP 约定 GET 应只读。框架基于方法判断。如果 GET 接口修改数据,就是接口设计错误,也会破坏 CSRF 防护假设。

Q9:permitAll() 是否等于关闭 CSRF?

不等于。permitAll() 只是不要求登录;CSRF 过滤器可能更早执行。真正跳过 CSRF 的是 ignoringRequestMatchers() 或禁用 CSRF。

Q10:为什么退出登录以后 XSRF-TOKEN 还在?

因为 Session 失效和 CSRF Cookie 删除是两件事。当前代码失效了 HttpSession,但没有明确发过期 Cookie 删除 XSRF Token。

Q11:后端重启后,为什么 CSRF Token 还在但登录没了?

浏览器 Cookie 没有随着 JVM 重启自动消失;默认内存 HttpSession 却消失了。CSRF Token 不是登录凭证,所以不能恢复身份。

Q12:SameSite 能不能完全替代 CSRF Token?

不建议。SameSite 是很重要的浏览器侧防线,但要考虑旧客户端、顶级导航、同站子域、业务跨站需求和配置错误。高价值写操作仍适合多层防护。

Q13:CORS 和 CSRF 是一回事吗?

不是。CORS 控制其他 Origin 的 JavaScript 能否跨域读取或发出某些请求;CSRF 防止浏览器借登录 Cookie 执行非本人意愿的操作。它们相关但职责不同。

Q14:Vite 的 ws://localhost:5173/?token=... 是 CSRF Token 吗?

不是。它是 Vite HMR WebSocket 的连接 token。状态 101 表示升级成长连接成功。


十六、后端岗常见面试题(按常考程度排序)

第一梯队:必须会

答题骨架:

  • Cookie 是浏览器保存、随请求携带的数据机制。

  • Session 是服务端保存的会话状态。

  • JSESSIONID 常作为 Cookie 保存,它只是 Session ID。

  • 默认内存 Session 不适合直接支撑多实例,应考虑共享存储、粘性会话或无状态方案。

答题骨架:

  • 浏览器会自动携带目标站点 Cookie。

  • 攻击者诱导已登录用户向目标站点发写请求。

  • 后端误以为请求来自用户本人。

  • 防护包括 CSRF Token、SameSite、Origin/Referer 检查、正确的 HTTP 方法和二次确认。

答题骨架:

  • token 作为 Cookie 自动携带一份;

  • 前端再把同一个值放进自定义请求头或表单字段;

  • 后端比较两份;

  • 攻击站点通常能触发 Cookie 自动携带,却读不到 token、也不能添加自定义头。

4. 401403 有什么区别?

答题骨架:

  • 401 Unauthorized 实际表达“尚未认证或认证凭证无效”。

  • 403 Forbidden 表达“服务器理解请求,但不允许”。

  • CSRF 缺失或不匹配通常返回 403。

  • 已登录但没有资源权限也应返回 403。

5. XSS 和 CSRF 有什么区别?

答题骨架:

  • CSRF:攻击代码通常在其他网站,借浏览器自动 Cookie 发请求。

  • XSS:攻击脚本已经在目标网站源下执行。

  • CSRF Token 通常挡不住 XSS,因为 XSS 可以读取 token 并正常调用接口。

第二梯队:Spring 后端岗位高频

6. Spring Security 中 permitAll() 为什么仍可能被 CSRF 拦截?

因为授权过滤和 CSRF 过滤是不同环节。permitAll() 只影响授权规则,不能自动跳过 CsrfFilter。要跳过需配置忽略匹配器,但应谨慎缩小范围。

7. CookieCsrfTokenRepositoryHttpSessionCsrfTokenRepository 有什么区别?

前者把 token 持久化在客户端 Cookie,服务端无需按用户保存 CSRF 状态,适合同源 SPA;后者把期望 token 放在 Session,更直接绑定会话,但增加服务端状态。两者都要考虑轮换、XSS、SameSite 和 Cookie 域安全。

8. Spring Security 如何让刷新页面后仍然登录?

登录成功后将 Authentication 放进 SecurityContext,再由 SecurityContextRepository 保存进 HttpSession。后续请求通过 JSESSIONID 找到 Session,安全过滤器恢复 SecurityContext

9. 为什么登录成功后建议轮换 Session ID?

防止 Session Fixation。攻击者若提前让受害者使用一个已知 Session ID,登录后不轮换可能导致攻击者复用。Spring Security 标准流程通常提供 Session 固定攻击防护,自定义登录流程必须确认相应策略是否真正执行。

10. 为什么登录和登出后建议轮换 CSRF Token?

身份边界发生变化时废弃旧 token,可以降低 token 固定、跨身份复用和旧页面继续提交的风险。自定义 Controller 认证/登出时要显式调用仓库或相应 Handler。

第三梯队:进阶与架构题

11. 多实例部署时 HttpSession 有什么问题?

请求第一次落到实例 A,Session 在 A 内存;下一次落到 B,B 找不到。解决方案包括 Spring Session Redis、负载均衡粘性会话,或改成经过权衡的无状态认证。共享 Session 更容易立即撤销,但引入 Redis 可用性和网络开销。

12. SameSite 的 Strict、Lax、None 有什么差别?

  • Strict:跨站场景最严格,可能影响外部链接登录回跳。

  • Lax:兼顾常见顶级导航,现代浏览器常作为默认趋向。

  • None:允许跨站携带,必须配合 Secure,风险与业务需求都要明确。

实际回答要结合“跨站”和“同站”的区别:子域可能跨 Origin,但仍属于同一个 Site。

13. CORS 配置中为什么不能随便同时使用通配 Origin 和凭据?

携带 Cookie 的跨域请求必须精确评估可信 Origin。若反射任意 Origin 并允许 credentials,恶意站点可能读取用户身份下的响应,破坏浏览器同源安全边界。

  • 依赖攻击者不能读取或注入目标 Cookie;

  • 过宽 Domain 或不可信子域会放大风险;

  • XSS 可以直接绕过;

  • 需要正确配置 CORS、SameSite、Secure;

  • 更强设计可以把 token 与会话绑定,或采用带服务端秘密签名的 Double Submit Token。

15. WebSocket 为什么也可能出现类似 CSRF 的问题?

WebSocket HTTP 握手可能自动携带 Cookie,但建立连接后消息不再是普通 HTTP 请求。服务端应验证握手 Origin、使用一次性连接 token,并对每条业务消息继续做认证和授权,防止 Cross-Site WebSocket Hijacking。


十七、面试时用 AgentLog 做 90 秒项目表达

AgentLog 的 Web 端采用 Spring Security Session 认证。登录成功后,我把 Authentication 放进 SecurityContext,再通过 HttpSessionSecurityContextRepository 持久化,浏览器持有 HttpOnly 的 JSESSIONID。因为 Cookie 会被浏览器自动携带,所以写请求启用了 CSRF 防护。前端启动时访问 /api/v1/web/csrf,后端的 CookieCsrfTokenRepository 下发 JS 可读的 XSRF-TOKEN,Vue 把响应 token 暂存在内存,axios 拦截器为 POST、PUT、PATCH、DELETE 自动添加 X-XSRF-TOKEN。后端 CsrfFilter 比较 Cookie 和 Header,随后再做 Session 认证和业务授权。这个实现属于 Cookie Double Submit。进一步审视时,我发现自定义登录和登出流程没有显式清除 CSRF Cookie,因此登录后再次请求 /csrf 未必会轮换 token;生产化时我会接入 CsrfAuthenticationStrategyCsrfLogoutHandler 或显式清理仓库,并完善 Secure、SameSite、多实例 Session Redis 和真实浏览器集成测试。

这段回答同时覆盖:认证、会话、CSRF、前后端协作、过滤器顺序、现状审计和生产化思考。


结语

以后如果又忘了,只沿着两条线追:

身份线:JSESSIONID → HttpSession → SecurityContext → Authentication

防伪线:XSRF-TOKEN Cookie → Vue 内存 → X-XSRF-TOKEN Header
       → CsrfFilter 比对

第一条线回答“你是谁”,第二条线回答“这不像是别的网站借你的浏览器偷偷发的写请求”。两条线都通过,才轮到 Controller 处理真正的业务。


评论