一、什么是JWT?
1.1 JWT的基本概念
JWT(JSON Web Token)是一种用于在各方之间传递JSON格式信息的紧凑、URL安全的令牌(Token)。JWT的主要作用是验证用户身份或权限。它由三部分组成:
- Header(头部):标识令牌的类型和加密算法。
- Payload(载荷):包含了实际的身份信息及其他数据。
- Signature(签名):使用头部和载荷生成的签名,用于验证数据完整性和来源的可靠性。
1.2 JWT的结构
JWT的结构由三部分组成,它们通过点号(.
)进行分隔,格式如下:
Header.Payload.Signature
具体每一部分的内容如下:
-
Header(头部): 通常包含两部分信息:令牌的类型(JWT)和签名的算法(如HMAC SHA256或RSA)。
{"alg": "HS256","typ": "JWT" }
-
Payload(载荷): 载荷是JWT的主体部分,包含了需要传输的数据。通常包含以下几种常见的声明(Claims):
iss
:签发者exp
:过期时间sub
:主题aud
:接收者iat
:签发时间nbf
:在此之前不可用
{"sub": "1234567890","name": "John Doe","admin": true }
-
Signature(签名): 签名部分是用来验证消息的完整性,并确保其未被篡改。签名的生成方式如下:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
二、JWT的原理和机制
2.1 JWT的工作机制
JWT是无状态的令牌,每次客户端(如浏览器)与服务器进行交互时,客户端会在请求头中携带这个令牌,服务器根据JWT中的签名验证令牌的合法性,并通过载荷(Payload)中的信息进行身份确认。
JWT认证的基本流程:
- 用户登录:用户提交用户名和密码。
- 服务器验证用户:服务器验证用户名和密码是否正确。
- 生成JWT:验证成功后,服务器根据用户信息生成一个JWT并返回给客户端。
- 客户端保存JWT:客户端通常会将JWT存储在本地存储(LocalStorage)或Cookies中。
- 请求携带JWT:每次客户端向服务器发送请求时,会在请求头中附加JWT。
- 服务器验证JWT:服务器通过解析JWT,确认用户身份及权限,进而决定是否响应请求。
2.2 为什么使用JWT?
在一个复杂的分布式系统中,例如电商交易系统,多个微服务之间需要频繁通信。传统的基于会话的认证方式存在以下缺点:
- 状态依赖:服务器需要存储每个用户的会话信息,随着用户数量的增加,服务器的存储负担增加。
- 扩展性差:在分布式环境中,多个服务器需要共享会话状态,这增加了系统复杂性。
相比之下,JWT是一种无状态的认证机制,不需要服务器存储会话信息。JWT本身携带了用户的认证信息,具有如下优点:
- 扩展性强:无状态特性使得JWT在分布式架构下具有良好的扩展性。
- 跨平台、跨语言支持:JWT是JSON格式的令牌,几乎可以被所有语言和平台解析使用。
- 灵活性:JWT的Payload部分可以自定义数据,从而支持复杂的权限系统。
2.3 JwtUtils.extractClaims
实现
JwtUtils.extractClaims
是一个工具方法,用于解析JWT并提取其中的声明(claims),如用户ID、角色信息和过期时间。下面是一个典型的实现,它展示了如何验证JWT的有效性、检查签名,以及处理异常。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;import java.security.Key;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;public class JwtUtils {// 用于签名验证的密钥(示例使用HMAC)private static final String SECRET_KEY = "your-256-bit-secret";// 解码并生成用于验证的密钥对象private static Key getSigningKey() {byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY);return new SecretKeySpec(keyBytes, "HmacSHA256");}// 解析JWT并提取Claimspublic static Claims extractClaims(String token) {try {return Jwts.parserBuilder().setSigningKey(getSigningKey()) // 设置密钥用于签名验证.build().parseClaimsJws(token) // 解析并验证JWT.getBody(); // 返回Payload部分的Claims} catch (ExpiredJwtException e) {System.err.println("JWT已过期: " + e.getMessage());throw e;} catch (UnsupportedJwtException | MalformedJwtException e) {System.err.println("无效的JWT: " + e.getMessage());throw new IllegalArgumentException("Invalid JWT");} catch (SignatureException e) {System.err.println("JWT签名不匹配: " + e.getMessage());throw new SecurityException("Invalid signature");} catch (IllegalArgumentException e) {System.err.println("JWT为空或格式不正确: " + e.getMessage());throw e;}}
}
解析说明:
- 密钥管理:
getSigningKey()
方法用于将Base64编码的字符串转换为签名密钥。 - 异常处理:解析JWT时可能出现多种异常,例如JWT过期、签名无效、格式错误等。通过捕获这些异常,我们可以进行相应的错误处理。
- 验证Token的有效性:
parseClaimsJws()
会自动验证签名和Token格式,确保JWT未被篡改。
2.4 JwtUtils.extractClaims
所需的 Maven 依赖
为了实现 JwtUtils.extractClaims
方法,需要使用一个专门处理 JWT 的库。在之前的实现中,我们使用了 JJWT (Java JWT) 库。以下是 JJWT
所需的 Maven 依赖配置。
<!-- pom.xml -->
<dependencies><!-- JJWT: JSON Web Token for Java and Android --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency><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 you prefer --><version>0.11.5</version><scope>runtime</scope></dependency><!-- 其他依赖项 --><!-- ... -->
</dependencies>
依赖说明:
jjwt-api
:提供 JWT 的核心接口和类。jjwt-impl
:包含jjwt
的实现部分,必须与jjwt-api
一起使用。jjwt-jackson
:用于 JSON 处理,支持使用 Jackson 库进行序列化和反序列化。如果您更喜欢使用 Gson,可以替换为jjwt-gson
。
注意事项:
- 确保所有
jjwt
依赖的版本一致,以避免兼容性问题。 - 如果您使用的是 Spring Boot,可以考虑使用 Spring Security 提供的 JWT 支持,以简化集成过程。
三、JWT在电商交易系统中的应用
3.1 电商系统中的角色与权限
在电商交易系统中,涉及多个角色,例如:
- 普通用户:可以浏览商品、下订单、查看订单状态。
- 商家用户:可以管理商品、处理订单。
- 管理员:可以管理平台用户、审核商家、处理投诉等。
系统需要根据不同用户的角色赋予相应的权限,而JWT可以通过其载荷中的角色信息实现权限管理。
3.2 电商系统的JWT认证流程
我们以一个电商交易系统为例,说明JWT在用户身份验证、订单管理中的应用。系统架构主要分为以下几个模块:
- 用户服务:负责用户的注册、登录、生成JWT令牌。
- 商品服务:提供商品的查询、展示等功能。
- 订单服务:处理订单的生成、状态管理。
- 支付服务:处理用户支付。
3.2.1 用户登录与JWT生成
用户登录时,系统会验证用户的用户名和密码,验证通过后生成JWT令牌并返回给客户端。这个令牌包含了用户的身份信息和角色权限。
登录流程的时序图:
3.2.2 请求保护资源
用户登录后,可以浏览商品或下订单。在每次请求时,客户端会在请求头中携带JWT令牌,服务器通过验证JWT来确认用户身份。
请求订单详情时的时序图:
3.3 电商系统中的JWT权限控制
在电商系统中,不同角色的用户具备不同的权限,JWT的Payload中可以存储用户的角色信息(如role
),系统在处理请求时,根据JWT中的角色信息进行权限验证。
3.3.1 权限检查
例如:
- 普通用户只能查看自己的订单;
- 商家用户可以查看商家店铺的订单;
- 管理员可以查看所有订单。
订单服务中的权限检查流程:
@RestController
@RequestMapping("/orders")
public class OrderController {@GetMapping("/{orderId}")public ResponseEntity<?> getOrder(@RequestHeader("Authorization") String token, @PathVariable Long orderId) {Claims claims = JwtUtils.extractClaims(token.replace("Bearer ", ""));String role = claims.get("role", String.class);Long userId = claims.get("userId", Long.class);Order order = orderService.findOrderById(orderId);if (order == null) {return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Order not found");}// 权限检查if ("USER".equals(role) && !order.getUserId().equals(userId)) {return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You don't have permission to access this order");}return ResponseEntity.ok(order);}
}
在这个示例中,OrderController
首先从JWT中提取用户角色和用户ID,然后根据不同角色的权限规则决定是否返回订单详情。
四、前端与后端的JWT交互示例
在电商系统中,前端通常通过JavaScript与后端进行交互。以下我们提供一个简单的HTML和JavaScript示例,展示如何通过JWT进行用户登录、访问受保护的资源。
4.1 登录功能
当用户提交用户名和密码时,前端会将这些数据发送给后端,后端验证成功后返回JWT,前端将其存储在localStorage
中,用于后续的API请求。
HTML和JavaScript代码:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>JWT Login</title>
</head>
<body><h1>JWT Login Example</h1><form id="loginForm"><label for="username">Username:</label><input type="text" id="username" required><br><label for="password">Password:</label><input type="password" id="password" required><br><button type="submit">Login</button></form><div id="orderDetails" style="display:none;"><h2>Order Details</h2><pre id="orderContent"></pre></div><script>document.getElementById('loginForm').addEventListener('submit', function (event) {event.preventDefault();const username = document.getElementById('username').value;const password = document.getElementById('password').value;// 发送登录请求fetch('http://localhost:8080/api/login', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ username, password })}).then(response => response.json()).then(data => {// 存储JWT到localStoragelocalStorage.setItem('jwt', data.token);alert('Login successful');}).catch(error => console.error('Error:', error));});// 请求受保护的资源function fetchOrderDetails(orderId) {const token = localStorage.getItem('jwt');if (!token) {alert('Please login first');return;}fetch(`http://localhost:8080/api/orders/${orderId}`, {method: 'GET',headers: {'Authorization': `Bearer ${token}`}}).then(response => response.json()).then(order => {document.getElementById('orderContent').textContent = JSON.stringify(order, null, 2);document.getElementById('orderDetails').style.display = 'block';}).catch(error => console.error('Error:', error));}// 示例调用fetchOrderDetails(12345);</script>
</body>
</html>
4.2 前端代码说明
- 用户登录时,前端通过
fetch
API向后端发送POST请求,后端验证成功后返回JWT,前端将其存储在localStorage
中。 - 在请求订单详情时,前端会将JWT添加到请求头中发送给后端,后端验证JWT的合法性后返回订单数据。
五、常见问题与解决方案
5.1 JWT过期问题的细化解决方案
问题:JWT一旦达到exp
设定的过期时间,就会被视为无效,用户需要重新登录。这在用户体验上可能造成困扰,特别是当会话中断时。
解决方案一:刷新Token机制(Refresh Token)
Refresh Token是一种辅助机制,用于延长会话时长。具体步骤如下:
-
初次登录:服务器返回一个短时效的JWT和一个长期有效的Refresh Token。
-
Token过期检查:在客户端请求时,如果JWT已过期,则使用Refresh Token获取新的JWT。
-
刷新JWT:
- 客户端向服务器发送Refresh Token。
- 服务器验证Refresh Token的合法性,若有效则生成新的JWT并返回。
- 将新的JWT存储到客户端,并继续操作。
示例代码:刷新Token的实现
@PostMapping("/api/refresh-token")
public ResponseEntity<?> refreshToken(@RequestBody String refreshToken) {try {Claims claims = JwtUtils.extractClaims(refreshToken);if (claims != null) {// 验证通过,生成新的JWTString newJwt = JwtUtils.generateToken(claims.getSubject(), 15); // 新的JWT有效期15分钟return ResponseEntity.ok(Map.of("token", newJwt));}} catch (ExpiredJwtException e) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh Token expired");}return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid Refresh Token");
}
注意:Refresh Token应该比JWT更长效,但同样需要妥善保护,避免泄露。
解决方案二:短时效与长会话相结合
思路:通过设置短时效JWT并动态刷新,确保在用户活动期间会话不会中断。
流程:
- JWT过期时间设置为10~15分钟。
- 每次用户操作时,通过客户端检测JWT的剩余有效期:
- 如果剩余有效期不足5分钟,则向服务器请求新的Token。
- 用户不活动时,不自动刷新JWT。这样可以限制Token滥用的风险。
示例:检测并自动刷新Token的JavaScript代码
function isTokenExpiringSoon(token) {const payload = JSON.parse(atob(token.split('.')[1]));const expTime = payload.exp * 1000; // 转换为毫秒const currentTime = Date.now();return (expTime - currentTime) < 5 * 60 * 1000; // 剩余不足5分钟
}function refreshTokenIfNeeded() {const token = localStorage.getItem('jwt');if (token && isTokenExpiringSoon(token)) {fetch('http://localhost:8080/api/refresh-token', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(token)}).then(response => response.json()).then(data => localStorage.setItem('jwt', data.token)).catch(err => console.error('Token刷新失败:', err));}
}// 页面加载或用户操作时调用
document.addEventListener('click', refreshTokenIfNeeded);
5.2 JWT的大小问题
问题: JWT包含较多信息(如用户ID、角色、权限等),且使用Base64编码后体积较大,增加了网络带宽消耗。
解决方案:
- 精简 Payload:减少JWT中存储的数据,只存储必要的用户身份信息。
- 压缩 Token:可以通过GZIP等方式对JWT进行压缩后再进行传输。
5.2.1 精简 Payload
策略:
- 仅存储必要信息:确保JWT的Payload中只包含验证用户身份所需的最少信息。例如,用户ID、用户名和角色可能是必要的,而不需要包含用户的详细资料。
- 使用简短的声明名称:采用简短的键名来减少总体大小。例如,使用
uid
代替userId
,r
代替role
。
示例:
{"uid": "1234567890","n": "John Doe","r": "ADMIN"
}
实现示例:
public class JwtUtils {// 生成精简的JWTpublic static String generateToken(String userId, String username, String role, long expirationMinutes) {Claims claims = Jwts.claims().setSubject(userId);claims.put("n", username); // 简短的键名claims.put("r", role); // 简短的键名return Jwts.builder().setClaims(claims).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + expirationMinutes * 60 * 1000)).signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();}
}
优点:
- 减少 Token 大小:通过减少Payload中的数据量,显著降低JWT的总体大小。
- 提升性能:较小的Token减少了网络传输的带宽消耗,提升请求的响应速度。
注意事项:
- 平衡信息量与安全性:确保仅删除不必要的信息,避免影响系统的功能和安全性。
- 一致性:在前后端以及各个微服务中统一Payload的结构和字段名称,避免解析错误。
5.2.2 压缩 Token
策略:
- 使用压缩算法:在生成JWT后,使用GZIP等压缩算法对其进行压缩,然后进行Base64编码传输。
- 服务器端解压:接收端需要在解析JWT前先进行解压缩。
实现示例:
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPOutputStream;
import java.util.zip.GZIPInputStream;public class JwtUtils {// 压缩JWTpublic static String compressToken(String token) throws IOException {ByteArrayOutputStream byteStream = new ByteArrayOutputStream(token.length());try (GZIPOutputStream gzip = new GZIPOutputStream(byteStream)) {gzip.write(token.getBytes());}return Base64.getEncoder().encodeToString(byteStream.toByteArray());}// 解压JWTpublic static String decompressToken(String compressedToken) throws IOException {byte[] compressedBytes = Base64.getDecoder().decode(compressedToken);ByteArrayInputStream byteStream = new ByteArrayInputStream(compressedBytes);ByteArrayOutputStream out = new ByteArrayOutputStream();try (GZIPInputStream gzip = new GZIPInputStream(byteStream)) {byte[] buffer = new byte[256];int n;while ((n = gzip.read(buffer)) >= 0) {out.write(buffer, 0, n);}}return out.toString();}
}
前端与后端的集成:
-
生成压缩Token:
String token = JwtUtils.generateToken(userId, username, role, 15); String compressedToken = JwtUtils.compressToken(token); // 将compressedToken发送给客户端
-
客户端存储与传输:
客户端接收并存储压缩后的Token。每次发送请求时,发送压缩Token。
-
服务器端接收与解压:
@GetMapping("/api/orders/{orderId}") public ResponseEntity<?> getOrder(@RequestHeader("Authorization") String compressedToken, @PathVariable Long orderId) {try {String token = JwtUtils.decompressToken(compressedToken.replace("Bearer ", ""));Claims claims = JwtUtils.extractClaims(token);// 继续处理请求} catch (IOException e) {return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid Token");}// ... }
优点:
- 显著减小Token体积:尤其是在Payload包含较多数据时,压缩效果显著。
- 透明性:对前后端系统的改动较小,只需在传输环节增加压缩与解压步骤。
缺点:
- 增加处理开销:压缩与解压缩会增加一定的CPU资源消耗,可能影响性能。
- 复杂性:需要在前后端系统中增加相应的压缩与解压缩逻辑,增加系统复杂性。
注意事项:
- 选择合适的压缩算法:GZIP是常用的选择,但也可以根据具体需求选择其他压缩算法。
- 确保一致性:前后端必须严格按照相同的压缩与解压缩流程进行操作,避免Token解析失败。
5.2.3 其他优化策略
除了精简Payload和压缩Token外,还可以考虑以下优化策略:
- 使用短期Token与会话机制:
- 概述:结合使用短期有效的JWT和基于会话的状态管理。
- 实现:JWT用于身份验证,短期内有效,结合服务器端的会话存储,用于管理用户的会话状态。
- 分片存储:
- 概述:将部分数据存储在客户端(如LocalStorage),将敏感数据存储在服务器端。
- 实现:JWT只包含必要的身份信息,其他详细数据通过API查询获取。
- 自定义编码:
- 概述:使用自定义的编码方式替代标准的Base64,以减少字符数。
- 实现:例如使用Base62编码替代Base64,减少Token长度。
选择适合的优化策略需要根据具体的系统需求和约束条件进行权衡。
5.3 JWT的安全性问题
问题: 虽然JWT签名可以保证数据不被篡改,但JWT的Payload部分是明文的,攻击者可以解码其中的敏感信息。
解决方案:
- 加密JWT:使用JWE(JSON Web Encryption)标准对JWT进行加密,确保敏感信息的安全性。
- 使用 HTTPS:始终通过HTTPS协议传输JWT,避免在网络传输中被拦截。
5.3.1 加密JWT
概述:
- JWE (JSON Web Encryption) 是一种标准,用于对JWT的内容进行加密,确保数据的机密性。
- 与JWS(JSON Web Signature)不同,JWE不仅可以验证数据的完整性和来源,还能保护数据不被未经授权的第三方读取。
实现示例:
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;public class JwtUtils {// 生成加密密钥(示例使用AES)private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret"; // 32字节密钥private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());// 生成加密JWTpublic static String generateEncryptedToken(String userId, String username, String role, long expirationMinutes) {return Jwts.builder().setSubject(userId).claim("n", username).claim("r", role).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + expirationMinutes * 60 * 1000)).signWith(KEY, SignatureAlgorithm.HS256).compressWith(CompressionCodecs.GZIP) // 可选:压缩Payload.encryptWith(Keys.secretKeyFor(SignatureAlgorithm.HS256)) // 使用对称密钥加密.compact();}// 解密JWTpublic static Claims extractEncryptedClaims(String encryptedToken) {try {return Jwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(encryptedToken).getBody();} catch (JwtException e) {// 处理异常throw new SecurityException("Invalid encrypted JWT");}}
}
注意事项:
- 密钥管理:加密密钥必须妥善保管,避免泄露。建议使用环境变量或专用的密钥管理服务(如 AWS KMS、Azure Key Vault)。
- 性能开销:加密与解密会带来额外的计算开销,需评估对系统性能的影响。
- 兼容性:确保前后端系统支持JWE标准,或使用相同的加密机制。
5.3.2 使用 HTTPS
概述:
- HTTPS (HTTP Secure) 是在HTTP基础上加入SSL/TLS协议,确保数据在传输过程中被加密,防止被窃听或篡改。
- 在传输JWT时,使用HTTPS可以防止Token被中间人攻击者截获和利用。
实现步骤:
- 获取 SSL/TLS 证书:
- 可以通过证书颁发机构(如 Let’s Encrypt)获取免费的SSL/TLS证书。
- 配置服务器:
-
在后端服务器(如 Nginx、Apache、Tomcat)上配置SSL/TLS,确保所有API请求通过HTTPS进行。
-
示例(Nginx):
server {listen 443 ssl;server_name yourdomain.com;ssl_certificate /path/to/fullchain.pem;ssl_certificate_key /path/to/privkey.pem;ssl_protocols TLSv1.2 TLSv1.3;ssl_ciphers HIGH:!aNULL:!MD5;location /api/ {proxy_pass http://localhost:8080/api/;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;} }
- 强制 HTTPS:
-
确保所有HTTP请求重定向到HTTPS,避免用户通过不安全的连接访问系统。
-
示例(Nginx):
server {listen 80;server_name yourdomain.com;return 301 https://$host$request_uri; }
- 前端配置:
- 确保前端应用通过HTTPS协议与后端进行通信,避免混合内容问题。
优点:
- 数据加密:确保JWT在传输过程中不被窃听或篡改。
- 数据完整性:防止数据在传输过程中被修改。
注意事项:
- 证书管理:定期更新SSL/TLS证书,避免证书过期导致服务中断。
- 性能优化:使用HTTP/2等协议优化HTTPS连接的性能。