
从零开始撸一个博客系统二登录 JWT令牌 强制登录让系统先有“门卫”做博客系统绕不开的第一道坎就是登录。上一篇我们把项目的骨架搭起来了文章列表、文章详情都能正常访问但整个系统还是大敞着门——谁都能点进来谁都能发文章这显然不对劲。这一篇就把“门卫”安排明白基于JWT令牌做登录认证再配合登录拦截器实现强制登录把需要身份的操作全部关进门槛里。技术栈还是JavaEE那一套Spring Boot打底用到的核心东西包括jjwt、BCrypt密码加密、HandlerInterceptor拦截器和ThreadLocal用户上下文。这篇适合正在跟着做博客系统的同学也适合想把登录模块从Session方案升级到JWT方案的朋友参考。1. 登录模块的整体设计为什么非要用JWT令牌1.1 先聊聊Session方案为什么在前后端分离下不够用很多JavaWeb入门教程讲登录时用的还是传统Session方案用户登录成功服务器往Session里存一份用户信息再把SessionId写进Cookie返回给浏览器之后的每次请求浏览器自动带上Cookie服务器比对SessionId就能认出这个用户。这套方案用在传统服务端渲染的项目里确实简单顺手。但在前后端分离的架构下它有几个麻烦点第一服务器变成了有状态服务。用户的登录状态保存在服务器内存里一旦部署多台实例做负载均衡用户第一次请求落在A机器第二次请求落到B机器B机器没有他的Session用户就莫名其妙被登出了。要解决就得做Session共享要么用Nginx做IP粘滞要么把Session甩到Redis里统一存取复杂度一下子就上去了。第二跨域场景下Cookie很难处理。现在前端是独立的Vue/React应用可能跑在8080端口后端跑在9090端口两边域名端口都不一样Cookie跨域时要配置Domain、SameSite浏览器策略也越来越严。而移动端的App压根没有Cookie这个概念原生请求不会自动帮你带SessionId每个接口都还得手动传那Session的意义就没了一大半。JWT方案就不一样了登录成功服务器把用户身份信息做签名生成一个令牌String返回给前端后端完全不存这个token。后续前端每个请求都把token放在Authorization请求头里带回来后端验签通过就认你是这个用户。后端不需要Session存储请求过来是哪个实例都能验天然适合水平扩展也天然适配Web、App、小程序多端。1.2 登录认证的完整流程拆解先把整条链路在脑子里过一遍具体步骤如下用户在前端输入用户名和密码点击登录。后端AuthController收到请求调用UserService校验用户名密码。校验通过用JwtUtil生成一个携带用户ID和用户名的JWT令牌。后端把token和用户基础信息一起返回给前端前端把token存到localStorage或Pinia/Vuex。前端在发起需要身份认证的请求前向请求头写入Authorization: Bearer 。后端登录拦截器拦截所有请求先解析请求头里的token验签、校验有效期。校验通过把token里的用户信息放进ThreadLocal请求继续进入Controller。校验失败直接返回401状态码前端收到后跳回登录页。这个流程里最核心的两个角色就是JWT令牌和登录拦截器。前者负责“身份凭证”的签发与验证后者负责在每扇门前面把凭证检查一遍。两者配合才算是给博客系统装上了正儿八经的“门卫”。1.3 为什么博客系统也值得用JWT有人可能会说博客系统就一个后端一个前端用户量也不大Session照样能跑何必上JWT我的观点是这个案例的价值不在于博客本身而在于让你掌握一套前后端分离项目通用的认证方案。你以后做电商后台、做内容管理系统、做小程序接口这套登录认证的架子可以直接搬过去用。而且就算博客系统现在不大JWT写起来也没有多多少成本——无非是一个工具类加一个拦截器的事后面真正需要做多端或分布式的时候不需要推翻重来。所以想清楚了就直接上JWT令牌方案。2. JWT令牌核心原理与工具类实现2.1 拆开一个JWT三段式结构到底在存什么JWT的全称是JSON Web Token它本质上就是一个很长的字符串用英文句点分成三段xxxxx.yyyyy.zzzzz第一段是Header第二段是Payload第三段是Signature。Header通常长这样里面声明了签名算法和令牌类型{alg:HS256,typ:JWT}Payload是载荷也就是你想塞进令牌里的业务数据比如{sub:18,username:zhangsan,iat:1700000000,exp:1700604800}这里的sub我用来存用户IDusername存用户名iat是签发时间exp是过期时间。注意Payload只是Base64URL编码不是加密任何人拿到这个token都能解码看到里面的内容所以千万不要把密码、手机号这种敏感数据放进去。第三段Signature是签名。签名的生成方式是把Header和Payload分别做Base64URL编码后用句点拼起来再用Header里声明的算法和服务器自己的密钥对这段字符串做HMAC-SHA256计算得到一串不可逆的签名。为什么要这个签名因为Header和Payload部分是公开可读的如果不加签名用户完全可以自己把Payload里的用户ID改成别人的服务器根本发现不了。有了签名之后哪怕只改动一个字符服务端重新计算签名就会发现对不上直接判定令牌为伪造。2.2 JWT用的不是普通Base64是Base64URL这里有个很容易踩的坑JWT里用的编码是Base64URL不是我们平时见的Base64。普通Base64编码会产生三种特殊字符、/和。但JWT的令牌经常要放在URL、请求头、Cookie里在URL里会被解码成空格/会干扰路由路径虽然出现在末尾但也会带来各种麻烦。所以JWT规范规定把换成-把/换成_去掉末尾的。这就是Base64URL。如果用Java的Base64.getEncoder()去对JWT编码你会发现生成的字符串和标准JWT对不上。正确做法是使用Base64.getUrlEncoder().withoutPadding()或者干脆让jjwt框架内部处理不要自己手动拼接。2.3 JwtUtil工具类生成与解析令牌先引入依赖。这里用业界比较常见的jjwt 0.9.1版本稳定、教程多dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency然后写一个JwtUtil工具类import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; public class JwtUtil { // 密钥生产环境请放在配置中心或环境变量里不要硬编码 private static final String SECRET my-blog-jwt-secret-key-change-me-32bytes!; // 令牌有效期7天 private static final long EXPIRE_MS 7 * 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String username) { Date now new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(new Date(now.getTime() EXPIRE_MS)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }这里解释几个参数setSubject(String.valueOf(userId))主题字段用来存用户ID。官方语义里subject就是标识用户主体的比塞在自定义claim里更规范。claim(username, username)自定义字段把用户名一起放进去后续文章列表显示作者名时可以直接拿不用再查一次数据库。setIssuedAt(now)签发时间有些框架的安全策略会要求校验签发时间。setExpiration(...)过期时间。我设置为7天。太短用户写一篇文章中途回来发现要重新登录体验差太长token泄露的风险窗口大。博客系统这种中低安全级别的场景7天比较均衡。signWith(SignatureAlgorithm.HS256, SECRET)指定签名算法和密钥。解析时jjwt会自动做三件事检查签名是否正确、检查token是否过期、检查token格式是否合法。任何一步有问题都会抛出对应的JwtException异常。所以调用方直接try-catch外层兜底就行。有一点要提醒jjwt 0.9.x的signWith传字符串密钥时内部会直接把字符串当作字节数组去算HMAC所以密钥长度不能太短。HS256要求密钥至少256位也就是32字节。如果你给个8位的短字符串运行时会报错或者生成出来的令牌安全性形同虚设。如果用的是新版jjwt0.11API变成这样SecretKey key Keys.hmacShaKeyFor(SECRET.getBytes()); Jwts.builder() .setSubject(...) .signWith(key) .compact();新老版本的API差异比较大抄代码时一定要和你pom里的版本对应上。2.4 令牌里面到底放什么信息我在实际项目里见过有人为了方便把一个User对象整个塞进claim里姓名、手机号、邮箱什么都在。这种做法有两个问题第一token会变得很大。JWT每次请求都要通过请求头传输token越大网络开销越大。第二Payload是明文编码等于把用户敏感信息暴露给了前端。虽然前端本来就能看到这些信息但token一旦在日志、网络请求记录里被抓到等于敏感信息被人看个精光。我的经验是令牌里只放必备的标识信息。用户ID是必须的因为业务层拿到它才能定位用户username是常用字段放进去方便显示作者名其他的头像、邮箱、角色用到哪个再从数据库查或者等真正需要时再加claim。保持token最小化是JWT设计里很重要但容易被人忽略的原则。3. 登录接口与密码安全先守住用户这道关3.1 密码为什么必须用BCrypt而不是MD5用户密码存储是登录模块里最不能将就的地方。直白说如果数据库里的密码是明文或者只做了简单MD5那一旦数据库泄露所有用户的密码就等于裸奔。MD5看起来很美好定长32位字符串、计算快。但恰恰是“计算快”成了它的致命伤。攻击者可以预先算好常用密码的MD5值那张表就是彩虹表拿数据库里的MD5去查表很快就能反推出明文密码。而且同一个密码的MD5永远是一样的两个用户密码相同MD5值也相同攻击者一旦破解一个等于破解了一串人。BCrypt的做法完全不同。它会把随机盐值和密码一起做哈希每次加密同一个密码得到的密文都不一样它本身还故意设计得很慢让暴力破解的成本大幅上升。你在数据库里看到的BCrypt密文通常长这样$2a$10$cFfIuGxJq2QpU2iX9Lq4EeR7yKzFqUxGc1c7b7m7B6sMmfM0Sl3lS这个字符串里包含了版本、计算强度、盐值和哈希结果。校验的时候BCryptPasswordEncoder的matches方法会从密文里取出盐值用同样的参数重新计算再比对结果所以不需要额外存盐。在Spring Boot项目里引入BCrypt非常方便。只引一个crypto模块就行不需要引入完整的Spring Securitydependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency如果版本冲突也可以直接引入jBCrypt的开源库。不过Spring的BCryptPasswordEncoder封装更友好推荐用它。3.2 注册功能里的密码加密这一篇虽然重点是登录但注册是登录的前置条件我顺手把密码加密也在注册接口里带上。注册Service的伪代码如下public void register(RegisterDTO dto) { // 先检查用户名是否已存在 User exist userMapper.findByUsername(dto.getUsername()); if (exist ! null) { throw new BusinessException(用户名已被注册); } User user new User(); user.setUsername(dto.getUsername()); // 核心点密码加密后再入库 user.setPassword(passwordEncoder.encode(dto.getPassword())); userMapper.insert(user); }注意你永远不要去尝试反向解出密码也不需要。唯一需要做的就是在注册时加密存储登录时用matches做比对。这也意味着如果用户忘密码系统只能生成一个重置链接让用户设置新密码而不能把旧密码明文“找回来”。3.3 登录接口的Service层与Controller层实现登录接口的代码不复杂但细节很多。先看Servicepublic LoginVO login(String username, String password) { // 1. 查询用户 User user userMapper.findByUsername(username); // 2. 用户不存在或者密码不匹配统一返回“用户名或密码错误” // 这里故意不区分用户不存在和密码错误避免攻击者通过报错信息批量探测已注册用户名 if (user null || !passwordEncoder.matches(password, user.getPassword())) { throw new BusinessException(用户名或密码错误); } // 3. 签发JWT令牌 String token JwtUtil.createToken(user.getId(), user.getUsername()); // 4. 组装返回结果密码字段绝对不能返回前端 LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserId(user.getId()); vo.setUsername(user.getUsername()); vo.setAvatar(user.getAvatar()); return vo; }Controller层就很简单RestController RequestMapping(/api/auth) public class AuthController { Autowired private UserService userService; PostMapping(/login) public ResultLoginVO login(RequestBody Validated LoginDTO dto) { return Result.success(userService.login(dto.getUsername(), dto.getPassword())); } PostMapping(/register) public Result? register(RequestBody Validated RegisterDTO dto) { userService.register(dto); return Result.success(); } }我在写这个接口时遇到过两个常见问题提醒一下第一个是DTO校验。很多新手在Controller里直接拿两个String参数接收或者RequestBody里不做空值校验最终导致username传一个空格也去查库password传null直接NPE。建议用NotBlank注解配合Validated字段级别就把非法请求挡掉public class LoginDTO { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) private String password; // getter/setter }第二个是返回用户信息时千万别把整个User对象直接序列化出去。User对象里有password字段一旦忘了加JsonIgnore前端就能直接从登录接口拿到加密后的密码密文虽然攻破它成本高但完全没必要留这个风险。最稳妥的做法就是专门建一个LoginVO只放允许暴露的字段。4. 强制登录拦截器让门卫真正上岗4.1 拦截器还是Filter业务鉴权优先用拦截器实现“强制登录”最直接的手段有两个Servlet里的Filter以及Spring MVC里的HandlerInterceptor。Filter是Servlet规范层面的东西在Spring的DispatcherServlet之前就执行了能拦截到所有请求包括静态资源。HandlerInterceptor是Spring MVC提供的钩子运行在DispatcherServlet分发之后、具体Controller方法执行之前它能拿到“这次请求到底要调用哪个Controller方法”还能直接访问Spring管理的Bean。选型时的标准很简单如果是做编码过滤、跨域处理这种纯粹底层的事情用Filter如果是做登录鉴权、权限校验这种依赖业务逻辑的事情用拦截器。原因是拦截器可以依靠Spring容器注入Redis客户端、用户服务等而且通过HandlerMethod能精确判断这次请求是不是一个Controller接口静态资源可以直接放行不会误伤。4.2 LoginInterceptor完整实现直接上代码public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 跨域预检请求直接放行否则OPTIONS请求会在鉴权层就被拦掉前端CORS直接失败 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 2. 不是Controller方法比如静态资源、错误页直接放行 if (!(handler instanceof HandlerMethod)) { return true; } // 3. 从请求头获取token String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } // 4. 解析token成功则放行失败则返回401 try { Claims claims JwtUtil.parseToken(token); LoginUser loginUser new LoginUser(); loginUser.setUserId(Long.valueOf(claims.getSubject())); loginUser.setUsername((String) claims.get(username)); UserHolder.setUser(loginUser); return true; } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录状态已过期\}); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 5. 请求结束必须清理ThreadLocal防止Tomcat线程池复用导致用户数据串号 UserHolder.remove(); } }这段代码里有三个细节值得展开讲。先说OPTIONS放行。前后端分离部署时前端在浏览器里调用后端接口几乎一定会触发跨域。如果前端请求头里带了Authorization浏览器会先发一个OPTIONS预检请求探测后端允不允许这个跨域请求。预检请求本身不会携带业务数据如果拦截器把它拦了前端会一直报跨域错误而你排查半天也找不到原因。所以只要看到OPTIONS直接放行。再说HandlerMethod判断。很多人写拦截器时不做这个判断结果把所有请求都拿去解析token。静态资源没有token于是报401页面样式全部丢失。加了HandlerMethod判断只有真正调Controller方法时才鉴权静态资源不会再被误伤。最后说ThreadLocal的清理。Tomcat的工作线程是复用的线程处理完一个请求后不会销毁而是回到线程池等待下一个请求。如果afterCompletion里不调UserHolder.remove()下一个请求可能在这个线程里读到上一个用户的ThreadLocal数据轻则串号重则出现越权漏洞。这个坑我在做其他项目时踩过后来就养成了“用完必清理”的习惯。4.3 ThreadLocal工具类与用户上下文传递ThreadLocal在这里的作用是把当前登录用户的信息从拦截器一路传递到Controller、Service层避免在每个方法里都手动传参数。public class UserHolder { private static final ThreadLocalLoginUser TL new ThreadLocal(); public static void setUser(LoginUser user) { TL.set(user); } public static LoginUser getUser() { return TL.get(); } public static void remove() { TL.remove(); } }使用的时候在Controller里PostMapping(/article) public Result? createArticle(RequestBody ArticleDTO dto) { LoginUser user UserHolder.getUser(); // 直接用user.getUserId()作为作者ID articleService.createArticle(dto, user.getUserId()); return Result.success(); }不需要再从token里解析、不需要再从请求参数里拿用户ID只要在拦截器里已经做过认证后面全程都能通过UserHolder拿到当前登录用户。这就是ThreadLocal在Web项目里最常见的用法。4.4 白名单配置不要一股脑全拦住拦截器写好后需要在WebConfig里注册并配置拦截规则。这里的配置策略直接决定系统哪些接口公开、哪些接口必须登录Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /api/article/**, /error, /static/** ); } }addPathPatterns(/**)表示拦截所有请求excludePathPatterns放行不需要登录的接口。以博客系统为例放行登录接口、注册接口、首页文章列表、文章详情页。这些是游客也能看的。需要登录发表文章、删除文章、评论、点赞、上传头像、个人中心等操作类接口。配置白名单时容易犯的错是图省事直接放行整个/api。这样会导致有些需要登录的接口被白名单穿透。我建议把公开接口一条条列出来宁可多写几行excludePathPatterns也别用一个宽泛的路径把不该放行的接口漏出去。这里再补充一点如果你没有用Spring Boot默认的static目录而是把前端页面打包后放进了resources那还要注意静态资源路径的放行。否则页面样式、JS全部被拦截登录页面都打不开。5. 常见问题与排查技巧实录5.1 前端联调最常见的几个问题登录认证是前后端协作最多的地方这块翻车基本集中在几个地方。第一个是前端没带token。前端调用接口时完全没有设置Authorization头后端拦截器解析到一个null直接返回401。排查方法很简单打开浏览器开发者工具看Network面板点开请求头确认有没有Authorization这一项。第二个是token带了但格式不对。有些前端设置的是config.headers[Authorization] token忘了拼Bearer前缀后端代码用startWith(Bearer )判断时匹配不上token就变成了带引号的字符串或者undefined。推荐前端统一写成一个请求拦截器axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; });第三个是401之后页面没有跳转。前端拿到401响应后最好在全局响应拦截器里统一处理axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );这样只要登录状态失效不管是token过期还是用户被强制登出前端都会自动把用户引导回登录页体验会顺很多。5.2 401和403的区别很多新手分不清401和403这里多说一句。401表示“未认证”意思是服务器不知道你是谁——典型场景就是没带token、token过期、token无效。403表示“已认证但没有权限”——比如你登录了但要删除的文章不是你写的。在登录拦截器里token校验失败应该返回401返回403语义就不对。权限不足的校验应该放到接口层或者专门的权限拦截器里这两层职责不同别在登录拦截器里混着搞。5.3 JWT解析报错速查表我在实操中整理了一份JWT解析时的常见异常可以直接对照排查异常类型触发原因处理建议ExpiredJwtException令牌已过期前端跳转登录页或刷新tokenSignatureException签名校验失败检查密钥是否一致检查token是否被手动修改过MalformedJwtException令牌格式不对确认token是否完整三段确认没有被截断UnsupportedJwtException算法不支持确认Header里alg是否被篡改为不支持的算法IllegalArgumentExceptiontoken为空或密钥为空确认Authorization头是否存在确认配置里密钥是否被加载还有一个容易被忽略的坑如果同一套代码部署到两台机器但一台机器的环境变量里没有配置JWT密钥或者配置了不同的密钥那么A机器签发的token在B机器验证时一定会报SignatureException。密钥保持统一是分布式部署时的底线要求。5.4 密钥管理与安全建议最后聊点安全上的事。现实中很多项目挂掉不是JWT方案不好而是密钥被硬编码到了代码里然后代码库泄露攻击者拿着密钥随便伪造token等于整个门卫形同虚设。JWT密钥一定要放在配置文件里或者更保险的放在环境变量、配置中心里。在Spring Boot项目里可以这样jwt: secret: ${JWT_SECRET:my-blog-default-secret} expire-days: 7然后用Value注入到工具类里。生产环境通过部署平台的变量注入不要把真实密钥提交到Git仓库。另外再提一个“是否能主动退出”的设计问题。JWT是无状态的服务器端不保存token逻辑上无法主动删除一个未过期的token。所以如果你要求“用户点退出后token立即失效”单靠纯JWT方案做不到。简单项目的做法是前端退出时直接删掉本地token后端配合把用户账号加入黑名单列表用Redis存被拉黑的token这样才算真正的服务端失效。博客系统从简我一般只做前端删token加过期时间控制两重保护已经足够日常使用。把这个登录模块搭完之后我回过头来最大的感受是登录认证的代码量真心不多但每一步的选择都在给项目打底子。用JWT令牌做无状态认证用拦截器做统一鉴权用ThreadLocal传递用户上下文这几个组合在一起解决的不只是博客系统的登录问题更是后续所有“需要知道当前用户是谁”的功能的基础。后面不管是做“我的文章”还是做“我的评论”直接从UserHolder里拿用户ID就行代码写起来特别顺。如果你也在跟着这个系列走建议先亲手敲一遍JwtUtil和LoginInterceptor把流程真正跑通再往后加功能就会顺畅很多。