认证、Session 与 JWT:登录状态的建立与撤销
登录成功后,客户端会在后续请求中携带会话标识或访问令牌:
Cookie: SEC15SESSION=<随机会话 ID>Authorization: Bearer <access token>第一种常用随机 ID 查找服务端会话,第二种把令牌交给资源服务验证。Bearer 令牌可以是 JWT,也可以是需要查询服务器的不透明字符串;Cookie 同样可以承载不同格式的值。存放位置、数据格式和验证方式是三个独立选择。
从账号凭证到认证结果
密码怎样保存和验证
登录服务通常保存密码哈希及其算法参数。验证时,编码器从已存记录取得参数和盐,用输入密码重新计算并比较。盐使相同密码的记录具有不同结果;它可以与哈希一起保存。攻击者拿到数据库后仍可离线猜测密码,因此算法需要有可调计算或内存成本。
Argon2id、scrypt、bcrypt 和 PBKDF2 适合不同技术与合规条件。普通 SHA-256 适合完整性摘要,其计算速度不适合直接存储用户密码。bcrypt 还存在输入字节限制,支持长密码或多字节字符时必须确认库的行为,不能静默截断。OWASP 密码存储指南列出了算法选择、参数和升级要求。
Spring 的 DelegatingPasswordEncoder 在哈希前记录算法标识:
{bcrypt}<算法编码结果>
└── bcrypt:选择对应 PasswordEncoder
└── 编码结果包含成本、盐与密码派生结果升级时保留旧编码器用于验证。用户成功登录后,upgradeEncoding 判断记录是否需要升级,再用本次已验证的密码生成新记录。已有哈希不能凭空转换成更强算法的哈希;长期不登录的用户可通过受控重置流程迁移。配置接口见 Spring 密码存储。
成本需要在实际登录容量下测量。单次验证越昂贵,数据库泄露后的离线猜测成本通常越高,但登录洪泛也越容易消耗服务 CPU。入口速率、账号维度限制和资源预算应同时配置,既防撞库,也避免一个账号被攻击者无限锁死。
长度、泄露密码筛查与失败响应
NIST SP 800-63B-4 对单因素密码规定至少 15 个字符;仅作为多因素流程的一部分时可允许至少 8 个字符,并建议最大允许长度至少达到 64。它反对机械组合规则和无原因定期换密,要求与常见、预期或已泄露密码集合比较。具体规范适用范围及完整要求见 NIST SP 800-63B-4。
长度检查按应用明确的字符规则执行,数据库列和编码器输入也要容纳同一范围。先截断再哈希会让用户以为有效的密码后半段根本没有参与认证。输入超长时应明确拒绝,并限制请求体大小,避免攻击者提交巨大字符串消耗解析与哈希资源。
登录失败不向外区分“账号不存在”“密码错误”或“账号正在某种内部处理状态”。同时检查响应时间、密码重置入口和注册入口,避免错误文本统一了,其他接口仍能批量枚举账号。内部安全事件可以保存失败类别,但应脱敏并限制访问。
MFA、重新认证和账号恢复
多因素认证使用不同类型的认证因素;密码再加一个 PIN 仍属于相同类型。TOTP 和短信验证码常用于第二步验证,但验证码可以被实时钓鱼转发。WebAuthn/FIDO 类凭证把认证绑定到服务标识,能够提供抗钓鱼能力;具体强度还取决于设备、密钥保护与认证流程。
修改收款账户、绑定新认证器、关闭 MFA 或导出大量敏感数据时,可以要求近期重新认证或更强的认证方式。应用应记录认证发生的时间和强度,再按操作检查,而不是只读取“已登录”的布尔值。
恢复流程会重新授予账号控制权。恢复码应高熵、一次性使用并安全存储;换绑认证器应验证现有身份、通知用户,并撤销不再可信的会话。不能让一个弱于正常登录的客服问答绕过所有 MFA。OWASP MFA 指南讨论了因子选择、变更和恢复风险。
Session 与 Cookie 怎样维持浏览器登录
服务端会话保存什么
典型 Session 包含主体标识、认证结果、创建和最近活动时间,以及应用需要的少量上下文。浏览器只持有随机会话 ID。请求到达后,服务端根据 ID 找到会话,检查其有效性,再恢复当前请求的身份。
登录前可能已经存在匿名 Session,例如用于保存 CSRF token 或登录前访问地址。成功认证时应轮换 Session ID,防止攻击者预先固定一个 ID 后等待用户登录。权限提升、密码变更等事件也应重新考虑会话生命周期。
空闲超时限制长期没有活动的会话,绝对超时限制一次认证持续多久。只滑动延长空闲时间,可能让被盗会话永久存活。多实例部署则需要共享会话存储或明确的路由策略;进程内 Session 在请求切到另一实例、应用重启时可能找不到。OWASP Session 指南给出了会话生成、轮换和失效要求。
Spring Security 中,请求线程的 SecurityContextHolder 与跨请求的 SecurityContextRepository 分工不同。自己实现登录时应显式保存到适用 repository;只向 ThreadLocal 设置身份,下一次请求不会自动获得相同登录状态。过滤器实现见 Spring Session 管理和过滤链文章。
Cookie 属性与浏览器行为
| 属性 | 主要作用 | 配置时需要考虑的条件 |
|---|---|---|
| Secure | 限制 Cookie 在安全传输条件下发送 | 生产入口使用 HTTPS;本机 HTTP 实验需单独配置 |
| HttpOnly | 限制脚本读取 Cookie | XSS 仍可能以当前用户身份发起请求 |
| SameSite | 约束跨站请求携带 Cookie | OAuth 回调、嵌入页面和跨站业务需按实际方法验证 |
| Domain / Path | 控制发送范围 | 避免把登录 Cookie 发给不需要它的子域或路径 |
| Max-Age / Expires | 控制浏览器保留时长 | 服务端必须独立检查会话是否已撤销 |
没有 Domain 的 Cookie 通常是 host-only;Cookie 不以端口隔离,两个本机端口使用同名 Cookie 可能互相影响。__Host- 前缀要求 Secure、Path=/ 且不设置 Domain,可减少某些覆盖风险。完整属性语义见 Set-Cookie。
浏览器会自动附带合适范围内的 Cookie,所以写操作需要 CSRF 防护。SameSite 是有用的附加限制,但复杂跨站场景仍需 token、来源检查等方案。把 token 存入 localStorage 能让脚本读取,XSS 也会获得读取能力;HttpOnly Cookie 降低直接窃取风险,却需要正确处理自动携带凭证的请求。选择存储方式时应一起考虑 XSS、CSRF、跨站方式和令牌续期,而不只比较使用 API 的方便程度。
构建应用并观察会话
下载 security-http-lab.zip。在具有 Docker daemon 访问权限的 Linux 账号下操作,需要 curl、jq、unzip、OpenSSL。工程采用 Spring Boot 4.1.1 / Security 7.1.1、Maven 3.9.12,编译目标 Java 17,可在 Java 17 或 25 执行测试。
unzip security-http-lab.zip
cd security-http-lab
mkdir -p .m2 secrets
umask 077
openssl rand -base64 24 > secrets/password
LAB_UID=$(id -u)
LAB_GID=$(id -g)
docker run --rm --user "$LAB_UID:$LAB_GID" -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify
docker run -d --name sec15-auth --user "$LAB_UID:$LAB_GID" \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=128m \
-p 127.0.0.1:18513:8080 -v "$PWD/target:/app:ro" \
-v "$PWD/secrets:/run/secrets:ro" \
eclipse-temurin:17.0.20_8-jdk java -jar /app/security-http-lab-1.0.0.jar \
--spring.profiles.active=test --lab.password-file=/run/secrets/password
docker logs --tail 20 sec15-auth
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18513/health构建容器写入当前 UID/GID 拥有的缓存和 target,应用只读挂载文件。下载失败检查镜像/Maven 仓库、代理与证书信任;离线环境先同步这些依赖。应用完成监听后健康接口返回 ready;启动阶段连接被拒绝时先读日志,再重试。
test profile 额外启用 JWT 实验组件和受认证保护的本地签发路由。正常 profile 没有这些路由。所有用户、会话、撤销版本和 RSA 密钥都在内存中,重启会丢失;只在回环地址使用本轮随机密码,不把该 profile 部署为身份服务。
BASE=http://127.0.0.1:18513
LAB_PASSWORD=$(cat secrets/password)
curl -q --noproxy '*' --fail-with-body -sS -c auth.cookies \
"$BASE/browser/csrf" > csrf.json
BEFORE=$(awk '$6 == "SEC15SESSION" {print $7}' auth.cookies)
CSRF=$(jq -r .token csrf.json)
curl -q --noproxy '*' --fail-with-body -b auth.cookies -c auth.cookies \
--data-urlencode username=alice --data-urlencode "password=$LAB_PASSWORD" \
--data-urlencode "_csrf=$CSRF" "$BASE/browser/login"
AFTER=$(awk '$6 == "SEC15SESSION" {print $7}' auth.cookies)
test -n "$BEFORE" && test -n "$AFTER" && test "$BEFORE" != "$AFTER" || \
{ printf '登录前后 Session ID 没有按预期轮换\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body -b auth.cookies "$BASE/browser/me"最后返回 alice 的身份。Cookie 文件属于本轮会话材料,不粘贴到外部网站或日志。成功登录会清理旧 CSRF token,写请求和注销前重新取得 token:
curl -q --noproxy '*' --fail-with-body -sS -b auth.cookies \
"$BASE/browser/csrf" > csrf.json
CSRF=$(jq -r .token csrf.json)
curl -q --noproxy '*' --fail-with-body -b auth.cookies \
-H "X-CSRF-TOKEN: $CSRF" -X POST "$BASE/browser/logout"
code=$(curl -q --noproxy '*' -sS -b auth.cookies \
-o stale.body -w '%{http_code}' "$BASE/browser/me")
transport=$?
test "$transport" -eq 0 && test "$code" = 401 || \
{ printf '服务端仍接受已注销会话或请求未完成\n' >&2; exit 1; }注销返回 204,再发送原 Cookie 得到 401。这个结果来自服务端会话失效,不依赖浏览器是否成功删除 Cookie。
JWT 怎样被资源服务接受或拒绝
三段编码和声明的含义
常见的签名 JWT 使用 JWS Compact Serialization:
BASE64URL(header).BASE64URL(payload).BASE64URL(signature)
│ │ │
│ │ └─ 校验签发来源和完整性
│ ├─ iss:签发方
│ ├─ sub:主体
│ ├─ aud:预期接收方
│ ├─ exp / nbf / iat:时间声明
│ └─ scope / jti / 自定义声明
└─ alg:算法标识;kid:密钥标识;typ:类型提示Base64URL 编码便于传输,任何持有者都能读取普通签名 JWT 的载荷。敏感信息不应因为“有签名”就写进 token;需要加密时使用不同的 JWE 机制,并管理解密权限。JWT 声明定义见 RFC 7519。
iat 表示签发时间,exp 限制有效期,nbf 限制最早接受时间;jti 是标识符,本身不会自动完成去重或撤销。应用还要规定哪些声明必须存在,以及不同 token 用途接受什么类型、issuer、audience 和权限。
验签之后继续检查用途
资源服务应固定允许的算法和可信密钥来源,拒绝客户端自行决定验证策略。kid 用于从受信任的密钥集合中选择密钥,不能直接变成任意文件路径、数据库表达式或远程 URL。带签名的 token 仍可能属于另一个服务,因此必须核对 issuer 和 audience,并检查有效期与业务所需声明。
HS256 使用共享密钥进行 MAC,持有验证密钥的一方也能产生有效令牌;RS256 等非对称算法把签名私钥和验证公钥分开,适合多个资源服务验证同一签发方的令牌。两者都需要可靠的密钥管理。RFC 8725规定了算法校验、用途隔离和其他 JWT 最佳实践。
ID token 给 OIDC 客户端表达认证结果,access token 给资源服务授予访问能力。即使两个 token 都是 JWT,也不应在同一入口不加区分地接受;OIDC 关联规则见 OAuth 与 OIDC。
Spring Resource Server 中的验证配置
JwtDecoder 负责解析、验签和声明验证,JwtAuthenticationConverter 把已经接受的声明转换成 principal 与 authorities。默认 scope/scp 映射常使用 SCOPE_ 前缀;业务角色转换需要显式配置。错误签名、过期或错误 audience 产生认证拒绝,已经认证但缺少要求的 scope 则进入授权拒绝。
实验的 JwtLaboratory 固定 RS256、issuer 和 audience,并要求 exp;ver 是应用自定义的账号安全版本。核心配置如下:
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey)
.signatureAlgorithm(SignatureAlgorithm.RS256)
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://issuer.example"),
new JwtClaimValidator<Instant>("exp", value -> value != null),
new JwtClaimValidator<List<String>>("aud",
value -> value != null && value.contains("billing-api")),
accountVersionValidator));资源链只接受显式 Authorization Bearer,不使用 Cookie 或 Basic 恢复认证,因此在该链禁用 CSRF;Session 表单链继续保留 CSRF。完整组件与验证器扩展说明见 Spring JWT Resource Server。
取得本轮实验 token 并调用资源接口:
umask 077
curl -q --noproxy '*' --fail-with-body -sS -u "alice:$LAB_PASSWORD" \
"$BASE/api/lab-token" > token.json
TOKEN=$(jq -r .access_token token.json)
curl -q --noproxy '*' --fail-with-body \
-H "Authorization: Bearer $TOKEN" "$BASE/token-api/me"响应包含 subject=alice。签发路由根据已认证主体生成 token,调用方不能指定其他用户或权限。它仅存在于 test profile;生产接入使用受控授权服务或既有身份提供方。
负向验证集中在 CredentialTest:正确签名但 issuer 或 audience 错误、过期超过容许时钟偏差、签名被改动,均从实际 HTTP 入口得到 401;合法 token 没有 bills:read scope 时返回 403。更改账号安全版本后,原 token 被拒绝,新 token 可以通过。测试还验证真实 PasswordEncoder 的随机盐、密码匹配和旧成本升级条件。
不要把生产 token 复制到在线 JWT 解码器。排查时可在受控本机工具读取声明,但解码结果还未经过验签;任何根据未验证 payload 进行的权限判断都必须避免。
过期、撤销和认证故障怎样处理
先选择允许的撤销延迟
服务端 Session 可以删除记录,并在下一次查询时拒绝使用。多个副本的缓存、会话同步和长连接仍会影响实际失效时间,需要一起测试。
仅本地验签的 JWT 在有效期内通常继续被接受。删除浏览器里的 token 可以停止该浏览器再次发送,却不能收回已经泄露的副本。常见方案有四类:
| 方案 | 失效判断依赖 | 主要代价 |
|---|---|---|
| 短期 access token | exp 与本机时间 | 在有效期内保留残余窗口,续期请求增多 |
| introspection | 授权服务的当前状态 | 每次调用或缓存窗口、认证依赖可用性 |
| 账号安全版本 | token 中版本与账号记录 | 增加状态查询,缓存影响失效速度 |
| jti 撤销集合 | 特定 token 是否被列入 | 撤销记录的容量、过期回收及同步 |
选择时先明确业务要求。例如“密码重置后一分钟内全部下线”需要把 token 剩余期限、版本缓存期限和各服务刷新时间算在一起。认证依赖超时应按接口风险拒绝或降级,不能把查询失败当作“账号未撤销”。
实验的版本表在内存中,每次验证读取一次。生产中若改成数据库或缓存,要决定记录缺失、查询超时、回退版本和跨区域同步的处理方式;重启进程不能把已撤销版本恢复成初始值。
Refresh token 与签名密钥分别轮换
Refresh token 通常交给授权服务换取新的 access token,访问业务 API 时不应发送它。轮换会用新 refresh token 代替旧值;两个并发续期请求可能争用同一个旧值。客户端需要串行化续期或共享一次刷新结果,避免把正常并发变成反复登录。
发现旧 refresh token 被再次使用时,授权服务可以按策略撤销相关令牌家族。这个家族联动行为需要服务器支持和正确配置,不能根据“每次都返回了新 refresh token”推断已经实现。有关重放保护见 OAuth 安全最佳实践。
签名密钥轮换则改变谁能产生新 token。通常先发布新公钥,再使用新私钥签发,保留旧公钥覆盖尚未过期 token 与时钟偏差,最后移除旧公钥。紧急私钥泄露时,继续保留旧公钥会继续信任攻击者生成的 token;此时要接受更大范围的重新认证,并配合撤销机制。JWKS 缓存和刷新失败会影响各资源服务看到新旧密钥的时间。
Cookie 未发送、会话不存在与 JWT 拒绝
浏览器 F12 的 Network 面板可检查登录响应中的 Set-Cookie,以及下一条请求是否携带 Cookie。被浏览器拒收或未发送时,查看 Secure、Domain、Path、SameSite 和跨站请求方式;不要通过删除生产 Secure 属性来修复一个 HTTP 测试入口。
Cookie 已发送但仍返回匿名时,检查 Session ID 是否已轮换、是否命中另一实例、会话是否过期,以及登录流程是否完成保存。先比较响应和服务端会话查询,再看 Controller。
JWT 的 401 应分辨签名、issuer、audience、时间、必需声明或撤销状态。日志使用失败类别和请求 ID,不输出 token;403 则继续看已解析的 authorities 和所需 scope。排查时钟问题需要检查签发服务和资源服务的时间同步,不能无限扩大 clock skew。
curl 请求必须忽略客户端默认配置并控制代理;预期成功用 --fail-with-body,负例保留状态码和响应。完整参数见 curl 手册。
停止本机实验:
docker stop sec15-auth
docker rm sec15-auth
unset LAB_PASSWORD CSRF TOKEN BEFORE AFTERtoken.json、Cookie 和密码文件仍属于敏感实验材料,确认不再使用后删除。生产故障处理还要区分账号接管、认证器丢失与签名密钥泄露,分别撤销受影响材料并验证重新登录结果。
权威资料与规范地址
OWASP 密码存储:https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
Spring Password Storage:https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
NIST SP 800-63B-4:https://pages.nist.gov/800-63-4/sp800-63b.html
OWASP MFA:https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
OWASP Session Management:https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
Spring Session 管理:https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
MDN Set-Cookie:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
RFC 7519 JWT:https://www.rfc-editor.org/rfc/rfc7519.html
RFC 8725 JWT Best Current Practices:https://www.rfc-editor.org/rfc/rfc8725.html
Spring JWT Resource Server:https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
RFC 9700 OAuth 安全最佳实践:https://www.rfc-editor.org/rfc/rfc9700.html
