说到 SpringBoot 里的用户登录,很多人第一反应是上框架、上安全组件,其实最经典、也最容易被忽视的一套组合拳,就是基于 Cookie 和 Session 的登录状态保持。HTTP 本身是无状态的,一次请求结束后服务器就“忘了你”,而我们要做的,就是让服务器在用户登录之后能记住他的身份。这篇文章我会用一套完整的 SpringBoot 项目示例,把用户登录、Session 存取、Cookie 传递、拦截器校验、退出登录以及状态保持的细节全部拆开讲,包括我实际开发中踩过的坑和排查思路。无论你是刚接触 SpringBoot 的初学者,还是被“登录态丢失”“Session 取不到值”折磨过的开发,这篇内容都值得看完。
1. 用户登录与状态保持:先想清楚要解决什么问题
1.1 HTTP 无状态协议带来的“记忆难题”
在开始写代码之前,我想先聊聊这个问题的本质。Web 应用的底层协议是 HTTP,而 HTTP 从设计之初就是无状态的。什么叫无状态?就是你发给服务器的每个请求,在服务器眼里都是“第一次来”,它不会自动记住你上一次请求做了什么。
反过来想,如果没有登录状态保持机制,用户每次访问需要权限的页面,都得重新输入一次用户名密码。这种体验显然是没法用的。所以我们需要一套机制,让服务器在用户身份验证成功后,给这个用户发一个“通行证”,浏览器在后续请求中自动带上这个通行证,服务器看到通行证就知道这个人是谁、已经登录过了。
Cookie 和 Session 就是这套机制里最经典的一对。Session 解决“服务器如何记住用户身份”的问题,Cookie 解决“浏览器如何把身份凭证传给服务器”的问题。两者配合,构成了一个完整的登录状态保持链路。
1.2 为什么还在用 Cookie + Session,而不是一律上 Token
你可能要问了,现在前后端分离、微服务这么流行,Token(比如 JWT)不是很常见吗?为什么还要学 Cookie + Session?
我的观点是:这两套方案不是替代关系,而是适用场景不同。传统服务端渲染的 Web 应用,比如用 Thymeleaf、JSP 或者 Freemarker 写的页面,天然适合 Cookie + Session,因为页面跳转是服务端掌控的,Session 数据存在服务端也更安全、更方便控制(比如管理员能直接踢人下线)。而纯 API 服务、移动端 App 对接,Token 确实更灵活。
更重要的是,很多旧系统、企业内部系统、管理系统,至今还在用 Cookie + Session 这套架构。你如果不理解 Session 的存储机制、Cookie 的传递规则,遇到“登录后跳转又变回未登录”“Session 时不时丢失”这类问题,会非常被动。这套基本功不是过时的知识,而是每个后端都要掌握的底层能力。
1.3 这套方案解决的核心问题与适用人群
具体来说,这篇要实现的登录方案解决四件事:
- 用户提交用户名和密码,服务端完成身份校验。
- 校验通过后,服务端创建 Session,并把用户信息放入 Session。
- 服务端通过响应头把 Session ID 写入浏览器的 Cookie。
- 后续请求带着 Cookie,服务端根据 Session ID 找到对应 Session,从而识别用户身份并保持登录状态。
同时,还要实现登录拦截——未登录用户访问受保护页面时,自动跳转到登录页;以及退出登录——销毁 Session,让登录状态失效。
适合阅读这篇文章的人包括:刚开始接触 SpringBoot 登录开发的新人,面试前想梳理登录流程的同学,以及在实际项目中遇到 Session 丢失、拦截器不生效、登录状态串号等问题的开发者。关于一些更进阶的集群 Session 共享问题,我也会在后面的篇幅里展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Cookie 与 Session:先把底层机制彻底讲透
2.1 Cookie:浏览器替你保管的小纸条
Cookie 本质上就是一小段文本,由服务器在 HTTP 响应里通过 Set-Cookie 头下发,浏览器收到后按规则存起来。之后浏览器访问同一域名下的资源时,会自动在请求头里带上 Cookie 字段,把这个小纸条原样送回服务器。
举个例子,用户登录成功后,服务器返回的响应头大概长这样:
code复制HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=7A1B2C3D4E5F; Path=/; HttpOnly
浏览器收到后,就把 JSESSIONID=7A1B2C3D4E5F 这个键值对存到本地 Cookie 里。下一次请求发起时,浏览器自动在请求头里加上:
code复制Cookie: JSESSIONID=7A1B2C3D4E5F
服务器拿到这个 JSESSIONID,就知道该去哪个 Session 里找数据了。这里有个重要特点:Cookie 是保存在客户端的,所以它有几个天然短板——用户可以手动删除、可以被篡改、真机抓包也能看到明文内容。所以千万不要把密码、身份证号这类敏感信息直接塞进 Cookie,Cookie 里最合适的角色就是“Session 的唯一标识”,真正的用户数据留在服务端 Session 里。
2.2 Session:服务端的“用户档案柜”
如果说 Cookie 是贴在浏览器上的便利贴,那 Session 就是服务端的一排档案柜。用户登录成功后,服务器会创建一个独立的 Session 对象,分配一个唯一的 Session ID,然后把用户信息(用户名、昵称、角色等)写进这个对象里存好。
Session 在服务端默认是存在内存中的,也就是 JVM 堆里。以当前这个用户为例,HttpSession 内部就是一个 Map 结构,你往里 setAttribute("loginUser", user),本质就是往这个 Map 里放了键值对。因为数据存储在服务端,所以安全性比重型 Cookie 方案要好很多——客户端拿不到 Session 里的真实数据,只能拿一个不直观的 Session ID。
不过内存存储也带来两个问题。一是会话期间服务器重启,内存里的 Session 全部丢失,用户会被迫重新登录。二是应用部署多个节点时,用户的请求如果被负载均衡分发到不同节点,就可能出现“在这个节点登录了、下一个节点不认”的情况。这两个痛点我会在后面的集群 Session 共享部分给出解决思路。
2.3 握手流程:Session ID 是怎么从服务端跑到浏览器的
整个过程可以压缩成一张时序图自己脑补一下:
- 用户打开登录页,输入账号密码提交。
- 服务端校验用户名密码正确。
- 服务端调用
request.getSession(true)创建 Session(如果之前没有),并生成唯一 Session ID。 - 服务端把用户信息
setAttribute进 Session。 - 服务端在响应头写
Set-Cookie: JSESSIONID=xxx。 - 浏览器保存 Cookie,后续所有同域请求自动携带。
- 服务端拦截器通过 Cookie 里的 Session ID,调用
request.getSession(false)找到对应 Session,取出 loginUser 判断是否登录。
这里需要注意第 3 步和第 7 步里 getSession 的传参区别。getSession(true) 表示“如果没有 Session 就新建一个”,而 getSession(false) 表示“没有 Session 就返回 null,不要新建”。拦截器里判断登录状态时务必用 getSession(false),否则每个未登录请求都会被强行制造一个新 Session,白白浪费内存,还会干扰统计逻辑。
2.4 SpringBoot 里的 HttpSession 封装
SpringBoot 内置了 Tomcat 作为默认 Servlet 容器,HttpSession 这个接口就是 Servlet 规范里定义的会话模型。SpringBoot 帮我们把 Session 的创建、Cookie 下发、Session 查找全部封装好了,你在 Controller 里只需注入 HttpSession 参数即可使用。
需要注意,SpringBoot 对 Session 的自动配置还提供了几个非常实用的配置项。比如 server.servlet.session.timeout 控制会话超时时间;server.servlet.session.cookie.name 可以自定义 Cookie 名称,而不是默认的 JSESSIONID;Cookie 的 HttpOnly、Secure、SameSite 属性也可以通过配置直接控制。这些配置后面我会逐个给出示例和参数说明。
3. SpringBoot 登录功能的具体实现:从零搭一套可运行的示例
3.1 项目结构与必要依赖
为了把登录状态保持的完整链路跑起来,我们需要一个 SpringBoot Web 项目,并且为了展示“登录后页面记住用户”的效果,我引入了 Thymeleaf 作为服务端模板引擎。这样可以直接在页面上通过 Session 里的用户信息做展示,很适合用来观察登录状态的流转。
这里先给出最关键的 Maven 依赖:
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
为什么没引入 Spring Security?我刻意不引入。Spring Security 虽然提供了现成的登录和会话管理,但它的过滤器链和 SecurityContext 机制对初学者来说像个黑盒,一旦登录状态出问题,排查难度成倍增加。先用纯粹的 Servlet Session 实现一遍,把底层逻辑吃透,后面即使上 Spring Security,你也能快速理解它的 Session 管理思路。
项目结构我习惯这样组织:
code复制src/main/java/com/example/login
├── config
│ └── WebConfig.java
├── controller
│ └── LoginController.java
├── interceptor
│ └── LoginInterceptor.java
├── model
│ └── User.java
└── service
└── UserService.java
3.2 用户模型与模拟数据源
先写一个最简的 User 实体类:
java复制public class User {
private String username;
private String password;
private String nickname;
public User() {
}
public User(String username, String password, String nickname) {
this.username = username;
this.password = password;
this.nickname = nickname;
}
// getter / setter 省略
}
登录肯定要校验用户数据,实际项目中这里会查数据库、核对加密密码。为了让示例专注在“登录状态保持”这个主题,我写了一个基于内存 Map 的模拟 UserService,真实项目替换数据源即可:
java复制@Service
public class UserService {
private final Map<String, User> userMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
userMap.put("admin", new User("admin", "123456", "系统管理员"));
userMap.put("zhangsan", new User("zhangsan", "123456", "张三"));
}
public User login(String username, String password) {
User user = userMap.get(username);
if (user == null || !user.getPassword().equals(password)) {
return null;
}
return user;
}
}
这里有几个细节值得说明下。ConcurrentHashMap 在高并发读写场景比普通 HashMap 安全,虽然是模拟数据,也顺手养成好习惯。真实项目中密码绝不能明文保存,至少要加盐后做 BCrypt 这类不可逆哈希,但这不是本文的重点,所以示例里用了明文对比。
3.3 登录接口:校验用户名密码并创建 Session
登录接口是整套机制的核心入口。这里我用了 @Controller 而不是 @RestController,因为需要返回视图页面而不是 JSON。如果做前后端分离,接口改成返回 JSON、前端配合保存 Cookie 即可,原理是一样的。
java复制@Controller
public class LoginController {
@Autowired
private UserService userService;
@GetMapping("/login")
public String loginPage() {
return "login";
}
@PostMapping("/login")
public String doLogin(String username, String password,
HttpSession session, Model model) {
User user = userService.login(username, password);
if (user == null) {
model.addAttribute("error", "用户名或密码错误");
return "login";
}
// 用户信息放入 Session,登录状态从此开始
session.setAttribute("loginUser", user);
return "redirect:/index";
}
@GetMapping("/logout")
public String logout(HttpSession session) {
// 销毁整个 Session,登录状态彻底失效
session.invalidate();
return "redirect:/login";
}
}
通过表单提交过来的 username、password,SpringMVC 会自动进行参数绑定。校验失败时把错误信息塞进 Model 回到登录页提示用户;校验成功后,关键动作就是 session.setAttribute("loginUser", user),这一行代码执行完,服务端就记住了当前用户。
为什么这里用 redirect:/index 而不是直接返回 index 视图?重定向会发起一次全新的 GET 请求,让浏览器带着新下发的 Cookie 重新访问首页,这样能更真实地验证“登录状态是否在跨请求场景下保住了”。直接返回视图虽然也能看到页面,但地址栏还停留在 /login,刷新时会变成重复提交,属于反模式。
3.4 首页展示:利用 Thymeleaf 读取 Session 数据
登录状态保持成功之后,首页需要能读到当前登录用户。在 Thymeleaf 模板里,访问 Session 里数据的方式是使用 session.loginUser 这种表达式。比如这样:
html复制<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>系统首页</title>
</head>
<body>
<h1>首页</h1>
<p th:text="'欢迎你,' + ${session.loginUser.nickname}">欢迎你</p>
<a th:href="@{/logout}">退出登录</a>
</body>
</html>
这里用了一个很实用的 Thymeleaf 特性:直接通过 session 关键字访问 HttpSession 里的属性。session.loginUser.nickname 会先取 Session 中 key 为 loginUser 的对象,再读取该对象的 nickname 属性。如果用户没登录就访问这个页面,表达式会抛异常或显示空值,这也从侧面说明了拦截器存在的必要性——必须在请求进入页面之前就把未登录用户拦下来。
登录页模板也不复杂,注意表单的提交地址和字段名要和 Controller 的参数对应:
html复制<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>用户登录</title>
</head>
<body>
<h1>用户登录</h1>
<form th:action="@{/login}" method="post">
<div>
<label>用户名:</label>
<input type="text" name="username">
</div>
<div>
<label>密码:</label>
<input type="password" name="password">
</div>
<div th:if="${error}" style="color: red;" th:text="${error}"></div>
<button type="submit">登录</button>
</form>
</body>
</html>
3.5 登录拦截器:未登录用户一律拦住
现在服务端已经有 Session 能存登录状态了,但如果不加拦截,未登录用户直接访问 /index 也能看到页面,因为 Controller 里并没有校验 Session。拦截器就是这道安全防线。
SpringMVC 的 HandlerInterceptor 提供了 preHandle、postHandle、afterCompletion 三个回调方法。我们只要在 preHandle 里做登录判断即可:如果 Session 中有 loginUser,放行;否则重定向到登录页并拦截请求。
java复制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("loginUser") != null) {
return true;
}
response.sendRedirect("/login");
return false;
}
}
再强调一次,这里必须用 getSession(false)。如果改用 getSession() 或者 getSession(true),未登录用户的请求都会在拦截器里被强行创建 Session,等于每来一个陌生人就给人家发一张临时通行证,非常浪费。
接下来把拦截器注册到 SpringMVC 中。新版 SpringBoot 推荐实现 WebMvcConfigurer 接口来添加拦截器,同时要放行登录页、登录接口和静态资源,否则会出现“登录页被拦截器拦住形成死循环”的翻译现场:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/css/**", "/js/**", "/images/**", "/error");
}
}
addPathPatterns("/**") 表示拦截所有路径,excludePathPatterns 明确哪些路径不拦截。这里有个很容易遗漏的细节:Thymeleaf 模板里引用的 css、js、图片等静态资源路径,如果忘了写进白名单,拦截器会把这些资源请求也重定向到登录页,结果就是页面样式全部丢失,甚至报错。用 SpringBoot 默认静态资源路径的话,/css/**、/js/**、/images/** 这几个模式要记得带上。
3.6 配置 Session 超时与 Cookie 属性
到这里,一个最简版的登录链路已经能跑了。但要让登录状态保持更符合生产要求,还得在 application.properties 里做几个关键配置:
properties复制spring.application.name=login-demo
server.port=8080
# Session 超时时间,单位默认为分钟
server.servlet.session.timeout=30m
# 自定义 Session Cookie 名称
server.servlet.session.cookie.name=MY_SESSION_ID
# Cookie 属性
server.servlet.session.cookie.path=/
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=false
server.servlet.session.cookie.same-site=lax
逐条解释一下。server.servlet.session.timeout=30m 表示用户 30 分钟不操作,Session 自动过期,这是最基础的登录状态保持策略。server.servlet.session.cookie.name=MY_SESSION_ID 可以把默认的 JSESSIONID 换成自定义名称,这样在浏览器开发者工具里更容易辨别自己的 Cookie,也避免跟同域下其他应用冲突。
http-only=true 非常关键,它告诉浏览器这个 Cookie 不允许被 JavaScript 读取,能有效防范 XSS 攻击盗取会话凭证。secure=false 表示开发环境用 HTTP 也能传递 Cookie;生产环境开启 HTTPS 后应改成 true,仅通过加密连接传输。same-site=lax 是控制第三方请求场景下 Cookie 是否携带的属性,后面我会专门讲它的坑。
注意:
server.servlet.session.cookie.same-site这个配置项在较新版本的 SpringBoot 中才提供。老版本需要自定义 Cookie 序列化或通过内嵌容器配置实现,如果启动报配置无法识别,优先检查 SpringBoot 版本。
4. 登录状态保持的细节与进阶处理
4.1 “记住我”功能与 Session 超时策略
很多系统里会提供一个“记住我”复选框,让用户关闭浏览器后一段时间内仍然保持登录。严格来说,纯 Session 模式下“浏览器关闭后仍然有效”有一定实现局限,因为 Session Cookie 默认是会话级 Cookie,浏览器关闭就没了。
要支持“记住我”,常见做法有两种。第一种是延长 Session 超时时间,同时把 Cookie 的 Max-Age 设置得较长。在 SpringBoot 中,server.servlet.session.timeout=7d 就能让 Session 存活 7 天,但要注意这会让所有用户的会话都长期有效,安全风险偏高。
第二种做法更贴近生产:对勾选了“记住我”的用户,在登录时额外生成一个长效的随机令牌(Token)写入持久化 Cookie,同时服务端存一份令牌和用户 ID 的映射关系。每次请求先查 Session,Session 失效就用令牌自动重建登录状态。这个方案本质上是“Session + RememberMe Token”组合,Spring Security 里的 RememberMe 就是类似思路。
我的建议是:企业内部系统、金融类系统,不要轻易开放“记住我”;对体验要求高的门户类应用,可以用第二种方案,但必须给 Token 设置过期时间、绑定用户 IP 或者设备信息,并且要支持用户主动“注销所有设备”时把这些 Token 全部吊销。
4.2 Cookie 安全属性:HttpOnly、Secure、SameSite 怎么配
Cookie 属性里最少有三个安全项要重视,很多人只配 HttpOnly 就完事了,其实不够。
HttpOnly 我刚才说过,防止 JavaScript 读取 Cookie,这是 XSS 攻击的基础防线。Secure 的用处是限制 Cookie 只在 HTTPS 连接中传输,防止明文网络中 Cookie 被中间人截获。SameSite 则有三个取值:
Strict:任何跨站请求都不带 Cookie,最安全,但用户在站外点击链接进入本站时,第一次请求也不会带 Cookie,体验会差点。Lax:允许顶级导航场景下的跨站 GET 请求携带 Cookie,比如从外部链接点击进入,兼容性和安全性比较均衡。None:所有跨站请求都带 Cookie,前提是必须同时设置Secure,也就是说必须要 HTTPS。
实际开发中,如果发现“从别的应用链接跳转到本系统,Session 总是丢”“前端页面在 iframe 里嵌系统,登录态时好时坏”,大概率是 SameSite 设置问题。浏览器全面收紧第三方 Cookie 之后,None 的裸奔行为不可取,Lax 是多数站点的默认选择。
4.3 同一账号重复登录与强制踢人下线
曾经有个安全评审要求我处理“同一账号多地登录”问题。最简单的粗放方案是允许重复登录,但这在金融、管理后台场景里是不被允许的。要控制同一账号只能在一个会话中保持登录,核心思路是:每个用户只保留最新一次登录产生的 Session ID。
具体做法是,在用户登录成功后,把 Session ID 存到一个全局 Map 里,key 是用户 ID 或用户名,value 是最新的 Session ID。用户每次请求经过过滤器时,取当前 Session 的 ID 和全局 Map 中记录的 ID 比对,不一致就说明该账号已经在别处登录,当前请求直接踢出系统。
这里有个细节,“踢出系统”不能只靠比对后重定向,更彻底的方式是把这个旧的 Session 直接失效。但由于 HTTP 无状态,服务端无法主动销毁一个不活跃的 HttpSession,只能在对方的下一次请求时检测到冲突,然后调用 session.invalidate()。果不其然,老旧的 Session 在下一次请求时会走拦截器失败流程,用户自然被“踢下线”。如果项目用了 Redis 存储 Session,可以通过 Spring Session 的 SessionRegistry 直接在服务端把目标会话删除,响应会更及时。
4.4 多实例部署下的 Session 共享
当应用从单机部署变成多实例集群时,纯内存 Session 的问题立刻暴露出来。假设你有两个实例 A 和 B,用户在实例 A 登录了,Session 存在 A 的内存里;下一次请求被负载均衡转发到 B,B 的内存里没有这个 Session,它就会认为用户未登录,跳回登录页,用户体验就是“登录状态飘忽不定”。
解决思路其实也简单:把 Session 的存储位置从 JVM 内存挪到共享存储里,让所有实例都能读取。业界最常用的方案是 Spring Session + Redis,大致三步:
- 引入
spring-session-data-redis依赖。 - 配置 Redis 连接信息。
- 启动类加
@EnableRedisHttpSession注解。
引入之后,原本代码里的 HttpSession 操作完全不用改,Spring Session 自动接管了 Session 的创建和查询逻辑。它还会把 Session 数据序列化后写入 Redis,并且通过 Cookie 里保存的 Session ID 在 Redis 中查找对应会话。这样无论请求落到哪个实例,都能从 Redis 取到同一个 Session,登录状态就固定了。
另外要注意,Session 里的 loginUser 对象必须实现 Serializable 接口,否则 Spring Session 在往 Redis 存数据时会抛出 NotSerializableException。这个坑很多人第一次接入 Redis Session 都会踩,实际经验告诉我,提前给所有打算放进 Session 的实体类实现序列化接口,能省掉很多排查时间。
5. 常见问题与排查实录
5.1 Session 明明存了值,请求一到就没了
这是我在面试和技术群里被问得最多的问题。出现这种现象,优先排查几件事。
首先确认浏览器有没有正确保存 Cookie。打开浏览器开发者工具,找到 Network 面板,看登录请求的响应头里有没有 Set-Cookie: MY_SESSION_ID=xxx,再看后续请求的请求头里有没有把 Cookie 带上。如果后续请求没有带 Cookie,说明浏览器没存下或者不让带,常见原因就是 SameSite 设置过严格或者 Cookie 配置了 Secure 但访问还走 HTTP。
其次检查拦截器或过滤器里是否调用了 getSession() 新建了 Session。有一次我一个同事在过滤器里统一往 Session 里塞路径信息,写的是 request.getSession(),结果每个请求都新建 Session,老 Session 里的登录信息一直在新 Session 里找不到,登录状态永远保持不住。正确的做法是 request.getSession(false)。
最后,如果应用部署在集群环境,排查是否配置了 Session 共享。没有共享存储,Session 就只能沦为单机内存的临时数据,请求在节点间一飘,状态就丢。
5.2 静态资源被拦截导致样式丢失
这个问题的表现是首页能打开,但没有 CSS、JS 效果,控制台一堆 302 到 /login 的请求。本质就是拦截器把静态资源也给拦住了。
解决方案就是我在 WebConfig 里演示的 excludePathPatterns("/css/**", "/js/**", "/images/**", "/webjars/**")。要注意 Wildcard 的写法,Spring 的 Ant 风格路径里 /** 匹配多级目录,/* 只匹配一级目录,如果模板里引用的是 /js/lib/main.js,必须用 /js/**。
5.3 中文用户名或昵称乱码
这其实是两个环节的问题。一是表单提交到服务端的编码,SpringBoot 内置的 CharacterEncodingFilter 默认把请求和响应都设置为 UTF-8,一般不会乱;二是写入 Cookie 时如果直接往 Cookie 里放中文,旧版 Tomcat 会抛 Cookie value cannot be represented as ASCII 异常。
解决方式是不要在 Cookie 里直接存中文,Cookie 只放 Session ID 即可,用户昵称等中文信息统一放 Session。这正是我们这套方案的设计原则——Cookie 当钥匙,Session 当保险柜。
5.4 Session 超时时间配了不生效
server.servlet.session.timeout=30 这个配置如果你直接写数字,有些人会发现实际超时时间不对。这里有个容易搞混的点:SpringBoot 里这个配置如果只写数字,单位是分钟;但不同版本可能解析方式有细微差异。保险做法是写明确单位,比如 30m 代表 30 分钟,1h 代表 1 小时,7d 代表 7 天。
另外要特别注意,有些应用通过 spring.session.timeout 配了属性,这个属性在旧版本里对应 Spring Session 模块,优先级和新版本的内嵌容器配置同名不同源,建议统一使用 server.servlet.session.timeout,并把 Spring Session 相关配置单独用 spring.session.redis.* 管理。
5.5 前后端分离跨域时 Cookie 携带失效
前后端分离项目里,前端站点是 http://localhost:3000,后端接口是 http://localhost:8080,浏览器默认不会在跨域 AJAX 请求里带上第三方 Cookie。要解决这个问题,后端 CORS 配置必须显式允许携带凭证:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
}
同时前端 AJAX 请求要设置 withCredentials: true(axios 里就是 axios.defaults.withCredentials = true)。注意,一旦 allowCredentials(true),allowedOrigins 就不能再用 *,必须写明确的具体域名,否则浏览器会拒绝响应。
5.6 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 登录后立即跳回登录页 | Session 未保存或 Cookie 未下发 | 检查 Set-Cookie 响应头、拦截器是否误新建 Session |
| 集群下登录状态偶尔丢失 | 无 Session 共享 | 应用接入 Spring Session + Redis |
| 页面样式全丢 | 静态资源被拦截 | 检查拦截器 excludePathPatterns |
| Cookie 带中文报异常 | Cookie 值不能安全编码 | Cookie 只放 Session ID,中文放 Session |
| 跨域请求不带 Cookie | CORS 未允许凭证 | 配置 allowCredentials(true),前端设 withCredentials |
| 修改超时时间无效 | 配置项单位或来源错误 | 用 server.servlet.session.timeout=30m 并明确单位 |
我给的好习惯是,排查这类问题时别光看代码,先用浏览器开发者工具把“请求链路”完整看一遍——请求发没发、响应头有没有 Set-Cookie、后续请求带没带 Cookie。很多 Session 问题在浏览器面板里一眼就能定位,比反复加日志快得多。
6. 最后分享一点个人体会
写这套登录方案的过程中,我自己又踩了一遍当年入行时的坑。最大的体会是,Cookie 和 Session 的问题,十有八九不是因为代码写错,而是因为对浏览器行为和服务端存储的理解不够透彻。你理解了 Cookie 是浏览器侧的“通行证”,Session 是服务端侧的“档案柜”,很多玄学问题就变成了常识问题。
如果你现在正要给项目加登录功能,我建议先别急着引入安全框架,用原生 Session 把整条链路跑通一遍:登录、存 Session、带 Cookie、拦截器校验、退出销毁。这套流程吃透了,再去看 Spring Security 的过滤器链、再去做 Redis Session 共享,都会轻松很多。后面我打算再写一篇关于 Spring Session + Redis 实现分布式登录态保持的文章,如果你也感兴趣,可以先按本文第四节的内容把 Spring Session 的基础知识过一遍。
