☰
SpringBoot登录实战:Cookie与Session机制、登录态保持与踩坑指南
2026/10/6 3:21:54 网站建设 项目流程

1. 为什么2025年还在用Cookie与Session实现用户登录

先说个场景。我最近接手一个老项目的改造,SpringBoot单体应用,用户量不大,内部系统加对外开放的轻量级站点。需求很朴素:用户能登录、登录状态别动不动就掉、退出登录后Session别残留。技术选型阶段,团队里有人提议直接上JWT加Redis,理由是新项目就该用新方案。我倒是先问了三个问题:目前有没有Redis等中间件在跑?前段是不是纯静态分离?Token失效后用户被踢下线能不能接受?

问完这三点,结论很清楚:Cookie + Session这套经典方案完全够用,而且比JWT在某些场景下更省心。这不是什么技术倒退,而是选型要匹配实际需求。SpringBoot对原生Servlet容器内的HttpSession做了完整支持,只需要引入spring-boot-starter-web,甚至不需要额外加Redis依赖就能跑通整套登录链路。开发一个内部管理系统、中小型Web站点、或者毕业设计级别的项目,用Session做登录态保持,代码量少、逻辑直观、调试方便,出了问题直接在服务端查Session状态就行。

这个方案能解决的问题也很明确:用户输入用户名密码登录后,服务端生成一个会话标识,通过响应头Set-Cookie下发给浏览器,浏览器后续每次请求自动带上这个Cookie,服务端通过Session中的用户信息判断"你是谁、你是否已登录"。整个过程不涉及复杂的签名算法、不需要额外的基础设施、也不依赖前端配合做Token存储。对小白开发者来说,这是理解HTTP无状态协议、会话管理、登录态保持的最佳切入点;对有经验的开发者来说,理清这套机制也有助于后面理解Spring Security、Shiro等安全框架的底层设计。

真正的核心问题不只是"怎么实现登录",而是"登录之后的状态是怎么跨请求保持的"。这个机制没搞清楚,哪怕代码抄对了,一旦遇到Cookie丢失、Session失效、前后端联调对不上,照样一脸懵。

2. Cookie与Session的底层协作机制:登录态跨请求保持的原理

2.1 HTTP是无状态的,但业务是有状态的

HTTP协议本身是无状态的,每个请求都是独立的。你在浏览器里输入账号密码点击登录,请求到达服务器,服务器校验通过,返回一个"登录成功"的响应。但如果下一个请求不带任何额外的会话信息,服务器根本不知道这个请求是从刚才那个登录用户那边发来的。

为了解决这个问题,就引入了会话跟踪技术。Cookie和Session是其中最经典的一对组合。简单来说:Session存在服务器端,Cookie存在客户端(浏览器)。Session里保存用户的登录状态和用户信息,Cookie里保存Session的ID,用于告诉服务器"我是哪个会话的请求"。

用一个不太恰当但很好理解的类比:Session是酒店房间里的住宿档案,记录了住客的一切信息;Cookie是房卡,上面只写了房间号。你每次走到酒店前台(发起请求),递上房卡(Cookie),前台凭房号查出住宿档案(Session),就知道你是谁、住哪个房、住了几天。房卡丢了,前台就无法定位你的档案,你就算不上了。

2.2 Cookie在请求中到底是怎么传递的

很多初学者问"Cookie是在请求头里吗"。答案是:Cookie主要通过请求头传递,但它的源头在响应头。

整个流程是这样的:

  1. 服务端在响应时,通过Set-Cookie响应头把Cookie信息下发到浏览器。比如:
    • 响应头:Set-Cookie: SESSION=abc123; Path=/; HttpOnly; Max-Age=2592000
  2. 浏览器收到这个响应头后,会把Cookie存储在本地。只要Cookie未过期、未被人为清除、且满足Domain和Path匹配规则,浏览器就会在后续对同域名发起的请求中自动带上它。
  3. 请求头中携带Cookie,格式类似:Cookie: SESSION=abc123; USER_NAME=zhangsan

需要注意的是,浏览器对Cookie的处理遵循同源策略和路径匹配规则。Domain不匹配的域名不会带,Path不符合的路径也不会带。比如Cookie的Path设置/admin,那访问/user的请求就不会携带这个Cookie。

在一个典型的登录场景中,用户提交账号密码后,服务端把用户ID存入Session,然后通过Set-Cookie把SessionID下发。后续的所有请求,浏览器自动带上这个SessionID,服务端就能识别出当前请求属于哪个已登录用户。

2.3 Session是怎么在服务端存活的

Session本质上是一个存储在服务端的Map结构,以SessionID为Key,以会话数据为Value。在SpringBoot的默认配置下,HttpSession存储在内存中,也就是一个ConcurrentHashMap。默认实现类是StandardSessionManager(Tomcat容器自带)。

Session的生命周期有几个关键节点:

  • 创建时点:第一次调用request.getSession(true)或request.getSession()时创建。
  • 数据保存:Session中以属性(Attribute)为单位保存数据,比如session.setAttribute("userId", 10001)。
  • 销毁时点:调用session.invalidate()时立即销毁;超过超时时间无请求时自动失效;服务器重启时内存中的Session丢失。

这就是内存Session的一个天然短板:服务重启后所有用户被迫下线。所以稍微上规模的项目都会把Session往Redis里放,用spring-session-data-redis做分布式会话管理。但对中小项目来说,内存Session完全够用,重启就重启,用户重新登录一下而已。

2.4 Cookie的属性和登录态保持的关系

Cookie不是简单地存一个值就行,它上面挂的每个属性都在影响登录态的保持:

  • Max-Age / Expires:决定Cookie能在浏览器存多久。不设置的话就是临时Cookie,浏览器关闭即失效。对"记住我"功能来说,必须设置足够的存活时间。
  • Path:决定Cookie在哪些路径下会发送。一般设置为Path=/,让整个域名下的请求都能带上。
  • Domain:决定Cookie属于哪个域名。默认是当前域名,设置为.example.com可以实现跨子域名共享。
  • HttpOnly:设置为true后,JavaScript的document.cookie无法读取该Cookie,能有效降低XSS攻击导致Cookie泄露的风险。
  • Secure:要求只在HTTPS连接下传输,避免明文传输被窃取。
  • SameSite:控制第三方请求中是否携带Cookie。SameSite=Lax是主流浏览器的默认行为,在跨站场景下能有效防CSRF。

说白了,登录态保持不是单靠Session一个东西实现的,而是Session的存活时间、Cookie的Max-Age、浏览器的清理策略三者共同作用的结果。任何一个环节不匹配,都会导致"为什么过一会儿就掉线"这类问题。

3. SpringBoot登录模块实操:从项目搭建到完整代码实现

3.1 项目结构与依赖引入

新建一个SpringBoot项目,我这边用的是SpringBoot 2.7版本,JDK 8,Maven构建。核心依赖只需要一个spring-boot-starter-web,如果你要连数据库做用户查询,再引入MySQL驱动、MyBatis或JPA。为了把你从完整的代码堆砌里解放出来,这里用一个内存用户列表模拟用户表,后面替换成数据库查询逻辑即可。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

注意,千万不要加spring-boot-starter-security,除非你想在它的登录表单基础上做二次开发。大多数人第一次写登录功能,就是想看自己手写的流程跑通,Security的过滤链会绕开你写的控制器逻辑,很多新手在这上面卡了两三天,最后把依赖删了才跑通。

项目结构建议按模块分包,不要全塞在Controller里:

  • controller:LoginController、UserController
  • service:UserService
  • model:User、LoginRequest、LoginResponse
  • interceptor:LoginInterceptor(登录拦截器)
  • config:WebConfig(注册拦截器)

3.2 登录接口的具体实现

登录接口的核心任务:校验用户名密码,校验通过后创建Session,并把用户信息放进Session。

@RestController @RequestMapping("/api/user") public class LoginController { @Resource private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { // 1. 参数校验 if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) { return Result.error("用户名和密码不能为空"); } // 2. 用户认证 User user = userService.authenticate(loginRequest.getUsername(), loginRequest.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } // 3. 判断是否勾选"记住我" boolean rememberMe = loginRequest.getRememberMe() != null && loginRequest.getRememberMe(); // 4. 创建或获取Session,存放用户信息 HttpSession session = request.getSession(true); session.setAttribute(Constants.SESSION_USER, user); session.setAttribute(Constants.SESSION_LOGIN_TIME, System.currentTimeMillis()); // 5. 如果勾选"记住我",延长会话的最大存活时间 if (rememberMe) { session.setMaxInactiveInterval(7 * 24 * 3600); // 7天 // 同时需要让JSESSIONID持久化,见下文"记住我"部分 } // 6. 直接设置一个自定义Cookie做客户端标记(可选) Cookie cookie = new Cookie(Constants.COOKIE_USER_NAME, URLEncoder.encode(user.getUsername(), "UTF-8")); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(rememberMe ? 7 * 24 * 3600 : -1); response.addCookie(cookie); return Result.success("登录成功", userService.toView(user)); } }

注意几个容易踩的细节:

  • request.getSession(true)的参数true表示如果没有Session则创建。如果用getSession(false),在未登录的情况下永远拿不到Session,容易在后续逻辑里抛NPE。
  • 用户密码绝对不能明文存储。数据库里存的是BCrypt加密后的密文,UserService里用BCryptPasswordEncoder.matches(明文, 密文)做比对。
  • Session里只放用户ID、用户名、角色这类必要的认证信息,不要把整个密码对象往Session里塞。
  • Session ID的刷新和创建由容器管理,不需要手动操作JSESSIONID。

3.3 登录拦截器的完整实现与注册

登录接口写完后,需要对受保护的接口做拦截。最经典的实现是写一个HandlerInterceptor,在preHandle中检查Session里有没有用户信息。

public class LoginInterceptor implements HandlerInterceptor { private static final Set<String> EXCLUDE_URIS = new HashSet<>(Arrays.asList( "/api/user/login", "/api/user/register", "/api/captcha", "/error" )); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果是OPTIONS预检请求,直接放行(跨域场景需要) if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return true; } // 放行登录注册等开放接口 String requestURI = request.getRequestURI(); if (EXCLUDE_URIS.contains(requestURI)) { return true; } HttpSession session = request.getSession(false); User user = session == null ? null : (User) session.getAttribute(Constants.SESSION_USER); if (user == null) { // 未登录,返回401状态码,或重定向到登录页 if (isAjaxRequest(request)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); } else { response.sendRedirect("/login.html"); } return false; } // 已登录,放行,并把用户信息传给Controller request.setAttribute(Constants.REQUEST_USER, user); return true; } private boolean isAjaxRequest(HttpServletRequest request) { String requestedWith = request.getHeader("X-Requested-With"); return "XMLHttpRequest".equals(requestedWith); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 可以在这里记录访问日志 } }

拦截器注册的代码一定要写对,否则拦截不生效:

@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/captcha"); } }

这一步有个血泪教训:excludePathPatterns里的路径必须和addPathPatterns的匹配规则完全对得上。比如拦截的是/api/**,排除的是/user/login,那这个排除就不生效——因为/user/login根本不在拦截范围内。新手很容易把路径写岔,最后排查半天发现是路径前缀不一致。

3.4 退出登录与Session销毁的正确姿势

退出登录看似简单,就一行代码,但实际上有几件事要做:

@PostMapping("/logout") public Result logout(HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(false); if (session != null) { // 1. 清空Session中的用户信息 session.removeAttribute(Constants.SESSION_USER); // 2. 使Session失效,服务端彻底销毁该会话 session.invalidate(); } // 3. 删除浏览器端的相关Cookie Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { cookie.setMaxAge(0); cookie.setValue(null); cookie.setPath("/"); response.addCookie(cookie); } } return Result.success("退出成功"); }

退出时不能只删Cookie不管Session,也不能只销毁Session不清理Cookie。只删Cookie,服务端的Session对象如果没超时,还是会驻留在内存里,大量用户反复登录退出会积累很多无效Session;只销毁Session,浏览器端的JSESSIONID还有残留,但下次请求时服务端找不到对应Session会新建一个,问题倒不大,脏Cookie留在浏览器里始终不干净。

如果有自定义的"记住我"Cookie,上面for循环已经删了所有Cookie。但要注意,删除Cookie时setPath("/")必须和当初设置时保持一致,Path不一致的话删除操作是无效的。

3.5 获取当前登录用户的接口

登录后前端需要拿当前用户信息来渲染页面,这个接口务必要从会话中获取,而不是让前端传来一个用户ID(那样任何人都能伪造):

@GetMapping("/currentUser") public Result currentUser(HttpServletRequest request) { User user = (User) request.getAttribute(Constants.REQUEST_USER); if (user == null) { return Result.error("获取用户信息失败"); } return Result.success(userService.toView(user)); }

这里的request.getAttribute拿到的值,就是在LoginInterceptor里放进去的。因为拦截器已经确保了这个接口必须有登录态,所以这里不会出现user为null的情况,但稳妥起见还是判断一下。

4. 登录状态保持的进阶处理:会话超时、记住我与并发控制

4.1 Session超时时间到底在哪配

SpringBoot中Session超时时间有三种配置方式,优先级不同,很容易搞混:

  1. SpringBoot配置文件(application.yml):

    server: servlet: session: timeout: 30m # 默认30分钟,单位支持秒(s)、分钟(m)、小时(h),也可以直接写数字表示秒

    这是最推荐的方式,配置直观、全局生效。

  2. Servlet容器层面:如果是SpringBoot内嵌的Tomcat,可以在配置里加server.tomcat.*相关参数。但通常不需要,SpringBoot的配置已足够。

  3. 代码层面动态设置:

    session.setMaxInactiveInterval(1800); // 单位:秒

    这个值的优先级最高,会覆盖前两种配置。因为它是针对某个具体Session实例的,而不是全局配置。

需要特别强调的是,每次请求访问Session之后,超时时间会顺延,这是Servlet规范定义的行为。也就是说,只要用户持续操作,Session就不会过期;用户超过设定时间没有请求,Session才会被容器回收。理解这一点,就不会遇到"明明没关浏览器,过一会儿就掉线"的困惑了——那不是Bug,是超时机制生效了。

4.2 实现"记住我"功能的两层含义

"记住我"在很多项目里其实是两个不同的需求:

第一层:浏览器关闭后Session不丢。默认情况下,JSESSIONID是临时Cookie,Chrome关闭后它就被清掉了,下次打开浏览器需要重新登录。这就是为什么很多站点"功能明明没坏,用户每次打开都要重新登录"的原因。要让Session跨浏览器会话存活,必须在创建Cookie时设置一个足够的Max-Age:

SpringBoot默认使用容器管理的Session和Cookie,如果你只是想让"记住我"生效,推荐的姿势是使用Spring Boot 2.x的server.servlet.session.cookie.max-age配置,或者干脆下发给前端的JSESSIONID由容器设置。这里有个细节:SpringBoot内嵌Tomcat会在每次request.getSession()时从ServletContext中读取默认的会话Cookie配置再返回Set-Cookie,因此全局配置能影响它。

如果不用全局配置,而是按用户勾选动态控制,就得偏门一点。绝大部分项目直接采用全局max-age配置为7天、1个月,或者Session超时时间本身配长一点。这比动态修改JSESSIONID简单可靠得多。

第二层:服务端Session尽量持久。即便JSESSIONID存住了,如果服务端Session因为超时被销毁,无状态后端照样只能让用户重新登录。所以"记住我"的本质是:把session.setMaxInactiveInterval调大,同时让JSESSIONID的Max-Age与之匹配。

实操中要注意,这两者的时间如果不匹配,就会出现一种很尴尬的现场:浏览器里Cookie还在(7天),但服务端Session已经没了两小时。用户点一下,跳回登录页。排查时浏览器开发者工具里Cookie明明在,很容易误判成服务端问题。

4.3 并发登录控制:同账号多端登录的取舍

要不要做"同账号多端同时登录",取决于业务需要。内部管理系统一般允许同一账号多地登录,方便用户同时用电脑和手机。但涉及金融、支付、内容审核类系统,通常要求一个账号只允许一处登录,新登录会顶掉旧登录。

用Session做单端登录的实现思路:

  • 服务端维护一个Map<userId, sessionId>映射。
  • 每次登录成功,把userId -> 新sessionId写入map。
  • 在拦截器里检查当前请求的SessionID是否等于map中该userId对应的sessionId,如果不等于,说明被顶号了,返回"账号在其他设备登录"的提示。
public class SessionRegistry { private final ConcurrentHashMap<Integer, String> userSessionMap = new ConcurrentHashMap<>(); public void register(Integer userId, String sessionId) { userSessionMap.put(userId, sessionId); } public boolean isActiveSession(Integer userId, String sessionId) { String current = userSessionMap.get(userId); return sessionId.equals(current); } }

顶号逻辑还有一点要注意:被顶掉的那一侧,最好能把旧Session直接invalidate掉,防止它继续占用服务端内存。但旧Session此刻没法自己在服务端销毁,因为顶号操作发生在别的请求中。所以更稳的做法是给每个用户的Session里保存loginToken,map里存userId -> loginToken,每次拦截器校验的时候对比loginToken。被顶号后,旧请求即使带着旧Session,也会因为token不匹配而直接终止,再配合定期清理,内存可以被及时回收。

4.4 多环境下的Session配置:本地、测试、生产

不同环境,登录态的配置应当不同:

配置项本地开发测试环境生产环境
Session超时30m30m2h
Cookie SameSiteLaxLaxLax或Strict
Cookie Securefalsefalsetrue(强制HTTPS)
域名localhosttest.xxx.comwww.xxx.com

建议把Sesssion相关配置抽到application-{env}.yml中,用spring.profiles.active切换。生产环境务必把server.servlet.session.cookie.secure设为true,这样JSESSIONID只会在HTTPS链路中传输。如果你的服务还没上HTTPS,那Secure别急着开,否则Cookie根本下发不了,用户永远登不上。

5. 踩坑实录:Cookie丢失、Session失效、前端联调对不上

5.1 坑一:Chrome开发者工具Application里能看到Cookie,但请求头里没有

这是一个特别经典的问题。明明登录成功,响应里也看到了Set-Cookie,浏览器Application面板里也显示了这个Cookie,但Network里发出的请求没有Cookie头。

常见原因有两个:

  • Path不匹配。Cookie的Path被设置成了/api,而后续请求的URL是/api/user/currentUser,按说匹配,但如果请求路径前缀不对就丢。看一下Cookie的Path设置和实际请求路径是否一致。
  • SameSite导致跨站不携带。浏览器请求了另一个域名的资源,比如前端在http://localhost:3000,后端在http://localhost:8080,这属于跨站(不同站点),Chrome的SameSite默认Lax不会在跨站请求中携带Cookie。如果你部署时前端和后端是不同域名,比如前端app.example.com、后端api.example.com,这算跨站。解决方案是把后端接口做反向代理,让请求路径保持在同域内;或者将Cookie的SameSite设为None,但前提是同时设置Secure=true,且必须HTTPS。

5.2 坑二:改了代码重启服务,用户全掉线

开发阶段无所谓,生产环境如果用的内存Session,发布服务就是一次全员踢下线。这是内存Session的固有缺陷,只有引入spring-session-data-redis、spring-session-jdbc这类外部会话存储方案才能解决。

项目中引入Spring Session非常轻量:

<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>

加完依赖后,SpringBoot自动配置会让HttpSession存放在Redis中。业务代码完全不用改,session.setAttribute、session.getAttribute照常使用。注意Redis本身的持久化策略也要处理好,Redis重启不掉数据,才算是真正解决了"重启即掉线"的问题。

5.3 坑三:后端一切正常,前端却拿到了401

拦截器里用了response.setStatus(401),但SpringBoot对/error路径有一个默认的BasicErrorController,它可能会把你的401响应给拦截并重写,最终前端拿到的可能是200加HTML错误页。

解决方案一:在过滤链处理。解决方案二:在拦截器里用response.getWriter().write(JSON)并flush缓存,然后return false。实测下来,如果接口是前后端分离,建议返回200再加业务状态码,比如{"code":401},前端只要判断code就可以,省去和HTTP状态码较劲的过程:

response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); response.getWriter().flush();

这种方式在Ajax请求中非常稳。如果是页面跳转形式,直接sendRedirect到登录页就行,别混用两套逻辑,否则容易有漏网之鱼。

5.4 坑四:跨域配置与Session连不上

前端用Vue,后端SpringBoot做了CORS跨域配置,前端发请求带withCredentials: true(axios的withCredentials,fetch的credentials: 'include'),结果Session还是对不上。

关键点在于,CORS跨域携带Cookie有两个硬性要求:

  • 服务端Access-Control-Allow-Origin不能是*,必须是具体的前端域名。
  • 服务端必须显式设置Access-Control-Allow-Credentials: true。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

这里有个坑:SpringBoot的allowedOriginPatterns和allowCredentials(true)同时使用才有效,如果用了allowedOrigins("*")加allowCredentials(true)直接报错。另外,同一个Cookie在浏览器里是有域名归属的,跨域名访问时即使服务端返回了Set-Cookie,某些浏览器也可能直接拒绝写入或者后续请求带不上。

5.5 坑五:页面刷新后登录态丢失

这个坑的根源往往不是后端,而是前端把登录态信息存错了地方。新手常见做法是把用户信息存在sessionStorage里,关掉标签页就没——这不是后端会话的问题,纯粹是前端存储位置的问题。

判断登录态是否有效的唯一标准应该是:带着Cookie去请求/api/user/currentUser,返回200说明登录态有效,而不是看前端本地有没有存userInfo。建议前端在页面刷新后,先调一次这个接口再初始化路由和页面,而不是从存储里赌一把。这也是早期前后端分离项目最常见的"假掉线"问题来源。

6. 安全与性能加固:登录模块上线前必做的几件事

6.1 防会话固定攻击(Session Fixation)

用户登录前,服务端可能已经下发了一个匿名SessionID(比如在访问登录页时)。如果攻击者诱导用户用攻击者已知的SessionID去登录,登录成功后服务端没有生成新的SessionID,那么这个会话就被攻击者劫持了。

防御方式很简单:登录成功后,服务端应该让旧的Session失效,创建新的Session。Spring Security默认就做了这件事,如果你纯手写登录,千万别漏:

// 登录成功后的安全处理 HttpSession oldSession = request.getSession(false); if (oldSession != null) { oldSession.invalidate(); } HttpSession newSession = request.getSession(true); newSession.setAttribute(Constants.SESSION_USER, user);

注意顺序:先失效旧Session,再创建新Session,最后把用户数据放进新Session。这样才能确保浏览器拿到的是全新的SessionID。

6.2 Cookie的HttpOnly与Secure必须配置

如果你不显式设置Cookie属性,SpringBoot内嵌Tomcat默认创建的JSESSIONID是HttpOnly的,这是个好消息。但你自己创建的业务Cookie,比如存放用户名的那个,必须手动加HttpOnly。

cookie.setHttpOnly(true);

HttpOnly的作用是禁止JavaScript读取该Cookie。如果没有它,站点一旦存在XSS漏洞,攻击者可以通过document.cookie直接把SessionID偷走。这是Web安全中最常见的一条攻击路径,代价极小收益极大,上线前务必检查每个Cookie。

6.3 验证码与登录频率限制

Session的实际使用中,验证码、防暴力破解也依赖Session。图形验证码/短信验证码存Session时要注意:

// 生成验证码后存入Session Integer randomCode = generateCode(); session.setAttribute("captchaCode", randomCode); session.setAttribute("captchaTime", System.currentTimeMillis()); // 校验时 Object code = session.getAttribute("captchaCode"); if (code == null || !code.toString().equalsIgnoreCase(inputCode)) { return Result.error("验证码错误"); } // 校验通过后立即清除,防止复用 session.removeAttribute("captchaCode");

验证码存Session要比存Redis简单得多,但有两个关键:验证码设置超时时间(比如2分钟),校验通过后用移除以防止复用。千万别只存邮箱/手机号验证码不设过期,那可是安全隐患。

登录接口一定要做频率限制。最简单的做法是记录请求IP加用户名的失败次数,连续失败5次锁定一小时。这个计数器可以放在Session里或某个本地缓存中。暴力破解的成本就这么被拦住了。

6.4 Session的性能与内存管理

内存Session看似简单,但高并发下容易产生两个问题:

  • Session泄漏:用户退出时没有调用invalidate(),Session对象一直驻留在内存里。大量用户访问过系统但从不退出,内存逐渐被填满。
  • Session数据放大:往Session里塞大对象(比如把整个用户列表放进去),导致内存膨胀。

实操建议:

  • 登录成功时只存必要字段,比如userID、用户名、角色列表,不要塞整个Entity。
  • 定时监控Session数量,Tomcat自带的管理面板或JVM监控里都能看到。
  • 如果你用的是内嵌Tomcat,默认最大Session数量没有明确上限,完全受堆内存约束。一旦发现OOM,优先排查是不是Session没清理。

登录功能在SpringBoot里写得顺手后,你会发现Spring Security的很多设计理念其实和这套手写流程一脉相承——只不过它会强制你遵循安全最佳实践。从小项目起步时手写这套登录逻辑,会让人对Session机制印象非常深,后面再看任何权限框架都会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询