
登录注册大概是 Spring Boot 项目里被写烂、但也最容易写翻车的模块。常有人觉得注册就是往用户表插一条记录登录就是查一条记录再比对密码听起来完全没有技术含量。可你真去做一个能上线的登录注册会发现要处理的东西远不止这些密码怎么存、用户名重复了怎么办、登录态怎么保持、退出登录之后 Session 要不要销毁、接口被脚本狂刷怎么办。这些边界问题才是线上真正会出事故的地方。这篇是 Spring Boot 实战系列的第四篇我把用户登录与注册从需求拆解、表结构设计到接口实现、测试和安全自查完整走一遍最后再聊聊 Session 与 JWT 的取舍。方案上用手动管理 Session 的方式而不是一上来就上 Spring Security 全家桶目的是先把认证原理讲透。你后面再切到安全框架或者 JWT至少清楚每一步在干什么。适合刚入门 Spring Boot、想认真把注册登录做成型的开发者也适合已经能跑通简单 Demo、但不知道为什么线上会出各种奇怪问题的人。1. 先别急着写接口登录注册的需求拆解与安全底线1.1 登录注册不只是“插一条、查一条”还有这些隐性需求从功能上看登录注册确实不复杂。注册接口接收用户名和密码往用户表里插一行登录接口拿用户名密码查询查到了就返回成功。但如果你照着这个思路直接开发第一版大概率能跑等上了线就开始到处漏风。我把实际项目里会涉及的清单展开一下注册接口用户名、密码、邮箱/手机号等资料的录入与校验登录接口身份确认与登录态建立退出接口登录态销毁受保护接口未登录的请求要被拦下来账号状态正常、禁用如何影响登录唯一性用户名、邮箱、手机号在系统内不能重复密码策略长度、复杂度以及加密存储的方式异常响应参数非法、账号不存在、密码错误、账号禁用分别返回什么日志审计注册时间、最近登录时间、被拦截的请求要不要记录这里面唯一性、密码策略、异常响应三块最容易糊弄。我评审过不少练习项目的代码表面上功能都有密码明文躺在数据库里、用户名没有唯一约束导致同名账号能注册成功、登录接口直接把参数拼进 SQL 的也不少见。这些问题本地自测不一定暴露但每一个都是线上事故的种子。所以我不建议只按“插一条、查一条”来做需求阶段就把这些隐性要求写清楚编码时就按业务规则来落。1.2 密码存储的底线BCrypt 而不是 MD5密码怎么存是登录注册第一个要拍板的技术决策。最离谱的是明文存储数据库一旦被拖走所有账号等于集体脱库。稍微好一点的做法是用 MD5 或 SHA-256 做哈希但这些算法的设计目标就是快现代 GPU 每秒能算几亿次哈希配合彩虹表弱密码很快就能还原。正确方向是使用慢哈希算法比如 BCrypt。它的优势有三个第一每次计算会生成随机盐同一个密码在不同用户身上得到的哈希值都不同彩虹表基本失效第二计算速度刻意调慢攻击者暴力破解的成本被大幅抬高第三成本因子可以随硬件升级调整未来想加强不需要改存储格式。Spring Security 的 spring-security-crypto 只提供了密码编码器不需要拉进整套 Spring Securitydependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency配置一个 Bean 就行Configuration public class AppSecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }注册时 encode登录时 matches。这里有个经验不要在业务代码里到处 new BCryptPasswordEncoder而是注入统一的 Bean。这样将来要换算法比如升级到更高成本因子或者换 Argon2只改一处配置就行。我在练习项目里一开始就到处 new后来换参数时改了一堆文件才体会到依赖注入的意义。1.3 技术选型为什么这次先手写 Session不上 Spring Security聊到登录认证总会有人问为什么不直接用 Spring Security或者直接上 JWT我的回答是能用但第一版不建议这么搞。Spring Security 是个很优秀的框架但学习曲线集中在过滤器链、认证管理器、用户详情服务那一整套抽象上。新手用它写登录注册最常见的状态是照着网上的配置抄一遍能跑一旦登录不进去就搞不清是过滤器链顺序问题、密码编码器没对上还是 CSRF 没关。排查过程相当劝退。手动管理 Session 则完全不同。登录成功往 HttpSession 里写一个标记拦截器在每次请求进来时检查这个标记整条链路是可见的你看到的每一步都是真实执行过程。把这一套基础弄明白再去看 Spring Security 的过滤器链或者转 JWT会顺很多。这一篇的主线就定成 Session 方案最后一节我会专门聊 Session 和 JWT 怎么取舍以及切换时要动哪些代码。2. 用户表设计与注册接口从建表到第一个能用的接口2.1 用户表设计唯一约束和状态字段不能省登录注册的核心表只有一张用户表。字段设计我倾向够用原则别一上来堆十几个业务扩展字段但下面这些必须有CREATE TABLE 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 昵称, email varchar(100) DEFAULT NULL COMMENT 邮箱, phone varchar(20) DEFAULT NULL COMMENT 手机号, status tinyint NOT NULL DEFAULT 1 COMMENT 账号状态 1正常 0禁用, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_email (email), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我把关键字段的选型原因放张表里字段类型说明idbigint主键自增够用usernamevarchar(50)登录用户名非空唯一passwordvarchar(100)存 BCrypt 哈希不是明文长度留足nicknamevarchar(50)昵称允许为空emailvarchar(100)可选但填了就要唯一phonevarchar(20)可选但填了就要唯一statustinyint1正常、0禁用停用账号不用删数据created_at / updated_atdatetime注册时间与最后更新时间这里有几个细节值得单独说。第一唯一约束必须做到数据库层不能只在应用层判断。并发场景下两个请求同时通过 existsByUsername 检查然后同时 insert没有数据库唯一索引兜底就会写入两条同名数据。第二email 和 phone 的唯一索引在 MySQL 里多个 NULL 是允许共存于普通唯一索引的所以“可选但唯一”这个规则可以放心交给数据库。第三password 列给到 100BCrypt 当前产出的哈希是 60 个字符左右未来成本因子调整可能变长留足余量避免将来改表。2.2 注册接口的完整实现从实体到 Controller持久层用 Spring Data JPA 做示例用 MyBatis 的同学照着同样的 Service 逻辑写就可以了。实体类Entity Table(name user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true, length 50) private String username; Column(nullable false, length 100) JsonIgnore private String password; private String nickname; private String email; private String phone; private Integer status 1; private LocalDateTime createdAt; private LocalDateTime updatedAt; // getter / setter 省略实际开发用 Lombok 的 Data 即可 }User 实体有两个点容易踩坑。一是 password 字段必须加 JsonIgnore否则用户对象一旦被序列化返回前端BCrypt 哈希也跟着泄漏攻击者可以拿它离线跑字典。二是不要把 User 直接返回给前端先转成 UserVO只保留前端需要的 id、username、nickname 这类字段。Repository 很薄public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByUsername(String username); boolean existsByUsername(String username); }注册请求体用一个 DTO加上校验注解public class RegisterRequest { NotBlank(message 用户名不能为空) Size(min 3, max 20, message 用户名长度需要在3到20个字符之间) private String username; NotBlank(message 密码不能为空) Size(min 6, max 32, message 密码长度需要在6到32个字符之间) private String password; Email(message 邮箱格式不正确) private String email; // getter / setter 省略 }Service 是注册逻辑真正所在的地方Service public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } public User register(RegisterRequest request) { String username request.getUsername().trim(); if (userRepository.existsByUsername(username)) { throw new BusinessException(用户名已被注册); } User user new User(); user.setUsername(username); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setEmail(request.getEmail()); user.setStatus(1); return userRepository.save(user); } }Controller 只做接收和返回RestController RequestMapping(/api/auth) public class AuthController { private final AuthService authService; public AuthController(AuthService authService) { this.authService authService; } PostMapping(/register) public ResultUserVO register(RequestBody Valid RegisterRequest request) { User user authService.register(request); return Result.success(toVO(user)); } private UserVO toVO(User user) { UserVO vo new UserVO(); vo.setId(user.getId()); vo.setUsername(user.getUsername()); vo.setNickname(user.getNickname()); return vo; } }统一返回结构 Result 我建议每个项目都准备一份别把裸对象直接丢出去public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT failure(int code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } // getter / setter 省略 }到这里最小可用的注册接口已经能跑通接下来要处理的是校验和异常。2.3 参数校验脏数据比功能 bug 更隐蔽加了校验注解之后还得配一个全局异常处理否则 Spring 会返回默认的 400 错误体前端拿到一堆英文描述很难看RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResultVoid handleBusinessException(BusinessException e) { return Result.failure(400, e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValidationException(MethodArgumentNotValidException e) { String message e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .findFirst() .orElse(请求参数格式不正确); return Result.failure(400, message); } }BusinessException 就是普通运行时异常靠 RestControllerAdvice 统一捕获Service 里不用到处 try-catch。注解校验解决的是“格式对不对”但有种脏数据是格式对、内容脏。比如用户名前后带着空格前端看着两个名字差不多注册出来却是两个账号又或者用户复制粘贴时混进了不可见字符第二次注册永远提示已存在肉眼完全看不出原因。所以我在 Service 层总会先 trim 一次把值规范化之后再做业务判断。这个习惯很小但对线上数据的干净程度影响很大。3. 登录认证Session 的工作链路与退出登录的坑3.1 登录接口比对密码只是第一步登录的 Service 逻辑看起来简单但有三个判断不能少public User login(String username, String rawPassword) { User user userRepository.findByUsername(username.trim()) .orElseThrow(() - new BusinessException(用户名或密码错误)); if (user.getStatus() ! 1) { throw new BusinessException(账号已被禁用); } if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BusinessException(用户名或密码错误); } return user; }第一个细节用户不存在和密码错误返回同一个提示“用户名或密码错误”。如果提示“用户名不存在”攻击者可以在你的登录接口上批量探测哪些用户名注册过完成账号枚举。统一提示虽然没有彻底解决问题但至少不会配合攻击者把信息喂到嘴边。第二个细节账号状态判断放在密码比对之前。禁用账号应该直接拒绝登录而不是等密码比对过了再判断不然逻辑顺序混乱后面排查维护都麻烦。第三个细节密码比对用 matches 而不是把数据库里的哈希拿出来重新 encode 一次比较。BCrypt 每次 encode 生成的盐都不同直接比较 encode 结果永远不会相等这是一个很容易犯的错误。登录的 Controller 要把用户写入 SessionPostMapping(/login) public ResultUserVO login(RequestBody Valid LoginRequest request, HttpSession session) { User user authService.login(request.getUsername(), request.getPassword()); session.setAttribute(AuthConstants.SESSION_USER_KEY, user.getId()); return Result.success(toVO(user)); }我存的是 userId 而不是整个 User 对象一方面是减少 Session 里的数据量另一方面是业务上每次请求如果需要完整的用户信息再按 id 查一次实时数据避免拿到旧对象。极简单的系统也可以存 userId 加 username够用就行。3.2 登录态保持Cookie、Session 与拦截器如何串起来新手往往对“登录态”没有直观概念。实际上过程是这样的浏览器第一次请求登录接口成功后Servlet 容器会在服务端创建一个 HttpSession并通过 Set-Cookie 把 JSESSIONID 种到浏览器后续浏览器访问同一站点时会自动带上这个 Cookie服务器根据 session id 找到对应的 Session 对象Session 里有没有登录标记就决定了这次请求算不算已登录。所以登录态本质上是客户端保存了一个 id服务端保存了对应的状态数据。理解了这一点拦截器就顺理成章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(AuthConstants.SESSION_USER_KEY) null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } return true; } }注意这里用的是 request.getSession(false)传 false 表示如果没有 Session 就不要顺手创建一个。如果不加参数每次未登录请求都会被迫新建一个 Session 对象白占内存这个细节对高并发下的稳定性有实际影响。注册拦截器时要把登录和注册接口排除掉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(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }Session 超时时间可以单独配置比如在 application.yml 里写 server.servlet.session.timeout30m默认是 30 分钟按业务需要调整。登录接口写的属性存进去之后到时间 Session 会自动失效下一次请求就会被拦截器拦住这个机制是容器帮我们做的不用自己定时去清理。3.3 退出登录的两种写法差别很大我看到过一种退出登录的写法接口里什么都不做直接返回“退出成功”让前端跳回首页。问题在于服务端的 Session 还活着登录标记还在如果前端没有完成 Cookie 清理或者请求是自动带 Cookie 的用户实际上还处于登录态只是页面看着像退出而已。正确做法是销毁 SessionPostMapping(/logout) public ResultVoid logout(HttpSession session) { session.invalidate(); return Result.success(null); }session.invalidate() 会把服务端的 Session 对象标记为失效之后即使客户端继续带同一个 JSESSIONID服务端也找不到对应的有效 Session拦截器会把它当成未登录。从设计上说退出登录不是让客户端“假性跳转”而是真正废除服务端的认证状态。这里还有一个建议退出接口应该用 POST而不是 GET。GET 请求可能被预取、被缓存、被第三方页面以图片形式触发容易产生未预期的副作用登录、退出这类会改变服务端状态的操作用 POST 更稳。3.4 进阶小点登录成功后最好更换 SessionID如果你的系统会直面上公网我建议了解一个叫“会话固定攻击”的概念。攻击者可以先自己跟服务器建立会话拿到一个 JSESSIONID然后诱导目标用户带着这个 id 去登录。如果服务端登录成功后不更换 session id攻击者手里那个旧 id 就能复用来冒充已登录用户。对策很简单登录成功后调用 request.changeSessionId()把会话 id 换成一个新的。这个操作不会丢 Session 里的数据只是把钥匙换了攻击者手上的旧钥匙就废了。Spring Security 默认就会在登录成功后执行类似操作手写 Session 方案时容易漏所以我把这个小点单拎出来。一行代码能挡掉一类攻击性价比非常高。4. 接口测试与安全自查本地跑通只是开始4.1 完整测试流程注册、登录、访问受控接口、退出代码写完之后我习惯在接口测试工具里把完整流程跑一遍而不是只测单个接口。下面这套用例基本是每个登录注册模块的标配POST /api/auth/register提交一组合法数据期望返回注册成功用同一个用户名再次注册期望返回“用户名已被注册”不携带 Cookie 访问受保护接口期望返回 401POST /api/auth/login提交正确密码期望登录成功携带登录后返回的 Cookie 访问受保护接口期望正常返回POST /api/auth/logout之后再用旧 Cookie 访问受保护接口期望返回 401整个过程里最值得看的是第 5 步之前响应头里的 Set-Cookie。浏览器把 JSESSIONID 当成一个普通 Cookie 处理所以在接口测试工具里要手动管理 Cookie而浏览器开发者工具的 Network 面板可以直观看到请求自动带上的 Cookie对理解登录态很有帮助。如果有条件再补一组边界用例密码错误的登录、禁用账号的登录、非法参数注册。这些用例应该全部有对应断言而不只是“能返回 JSON 就行”。4.2 对线上有杀伤力的安全问题逐条过一遍登录注册是攻击者重点关照的入口本地跑通不代表安全我习惯把下面这些问题整体过一遍问题风险对策SQL 注入参数拼接导致脱库JPA/MyBatis 参数绑定MyBatis 用 #{} 不用 ${}XSS用户输入在页面上被当成脚本执行前端转义后端对输出编码Cookie 加 HttpOnlyCSRF已登录用户被第三方网站诱导发请求修改类请求用 POST并校验 Token 或 Origin暴力破解弱口令被批量尝试登录失败计数、验证码、临时锁定敏感字段返回密码哈希或内部字段泄漏JsonIgnore、VO 过滤SQL 注入这条用 Spring Data JPA 时基本是安全的因为方法名派生查询和 Query 都走预编译参数如果是 MyBatis记得动态 SQL 里拼接字段排序这种场景也要避免直接使用 ${}。CSRF 在接口测试工具里经常测不出来因为工具不会像浏览器那样自动带 Cookie 去执行跨站请求。真正上线时如果前端是服务端渲染的老项目建议开启 CSRF Token 校验如果是前后端分离且接口只服务自己的前端至少要在网关或过滤器里校验 Origin/Referer并把 Cookie 的 SameSite 属性设置好。暴力破解这个点最容易在自测阶段被忽略。我见过只做了登录逻辑的项目接口裸奔在公网上密码没有失败次数限制。这种接口被脚本扫是迟早的事哪怕密码是 BCrypt弱口令依然会被试出来。所以从第一版就加一个简单的失败计数同一个用户名或同一个 IP 连续失败五次锁定五分钟能挡掉大量脚本。4.3 并发注册与重复提交数据库约束比代码判断更可靠我前面提过 existsByUsername 和 insert 之间存在竞态窗口。实际测试中用两三个线程同时对一个未注册用户名发起注册请求就有概率出现两条记录都通过检查、然后其中一条被数据库的唯一索引拦下抛 DuplicateKeyException 的情况。代码层面能做的防御是先查再插但真正兜底的一定是数据库唯一索引。所以在代码里要把唯一索引冲突当成业务异常处理try { return userRepository.save(user); } catch (DataIntegrityViolationException e) { throw new BusinessException(用户名已被注册); }这样即便并发窗口没躲开用户拿到的也是友好提示而不是一整段堆栈。重复提交是另一个常见问题。用户快速点了两次注册按钮或者前端提交后没有及时禁用按钮就会产生两个一样的请求。对注册这种幂等性要求不高、但重复会造成脏数据的接口除了数据库约束前端还可以做按钮防连点后端也可以用一次性请求令牌来拦截重复提交。这个组合在多数场景下够用不需要引入分布式锁。5. Session 与 JWT 的取舍什么时候该切换5.1 JWT 解决的核心问题与它付出的代价Session 方案在单体应用里很简单但一旦系统变成前后端分离、多端接入、接口被多个第三方系统调用Session 的短板就出来了服务端必须保存会话状态分布式部署后还得用 Redis 等中间件做 Session 共享管理成本明显上升。JWT 的选择是反过来把认证信息本身交给客户端保存服务端不存状态。一个 JWT 由 Header、Payload、Signature 三段组成服务端只负责验签签名通过且有效期内的 Token 就认定为合法用户。这个模型对横向扩展非常友好新加一台服务节点不需要共享任何会话数据。但 JWT 不是没有代价。最明显的三个问题一是服务端无法主动让 Token 失效退出登录只能靠客户端丢弃Token 泄露后的有效期内会一直有效二是 Token 体积通常比 Session id 大每次请求都要带在 Header 里请求头会变大三是绑定黑名单、刷新策略这些逻辑都需要自己设计不是开箱即用。所以我的态度一直是JWT 是个好方案但它解决的是 Session 在多端、多服务节点下的问题而不是“更安全”的代名词。安全取决于签名算法、密钥管理和使用规范。5.2 从 Session 切换到 JWT需要动哪些代码真要从 Session 切到 JWT改动其实很集中。密码存储和比对逻辑完全不用动因为用户认证的核心还是 BCrypt 那一套。登录接口从“写 Session”改成“生成 Token 返回”// 示意写法具体 API 以当前使用的 jjwt 版本文档为准 String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 3600 * 1000)) .signWith(secretKey) .compact();拦截器从“查 Session”改成“解析 Authorization 头里的 Token”。解析时先验签名、再查有效期从 Payload 里取出 userId再按业务需要去加载用户。这个逻辑在 HandlerInterceptor 的 preHandle 里实现结构和 Session 版几乎一致。退出接口变化最大。Session 版调 session.invalidate() 就行JWT 版服务端没有 Session 可删常规做法是客户端删除本地 Token。如果业务上要求“立刻失效”就得维护一个 Token 黑名单或者把 Token 的 jti、userId 存在 Redis 里做集中校验这就等于把无状态方案又拉回有状态了需要权衡。5.3 我实际项目里的选择顺序我个人对登录认证方案的排序大概是这样的如果是初学阶段先用 Session 把原理跑通这个阶段比方案多比效率重要如果是单体应用、服务端渲染或者只有少量前端页面Session 加拦截器已经足够何必引入 Token 解析和密钥管理如果是前后端分离、有移动端和第三方接入再考虑 JWT如果还有“管理员能踢人下线”“所有会话可集中查看”这类需求Session 加 Redis 或者直接引入成熟安全框架更合适。我也见过不少项目为了“技术栈好看”强行上 JWT结果 Token 刷新、黑名单、密钥轮换全要自己维护团队又不熟悉出了问题排查更困难。认证方案的选择不是越新越好而是看你的部署形态和运维能力能不能接住它的复杂度。先把 Session 这套吃透了切换其他方案时你会非常清楚每个决策背后的代价。