用户登录是所有Web应用都躲不开的"基建工程",而SpringBoot下的Cookie与Session方案,又是这套基建里最经典、也最容易稀里糊涂"能用但不知道原理"的部分。我见过太多项目,登录接口写完能跑就完事,结果一到线上就出现"明明登录了,一刷新就掉线""session老失效""多端登录互相挤下线"这类问题。这篇文章不打算给你堆概念,我就以一套完整的用户登录及登录状态保持功能为线索,从为什么用Session配合Cookie讲起,一直到代码落地、拦截器校验、常见坑排查,全部过一遍,代码可以直接抄。
1. 为什么这套"老掉牙"的Cookie+Session方案依然值得用
先把话说透:网上铺天盖地都在聊JWT、OAuth2、单点登录,但中小型项目里,SpringBoot配合Servlet容器自带的HttpSession,加上浏览器端的Cookie,依然是最省心、最不容易写错的选择。尤其当你做的后台管理系统、小型电商站点、内部工具平台,用户量和安全等级还没有到必须上分布式会话那一步时,这套方案读起来简单,排查起来也有明确的锚点。
Cookie和Session的分工其实非常朴素。Cookie是放在客户端的小纸条,上面只写一个会话标识(Session ID),本身不携带任何用户隐私数据;Session是放在服务端的一块内存,存储当前会话对应的用户ID、登录时间、权限范围等信息。用户每次请求都带上这张小纸条,服务端根据小纸条上的Session ID去自己的仓库里找到对应的用户信息,这就是"登录状态保持"的全部秘密。
这里面有几个点必须搞清楚,也是网上很多教程讲不明白的地方:
第一,Session是服务端状态,Cookie只是钥匙。你完全可以不用Cookie,让客户端通过Header传Session ID,甚至把Session ID写死在URL里(不推荐),但Cookie是浏览器自动携带会话标识的最顺手通道,这也是默认做法。
第二,Session默认存哪里。SpringBoot内嵌的Tomcat,默认把Session放在JVM内存里。单机部署时它非常好用,一旦你做多实例部署、负载均衡,请求打到了没有该Session的另一台机器上,用户就会"莫明其妙掉线"。
第三,Cookie的生存期决定"登录保持多久"。Session本身默认30分钟不活动会过期,但你关掉浏览器再打开,Session ID还在不在,取决于这个Cookie是不是会话级Cookie。如果没设置Max-Age,它就是会话Cookie,浏览器一关就没了,下次打开还得重新登录。
所以,当我们说"实现用户登录及登录状态保持"时,实际要做的事情有三件:登录成功后写入Session;生成并下发Session ID到Cookie;在后续请求中拦截校验,确认会话合法且未过期。下面就从工程实现的角度把这三件事一件件落地。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的准备:依赖、数据表与核心实体
我假设你的项目是一个标准的SpringBoot工程,用的是Spring MVC,模板引擎我建议先用Thymeleaf或者接口式前后端分离都行,但为了演示完整,我会同时给出控制器返回视图和返回JSON的写法。如果你刚开始搭项目,依赖这块直接照抄下面这段:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql.cj</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
技术上用MySQL存储用户数据,用JPA做持久化。你也可以换成MyBatis,不影响本文的核心逻辑。先建一张最精简的用户表,字段宁少勿多,方便聚焦登录逻辑本身:
sql复制CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL COMMENT '登录账号',
`password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
`last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
密码字段必须要用BCrypt加密,这是直接关系到账户安全的底线。spring-boot-starter-web里已经带了spring-security-crypto的依赖吗?没有,你需要单加一个:
xml复制<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
只用它的BCryptPasswordEncoder做密码哈希,不引入Spring Security的过滤器链,避免过度设计。
对应的JPA实体类长这样:
java复制@Entity
@Table(name = "sys_user")
public class SysUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String password;
private String nickname;
private Integer status;
@Column(name = "last_login_time")
private LocalDateTime lastLoginTime;
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
}
有一个细节需要提醒:创建用户时,密码加密不要放在Controller里做,也不要散落在Service各处。写一个UserService统一处理注册与密码校验,之后所有的密码比对都走这一个入口。这样以后升级加密算法、增加密码策略,只需要改一个类。
我在真实项目中踩过一次坑:注册接口和后端脚本生成用户时用了不同的加密方式,导致某些用户用正确密码也登不进去。后来统一收口,问题直接消失。这就是"单一职责"带来的实际好处,不是嘴上说说的原则。
3. 登录核心链路:从校验用户名密码到会话落库
登录功能的代码看起来简单,但很多人会忽略"登录状态本质上是会话数据"这件事。我先给你看一个清爽的登录接口设计,然后逐行讲为什么这样写。
java复制@Service
public class UserService {
private final SysUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public SysUser login(String username, String rawPassword) {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
if (user.getStatus() != 1) {
throw new RuntimeException("账号已被禁用,请联系管理员");
}
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
return user;
}
}
这里有个小细节:用户名不存在和密码错误,抛出的异常文案必须都是"用户名或密码错误"。我见过有项目区分"用户不存在"和"密码错误"的,这就等于把注册用户名的可能性暴露给了攻击者,属于典型的信息泄露漏洞。
Controller里真正的登录动作是这样的:
java复制@PostMapping("/login")
public String doLogin(@RequestParam String username,
@RequestParam String password,
HttpServletRequest request,
HttpServletResponse response,
Model model) {
SysUser user = userService.login(username, password);
// 核心:获取或创建会话,并把用户标识写入Session
HttpSession session = request.getSession(true);
session.setAttribute("loginUserId", user.getId());
session.setAttribute("nickname", user.getNickname());
session.setMaxInactiveInterval(30 * 60); // 30分钟无操作过期
// 顺手更新最后登录时间
userService.recordLoginTime(user.getId());
// 如果希望关掉浏览器再打开依然保持登录,就要设置Cookie的Max-Age
Cookie cookie = new Cookie("JSESSIONID", session.getId());
cookie.setPath(request.getContextPath().isEmpty() ? "/" : request.getContextPath());
cookie.setMaxAge(2 * 60 * 60 * 24); // 2天
cookie.setHttpOnly(true);
response.addCookie(cookie);
return "redirect:/dashboard";
}
你要是只做纯API接口,返回值换成Map<String, Object>或者R<Object>结构就行,核心代码不变。
在继续写拦截器之前,我必须把上面两个"多余动作"讲透,它们才是网上教程经常一带而过但实战中绕不开的细节。
getSession(true)的意思是:如果当前请求没有关联Session,就新建一个。如果你用false,拿不到就返回null,适合在拦截器里判断"是否已登录"时使用。这里登录成功必须用true,否则用户信息没地方放。
会话超时时间设置为30分钟,setMaxInactiveInterval作用于这个Session的存活周期。注意这个时间是基于"不活动"计算的,不是绝对寿命——只要用户一直有操作,这个会话就一直有效;一旦超过30分钟没任何请求,会话才会失效。这是Servlet规范默认的行为,适合绝大多数后台系统。
然后是我整篇文章最想强调的一点:手动创建并下发JSESSIONID的Cookie。为什么?因为默认情况下,Tomcat虽然会把Session ID写到名为JSESSIONID的Cookie里,但这个Cookie默认是会话级别,不设Max-Age,关闭浏览器就没了。你可能觉得"这很合理啊",但产品经理绝对会提需求:"用户关掉浏览器再打开,不想重新登录。"
碰上这种需求,如果你不知道可以手动覆盖JSESSIONID的Cookie属性,你很可能去网上搜个所谓的"记住我"功能,绕一大圈用数据库记录登录令牌,反而把问题搞复杂了。
实际上,你只要在登录成功时,拿到当前session.getId(),放进一个同名Cookie并设置Max-Age,就能实现"关闭浏览器后会话保持"。这是因为Tomcat在接受Cookie时会优先使用客户端回传的JSESSIONID,只要这个Cookie没失效,Tomcat会根据ID在服务端找到对应的Session。Session在服务端的存活时间由setMaxInactiveInterval控制,只要30分钟内有操作,Session一直存在。
这里就体现出"Cookie生命周期"和"Session生命周期"是两套独立时钟:
| 对象 | 默认生命周期 | 作用 |
|---|---|---|
| Session | 30分钟不活动失效 | 存在于服务端,决定登录逻辑是否认为你已登录 |
| Cookie | 浏览器会话结束即失效(不设置Max-Age时) | 存在于客户端,决定浏览器是否会主动回传Session ID |
我在第六节会专门讲这两套时钟不一致时引发的诡异问题,这里先按下不表。
4. 登录状态校验:拦截器里搞定"我到底有没有登录"
登录接口写完后,下一步就是"受保护资源"的判断逻辑。网上很多做法是每个Controller里都拿request.getSession(false)判断一下,代码重复不说,还容易漏。正确姿势是写一个HandlerInterceptor,在请求进入Controller之前统一做会话校验。
java复制@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 放行预检请求,前后端分离时必须处理
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
HttpSession session = request.getSession(false);
// 关键:判断会话中是否存在我们登录时写入的标识
if (session == null || session.getAttribute("loginUserId") == null) {
// 如果是接口请求,返回401;如果是页面请求,重定向到登录页
String requestedWith = request.getHeader("X-Requested-With");
if ("XMLHttpRequest".equals(requestedWith)) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}");
} else {
response.sendRedirect("/login");
}
return false;
}
// 每接收一次请求,Session的过期时间会自动续期,不需要手动处理
return true;
}
}
这段代码看起来简单,却藏着一个重要知识点:request.getSession(false)不会新建Session,所以如果Cookie过期、会话超时,这里拿到的就是null,从而拦截下来。
拦截器注册方式如下:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginInterceptor loginInterceptor;
public WebConfig(LoginInterceptor loginInterceptor) {
this.loginInterceptor = loginInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/error");
}
}
排除路径的列表,一开始宁可多列一些静态资源,免得页面样式全部失效。我有一次就因为在本地开发时没排除/css/**,项目里模板页面全部裸奔,排查了半天才发现是被拦截器拦了。
这块还有一个平时不太注意但面试常问的点:为什么每次请求都会自动续期Session? 这是Servlet容器在HttpSession被访问时自动处理的逻辑。只要一个会话的Session被getSession()获取到,并且setAttribute被调用,容器就会更新它的"最后访问时间"。换句话说,一个活着的会话只要持续有请求,它永远不会超时。你不需要在拦截器里手动session.setMaxInactiveInterval,那反而是画蛇添足。
拦截器职责边界也要划清楚:它只负责"用户身份是否存在",不负责"用户权限是否足够"。角色权限是另一套体系,硬塞进登录拦截器会让代码膨胀且难以维护。我用过最舒适的组合是:登录拦截器负责阻断未登录访问,方法级注解配合切面做权限校验。
5. 退出登录不是只删Cookie就完事:清理服务端会话
退出登录这个功能经常被人随手一写就完事,比如最常见的错误写法:
java复制@PostMapping("/logout")
public String logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/login";
}
这段代码本身没有大毛病,但不够完善。session.invalidate()会销毁服务端会话,可客户端浏览器里的JSESSIONID Cookie还留着。虽然这个ID对应的Session已经不存在了,下一次请求拿着这个ID来,Tomcat找不到对应Session会新建一个空的,用户还是会被拦下来。问题不大,但属于流程上的不干净。
标准的退出流程应该是三步走:
java复制@PostMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// 让浏览器立即删除JSESSIONID Cookie
Cookie cookie = new Cookie("JSESSIONID", null);
cookie.setPath(request.getContextPath().isEmpty() ? "/" : request.getContextPath());
cookie.setMaxAge(0);
response.addCookie(cookie);
return "redirect:/login";
}
setMaxAge(0)就是告诉浏览器"马上删掉这个Cookie",和setMaxAge(负值)不同——负值表示会话级Cookie,浏览器关闭才删,不能用来做主动删除。路径也一定要和当初设置Cookie时的路径保持一致,否则浏览器找不到要删的Cookie,删除失败。
还有一个容易踩的坑:如果你登录时还往Session里塞过购物车、权限列表、菜单树之类的数据,退出登录后最好把这些一并清理。invalidate()会销毁整个会话,相当于全部带走,所以你不需要挨个removeAttribute,这也是比"逐个清理"更推荐的做法。
退出登录建议只支持POST请求。用GET接口做退出容易被CSRF攻击利用——攻击者只要在第三方站点放一张图片链接指向/logout,用户点击后就会在不知情的情况下被退出。这种攻击虽然不涉及敏感数据泄露,但足以影响用户心情,被安全测试标个中危漏洞也够你忙活半天。
6. 几个实测过的"掉登录"场景:Cookie与Session生命周期不一致引发的连锁反应
我这部分不写抽象的注意事项,只复盘几个曾经真实困扰过我、也困扰过同事的"用户明明登录了却像没登录"的排查案例,每一个都和Cookie/Session机制的核心逻辑有关。
6.1 定时任务里的Session操作:所有请求都在"续期",但后台任务不会
如果你在系统里集成了Quartz定时任务,或者使用@Scheduled跑批处理,而这些任务里也调用了需要登录态的业务Service,你要明白:这些后台任务不会携带HttpServletRequest,也不会触发Session访问,更不会让任何人的会话"续期"。所以后台任务执行时间再长,也不影响任何在线用户的登录状态。
怕的是另一种场景:在拦截器里你做过Session续期,但在一个长轮询请求里,请求还没结束但Session已经超时了。这其实是容器内部逻辑:只要请求正在进行,Session的访问会被请求上下文绑定,并不会因为等待时间长就立刻失效。这里真正要防的是"跨请求的线程池中使用Session"这种操作——比如把HttpSession对象存到静态变量里给别的线程用,这绝对是灾难,因为Session根本不是线程安全的,容器也没有承诺支持这种用法。
我在多个项目里反复强调过一条铁律:Session只能在当前HTTP请求线程内使用,任何异步线程、消息消费线程、定时任务线程都不许碰它。真要传用户信息,把需要的userId、nickname等值取出来,作为参数传下去。
6.2 浏览器Cookie还在,但服务端Session没了:最常见的"假登录"状态
这是线上反馈频率最高的现象。用户登录后把页面挂机一晚上,第二天打开还在页面上,点击跳转却发现被弹回登录页。用户的直观感受是"明明没关浏览器啊,为什么掉线了"。背后的真相是Session在服务端已经因为超过30分钟不活动被销毁了,但Cookie还在浏览器里。于是请求带着一个指向不存在Session的ID,request.getSession(false)返回null,拦截器拦下。
这在单体登录方案里是符合预期的,但如果我们想改善体验,常见做法有两种:
第一,适当延长setMaxInactiveInterval。后台管理系统设定为2小时,体验会明显好很多。但要注意这等同于让服务端保存更长时间的会话状态,内存占用会线性增长,尤其在用户基数大的情况下。
第二,增加"记住我"独立逻辑,刷新Cookie和Session的过期时间。严格来说这套逻辑在纯Session方案里需要做一次"会话续期"的变通:当用户在拦截器里被识别为已登录时,顺带检查这个Session的剩余空闲时间,如果低于某个阈值就延长。但这个操作在纯Session方案里没有官方API直接实现"续期到未来某个绝对时间点",只能靠设置更长的maxInactiveInterval。所以工业化最优解其实是引入Redis保存会话数据,通过Redis的TTL机制精确控制会话时长——这正好就是Spring Session要做的事,我在第八节讲。
6.3 集群部署后频繁掉线:罪魁祸首是负载均衡
这是SpringBoot应用部署成多实例之后最先出现的问题。如果有两个实例节点,用户的登录请求命中了节点A,Session写进了节点A的内存。下一次请求负载均衡把流量转发到了节点B,节点B没有这个Session的数据,于是直接判定未登录。表现就是用户疯狂掉线,而且刷新几次可能又好一下,完全看运气。
解决方案从简单到复杂排列:
- Sticky Sessions(会话粘滞):负载均衡层按Cookie里的JSESSIONID做哈希转发,让同一用户的请求始终落在同一台节点上。配置简单,但节点重启或扩缩容时依然会大面积失效。
- Session共享:把Session数据集中存放到Redis或数据库,所有节点共享同一份会话数据。这就是Spring Session方案,也是我在大项目里最推荐的路子。
6.4 证书与路径造成的Cookie丢失:优先级极低的排查项
还有一个容易忽略的就是Cookie的Path属性。设置Cookie时如果不指定Path,浏览器会用"当前请求路径"作为默认Path。如果你登录接口是/api/user/login,生成的Cookie Path就是/api/user/login,那你去访问/dashboard时浏览器根本不会带上这个Cookie,因为Path不匹配。所以我在前面的代码里,设置Cookie时一律显式指定setPath("/"),全站生效。
还有Secure属性和SameSite属性。setSecure(true)要求只有HTTPS下才发送这个Cookie,如果线上是HTTPS而本地是HTTP,你会遇到本地永远登不上、线上正常的神奇组合。setSameSite则是浏览器安全策略的一部分,影响跨站POST时能不能带Cookie,前端项目如果前后端不同域,这里要仔细调。
7. 从"能用"到"好用":三步加固你的登录系统
登录功能上线能用,和上线后扛得住各种折腾,中间至少隔了三层加固。很多博主讲Cookie和Session只讲到拦截器就收工,但实际项目里下面这三步几乎是必做的。
7.1 修改密码后强制会话失效
常规操作:用户在个人中心改密码,改完当前会话居然还活着,这其实存在安全隐患——如果账号被偷,攻击者改了密码,原用户依然可以通过既有会话保持登录。稳妥的做法是修改密码成功后,把当前用户的所有会话全部作废。
如果是纯内存Session,你没法一个API遍历所有存活的Sessions。变通做法是维护一个sessionRegistry,用一个Map记录userId到SessionId列表的映射,改密码时遍历标记失效。Spring Security内部有现成的SessionRegistry机制,不完全用Security的话,自己写也简单:
java复制@Component
public class SessionRegistry {
private final Map<Long, List<String>> userSessions = new ConcurrentHashMap<>();
public synchronized void registerSession(Long userId, String sessionId) {
userSessions.computeIfAbsent(userId, k -> new ArrayList<>()).add(sessionId);
}
public synchronized void removeSession(Long userId, String sessionId) {
List<String> sessionIds = userSessions.get(userId);
if (sessionIds != null) {
sessionIds.remove(sessionId);
}
}
public synchronized List<String> getSessionIds(Long userId) {
return userSessions.getOrDefault(userId, Collections.emptyList());
}
}
登录成功后调用registerSession(userId, sessionId),退出或超时时调用removeSession。修改密码后取出所有SessionId,调用session.invalidate()即可让所有旧会话失效。
7.2 同账号并发登录限制
产品经理经常提一个需求:"同一个账号只允许一个地方登录,新登录挤掉旧登录。"这个需求用Session机制实现起来其实很自然,因为Session本身就是"单个会话维度"。做好和7.1一样的SessionRegistry后,每当有新登录进入时,先查询该userId是否已有活跃会话,有就把旧的会话invalidate()掉。注意并发场景要加锁,我上面那段代码的synchronized就是为此加的。
7.3 防会话固定攻击(Session Fixation)
攻击者先自己登录一个合法会话,拿到JSESSIONID,然后诱导受害者带着这个ID去登录。如果登录成功后服务端没有更换Session ID,攻击者就能用同一个ID访问受害者的会话,实现"登录态劫持"。
防御手段极其简单:登录成功后必须调用request.changeSessionId(),让旧的会话ID作废,重新生成一个新的。这行代码成本极低,但往往被忽略。我要求项目里所有登录接口必须包含这一步,不管用的是Cookie+Session还是接入了Spring Security。
java复制// 登录成功后,替换Session ID,防止会话固定攻击
request.changeSessionId();
这样做之后,攻击者手里那个旧ID彻底失效,所以就算他费尽心思拿到了受害者登录前的Cookie,也办法利用。
8. 规模化后的必然升级:Spring Session + Redis 做分布式会话
单体应用做到一定规模,或者从部署第一天就知道要搞多实例,那纯内存Session方案就不能将就了,直接用Spring Session把会话挪到Redis。它的原理很简单:原本Servlet容器把Session放在JVM堆里,Spring Session通过包装HttpServletRequest和HttpServletResponse,把Session的数据读写重定向到Redis,Cookie依然是JSESSIONID,对开发者而言代码几乎零改动。
引入依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
然后在application.yml里配置Redis连接信息即可,Spring Boot的自动配置会自动接管Session存储策略。你之前写的session.setAttribute("loginUserId", user.getId())等等代码,一行都不用改,因为Servlet API被包装过,行为完全一致。
Spring Session带来的最大变化是:Session的存储和失效时长现在可以精确控制。你可以设置server.servlet.session.timeout=2h,也可以在Redis里看到每个会话的TTL,运维排查起来直观多了。
还有一种混合方案:本地内存Session + Redis缓存Session ID,请求优先走本地,本地没有再去Redis拉取。这种方案性能极好,但一致性处理复杂,非必要不整。对绝大多数项目来说,纯Spring Session Redis版已经足够稳定且简单。
如果你用的是Spring Boot 3.x,Spring Session的坐标有细微变化,建议直接查官方文档对应版本。我因为版本号写错踩过一次坑,卡了半天才反应过来是starter的artifactId都换了。这里不给你具体版本号,因为这东西更新太快,给旧版本号反而坑人。原则是:用Spring Boot的BOM统一管理版本,不要手动填版本号。
9. 登录功能自测清单:上线前照着跑一遍
代码写完了,部署到生产环境之前,我建议至少把下面这份"登录态自测清单"跑一遍。每一条都是项目里真实出现过的问题,我整理的时候把最容易出问题的点都挑出来了,照着走一遍能帮你拦住80%的线上"掉登录"事故。
| 测试场景 | 操作步骤 | 预期结果 | 关注点 |
|---|---|---|---|
| 正常登录 | 正确用户名密码 | 跳转成功,网络面板可见Set-Cookie | Cookie带HttpOnly,Path为/ |
| 错误密码登录 | 密码故意输错 | 提示"用户名或密码错误" | 文案不区分用户是否存在 |
| 关闭浏览器再打开 | 登录后关闭全部窗口再打开新窗口访问受保护页 | 仍然保持登录 | 验证JSESSIONID的Max-Age是否生效 |
| 会话过期 | 登录后闲置超过超时时间 | 再次访问被拦截,跳转登录页 | 验证Session超时策略 |
| 退出登录 | 登录状态下点退出 | 跳转登录页,浏览器Cookie被删除 | 验证Max-Age=0的删除行为 |
| 修改密码 | 登录状态下修改密码 | 当前会话失效,需重新登录 | 验证SessionRegistry是否生效 |
| 并发登录 | 两个浏览器同时登录同账号 | 旧登录被挤出 | 验证并发控制逻辑 |
| 多节点部署 | 两个实例轮询转发请求 | 用户不频繁掉线 | 验证Session共享方案 |
有一点需要特别说明:自测清单中的"会话过期"测试,不要用"修改系统时间"来模拟,那样会把Session和Cookie的时间全弄乱,容易产生误导。直接在登录后等够设定的超时时间再来访问最靠谱。如果是30分钟不方便等,测试时可以把setMaxInactiveInterval(60)临时换成60秒。
另外,调试登录态问题时,我强烈建议你学会看浏览器开发者工具。Network面板里看请求头Cookie值,Application面板里看Cookie详情,服务端日志里看Session创建和销毁的事件。很多"登录态丢了"的问题,光看代码是看不出来的,打开面板直接就能判断出是Cookie没带上还是Session不存在。
10. 最后分享一个排查技巧:让日志告诉你"会话去哪了"
项目里排查登录问题,最怕的就是无头苍蝇式乱猜。我这里分享一个实用的技巧:在拦截器和登录接口里把Session关键信息打印出来,方便快速定位问题。注意只需要在本地开发环境或者debug级别下输出,线上别打info级别,会刷日志。
java复制// 拦截器里可临时加的日志
if (session != null) {
log.debug("请求路径: {}, Session ID: {}, 创建时间: {}, 最后访问: {}, 登录用户ID: {}",
request.getRequestURI(),
session.getId(),
new Date(session.getCreationTime()),
new Date(session.getLastAccessedTime()),
session.getAttribute("loginUserId"));
}
这个日志能帮你快速回答三个问题:
- 浏览器发的JSESSIONID和Tomcat实际用的Session ID是否一致?
- Session的创建时间和最后访问时间是否在频繁更新?
- loginUserId属性到底有没有成功写入?
有一次我排查一个用户"A账号登录后访问却显示B账号信息"的诡异问题,靠的就是这个日志。最后发现是拦截器放行了一个/assets/**路径下伪造的Controller,它读的是另一个不同的Session Key。改成统一从session.getAttribute("loginUserId")取值后,问题立刻消失。
这些排查经验总结成一句话:登录状态保持并不难,难的是对它运行机制的深入理解——Cookie是钥匙,Session是保险箱,两者既要匹配又要各自管好生命周期。你真的把这两个东西吃透了,登录功能对你来说就是手到擒来的事。
