JWT令牌是如何实现登录认证的

前言: 本文将简单介绍一下JWT令牌机制

在Web开发中,由于HTTP协议的无状态特性, 为了使得每次客户端连接时服务器都能识别出用户, 我们引入了Cookie和Session来为HTTP请求添加了状态

1. 核心工作原理
  • 认证 (Authentication):用户提交账号密码,服务器验证通过。

  • 状态存储 (Stateful) :服务器在内存/内存数据库(如 Redis)中生成一个唯一的 Session ID,并以此为 Key 存储用户信息。

  • 客户端携带 :服务器通过 HTTP 响应头 Set-CookieSession ID 写入浏览器。后续请求浏览器会自动在 Header 中携带该 Cookie。

2. 核心局限性 (Limitations)

这种机制在单体架构时代很完美,但在现代互联网架构中,它有三个致命局限:

  • 服务器开销大(状态留存): Session 是有状态(Stateful)的。当用户量暴增时,服务器需要消耗大量内存来存储 Session。

  • 不适合分布式和微服务架构(扩展性差): 在多台服务器(集群)或微服务架构下,用户的请求可能会被负载均衡器分配到不同的服务器上。如果服务 A 存了 Session,服务 B 没有,用户就会"莫名其妙"地掉线。虽然可以通过 Session 复制、Session 粘滞或 Redis 集中存储来解决,但这也增加了系统的复杂性和对外部缓存的依赖。

  • 跨域与前后端分离的限制: 现代前端通常和后端部署在不同的域名下。Cookie 在跨域请求时会遇到诸多限制(如同源策略、SameSite 属性等)。此外,移动端(App、小程序)对 Cookie 的支持并不友好。

  • CSRF(跨站请求伪造)风险: 因为浏览器会自动携带 Cookie,黑客很容易利用这一点进行 CSRF 攻击。

二, 为什么引入 JWT?

为了解决上述问题,JWT(JSON Web Token) 应运而生。

JWT 的核心思想是:将状态从服务器端转移到客户端。它是一种无状态(Stateless)的认证机制。

服务器不再存储任何 Session 数据,而是把用户信息加密/签名成一个字符串(Token),直接发给客户端。客户端自己保存这个 Token,每次请求时手动放在 HTTP 请求头中传给服务器。服务器只需要"解密/验签"这个 Token,就知道是谁在访问了。

打个比方:

  • Session 模式: 游乐园发给你一张会员卡号,游乐园内部的电脑里记录着这个卡号对应是谁、买了什么票。每次你进去,服务员都要查电脑。

  • JWT 模式: 游乐园直接发给你一张盖了防伪钢印的纸质门票,上面写着你的名字和有效时间。服务员只要肉眼验一下钢印真伪,不需要查电脑就能放行。

三, JWT 的结构与原理

JWT 是一个很长的字符串,由英文句点 . 分隔成三个部分:Header.Payload.Signature(头部.负载.签名)。

① Header(头部)

通常包含两部分信息:Token 的类型(这里是 JWT)和使用的签名算法(如 HMAC SHA256 或 RSA)。

JSON

复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

这段 JSON 会被使用 Base64URL 算法转换为字符串。

② Payload(负载)

这里存放实际需要传输的用户信息(Claims),比如用户 ID、用户名、过期时间等。

JSON

复制代码
{
  "sub": "1234567890",
  "name": "John Doe",
  "exp": 1716715200
}

同样,这段 JSON 也会被 Base64URL 转换为字符串。

⚠️ 极重要注意: Base64 是一种编码方式,不是加密 !这意味着任何人拿到 JWT 都可以解码并看到 Payload 里的内容。因此,绝对不能在 Payload 中存放密码、银行卡号等敏感信息

如果在payload中存放用户的密码的话,就好比你把"秘密"翻译为了Secret,虽然可能不认识英语的人无法知道这个单词是什么含义,但是一旦有人了解Base64编码方式,那么你自认为的加密行为就形同虚设~~

③ Signature(签名)

这是 JWT 最关键的安全保障。它是对前两部分(Header 和 Payload)进行签名的结果。

相当于是基于头部(header) 和载荷(payload) 进行了一次加密

服务器有一个只有自己知道的 密钥(Secret)。生成签名的公式如下:

Signature=HMACSHA256(base64UrlEncode(Header)+"."+base64UrlEncode(Payload),secret)\text{Signature} = \text{HMACSHA256}(\text{base64UrlEncode}(\text{Header}) + "." + \text{base64UrlEncode}(\text{Payload}), \text{secret})Signature=HMACSHA256(base64UrlEncode(Header)+"."+base64UrlEncode(Payload),secret)

防篡改机制:

如果黑客尝试在客户端修改 Payload 里的用户 ID(比如把用户 ID 从 100 改成 1 企图变成管理员),由于黑客不知道服务器的 secret,他无法重新生成正确的签名。当服务器收到被篡改的 JWT 时,用自己的密钥一验算,就会发现签名对不上,从而拒绝请求。

四, JWT 的完整认证机制(工作流程)

  1. 登录: 用户在浏览器输入账号密码,发送 POST 请求到后端。

  2. 生成: 后端验证账号密码正确后,将用户 ID 等非敏感信息作为 Payload,加上服务器私有的 Secret 密钥,生成一个 JWT 字符串。

  3. 返回: 后端将这个 JWT 返回给前端(可以放在响应体中)。

  4. 存储: 前端收到 JWT 后,将其存储在 localStoragesessionStorageCookie 中。

  5. 携带请求: 之后前端每次向后端请求数据时,都会在 HTTP 请求头中加入 Authorization 字段,格式通常为:

    Authorization: Bearer <Your_JWT_Token>

  6. 验签放行: 后端收到请求后,不需要查数据库或 Redis,只需直接取出请求头中的 JWT,用本地的 Secret 密钥验证其签名是否有效、是否过期。如果有效,直接从 Payload 中获取用户 ID,处理业务并返回数据。

五, JWT实操演练

下面是在项目中使用JWT的代码案例

在项目中,我们通常会封装一个全局工具类来处理 JWT 的生成解析 。下面是基于最新版 io.jsonwebtoken (JJWT) 库实现的完整工具类:

在使用JWT前,我们需要引入相应的依赖

xml 复制代码
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-api</artifactId>  
    <version>0.11.5</version>  
</dependency>  
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-impl</artifactId>  
    <version>0.11.5</version>  
    <scope>runtime</scope>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->  
    <version>0.11.5</version>  
    <scope>runtime</scope>

对于生成令牌和校验令牌这种操作,我们一般构建一个工具类JwtUtils来封装这些操作

java 复制代码
public class JwtUtils {  
  
    //密钥,HS256 算法要求密钥的长度至少为 256 位(32 字节),SECRET不能太短  
    private static final String SECRET = "spring-blog-demo-jwt-secret-key-2026-amadeus";  
    //过期时间  
    private static final long EXPIRE_TIME = 1000L * 60 * 60 * 24;  
    //安全密钥  
    private static final Key KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));  
  
    private JwtUtils() {  
    }  
  
  
    //生成JWT令牌  
    public static String genJwt(Map<String, Object> claims) {  
        Date now = new Date();  
        return Jwts.builder()  
                .setClaims(claims)  
                .setIssuedAt(now)  
                .setExpiration(new Date(now.getTime() + EXPIRE_TIME))  
                .signWith(KEY, SignatureAlgorithm.HS256)  
                .compact();  
    }  
    
    //解析JWT令牌  
    public static Claims parseJwt(String token) {  
        try {  
            return Jwts.parserBuilder()  
                    .setSigningKey(KEY)  
                    .build()  
                    .parseClaimsJws(token)  
                    .getBody();  
        } catch (JwtException | IllegalArgumentException e) {  
            return null;  
        }  
    }  
}

genJwt 方法中,通过 Jwts.builder() 采用建造者模式进行链式配置。

  • setClaims(claims):将业务数据(如用户ID、角色等)打包进 Token。

  • setExpiration(...):设定了 24 小时后自动失效,确保了即使 Token 被窃取,其生命周期也是有限的。

parseJwt方法中,JWT 的验证不仅是解密,更重要的是防篡改防过期

  • 如果客户端传来的 Token 被修改过任何一个字符,parseClaimsJws(token) 会立刻察觉并抛出 SignatureException

  • 如果当前时间已经超过了 Expiration 设定的时间,它会抛出 ExpiredJwtException

  • 代码通过 catch (JwtException | IllegalArgumentException e) 将这些异常统一拦截并返回 null,避免了异常向上抛出导致前端收到 500 错误,让业务层鉴权逻辑更平滑。

项目中如何实际应用?

有了这个工具类后,在 Spring Boot 项目中通常有两个核心应用场景:

1.场景 A:用户登录成功后颁发 Token

在 Login 业务中,校验完用户名和密码后,将用户基本信息存入 Map 并生成 Token 返回给前端。

Java

java 复制代码
Map<String, Object> claim = new HashMap<>();  
claim.put("userId", userInfo.getId());  
claim.put("userName", userInfo.getUserName());  
return new UserLoginResponse(userInfo.getId(), JwtUtils.genJwt(claim));

这里我使用UserLoginResponse 来统一登录的返回类型

java 复制代码
public class UserLoginResponse {  
  
    private Integer userId;  
  
    private String token;  
}

3.场景 B:在拦截器(Interceptor)中进行鉴权

创建一个 LoginInterceptor,对受保护的 API 进行拦截。从请求头 Authorization 中获取 Token 并解析。

Java

java 复制代码
@Slf4j  
@Component  
public class LoginInterceptor implements HandlerInterceptor {  
  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {  
        String token = request.getHeader("Authorization");  
        if (!StringUtils.hasText(token)) {  
            log.warn("request path: {}, missing token", request.getRequestURI());  
            throw new BlogException("请先登录");  
        }  
  
        Claims claims = JwtUtils.parseJwt(token);  
        if (claims == null) {  
            log.warn("request path: {}, invalid token", request.getRequestURI());  
            throw new BlogException("登录已过期,请重新登录");  
        }  
  
        request.setAttribute("userId", claims.get("userId", Integer.class));  
        request.setAttribute("userName", claims.get("userName", String.class));  
        return true;    }  
}

生成令牌和解析令牌的工作都已完成, 接下来我们可以创建一个测试类来观察效果

java 复制代码
@SpringBootTest  
public class JWTTest {  
  
    // 24小时过期时间  
    private final long Expiration = 24 * 60 * 60 * 1000;  
  
    // 预先生成的安全的 Base64 密钥字符串  
    private final String secretString = "rXRoWn8J+bCcnadUFU88AIqhK9caLJCg+6c2rP8F1EE=";  
  
    // 根据密钥字符串生成签名需要的 Key 对象  
    private final Key key = Keys.hmacShaKeyFor(secretString.getBytes());  
  
    /**  
     * 测试用例 1:生成安全的随机密钥  
     * 生产环境的 SECRET 字符串应该由此方法生成  
     */  
    @Test  
    public void genKey(){  
        // 自动生成符合 HS256 算法安全强度的 SecretKey        SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);  
        // 将其转化为高度不可读、不可逆向猜测的 Base64 编码字符串  
        String encode = Encoders.BASE64.encode(secretKey.getEncoded());  
        System.out.println("生成的安全密钥 -> " + encode);  
    }  
  
    /**  
     * 测试用例 2:测试 JWT 令牌的生成  
     */  
    @Test  
    public void genJwt(){  
        Map<String, Object> claim = new HashMap<>();  
        claim.put("id", 1);  
        claim.put("name", "cheems"); // 放入我们可爱的测试用户 cheems  
        String compact = Jwts.builder()  
                .setClaims(claim)  
                .setIssuedAt(new Date())                                     // 设置签发时间  
                .setExpiration(new Date(System.currentTimeMillis() + Expiration)) // 设置有效期  
                .signWith(key)                                               // 签名  
                .compact();  
        System.out.println("生成的 Token -> " + compact);  
    }  
  
    /**  
     * 测试用例 3:测试 JWT 令牌的解析与合法性校验  
     */  
    @Test  
    public void parseJwt(){  
        // 传入一个待验证的 Token 字符串  
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiY2hlZW1zIiwiaWQiOjEsImlhdCI6MTc3OTc5NzYwMywiZXhwIjoxNzc5ODg0MDAzfQ.EDXyOGB0iY96-xuv1pJCzVri81CZzLiervjboE4G6P8";  
  
        // 1. 创建解析器,并注入相同的签名密钥  
        JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder().setSigningKey(key);  
  
        // 2. 解析并获取 Payload 载荷  
        Claims body = jwtParserBuilder.build().parseClaimsJws(token).getBody();  
        System.out.println("解析出的数据 -> " + body);  
    }  
}

那么这里测试类中的genKey方法有何作用呢?

Siginature 的 计算公式为:
Signature=HMACSHA256(base64UrlEncode(Header)+"."+base64UrlEncode(Payload),secret)\text{Signature} = \text{HMACSHA256}(\text{base64UrlEncode}(\text{Header}) + "." + \text{base64UrlEncode}(\text{Payload}), \text{secret})Signature=HMACSHA256(base64UrlEncode(Header)+"."+base64UrlEncode(Payload),secret)

使用genKey生成的 secret 随机且不同 ,从而使得 JWT的第三部分Signature(签名)不同

假设有两个不同的网站(网站 A 和 网站 B),它们都使用 JWT 存了同样的一句话:{"user": "cheems"}

  • 网站 A 的密钥是:secret-key-AAAAA...

  • 网站 B 的密钥是:secret-key-BBBBB...

虽然它们的前两部分(Header 和 Payload)由于数据相同,Base64 编码出来的内容一模一样;但因为密钥不同 ,经过加密算法计算后,网站 A 和网站 B 生成的第三部分(签名)会截然不同

这样就实现了隔离:网站 A 颁发的 Token,绝对没办法去登录网站 B。

同时如果坏人把 Payload 改成了 {"userId": 2}(哪怕只改了一个数字!),在密钥不变的情况下,重新计算出的第三部分会变成 Y(跟 X 没有任何相似之处)。

使用genKey() 就可以替换掉先前我们在JwtUtil中硬编码的密钥字符串了

除此之外,我们还可以把开发阶段生成的JWT密钥设置在配置文件中,以便于妥善保管,注意不要泄露哦~~

总而言之,这就是genKey的主要功能 : 生成一个随机且安全的JWT签名密钥

六, JWT 就完美无缺了吗?

虽然 JWT 解决了 Session 的扩展性问题,但它也不是万能药,同样存在局限:

  • 无法单点注销(一旦发出,覆水难收): 因为服务器不保存状态,如果用户点击了"退出登录",或者管理员想强制某个违规用户下线,服务器很难让一个未过期的 JWT 立即失效。通常需要引入 Redis 黑名单机制来配合解决(但这又让系统变重了)。

  • 体积较大: JWT 包含的信息较多,编码后字符串很长,每次请求都带着它,会消耗更多的网络带宽。

  • 续签麻烦: JWT 一旦过期,用户就必须重新登录。为了用户体验,通常需要引入双 Token 机制(AccessToken 短期用于请求,RefreshToken 长期用于刷新 Token)。

双Token

什么是双Token机制呢?假设你进出一家高安全级别的公司:

  • Access Token(访问令牌) :就像是一张"临时通行纸条"。上面写着你的名字,保安看一眼就放行。为了安全,这张纸条半小时就过期。

  • Refresh Token(刷新令牌) :就像是你的"员工大卡(工作证)"。它不能直接塞给大楼保安看,但当你的"临时通行纸条"过期时,你可以拿着"员工大卡"去 HR 部门 免费以旧换新,重新领一张半小时有效的纸条。这张大卡通常可以维持 30 天有效。

双 Token 的核心工作流程

在前端和后端的交互中,它们的配合非常默契,用户在这个过程中几乎是完全无感知的:

  1. 用户登录 :用户输入账号密码,服务器验证成功,一口气颁发两个 Token(Access TokenRefresh Token)给前端。

  2. 正常访问 :前端之后的每次请求,都只带上 Access Token。服务器验证它有效,正常返回数据。

  3. Access Token 过期 :过了 30 分钟,Access Token 失效了。前端再次发起请求时,服务器返回一个特殊的错误码(通常是 401 Unauthorized)。

  4. 静默刷新 :前端的拦截器(如 Axios Interceptor)拦截到这个 401 错误,不惊动用户 ,自动在后台把 Refresh Token 发送给服务器的一个"刷新接口"。

  5. 续命成功 :服务器检查 Refresh Token 有效,生成一个全新Access Token 给前端。

  6. 重新请求 :前端拿到新的 Access Token,重新发送刚才失败的那个请求。用户只觉得网络可能稍微卡了一下下,完全不需要重新输入密码。

为什么要这么麻烦?直接把 JWT 有效期设长一点不行吗?

如果只用一个 Token,把有效期设为 30 天,确实没有续签麻烦了,但会带来灾难性的安全隐患

  • JWT 的"泼水难收"特性 :JWT 是无状态的,服务器不保存它的状态。这意味着,一旦一个 JWT 被黑客窃取,只要它没过期,黑客就可以在任意地方冒充用户,服务器对此无能为力(无法主动让其失效)

  • 双 Token 的降维打击

    • Access Token 频繁在网络上传输,被窃取的风险高。但因为它寿命极短(如15分钟),黑客偷过去还没捂热就过期了,损失可控。

    • Refresh Token 只在刷新时传输一次 ,平时静静地躺在安全的存储里(如浏览器的 HttpOnly Cookie 中,JS 代码无法读取,免疫 XSS 攻击)。

    • 支持主动拉黑 :因为 Refresh Token 数量少且只用于刷新,服务器通常会在数据库或 Redis 里记录它。如果用户换了设备、修改了密码或者发现异常,服务器可以随时在后端撕毁(禁用)这个 Refresh Token 。这样,黑客的 Access Token 一旦过期,就再也换不到新 Token 了。

双Token总结

  • Access Token :负责前线打仗。寿命短、权限高、天天在网络上跑,过期了也不心疼。

  • Refresh Token :负责后方补给。寿命长、不见光、只在关键时刻出来给前线"续命",并且随时可以被总部(后端)切断。

通过这种"长短结合"的策略,既保证了用户不需要天天登录(极佳的 UX),又确保了即便 Token 泄露,黑客也无法长期作恶(极高的安全性)。

以上就是有关JWT的令牌机制的简单介绍,希望对你有所帮助~~ 如有纰漏,感谢指出哦~~

相关推荐
happyprince1 小时前
10-Hugging Face Transformers 量化系统深度分析
java·前端·数据库
budingxiaomoli1 小时前
利用Hutool完成验证码案例
java
山人在山上1 小时前
docker离线安装
java·docker·eureka
人间乄惊鸿客1 小时前
c++自记录
java·开发语言·c++
better_liang1 小时前
每日Java面试场景题知识点之-MySQL底层数据结构B+树
java·数据结构·mysql·性能优化·面试题·b+树·数据库索引
蓝影灵1 小时前
单体改微服务记录
java·开发语言
老码观察1 小时前
设计模式实战解读(五):策略模式——干掉 if-else 的优雅方案
java·设计模式·策略模式
李少兄1 小时前
Java 短路求值的优雅实践:用 `&&` 实现安全高效的批量操作控制
java·开发语言
oddsand11 小时前
AI应用开发学习步骤-java
java·人工智能·学习