登录功能大概是 Java 后端开发里最常碰到的需求了,只要是个带用户体系的系统,就绕不开“用户登录”和“登录状态保持”。SpringBoot 项目里最经典、也最稳的方案,就是 Cookie + Session:用户输完用户名密码,服务端校验通过后开一个会话(Session),再把会话标识(JSESSIONID)通过 Cookie 交给浏览器,后面每次请求浏览器自动带上这个 Cookie,服务端就能认出这是哪个用户。这篇文章我打算从项目视角把这条链路完整拆开讲一遍,包括原理、代码实现、常见坑和安全加固,适合刚开始用 SpringBoot 写登录模块的同学,也适合会写但不清楚底层为什么这么走的人。
很多教程会直接甩给你一段登录接口的代码,然后就没有然后了。结果你一部署、一换环境,登录状态莫名其妙就丢了,也不知道该查哪里。Cookie 和 Session 这套东西原理不复杂,但细节真的很碎:SameSite 跨域会丢、重启会丢、集群会串、HttpOnly 没配好会有安全问题。我会把这些全都放在一起说清楚。
1. 项目整体设计与登录状态保持的核心思路
1.1 为什么一定要有“登录状态”这个概念
HTTP 协议本身是“没记性”的,每个请求都是一次全新的握手,服务端根本不知道上一次请求的人是谁。这就相当于公司前台每天早上的打卡记录不存档案,你下午再过来的时候,保安压根不记得你是不是这家公司的员工,完全是从零开始接待。
所以我们需要一种机制,让服务端能记住“这个请求是谁发来的”。Cookie + Session 解决的正是这个问题:浏览器帮你保存一个“临时工牌”(Cookie),服务端把详细员工档案(Session)放在后台。每次请求的时候,浏览器自动把工牌亮出来,服务端拿工牌号去档案库一查,就知道是哪个员工了。
这就是登录状态保持的根本逻辑。它不是一个独立的功能点,而是贯穿“登录接口 -> 写会话 -> 放行后续请求 -> 过期/退出”整条链路的机制。你在网上搜“登录状态保持”,得到的答案大概率围绕着“Token”或者“Cookie + Session”两个方向。前者适合前后端分离和跨端场景,后者则是传统 Web 项目里最直观、最成熟的方案。如果你用的是 SpringBoot 做服务端渲染,或者前后端同域部署的架构,Cookie + Session 完全够用,而且实现成本极低。
1.2 登录状态保持的完整闭环设计
从项目设计的角度,一个完整的登录状态保持闭环包含五个环节:
第一,用户提交登录凭证。通常是用户名 + 密码,通过表单或 JSON 提交到登录接口。
第二,服务端校验凭证。查数据库,用 BCrypt 之类的算法比对密码,确认身份没问题。
第三,服务端创建会话。登录校验通过后,在服务端 Session 容器里生成一条会话记录,存进用户信息,同时生成一个全局唯一的 Session ID。
第四,服务端通过 Set-Cookie 把 Session ID 交给浏览器。浏览器收到后会存在本地,之后每次向同域发起请求,都会自动带上这个 Cookie。
第五,拦截器/过滤器校验会话。除登录接口以外的请求,SpringBoot 侧用拦截器先看 Session 里有没有用户,有就放行,没有就拦截。
整个链条里最容易被忽略的是第四和第五步之间的衔接。很多人以为“登录成功后设置了 Session 就完事了”,但如果不理解浏览器是怎么拿到 Session ID 的、Cookie 的路径和过期时间是怎么生效的,一换域名、一换浏览器配置,状态就“丢”了。其实状态根本没丢,是 Cookie 没有按预期传回服务端。
1.3 为什么不用 localStorage 或者纯前端方案“保持登录”
我见过不少前后端不分离但硬要用 localStorage 存登录状态的代码:登录成功后把用户信息塞进 localStorage,然后前端路由守卫去读。这在我眼里属于“假装登录”。localStorage 的最大问题是:它只存在于浏览器端,服务端完全不可控。用户用 DevTools 改一下 localStorage 里的用户 ID,前端可能就认为自己是管理员了。更要命的是,后端拿不到任何与会话生命周期相关的信息,无法主动让某个会话失效,也无法感知会话过期。
Cookie + Session 把状态的主导权放在服务端。浏览器手里只有一个小小 Session ID,真正的用户信息、登录时间、过期策略都在服务端。就算用户篡改了 Cookie 里的 Session ID,服务端查不到对应会话照样拦下来。所以从安全性和可控性来说,服务端会话方案是登录状态保持的基础设计,没有理由在这种基础功能上省事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Cookie 与 Session 在 SpringBoot 中的落地机制
2.1 Session 在服务端到底是什么“东西”
Session 不是 SpringBoot 发明的概念,它是 Servlet 容器(Tomcat、Jetty)提供的标准能力。在 Tomcat 内部,每个 Session 本质上就是一张“内存表”,里面以键值对形式存了一些属性。当你调用 request.getSession() 时,容器会先看请求里有没有携带有效的 Session ID,如果没有就新建一个,并把 Session 对象和这个新 ID 绑定到容器内部的一个并发 Map 里。
这个全局唯一的 ID 默认叫 JSESSIONID,是 Tomcat 自动生成的随机串。容器拿到这个 ID 后,会往响应头里写一条 Set-Cookie: JSESSIONID=xxxxx; Path=/,浏览器的 Cookie 管理器收到后负责保存。下一次请求进来,浏览器自动在请求头里带上 Cookie: JSESSIONID=xxxxx,容器在内部 Map 里查到对应 Session,于是整个请求链路里的 session.getAttribute("user") 就能拿到你之前放进去的用户对象。
换句话说,Session 是一个躺在服务端内存里的“档案袋”,Cookie 里的 JSESSIONID 是这个档案袋的编号。两者一拼,身份就出来了。SpringBoot 并没有重新发明这套机制,它只是把它们包装成了自动配置,配合 Servlet 容器使用。
2.2 Cookie 的关键属性拆解,一个都不能马虎
Cookie 本身是一个很小的文本片段,浏览器和服务器都能生成。它由几部分组成,每一个属性都和登录状态能否正确保持、是否安全密切相关:
| 属性 | 作用 | 实操建议 |
|---|---|---|
| Name/Value | Cookie 名和值,JSESSIONID 就是最常见的一对 | 自定义业务 Cookie 时用不到就少用,别把敏感信息放进去 |
| Domain | Cookie 生效域名,默认是当前域名 | 多级域名要手动设为父域名,但别设成全局顶级域 |
| Path | Cookie 生效路径,默认 / |
除非有特殊路径隔离需求,一律用 / |
| Max-Age | Cookie 存活秒数,不设置则关闭浏览器即失效 | 记住登录状态可以用,否则走会话级 Cookie |
| HttpOnly | 禁止 JavaScript 读取该 Cookie | 所有会话相关 Cookie 都建议开启 |
| Secure | 只允许 HTTPS 传输该 Cookie | 生产环境强烈建议开启 |
| SameSite | 控制跨站请求是否携带 Cookie | 前后端同域保持不变,跨域场景见后文 |
名字和值就不多说了,很多人容易忽略的是 Domain 和 Path 的匹配规则。浏览器判断“这个请求要不要带上某个 Cookie”,靠的是一套精确的匹配逻辑:请求的域名要能匹配 Cookie 的 Domain,请求的路径也要以 Cookie 的 Path 开头。比如你的 Cookie 设置成 Path=/admin,那么访问 /api/login 时这个 Cookie 就不会上传,登录状态自然无法覆盖全站。
HttpOnly 这个属性尤其值得注意。它不代表加密,只是禁止了 document.cookie 的读取,能防住一部分 XSS 窃取会话的攻击。SpringBoot 里可以这样配:
properties复制server.servlet.session.cookie.http-only=true
Max-Age 是“会话 Cookie”和“持久 Cookie”的分界线。如果不设置 Max-Age,浏览器这个标签页/窗口关闭后,Cookie 就没了,下次打开浏览器又得重新登录。如果设置 Max-Age=604800(7 天),Cookie 会被持久化到本地,7 天之内浏览器重启也一样会自动上报。我们后面讲“记住我”的时候会用到。
2.3 SpringBoot 中 Session 和 Cookie 的默认行为配置
SpringBoot 对 Session 的默认行为都可以通过配置文件控制,不用写一行 Java 代码就能调整关键参数:
properties复制# Session 闲置超时时间,默认 30 分钟
server.servlet.session.timeout=30m
# 自定义 Session 标识 Cookie 名称
server.servlet.session.cookie.name=MY_SESSION_ID
# Cookie 只在 HTTPS 下传输
server.servlet.session.cookie.secure=true
# Cookie 允许的路径
server.servlet.session.cookie.path=/
# 禁止前端 JS 读取会话 Cookie
server.servlet.session.cookie.http-only=true
# 会话 Cookie 的 SameSite 策略
server.servlet.session.cookie.same-site=lax
timeout 控制的是“会话闲置时间”。用户两次请求之间的间隔超过这个时间,Session 就失效,下次请求必须重新登录。30 分钟是系统默认值,适合大多数业务;如果是后台管理系统,可以放宽到 60 分钟;涉及支付的系统,建议收紧到 15 分钟以内。
需要注意的是,Session 数据默认是存在容器进程内存里的,SpringBoot 重启、容器重启、内存回收,都会让所有在线用户的登录状态归零。这是内存型会话的宿命,后面第 4 节我会讲生产环境怎么解决。
3. 实操:登录接口、会话创建与登录拦截器
3.1 登录接口实现与密码校验逻辑
先写一个最常规的登录接口。用户提交用户名和密码,我们查库校验。实际项目中密码不会明文存储,通常是 BCrypt 哈希。这里我直接用 BCrypt.checkpw 做比对:
java复制@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User login(String username, String rawPassword) {
User user = userMapper.findByUsername(username);
if (user == null) {
return null;
}
if (!BCrypt.checkpw(rawPassword, user.getPassword())) {
return null;
}
return user;
}
}
控制层负责两件事:调用业务层,以及登录成功后创建会话。创建会话时我用的是 request.getSession(true),获取当前请求关联的会话,如果没有就新建一个。注意在用户真正登录成功之前,理论上是不应该调用 getSession() 的,否则未登录用户也会被分配一个无意义的会话。
java复制@RestController
public class AuthController {
@Autowired
private UserService userService;
@PostMapping("/api/login")
public Result<Void> login(@RequestBody LoginReq req,
HttpServletRequest request,
HttpServletResponse response) {
User user = userService.login(req.getUsername(), req.getPassword());
if (user == null) {
return Result.fail("用户名或密码错误");
}
// 登录成功,创建/获取会话并写入用户信息
HttpSession session = request.getSession(true);
session.setAttribute(Constants.SESSION_USER_KEY, user);
// 可选:自定义一个业务 Cookie,比如记住用户名
Cookie cookie = new Cookie("REMEMBER_USERNAME", URLEncoder.encode(user.getUsername(), StandardCharsets.UTF_8));
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setHttpOnly(true);
response.addCookie(cookie);
return Result.ok();
}
}
3.2 会话创建后,浏览器是怎么“记住”的
很多人不知道的是:我们不需要手动创建 JSESSIONID 这个 Cookie。request.getSession(true) 执行之后,Servlet 容器会自动把 JSESSIONID 通过 Set-Cookie 响应头发给浏览器。上面代码里手动创建的 REMEMBER_USERNAME 只是业务辅助用的,它与会话无关。
所以“登录状态保持”在这一层的真相是:服务端生成了会话,自动向浏览器发包了一张会话标识。浏览器保存之后,后续每个请求自动附带。你可以打开 Chrome DevTools 的 Network 面板,登录一次看看响应头,一定能看到类似这样的字段:
code复制Set-Cookie: MY_SESSION_ID=240EE4C6F2A6D5C1E1D4A58D2FAB7B22; Path=/; HttpOnly; SameSite=Lax
如果你没看到这个响应头,先检查 request.getSession(true) 是不是真的执行了、是不是在响应提交之后才调用(响应已提交再 setAttribute 也不会有问题,但 set/changeSessionId 可能抛 IllegalStateException)。
3.3 登录拦截器:没有会话就拦下来
登录状态要全局保持,不能靠每个 Controller 自己判断。SpringBoot 里的标准做法是写一个 HandlerInterceptor。我一般在 /api/** 和页面路由上统一拦截,只放行登录接口和静态资源:
java复制@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 只要存在会话且会话里有用户,就放行
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute(Constants.SESSION_USER_KEY) != null) {
return true;
}
// 判断是页面请求还是接口请求,分别处理
String requestedWith = request.getHeader("X-Requested-With");
if ("XMLHttpRequest".equals(requestedWith)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}");
} else {
response.sendRedirect("/login");
}
return false;
}
}
重点说一下 request.getSession(false)。传 false 表示“没有有效会话就返回 null,别新建”。如果你写成 request.getSession() 或者 request.getSession(true),那么任何未登录请求都会被迫创建一个新会话,拦截逻辑就完全失效了,所有人都能拿到一个空 Session 然后被放行。我是真见过有人栽在这个默认参数上。
3.4 把拦截器注册进 SpringBoot
拦截器写完之后必须注册到 WebMvcConfigurer 里,并明确排除掉不需要登录的路径:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/api/login",
"/login",
"/register",
"/css/**",
"/js/**",
"/images/**",
"/error"
);
}
}
addPathPatterns("/**") 表示拦截所有路径,excludePathPatterns 是白名单。白名单里除了登录、注册,一定不要把静态资源漏掉,否则 CSS/JS 全加载不出来,页面一片白,你还以为是前端出 bug 了。如果项目里有 Swagger/SpringDoc,也要把 /swagger-ui/**、/v3/api-docs/** 放进去。
注册完成之后再回头访问任意受限接口,只要登录状态不存在,就会被拦截器拦下。这就是登录状态保持发挥作用的那一瞬间。
3.5 退出登录:销毁会话不是“删一下 Cookie”就行
很多人写退出登录只删除 Cookie,却不销毁服务端 Session。结果就是服务端档案袋还挂在内存里,只是没有 ID 对应了,看起来“退出成功”,实际上档案袋占着内存,用户重新登录后还能找到一些旧数据。
正确的退出方式是:
java复制@PostMapping("/api/logout")
public Result<Void> logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// 如果自定义了业务 Cookie,也需要清掉
Cookie cookie = new Cookie(Constants.REMEMBER_USERNAME_KEY, null);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
return Result.ok();
}
invalidate() 会让 Tomcat 立刻销毁这个 Session,并从会话 Map 中移除。Max-Age=0 是通知浏览器马上删除本地 Cookie,属性和原始 Cookie 必须完全一致才能删掉,所以 Path 尤其不能写错。
4. 常见问题排查与避坑实录
4.1 重启项目后登录状态全没了,是写错了吗
没写错,这是内存型 Session 的固有特性。Tomcat 默认把 Session 放在 JVM 堆里,只要进程重启,内存清空,所有在线状态全部作废。用户必须重新登录。
解决思路有两个方向:一是引入 Spring Session,将会话数据持久化到 Redis;二是如果项目不大、能接受重启后掉线,那就维持现状,重启后引导用户重新登录。对于实际运营中的系统,我建议还是上 Spring Session,代码改动量非常小,只要加一个依赖和配置:
xml复制<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
properties复制spring.session.store-type=redis
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
之后原本写的 request.getSession() 依然可以工作,但底层存储已经换成 Redis,重启项目、甚至多实例部署时,Session 数据都不会丢。这是登录状态保持层面性价比最高的生产级优化。
4.2 浏览器关了,登录状态为什么也没了
关掉浏览器后 Cookie 消失,这是一个特别常见的现象。原因是会话级 Cookie 没有设置 Max-Age,浏览器策略是“这是临时会话,关窗口即焚”。刷新页面时还在,一旦整个浏览器退出,Cookie 就被清掉了。
要解决这个体验问题,你得显式设置持久化 Cookie。很多人以为登录状态保持的“保持”是指“用户关掉浏览器再打开,还是登录状态”,恰恰这个需求必须靠 Max-Age 来实现。比如用 Spring Session Redis 后,你可以同时设置 Session 的 MaxInactiveInterval 和 Cookie 的 Max-Age:
java复制session.setMaxInactiveInterval(7 * 24 * 60 * 60);
再配合:
properties复制server.servlet.session.cookie.max-age=604800
这样用户关闭浏览器再打开,只要 Session 没过期,登录状态还在。但要注意安全尺度,公共电脑上这种体验会很危险,所以“记住我”通常作为一个独立选项开放。
4.3 前后端分离跨域,登录状态莫名丢失
前后端分离架构下,前端的域名是 http://localhost:8080,后端是 http://api.example.com,浏览器跨域请求默认不带 Cookie,而且非简单请求会先发 OPTIONS 预检。此时登录状态根本不是后端逻辑的锅,而是浏览器跨域策略直接把 Cookie 拦截了。
解决的关键是两个配合动作。后端需要开启 CORS 并显式允许携带凭证:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
}
前端发起请求时必须带上 credentials,Axios 里就是:
javascript复制axios.defaults.withCredentials = true;
两个条件缺一不可。只配后端 allowCredentials(true),前端不带 withCredentials,一样发不了 Cookie;前端带了但后端没有 allowedOrigins 精确到域名、只写了 *,浏览器也会拒绝。还有 SameSite 策略要小心:后端的 Cookie 如果 SameSite=None,那么必须同时设置 Secure=true,因为浏览器只允许 HTTPS 传输 SameSite=None 的 Cookie。本地开发用 HTTP 调跨域接口,这是很多人的噩梦源头。
4.4 集群部署后,登录状态出现“串号”
一个系统跑了多个实例,前面挂 Nginx 做负载均衡。用户在 A 实例登录成功,Session 数据存在 A 实例内存里;下一次请求被 Nginx 转发到 B 实例,B 实例查不到这个 Session ID,于是又给用户创建一个新会话。用户还没反应过来,就被要求重新登录;如果运气差一点,多刷新几次请求被轮流转发到不同实例,就会出现“明明登录了,一下有状态、一下没状态”的灵异现象。
为了让所有实例共享 Session,高可用架构必须引入 Spring Session Redis,这个在上文已经提过。只是集群场景下它不是“可选优化”,而是“必选项”。同样,Nginx 的 proxy_pass 设置不当也可能导致 Cookie 里的 Path 对不上,需要让代理服务器原样透传 Set-Cookie,通常默认行为没问题,但如果你在 Nginx 里改过 proxy_cookie_path,就要特别小心。
4.5 Cookie 值里出现中文乱码
Cookie 规范规定值里不允许直接放中文,很多浏览器会自动编码,但为了可控性,建议在写入前自己手动编码一次:
java复制value = URLEncoder.encode(chineseValue, "UTF-8");
读取时再 URLDecoder.decode。前端如果遇到 Cookie 值为一串形如 %E7%94%A8%E6%88%B7 的东西,不要觉得奇怪,那是标准编码结果,解码后就是中文原文。
5. 安全加固:登录体系上线前必须做的几件事
5.1 防止会话固定攻击(Session Fixation)
会话固定攻击是一种老牌攻击手法:攻击者先自己访问目标网站,拿到一个合法 Session ID,然后诱导受害者使用这个 Session ID 去登录。如果登录前后服务端一直沿用同一个会话 ID,攻击者就能用自己的 Session ID 进入受害者的登录会话。
防御手段是在登录成功后更换 Session ID。Servlet 3.1 开始提供了 request.changeSessionId():
java复制request.changeSessionId();
session.setAttribute(Constants.SESSION_USER_KEY, user);
如果是老项目,也可以先 session.invalidate() 再 request.getSession(true) 重新创建。总之原则是:登录前与会话相关的 ID 不能沿用。这个细节很容易被忽略,但安全扫描几乎必查。同理,密码修改、权限提升等敏感操作后,也建议做一次会话刷新。
5.2 HttpOnly 与 XSS 的关系
XSS 攻击最可怕的地方之一就是可以偷走用户的会话。攻击脚本只要执行 document.cookie,就能把当前域下的所有 Cookie 内容窃取并发给攻击者服务器,攻击者拿着这个会话标识就能伪装成受害者。HttpOnly 属性专门阻断这条路:设置了 HttpOnly 的 Cookie 不会暴露给 JavaScript,document.cookie 里根本看不到它。
所以,凡是用来维系登录状态的 Cookie,我都建议强制开启 HttpOnly。SpringBoot 默认已经开启了,但你自定义业务 Cookie 时,别忘了自己手动加:
java复制cookie.setHttpOnly(true);
5.3 CSRF 防护与 SameSite 策略
CSRF 攻击是诱导用户携带 Cookie 向目标站点发起请求。浏览器规则是:只要 Cookie 的 Domain 和 Path 匹配,请求就会自动携带,不管这个请求是不是用户主动发的。比如用户登录了银行网站,又在一个恶意网站点了张图片,图片实际是向银行转账接口发起的 GET 请求,Cookie 照样被带上,服务端校验 Session 有效,转账就发生了。
现代浏览器已经有了第一道防线:SameSite 属性。SameSite=Lax 会阻止跨站子请求携带 Cookie;SameSite=Strict 更严格,跨站所有请求都不会带。我在 SpringBoot 里的默认推荐是 SameSite=Lax,既能挡掉大部分 CSRF,又不会影响正常的站外跳转。当然,敏感操作(转账、改密)光靠 SameSite 还不够,业务代码里还应该加 CSRF Token 校验,这是另一套东西了。
到这里,Cookie 和 Session 做登录状态保持这条链路就完整了:从 HTTP 无状态聊到 Session 底层存储,从 Cookie 属性拆解到拦截器落地,从重启丢 Session、跨域丢 Cookie这些高频坑,再到固定会话、XSS、CSRF 三个安全点。
根据我个人的经验,很多项目一开始图省事会把 Session 超时设得很长,觉得用户少,内存无所谓。可一旦用户量涨上来、会话数量上去了,Tomcat 默认攒不住那么多长期闲置的会话,反而会出现内存告警。我的建议是:单体项目直接上 Spring Session Redis,Online 用户多了也不慌;没有 Redis 资源的小项目,就不要把会话超时设得太长,30 分钟是性价比最高的平衡点。另外再分享一个小技巧,排查登录状态问题的时候,不要一上来就看代码,先打开 DevTools 看请求头里有没有带上 Cookie、响应头里有没有 Set-Cookie,十有八九的问题在这一步就能定位。登录状态下线了,优先确认是不是浏览器没存住这个 Cookie,再去看代码,节省的时间比你想得多。
