开启图形验证码后,登录接口一直返回“验证码已失效,请刷新后重试”。后端生成验证码时已经写入 HttpSession,但登录请求读取不到相同的 Session。
根因不是验证码内容过期,而是验证码请求与登录请求没有使用同一个 JSESSIONID:浏览器未保存 Cookie,或登录请求没有把 Cookie 带回 API。
一、验证码为什么依赖同一个 Session
① 前端请求 /captcha
↓
② 后端创建 Session,并把验证码写入 session["captcha"]
↓
③ 响应通过 Set-Cookie 返回 JSESSIONID
↓
④ 浏览器保存 Cookie
↓
⑤ 前端请求 /login,并携带相同 JSESSIONID
↓
⑥ 后端定位原 Session,读取验证码并校验任一步没有完成,登录请求都会进入一个没有 captcha 属性的 Session。request.getSession() 还可能自动创建新 Session,使问题看起来像“验证码被清空”。
二、后端示例
生成验证码
public void getCaptcha(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(100, 30, 4, 20);
HttpSession session = request.getSession(true);
session.setAttribute("captcha", captcha.getCode());
session.setAttribute("captchaCreatedAt", System.currentTimeMillis());
response.setContentType("image/png");
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
captcha.write(response.getOutputStream());
}登录时校验
public R login(
String username,
String password,
String captcha,
HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
throw new BusinessException(400, "验证码会话不存在,请刷新后重试");
}
String expected = (String) session.getAttribute("captcha");
Long createdAt = (Long) session.getAttribute("captchaCreatedAt");
if (expected == null || createdAt == null
|| System.currentTimeMillis() - createdAt > 5 * 60_000L) {
throw new BusinessException(400, "验证码已失效,请刷新后重试");
}
if (captcha == null || !expected.equalsIgnoreCase(captcha.trim())) {
throw new BusinessException(400, "验证码错误");
}
session.removeAttribute("captcha");
session.removeAttribute("captchaCreatedAt");
// 继续校验用户名和密码……
return R.ok();
}- 使用 getSession(false) 可以区分“Cookie 对应的 Session 不存在”和“验证码属性不存在”。
- 校验成功后删除验证码,保证一次性使用。
- 自行保存创建时间,才能实现明确的业务过期时间。
三、前端必须携带凭证
Axios 的跨源请求默认不会携带 Cookie。获取验证码与登录两个请求都必须使用 withCredentials。
const api = axios.create({
baseURL: "https://api.lvmaoya.cn",
withCredentials: true,
});
// 获取验证码图片
const captchaResponse = await api.get("/captcha", {
responseType: "blob",
});
captchaImageUrl.value = URL.createObjectURL(captchaResponse.data);
// 登录时会携带相同 API 域下的 JSESSIONID
await api.post("/login", {
username,
password,
captcha,
});只给登录请求设置 withCredentials 不够:如果验证码响应阶段没有凭证模式,浏览器可能直接忽略跨源响应中的 Set-Cookie。
四、Spring CORS 配置
@Configuration
public class WebCorsConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(
"https://lvmaoya.cn",
"http://localhost:3001"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}- 凭证请求必须返回 Access-Control-Allow-Credentials: true。
- 启用凭证时,Access-Control-Allow-Origin 不能使用 *,应明确列出可信 Origin。
- 如果使用 Spring Security,应确保 CORS 在安全过滤链中正确启用,并正确处理 OPTIONS 预检。
五、Cookie 的 SameSite 与 Secure
Origin 与 Site 不是同一个概念。协议、主机或端口不同会造成跨 Origin;SameSite 判断还会考虑 scheme。原来的前端 http://lvmaoya.cn:8520 与 API https://api.lvmaoya.cn 同时存在协议和 Origin 差异,因此更容易触发 Cookie 限制。
推荐:前后端统一 HTTPS
把前端部署为 https://lvmaoya.cn,API 保持 https://api.lvmaoya.cn。二者仍是跨 Origin,需要 CORS 与 withCredentials,但在常见浏览器的 schemeful same-site 判断中属于同站点。
确实需要跨站 Cookie 时
server.servlet.session.cookie.same-site=none
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.path=/SameSite=None 必须同时设置 Secure,也就是通过 HTTPS 传输。HttpOnly 可以阻止前端 JavaScript 读取 Session Cookie。
六、最稳妥的部署方式:同源反向代理
更简单的方案是让浏览器始终请求 https://lvmaoya.cn/api,再由 Nginx 或 Caddy 转发到后端。对浏览器而言前端与 API 同源,可以减少 CORS 和跨站 Cookie 配置。
location /api/ {
proxy_pass http://blog-api:8081/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}不建议把 proxy_cookie_path / "/; SameSite=None; Secure" 当作长期首选方案。它会批量改写代理响应中的 Cookie 属性,容易掩盖应用配置问题;应优先在 Spring Boot 中声明 Session Cookie 策略。
七、浏览器排查步骤
- 打开开发者工具 Network,清除旧 Cookie 后重新请求 /captcha。
- 检查响应是否包含 Set-Cookie: JSESSIONID=...,并查看浏览器是否给出拦截原因。
- 在 Application / Storage → Cookies 中确认 JSESSIONID 已保存到正确域名和路径。
- 请求 /login,检查 Request Headers 中是否出现 Cookie: JSESSIONID=...。
- 确认 /captcha 与 /login 请求携带的 JSESSIONID 完全相同。
- 检查 CORS 响应头是否返回明确 Origin 和 Access-Control-Allow-Credentials: true。
- 检查是否发生负载均衡、Session 未共享、服务重启或 Session 超时。
八、仍然失效时继续检查
- Cookie 的 Domain 或 Path 与登录接口不匹配。
- 前后端混用 HTTP 与 HTTPS,导致 Secure 或 SameSite 策略不满足。
- 多实例部署未配置粘性会话,也没有使用 Spring Session / Redis 共享 Session。
- 浏览器隐私设置或第三方 Cookie 策略阻止了 Cookie。
- 重复刷新验证码覆盖了原值,多标签页使用同一 Session 时相互影响。
- 验证码接口被 CDN 或浏览器缓存,应设置 no-store 并给图片请求增加缓存破坏参数。
九、安全补充
- 验证码只能降低自动化攻击风险,不能替代登录限流、账号锁定和审计。
- 基于 Cookie 的认证仍需评估 CSRF 防护,不能因为配置了 CORS 就忽略 CSRF。
- 不要在日志中输出验证码、Session ID、密码或完整 Cookie。
- 失败提示应避免泄露账号是否存在等敏感状态。
最终判断标准很简单:验证码请求设置的 JSESSIONID,必须在登录请求中原样返回,并由后端定位到同一个 Session。
