
引言
JSON Web Token (JWT) 是一个开放标准 ( RFC 7519 ),它定义了一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式安全传输信息。此信息可以验证和信任,因为它是数字签名的。JWT 可以使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
JWT通常用于授权(最常见)、信息交换
JWT将所有数据都保存在客户端,每次请求都发回服务器
JWT的原理
JWT原理是,服务器认证通过后,生成一个类似以下内容的JSON对象,将其发给用户,并且用户与服务端通信都要再发回这个JSON对象。
{
"name": "sunjk",
"character": "admin",
"experiation_time": "2026年1月1日0点0分"
}
JWT的组成
JWT的组成如下图所示,包含三个部分(用.隔开)
Header:头部Payload:负载Signature:签名
Header
Header部分是一个JSON对象,描述JWT的元数据,通常如下所示。之后,使用Base64URL算法,将其转为字符串
{
"alg": "HS256", // 签名的算法
"typ": "JWT" // 令牌的类型
}
Payload
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。Payload也使用Base64URL算法转为字符串。
- 注册声明:预定义的声明
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
- 公共声明:可随意定义,但为了避免冲突,应该在IANA JSON Web Token Registry中定义
- 私人声明:这些是为在同意使用它们的各方之间共享信息而创建的自定义声明
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
Signature
Signature是对前两个部分的签名,防止数据篡改
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
JWT的使用方式
客户端收到JWT后,将其存放在cookies中或本地,在之后与服务器的通信中,都要携带JWT
在Java中使用JWT

示例
以下示例中使用RSA非对称签名算法,而非HMACSHA256;使用双令牌(accessToken和refreshToken)
认证相关配置属性 AuthProperties.java
@ConfigurationProperties(prefix = "auth")
public class AuthProperties {
private final Jwt jwt = new Jwt();
@Data
public static class Jwt {
// JWT签名者标识
private String issuer = "zhiguang";
// 访问令牌有效期
private Duration accessTokenTtl = Duration.ofMinutes(15);
// 刷新令牌有效期
private Duration refreshTokenTtl = Duration.ofDays(7);
// JWT密钥标识,用于下游校验与轮换
private String keyId = "zhiguang-key"
// RSA 私钥 PEM 资源
private Resource privateKey;
// RSA 公钥 PEM 资源
private Resource publicKey;
}
}
PEM 密钥读取工具 PemUtils.java
因与JWT无太大关联,故在此省略
私钥 private.pem
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDONBk+TKRZ1ok1
mOksrWXKCiNrC+rk/uCpaguItFkM6PBjrfBEoHsY99UbAIh/9kxjotjgLqqKqou9
EG/Yg0Aq07RAAH3NRPb684vU8evV0HKtWON1BiPCCPq/HWFJvUqm08Q2s4qMUso7
rvNoyJ1ZAJ4mJO6yzhQ/lXB3XbLZvJOjoevaDn2zGKI+lneyhACZlbGQsP4NybQE
T5YRbJScWg+Svd+KweqB2JzR5BKlVJOOOgwoptzV79//vbd2USwwyG28umoATxtj
+C5NqiaLA8r3bPFPNqOsVSY11Z5Yzetp1mShImBPYpXtjjVTm7sPiloyZozP54I2
+0Z8A5E1AgMBAAECggEASdHw75K0i8z4Fg4F/A6fkDMM9vevQgfVGhFyy5/0+WAz
HxxYFdl1+DxROHd6ZoUgV9Mm3SsXtsjTef32bIagZDSiJP+ICgKUgMbL2pI5IbSc
OV7HH0/xYTehvOWhvKpdnVfWuNaR1SaWa8N4NJ0NZ52HAJTcN3r7WMABIVqT+sqZ
baO1R+OW/PmSRqPVuX1Yzaku76GIaqsYAfx3ODwAr1WDOhKgXZP7uaqH94IM43I1
WM3d12ombZvu3lIRSIgpF6VNXr0zUPaNGh1jx49oVwp/sMFVZ0tx31T50//Tzj8R
eKJLF7xZXtxiTnsamIvuxM0hI7OOOlK+aYJskzDSiQKBgQDqjepkgJ/M7H8LmbFX
BFj4w35wg+/sClO7rPx1Jriz/Y1r3+KHtkbTsDXTeSFpJP5fSMssbMQkbRDX2Qv/
E8Xnci1TOMn9nVGhYIdlwLrNgHPRfGMUGPEZn5BZQpq1toybIy0MYr5TGhlDBDHT
x5oM0d7sEAs/dGGDPPO6CKNNWwKBgQDhDpciUMJkZAsYdshnvemJvBmxE+gna1HN
g3dtl/A05/fXiBurzQOCYsvndhyLdQri0YrSUifVgG+Dd6rZuSRQYMlbMmdrxhwH
BfdzipvbHtONB+RIs/AzUZZ1bmDcN0sdbsuyQuRxeCzVLr24NTwFCvofdHebVKkZ
tN1i+GQQrwKBgQC8ixGUclSCPMUvRP9p7GO8rCESPux4eCCw5zAU1+h3PuUUhuao
otwyRB4wfNlflM4pN/GiJ1hP06BLcfyTsYhPMNfAYxDznidFKrwMDi3bDVuMVIms
WRRNvZciOUj1suU5u7/1idZmJL+TwGZxT6zEi1u/tHHmMx5DiZZ9v33NqwKBgDQj
nDHwAckeQE/PnQgjwEoPkOgsU3lmbJIvJej27UhkgU+mkbqQiYKYfuLFw0EvwXDg
2Md3DxFwauVLQQKl2NriQWPmXn1ibTXXVQeokgkGsD4ZGPMa9Mf+29ui0b/S840D
ER/gTWpYTBKrfHqDor8MVvwYkGklCPQSqcN3Bj+zAoGACrNC/t+FI1kaXQ8XOErN
zSFwUJav/FUehz0y3PHXeruA+f0wtSIUnMcMPsKLkJeLJs5w4aXKdbn8HOQULdFq
jbQmjVaGE61koQUywFWDIh+g3R8ttOE3RuCLFlfg6YmKQuYf9EdjZZ7t/z/aY483
FmLYciOPHqUiy7/JQe95JGU=
-----END PRIVATE KEY-----
公钥 public.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzjQZPkykWdaJNZjpLK1l
ygojawvq5P7gqWoLiLRZDOjwY63wRKB7GPfVGwCIf/ZMY6LY4C6qiqqLvRBv2INA
KtO0QAB9zUT2+vOL1PHr1dByrVjjdQYjwgj6vx1hSb1KptPENrOKjFLKO67zaMid
WQCeJiTuss4UP5Vwd12y2byTo6Hr2g59sxiiPpZ3soQAmZWxkLD+Dcm0BE+WEWyU
nFoPkr3fisHqgdic0eQSpVSTjjoMKKbc1e/f/723dlEsMMhtvLpqAE8bY/guTaom
iwPK92zxTzajrFUmNdWeWM3radZkoSJgT2KV7Y41U5u7D4paMmaMz+eCNvtGfAOR
NQIDAQAB
-----END PUBLIC KEY-----
访问令牌与刷新令牌的组合 TokenPair.java
public record TokenPair {
// 访问令牌
String accessToken,
// 访问令牌过期时间
Instant accessTokenExpiresAt,
// 刷新令牌
String refreshToken,
// 刷新令牌过期时间
Instant refreshTokenExpiresAt,
// 刷新令牌ID(jti,用于白名单存储和撤销)
String refreshTokenId
}
刷新令牌白名单存储接口 RefreshTokenStore.java
/**
* 刷新令牌白名单存储接口。
* <p>
* 负责管理 Refresh Token 的有效性:存储、校验、撤销单个令牌与撤销用户全部令牌。
* 实现可使用 Redis、数据库或其它持久化方案。
*/
public interface RefreshTokenStore {
/**
* 存储刷新令牌白名单记录
* @param userId 用户ID
* @param tokenId 刷新令牌ID
* @param ttl 生存时间
*/
void storeToken(long userId, String tokenId, Duration ttl);
/**
* 校验刷新令牌是否有效(是否在白名单内且未过期)
* @param userId 用户ID
* @param tokenId 刷新令牌ID
* @return 是否有效
*/
boolean isTokenVaild(long userId, String tokenId);
/**
* 撤销指定刷新令牌(从白名单中移除)
* @param userId 用户ID
* @param tokenId 刷新令牌ID
*/
void revokeToken(long userId, String tokenId);
/**
* 撤销用户的所有刷新令牌
* @param userId 用户ID
*/
void revokeAll(long userId);
基于 Redis 的刷新令牌白名单存储 RedisRefreshTokenStore.java
@Comonent
public class RedisRefreshTokenStore implements RefreshTokenStore {
// 操作Redis的工具对象
private final StringRedisTemplate redisTemplate;
public RedisRefreshTokenStore(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
/**
* 将刷新令牌写入白名单,设置过期时间。
*
* @param userId 用户 ID。
* @param tokenId 刷新令牌 ID。
* @param ttl 生存时间(Redis TTL)。
*/
@Override
public void storeToken(long userId, String tokenId, Duration ttl) {
String key = key(userId, tokenId);
// 存入键名、值(1代表有效)、过期时间
redisTemplate.opsForValue().set(key, "1", ttl);
}
/**
* 判断刷新令牌是否仍有效。
*
* @param userId 用户 ID。
* @param tokenId 刷新令牌 ID。
* @return 是否有效(键存在且值为 "1")。
*/
@Override
public boolean isTokenValid(long userId, String tokenId) {
String key = key(userId, tokenId);
return Objects.equals("1", redisTemplate.opsForValue().get(key));
}
/**
* 撤销单个刷新令牌。
*
* @param userId 用户 ID。
* @param tokenId 刷新令牌 ID。
*/
@Override
public void revokeToken(long userId, String tokenId) {
redisTemplate.delete(key(userId, tokenId));
}
/**
* 撤销该用户全部刷新令牌。
*
* @param userId 用户 ID。
*/
@Override
public void revokeAll(long userId) {
String pattern = "auth:rt:%d:*".formatted(userId);
var keys = redisTemplate.keys(pattern);
if(!keys.isEmpty()){
redisTemplate.delete(keys);
}
}
/**
* 生成白名单键名。
*
* @param userId 用户 ID。
* @param tokenId 刷新令牌 ID。
* @return Redis 键名。
*/
private static String key(long userId, String tokenId) {
return "auth:rt:%d:%s".formatted(userId, tokenId);
}
}
JWT 令牌服务 JwtService.java
/**
* JWT 令牌服务。
* <p>
* 功能:签发 Access/Refresh Token(RS256),解码 JWT,提取用户 ID、令牌类型与令牌 ID。
* 声明:
* - `token_type`:标识 access 或 refresh;
* - `uid`:用户 ID;
* - `jti`:令牌 ID(用作 Refresh Token 的白名单键)。
* 过期时间:来自 `AuthProperties.jwt.accessTokenTtl` 与 `refreshTokenTtl`。
*/
@Service
@RequiredArgsConstructor
public class JwtService {
private static final String CLAIM_TOKEN_TYPE = "token_type";
private static final String CLAIM_USER_ID = "uid";
private final JwtEncoder jwtEncoder;
private final JwtDecoder jwtDecoder;
private final AuthProperties properties;
private final Clock clock = Clock.systemUTC();
/**
* 为指定用户签发一对 Access/Refresh Token。
* <p>
* 令牌类型通过 `token_type` 声明区分;Refresh Token 的 `jti` 用于白名单存储与撤销。
* 过期时间取自配置 `AuthProperties.jwt`。
*
* @param user 用户实体。
* @return 令牌对与对应过期时间及刷新令牌 ID。
*/
public TokenPair issueTokenPair(User user) {
String refreshTokenId = UUID.randomUUID().toString();
Instant issuedAt = Instant.now(clock);
Instant accessExpiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
Instant refreshExpiresAt = issuedAt.plus(properties.getJwt().getRefreshTokenTtl());
String accessToken = encodeToken(user, issuedAt, accessExpiresAt, "access", UUID.randomUUID().toString());
String refreshToken = encodeRefreshToken(user, issuedAt, refreshExpiresAt, refreshTokenId);
return new TokenPair(accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, refreshTokenId);
}
/**
* 解码 JWT 字符串为 {@link Jwt}。
*
* @param token JWT 字符串。
* @return 解析后的 JWT 对象。
*/
public Jwt decode(String token) {
return jwtDecoder.decode(token);
}
/**
* 编码访问令牌。
*
* @param user 用户实体,作为 subject 与自定义声明来源。
* @param issuedAt 签发时间。
* @param expiresAt 过期时间。
* @param tokenType 令牌类型("access")。
* @param tokenId 令牌 ID(jti)。
* @return 编码后的 JWT 字符串。
*/
private String encodeToken(User user, Instant issuedAt, Instant expiresAt, String tokenType, String tokenId) {
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.getJwt().getIssuer())
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.subject(String.valueOf(user.getId()))
.id(tokenId)
.claim(CLAIM_TOKEN_TYPE, tokenType)
.claim(CLAIM_USER_ID, user.getId())
.claim("nickname", user.getNickname())
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
/**
* 编码刷新令牌。
*
* @param user 用户实体。
* @param issuedAt 签发时间。
* @param expiresAt 过期时间。
* @param tokenId 刷新令牌 ID(jti)。
* @return 编码后的刷新令牌字符串。
*/
private String encodeRefreshToken(User user, Instant issuedAt, Instant expiresAt, String tokenId) {
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.getJwt().getIssuer())
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.subject(String.valueOf(user.getId()))
.id(tokenId)
.claim(CLAIM_TOKEN_TYPE, "refresh")
.claim(CLAIM_USER_ID, user.getId())
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
/**
* 从 JWT 中提取用户 ID。
*
* @param jwt 已解析的 JWT。
* @return 用户 ID(long)。
* @throws IllegalArgumentException 当声明类型不合法时抛出。
*/
public long extractUserId(Jwt jwt) {
Object claim = jwt.getClaims().get(CLAIM_USER_ID);
if (claim instanceof Number number) {
return number.longValue();
}
if (claim instanceof String text) {
return Long.parseLong(text);
}
throw new IllegalArgumentException("Invalid user id in token");
}
/**
* 提取令牌类型声明。
*
* @param jwt 已解析的 JWT。
* @return 令牌类型字符串(例如:"access" 或 "refresh")。
*/
public String extractTokenType(Jwt jwt) {
Object claim = jwt.getClaims().get(CLAIM_TOKEN_TYPE);
return claim != null ? claim.toString() : "";
}
/**
* 提取令牌 ID(jti)。
*
* @param jwt 已解析的 JWT。
* @return 令牌 ID。
*/
public String extractTokenId(Jwt jwt) {
return jwt.getId();
}
}
Comments NOTHING