什么是 JWT
在现代Web开发中,身份认证与授权是保障系统安全的核心环节。随着前后端分离架构和微服务的普及,传统基于Session的认证方式面临着跨域、服务器存储压力大等问题。而
JWT(JSON Web
Token)作为一种轻量级、无状态的认证方案,凭借其自包含、可跨域、易扩展的特性,逐渐成为主流选择。
JWT全称为JSON Web Token,是基于RFC
7519标准定义的一种紧凑、自包含的令牌格式,用于在不同系统间安全地传递结构化的JSON数据。其核心价值在于“可验证性”
与“自包含性” :
可验证:通过数字签名确保数据未被篡改,接收方可通过签名反向验证令牌合法性;
自包含:令牌本身携带用户身份、权限、有效期等关键信息,无需频繁查询数据库或缓存;
跨平台兼容:基于JSON格式和Base64编码,支持所有主流编程语言和框架;
轻量灵活:体积远小于XML格式的令牌(如SAML),可通过URL、HTTP头或POST参数轻松传输。
跨域认证的问题
JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案
在单服务器架构下,上述基于 Session
的认证流程能够稳定运行,但随着互联网服务的发展,分布式系统 和多域名场景 日益普遍,传统
Session 认证逐渐暴露出难以解决的跨域问题
互联网服务离不开用户认证。一般流程是下面这样。
1、用户向服务器发送用户名和密码。
2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
3、服务器向用户返回一个 session_id,写入用户的 Cookie。
4、用户随后的每一次请求,都会通过 Cookie,将 session_id
传回服务器。
5、服务器收到
session_id,找到前期保存的数据,由此得知用户的身份。
在单服务器架构下,上述基于 Session
的认证流程能够稳定运行,但随着互联网服务的发展,分布式系统 和多域名场景 日益普遍,传统
Session 认证逐渐暴露出难以解决的跨域问题
浏览器的 “同源策略”
规定:只有当两个页面的协议、域名、端口完全一致时,才算 “同源”,此时
Cookie
才能正常传递。若用户需要访问的服务分散在不同域名下(例如,用户在a.co m 登录后,还需访问b.co m 的资源),由于a.co m 服务器写入的
Session Cookie 无法被b.co m 的服务器读取,b.co m 无法通过
Session ID
识别用户身份,导致用户需要重复登录,严重破坏用户体验。
在分布式架构中,用户的请求可能被分发到不同的服务器节点(例如,通过负载均衡将请求分配给服务器
A 或服务器 B)。传统 Session
数据通常保存在单个服务器的内存中,若用户首次请求被分配到服务器 A 并创建
Session,下次请求若被分配到服务器 B,由于服务器 B 没有该用户的 Session
数据,会判定用户未登录,导致认证失效。
为解决 Session 共享问题,开发者常采用 “Session 集群”(如 Redis
集中存储
Session)等方案,但这些方案不仅增加了系统的复杂度和运维成本,还可能因集中存储节点的故障引发整个认证系统的可用性风险。
传统 Session 认证依赖 Cookie 实现 Session ID
的传递,但在移动端(如 iOS、Android 应用)中,Cookie
的支持并不完善,且移动端应用更倾向于通过 HTTP 请求头(而非
Cookie)传递认证信息。若强行在移动端使用 Session
认证,需要额外开发适配逻辑,增加了开发成本,同时也可能因 Cookie
的安全性问题(如 CSRF 攻击)给应用带来风险。
为解决传统 Session 认证在跨域、分布式、移动端场景下的缺陷,JSON Web
Token(JWT)应运而生。JWT 是一种基于 JSON
的轻量级认证令牌,它将用户身份信息加密后存储在令牌中,通过客户端(如浏览器、移动端
App)主动携带令牌的方式实现认证,无需服务器存储 Session
数据,从根本上解决了跨域认证的核心痛点。
相比传统 Session 认证,JWT
的认证流程更简洁,且天然支持跨域场景,具体步骤如下:
用户首次登录 :用户向服务器发送用户名、密码等认证信息;
服务器验证与生成
JWT :服务器验证用户信息通过后,根据用户 ID、角色等信息构建 JWT
的载荷,结合头部声明的算法和服务器密钥生成 JWT 令牌;
服务器返回 JWT :服务器将 JWT 令牌以 JSON
格式(如{“token”:
“eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…”})返回给客户端,无需写入
Cookie ;
客户端存储 JWT :客户端(浏览器、移动端 App)收到
JWT
后,可将其存储在localStorage(浏览器)、SessionStorage或应用内存(移动端)中,而非依赖
Cookie;
后续请求携带 JWT :用户后续发起请求时,客户端需在
HTTP 请求头中携带 JWT 令牌
服务器验证 JWT :服务器收到请求后,提取请求头中的
JWT 令牌,按以下步骤验证:
拆分 JWT 的头部、载荷、签名三部分,对头部和载荷进行 Base64
解码;
检查头部声明的签名算法,使用服务器密钥对解码后的头部和载荷重新生成签名;
对比重新生成的签名与 JWT
中的原签名:若一致,说明令牌未被篡改;若不一致,直接拒绝请求;
验证载荷中的exp(过期时间)字段,若当前时间已超过exp,说明令牌已过期,拒绝请求;
验证通过后,从载荷中提取用户身份信息(如userId),基于该信息处理业务逻辑(如返回用户专属数据)。
JWT 解决跨域认证的核心优势
无状态性:服务器无需存储任何 Session
数据,所有用户身份信息都包含在 JWT
令牌中,降低了服务器的存储压力,同时使分布式系统中的各个节点无需同步
Session 数据,简化了系统架构;
跨域兼容性:JWT 通过 HTTP
请求头传递,不受浏览器同源策略限制,可轻松支持多域名、跨域接口调用场景(如a.com的前端调用api.b.com的接口);
移动端友好:无需依赖 Cookie,可在 iOS、Android
等移动端应用中灵活存储和传递,适配性更强;
安全性可控:通过签名机制保障令牌不被篡改,同时可通过exp字段设置令牌过期时间,降低令牌泄露后的安全风险(即使令牌被窃取,过期后也无法使用)。
JWT 的原理
JWT 的原理是,服务器认证以后,生成一个 JSON
对象,发回给用户,就像下面这样。
1 2 3 4 5 { "姓名" : "张三" , "角色" : "管理员" , "到期时间" : "2018年7月1日0点0分" }
以后,用户与服务端通信的时候,都要发回这个 JSON
对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。
服务器就不保存任何 session
数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
JWT 的数据结构
JWT 令牌的完整格式如下:
1 [ Header] .[ Payload] .[ Signature]
它是一个很长的字符串,中间用点(.
)分隔成三个部分。注意,JWT
内部是没有换行的
这三个部分分别承担不同的功能,且均采用 Base64URL 编码(一种适用于 URL
的 Base64 变体,替换了部分特殊字符),便于在网络中传输。
头部用于描述 JWT 的基本信息,主要包含两个字段:
alg
:声明签名算法(如 HS256、RS256 等)
typ
:声明令牌类型,固定为 “JWT”
示例头部的 JSON 结构:
1 2 3 4 { "alg" : "HS256" , "typ" : "JWT" }
经过 Base64URL 编码后,上述头部会转换为类似这样的字符串(作为 JWT
的第一部分):
1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
载荷(Payload)
载荷是 JWT
的核心部分,用于存储需要传递的用户数据和元信息。它包含三种类型的声明(Claims):
标准声明(Registered
Claims) :预定义的可选字段,用于描述令牌的基本属性,常见的有:
iss
:令牌签发者(Issuer)
sub
:令牌主题(Subject),通常为用户 ID
aud
:令牌接收者(Audience)
exp
:令牌过期时间(Expiration Time),以 Unix
时间戳表示
nbf
:令牌生效时间(Not
Before),在此时间前令牌无效
iat
:令牌签发时间(Issued At)
jti
:令牌唯一标识符(JWT ID),用于防止重放攻击
公共声明(Public
Claims) :由开发者自定义的字段,但需注意避免与标准声明冲突,通常用于传递业务相关信息(如用户名、角色等)。
私有声明(Private
Claims) :由服务端和客户端协商定义的字段,仅在特定场景下使用。
示例载荷的 JSON 结构:
1 2 3 4 5 6 7 { "sub" : "1234567890" , "name" : "John Doe" , "admin" : true , "iat" : 1516239022 , "exp" : 1516242622 }
经过 Base64URL 编码后,上述载荷会转换为类似这样的字符串(作为 JWT
的第二部分):
1 eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ
请注意,Base64URL
编码是可逆的,他不是安全加密,因此载荷中绝对不能存放敏感信息 (如密码、银行卡号等),只能存储非敏感的身份标识或业务数据。
签名(Signature)
签名是 JWT
的安全保障,用于验证令牌在传输过程中是否被篡改,以及确保令牌确实由合法的服务器签发。
签名的生成步骤如下:
用 Base64URL 编码头部和载荷,得到两个字符串
将这两个字符串用句号拼接,形成
HeaderEncoded.PayloadEncoded
使用头部中声明的签名算法(如
HS256),结合服务器端的密钥(Secret)对拼接后的字符串进行加密,生成签名
以 HS256 算法为例,签名的伪代码如下:
1 2 3 4 5 HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
生成的签名字符串作为 JWT 的第三部分,例如:
1 SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT
的三段式结构设计既满足了数据传输的简洁性(单一字符串便于在请求头中携带),又通过签名机制确保了数据的完整性和真实性。服务器在验证
JWT
时,只需重新计算签名并与令牌中的签名比对,即可判断令牌是否有效,无需查询数据库或缓存,这也是
JWT 适用于跨域和分布式场景的核心原因。
实际开发中使用 JWT
来到 JWT 官网找找依赖,我们用的比较多的就是右边这两个 Java
的,我们这次就用 jose4j
image-20250923141842714
我们使用这个 jwt 的实现来作为依赖导入我们的项目
1 2 3 4 5 <dependency > <groupId > org.bitbucket.b_c</groupId > <artifactId > jose4j</artifactId > <version > 0.9.6</version > </dependency >
首先,我们需要在配置文件里写上关于 JWT 的一些信息
1 2 3 4 5 6 jwt.secret =your-256-bit-secret-key-for-jwt-token-generation jwt.expiration =60 jwt.issuer =aries-app
然后我们创建 JWT 工具类
在这个工具类中,我们一般会创建 jwt 令牌,验证 jwt 令牌,然后从 jwt
令牌中拿出来一些你需要的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 package net.onest.aries.util;import lombok.extern.log4j.Log4j2;import org.jose4j.jwa.AlgorithmConstraints;import org.jose4j.jws.AlgorithmIdentifiers;import org.jose4j.jws.JsonWebSignature;import org.jose4j.jwt.JwtClaims;import org.jose4j.jwt.MalformedClaimException;import org.jose4j.jwt.consumer.ErrorCodes;import org.jose4j.jwt.consumer.InvalidJwtException;import org.jose4j.jwt.consumer.JwtConsumer;import org.jose4j.jwt.consumer.JwtConsumerBuilder;import org.jose4j.keys.HmacKey;import org.jose4j.lang.JoseException;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import java.security.Key;import java.util.HashMap;import java.util.Map;@Component @Log4j2 public class JwtUtil { @Value("${jwt.secret:your-256-bit-secret}") private String secret; @Value("${jwt.expiration:60}") private int expiration; @Value("${jwt.issuer:aries-app}") private String issuer; public String generateToken (Integer userId, String username) { try { JwtClaims claims = new JwtClaims (); claims.setIssuer(issuer); claims.setAudience("aries-clients" ); claims.setExpirationTimeMinutesInTheFuture(expiration); claims.setGeneratedJwtId(); claims.setIssuedAtToNow(); claims.setNotBeforeMinutesInThePast(2 ); claims.setSubject(username); claims.setClaim("userId" , userId); JsonWebSignature jws = new JsonWebSignature (); jws.setPayload(claims.toJson()); jws.setKey(new HmacKey (secret.getBytes())); jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); return jws.getCompactSerialization(); } catch (JoseException e) { log.error("生成JWT令牌失败" , e); throw new RuntimeException ("生成JWT令牌失败" , e); } } public Map<String, Object> validateToken (String token) { try { JwtConsumer jwtConsumer = new JwtConsumerBuilder () .setRequireExpirationTime() .setAllowedClockSkewInSeconds(100 ) .setRequireSubject() .setExpectedIssuer(issuer) .setExpectedAudience("aries-clients" ) .setVerificationKey(new HmacKey (secret.getBytes())) .setJwsAlgorithmConstraints( AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.HMAC_SHA256) .build(); JwtClaims jwtClaims = jwtConsumer.processToClaims(token); Map<String, Object> claims = new HashMap <>(); claims.put("sub" , jwtClaims.getSubject()); claims.put("userId" , jwtClaims.getClaimValue("userId" )); claims.put("exp" , jwtClaims.getExpirationTime().getValue()); claims.put("iat" , jwtClaims.getIssuedAt().getValue()); return claims; } catch (InvalidJwtException e) { if (e.hasErrorCode(ErrorCodes.EXPIRED)) { log.warn("JWT令牌已过期: {}" , token); } else { log.warn("JWT令牌验证失败: {}" , e.getMessage()); } return null ; } catch (MalformedClaimException e) { log.error("JWT声明格式错误" , e); return null ; } } public String getUsernameFromToken (String token) { Map<String, Object> claims = validateToken(token); return claims != null ? (String) claims.get("sub" ) : null ; } public Integer getUserIdFromToken (String token) { Map<String, Object> claims = validateToken(token); return claims != null ? (Integer) claims.get("userId" ) : null ; } }
随便写一个 repository
1 2 3 4 5 6 7 8 9 10 11 12 13 @Repository public interface UserRepository extends JpaRepository <User, Integer> { Optional<User> findByUsername (String username) ; }
然后把用户服务的接口和实现类写好
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public interface UserService { User login (String username, String password) ; User findByUsername (String username) ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 package net.onest.aries.user.service.impl;import lombok.RequiredArgsConstructor;import lombok.extern.log4j.Log4j2;import net.onest.aries.entity.User;import net.onest.aries.user.repository.UserRepository;import net.onest.aries.user.service.UserService;import org.springframework.stereotype.Service;@Service @RequiredArgsConstructor @Log4j2 public class UserServiceImpl implements UserService { private final UserRepository userRepository; @Override public User login (String username, String password) { log.info("用户登录: {}" , username); return userRepository.findByUsername(username) .filter(user -> user.getPassword().equals(password)) .orElse(null ); } @Override public User findByUsername (String username) { return userRepository.findByUsername(username).orElse(null ); } }
如果你写了 dto,请务必在登陆响应的dto 中把 token 放进去
接下来我们创建我们登录的控制器,也就是认证控制器,我们使用纯 jwt
验证安全情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 package net.onest.aries.user.controller;import lombok.RequiredArgsConstructor;import lombok.extern.log4j.Log4j2;import net.onest.aries.entity.User;import net.onest.aries.user.dto.LoginRequest;import net.onest.aries.user.dto.LoginResponse;import net.onest.aries.user.service.UserService;import net.onest.aries.util.JwtUtil;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @Log4j2 public class AuthController { private final UserService userService; private final JwtUtil jwtUtil; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { log.info("用户登录请求: {}" , loginRequest.getUsername()); User user = userService.login(loginRequest.getUsername(), loginRequest.getPassword()); if (user == null ) { log.warn("登录失败: 用户名或密码错误 - {}" , loginRequest.getUsername()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body("用户名或密码错误" ); } String token = jwtUtil.generateToken(user.getId(), user.getUsername()); LoginResponse response = new LoginResponse (token, user.getUsername(), user.getId()); log.info("用户登录成功: {}" , user.getUsername()); return ResponseEntity.ok(response); } }
我们再写一个JWT拦截器,用于验证请求中的JWT令牌,调用的还是上面
JwtUtil 的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 package net.onest.aries.config;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import lombok.extern.log4j.Log4j2;import net.onest.aries.util.JwtUtil;import org.springframework.stereotype.Component;import org.springframework.web.servlet.HandlerInterceptor;@Component @RequiredArgsConstructor @Log4j2 public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization" ); if (authHeader == null || !authHeader.startsWith("Bearer " )) { log.warn("请求被拒绝: 缺少有效的Authorization头" ); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("未授权: 缺少有效的令牌" ); return false ; } String token = authHeader.substring(7 ); if (jwtUtil.validateToken(token) == null ) { log.warn("请求被拒绝: 无效的JWT令牌" ); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("未授权: 无效的令牌" ); return false ; } String username = jwtUtil.getUsernameFromToken(token); Integer userId = jwtUtil.getUserIdFromToken(token); request.setAttribute("username" , username); request.setAttribute("userId" , userId); log.info("JWT验证通过: 用户 {} (ID: {})" , username, userId); return true ; } }
接下来我们写一个配置类,标记一下都拦截什么地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final JwtInterceptor jwtInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**" ) .excludePathPatterns("/api/auth/**" ); } }
写一个受保护的 api,看看 jwt 能否生效
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 package net.onest.aries.user.controller;import jakarta.servlet.http.HttpServletRequest;import lombok.RequiredArgsConstructor;import lombok.extern.log4j.Log4j2;import net.onest.aries.entity.User;import net.onest.aries.user.service.UserService;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.HashMap;import java.util.Map;@RestController @RequestMapping("/api/users") @RequiredArgsConstructor @Log4j2 public class UserController { private final UserService userService; @GetMapping("/me") public ResponseEntity<?> getCurrentUser(HttpServletRequest request) { String username = (String) request.getAttribute("username" ); Integer userId = (Integer) request.getAttribute("userId" ); log.info("获取当前用户信息: {} (ID: {})" , username, userId); User user = userService.findByUsername(username); if (user == null ) { log.warn("用户不存在: {}" , username); return ResponseEntity.notFound().build(); } Map<String, Object> response = new HashMap <>(); response.put("id" , user.getId()); response.put("username" , user.getUsername()); response.put("email" , user.getEmail()); return ResponseEntity.ok(response); } }
懒得搞数据库了,因为是从老项目上摘取然后改的,估计是差不多能对的
你可以创建一个 POST 请求试试
发送请求,如果成功,你将收到类似以下的响应:
1 2 3 4 5 6 { "token" : "eyJhbGciOiJIUzI1NiJ9..." , "username" : "testuser" , "userId" : 1 }
保存返回的token,我们将在后续请求中使用它
然后创建一个GET请求:
URL: http://localhost:8080/api/users/me
Headers:
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
(使用上一步获取的token)
发送请求,如果JWT验证成功,你将收到用户信息
可以使用在线工具如 jwt.io
来解析和验证JWT令牌