学习 JWT(JSON Web Token)是理解现代身份认证的关键步骤。
第一部分:深入理解 JWT(JSON Web Token)
JWT(JSON Web Token)是一个用于在网络应用环境中安全地传递声明性信息的开放标准(RFC 7519)。它通常用于身份认证和信息交换,结构简单且自包含。JWT 主要由三部分组成:
- 头部(Header)
- 载荷(Payload)
- 签名(Signature)
1.1 JWT 的基本结构
JWT 的结构由三部分组成,每部分之间用 .
分隔:
xxxxx.yyyyy.zzzzz
xxxxx
:头部(Header),是一个 JSON 对象,通常由两部分组成:声明使用的算法(如HS256
、RS256
)和类型(通常是JWT
)。yyyyy
:载荷(Payload),是一个包含声明的 JSON 对象。声明是关于实体(通常是用户)和其他元数据的数据。zzzzz
:签名(Signature),用来验证消息的完整性,确保消息没有被篡改,同时验证发送者的身份。
1.2 JWT 头部
JWT 头部通常包含两部分:
alg
:签名使用的算法(如HS256
、RS256
)。typ
:令牌类型,通常为JWT
。
例如,头部的 JSON 对象可能如下所示:
{
"alg": "HS256",
"typ": "JWT"
}
我们通常将它转化为 Base64Url 编码格式,以便在 JWT 中使用。
1.3 JWT 载荷
载荷部分包含了 声明(Claims)。声明是关于实体(通常是用户)和其他元数据的数据。JWT 中的声明分为三种类型:
- 注册声明(Registered Claims):一组预定义的字段,如
iss
(发行者)、exp
(过期时间)、sub
(主题)等。 - 公共声明(Public Claims):这些声明可以自定义,并且使用标准的注册名称,但需要避免冲突。
- 私有声明(Private Claims):由用户自定义,用于应用程序特定的需要。
一个常见的 JWT 载荷可以是:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
sub
:表示主题(通常是用户ID)。name
:用户的名字。iat
:签发时间(Issued At)。
同样,载荷部分也会被转化为 Base64Url 编码格式。
1.4 JWT 签名
签名部分的作用是验证 JWT 数据的完整性,并确认数据的来源。签名是通过将头部和载荷部分的编码结果以及一个密钥进行加密生成的。
生成签名的公式如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
这里,HMACSHA256
是一种加密算法,它会将头部和载荷用一个密钥(secret)进行加密,生成签名。然后,将生成的签名附加到 JWT 的末尾。
1.5 JWT 示例
一个完整的 JWT 示例可能如下所示:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
这就是一个由三部分组成的 JWT:
- 头部(
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
):包含了签名算法HS256
和类型JWT
。 - 载荷(
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
):包含了用户的 ID (sub
),用户名 (name
),以及签发时间 (iat
)。 - 签名(
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
):用来验证头部和载荷的完整性。
JWT 的实际应用
JWT 通常用于 身份认证 和 授权。在常见的身份认证场景中,用户登录后会收到一个 JWT,客户端将这个 JWT 存储在本地(通常是浏览器的 LocalStorage 或 Cookie 中)。每次访问需要身份验证的资源时,客户端都会在请求头中携带这个 JWT,以便服务器验证其合法性。
第二部分:JWT 的生成与验证
接下来,我们将展示如何在 Java 中使用代码生成和验证 JWT。
2.1 使用 Java 创建 JWT
在 Java 中,我们可以使用第三方库来生成和验证 JWT,比如 JJWT 库。以下是如何使用 JJWT 来创建 JWT 的示例。
首先,确保你已经将 JJWT 依赖添加到项目中。如果是 Maven 项目,可以在 pom.xml
中添加如下依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
然后,使用以下代码来创建一个简单的 JWT:
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
public class JwtGenerator {
// 将密钥原文转为 Key
public static SecretKey getKeyFromSecret(String secret) {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// 生成加密的 JWT,称为 JWE
public static String createJWE(String id, String issuer, String subject, long ttlMillis, SecretKey secretKey) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 创建 JWE
return Jwts.builder()
.id(id) // 设置 JWE ID
.issuedAt(now) // 设置发行时间
.issuer(issuer) // 设置发行者
.subject(subject) // 设置主题(用户)
.expiration(new Date(nowMillis + ttlMillis)) // 设置过期时间
.encryptWith(secretKey, Jwts.ENC.A128CBC_HS256) // 使用密钥签名
.compact(); // 生成 JWE
}
// 生成加密签名的 JWT,称为 JWS
public static String createJWS(String id, String issuer, String subject, long ttlMillis, SecretKey secretKey) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 创建 JWS
return Jwts.builder()
.id(id) // 设置 JWT ID
.issuedAt(now) // 设置发行时间
.issuer(issuer) // 设置发行者
.subject(subject) // 设置主题(用户)
.expiration(new Date(nowMillis + ttlMillis)) // 设置过期时间
.signWith(secretKey) // 使用密钥签名
.compact(); // 生成 JWS
}
public static void main(String[] args) {
// 例如从配置文件中读取原文密钥
String secret = "mySecretKeyForJwtGenerationAaron"; // 32字节是最合适的密钥长度,可以避免无谓的处理开销,同时保证足够的安全性
// 从原文密钥生成 Key 对象
SecretKey secretKey = getKeyFromSecret(secret);
// 用下面这个可以存到redis等,设置与jwt相同的过期时间
// SecretKey key = Jwts.SIG.HS256.key().build();
// 生成 JWT
String jwt = createJWS("1", "myIssuer", "mySubject", 3600000, secretKey);
System.out.println("Generated JWT: " + jwt);
}
}
2.2 使用 Java 验证 JWT
一旦 JWT 被生成并发送给客户端,客户端可以在请求时将 JWT 发送给服务器,服务器需要验证 JWT 的合法性。验证 JWT 的过程涉及解析 JWT,并验证签名是否正确。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
public class JwtValidator {
// 将密钥原文转为 Key
public static SecretKey getKeyFromSecret(String secret) {
// 使用 Keys.hmacShaKeyFor()
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// 验证 JWE
public static Claims parseJWE(String jwe, SecretKey secretKey) {
try {
// 使用 JwtParser 来解析和验证 JWT
return Jwts.parser()
.decryptWith(secretKey) // 设置验证签名的密钥
.build()
.parseEncryptedClaims(jwe).getPayload();
} catch (JwtException e) {
// 如果签名不匹配,则抛出异常
throw new RuntimeException("Invalid JWE signature", e);
}
}
// 验证 JWS
public static Claims parseJWS(String jws, SecretKey secretKey) {
try {
// 使用 JwtParser 来解析和验证 JWT
return Jwts.parser()
.verifyWith(secretKey) // 设置验证签名的密钥
.build()
.parseSignedClaims(jws).getPayload();
} catch (JwtException e) {
// 如果签名不匹配,则抛出异常
throw new RuntimeException("Invalid JWS signature", e);
}
}
public static void main(String[] args) {
// 生成的JWT
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwiaWF0IjoxNzMyODU4NjAyLCJpc3MiOiJteUlzc3VlciIsInN1YiI6Im15U3ViamVjdCIsImV4cCI6MTczMjg2MjIwMn0.9opRI41aC8e9muRrNYDai79GnyCvt-ngpHsBj9Z6fIo";
// 例如从配置文件中读取原文密钥
String secret = "mySecretKeyForJwtGenerationAaron"; // 32字节是最合适的密钥长度,可以避免无谓的处理开销,同时保证足够的安全性
// 从原文密钥生成 Key 对象
SecretKey secretKey = getKeyFromSecret(secret);
// 可以用从redis等获取刚才用这个 SecretKey key = Jwts.SIG.HS256.key().build(); 生成的key
try {
// 验证 JWT,使用相同的密钥进行验证
Claims claims = parseJWS(jwt, secretKey);
System.out.println("JWT Claims: " + claims);
} catch (Exception e) {
System.out.println("JWT validation failed: " + e.getMessage());
}
}
}
在这里,parseJWT
方法会解析 JWT 并验证其签名,如果验证失败,将抛出一个异常。
第三部分:JWT 过期时间与刷新令牌
3.1 设置 JWT 过期时间
在实际应用中,JWT 的过期时间非常重要。JWT 通常会设置一个 过期时间(exp
),以确保令牌不会永远有效,从而增强系统的安全性。过期时间可以通过在生成 JWT 时指定 exp
声明来设置。
在前面提到的 createJWS
方法中,我们已经设置了 JWT 的过期时间(通过 ttlMillis
参数)。这个过期时间设置了 JWT 的生命周期。以下是如何在生成 JWT 时设置过期时间的更详细步骤。
3.2 在生成 JWT 时设置 exp
声明
// 生成加密签名的 JWT,称为 JWS
public static String createJWS(String id, String issuer, String subject, long ttlMillis, SecretKey secretKey) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 设置过期时间
Date expiration = new Date(nowMillis + ttlMillis); // 过期时间为 ttlMillis 毫秒后
// 创建 JWS
return Jwts.builder()
.id(id) // 设置 JWT ID
.issuedAt(now) // 设置发行时间
.issuer(issuer) // 设置发行者
.subject(subject) // 设置主题(用户)
.expiration(expiration) // 设置过期时间
.signWith(secretKey) // 使用密钥签名
.compact(); // 生成 JWS
}
在这个例子中,我们通过 setExpiration
方法将 exp
设置为当前时间加上传入的 ttlMillis
(毫秒数)。这样,JWT 会在指定的时间后过期。
3.3 使用 Refresh Token 来延长会话
虽然 JWT 自带过期时间,但是为了避免用户每次都需要重新登录,通常我们会实现一个 刷新令牌(Refresh Token)机制。当 JWT 过期时,客户端可以通过 刷新令牌 来获取一个新的 JWT。刷新令牌通常是一个长期有效的令牌,且一般不会包含用户的敏感信息。
3.3.1 刷新令牌的工作流程:
- 用户登录时,服务器会生成两个令牌:一个是 Access Token(即 JWT),另一个是 Refresh Token。
- 客户端存储 Access Token(通常存储在 LocalStorage 或 sessionStorage 或 Cookie 中),并将 Refresh Token 存储在安全的地方(如 HttpOnly Cookie)。
- 当 Access Token 过期时,客户端使用 Refresh Token 向服务器请求一个新的 Access Token。
- 服务器验证 Refresh Token 是否有效,如果有效,则生成新的 Access Token 并返回给客户端。
3.3.2 刷新令牌的实现
首先,我们需要生成刷新令牌。与 Access Token 不同,刷新令牌通常较长且不需要频繁更新。
然后,创建一个 API 来处理刷新令牌请求:
public class RefreshTokenEndpoint {
// 生成 Refresh Token
public static String createRefreshToken(String userId, SecretKey secretKey) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 生成 Refresh Token,假设它是长期有效的
return Jwts.builder()
.id(userId) // 设置用户 ID
.issuedAt(now) // 设置签发时间
.issuer("myIssuer") // 设置发行人
.subject("refresh") // 设置主题
.signWith(secretKey, Jwts.SIG.HS256) // 设置签名算法
.compact();
}
// 刷新访问令牌
public static String refreshAccessToken(String refreshToken, SecretKey secretKey) {
try {
// 使用 JwtParser 来解析和验证 JWT
Claims payload = Jwts.parser()
.verifyWith(secretKey) // 设置验证签名的密钥
.build()
.parseSignedClaims(refreshToken).getPayload();
// 检查 Token 是否过期
Date expiration = payload.getExpiration();
if (expiration != null && expiration.before(new Date())) {
throw new RuntimeException("Refresh token has expired");
}
// 使用有效的刷新令牌生成新的 Access Token
return JwtGenerator.createJWS("1", "myIssuer", "mySubject", 3600000, secretKey);
} catch (JwtException | IllegalArgumentException e) {
// 捕获解析、签名验证或过期错误
throw new RuntimeException("Invalid or expired refresh token", e);
}
}
public static void main(String[] args) {
// 假设从配置文件中读取原文密钥
String secret = "mySecretKeyForJwtGenerationAaron"; // 密钥,最好使用一个安全的方式获取
SecretKey secretKey = JwtGenerator.getKeyFromSecret(secret);
// 生成一个有效的 refresh token
String refreshToken = createRefreshToken("1", secretKey);
System.out.println("Generated refresh token: " + refreshToken);
// 使用 refresh token 刷新 access token
String accessToken = refreshAccessToken(refreshToken, secretKey);
System.out.println("Generated access token: " + accessToken);
}
}
在这个例子中,服务器通过 Refresh Token 获取 userId,然后生成一个新的 Access Token。
3.4 客户端使用刷新令牌
客户端在收到 401 Unauthorized
错误时,可以检查 Access Token 是否已过期,并使用 Refresh Token 请求新的 Access Token。
function refreshToken() {
fetch('/api/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 不需要手动设置 Authorization 或发送 token
},
credentials: 'include', // 使浏览器带上 HttpOnly cookie
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to refresh token: ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data && data.accessToken) {
// 如果服务器返回了新的 accessToken,通过 HttpOnly Cookie 自动更新
console.log('Access Token refreshed successfully');
} else {
throw new Error('No access token in response');
}
})
.catch(error => {
console.error('Error refreshing token:', error);
// 处理刷新失败的情况
});
}
通过这种方式,客户端可以在不重新登录的情况下保持会话。
小结
- JWT 的过期时间:可以通过
exp
声明设置。 - 刷新令牌机制:通过一个长期有效的刷新令牌来获取新的 Access Token,避免频繁登录。
第四部分:JWT 安全性问题与防护措施
4.1 防止 JWT 泄露
JWT 作为一种自包含的令牌,在信息交换时,它可能暴露给不该访问的人。如果 JWT 被泄露,攻击者就可以冒充合法用户。为了防止 JWT 泄露,我们可以采取以下措施:
4.1.1 使用 HTTPS
- 问题:如果通过 HTTP 协议传输 JWT,攻击者可以通过 中间人攻击(MITM)窃取 JWT。
- 解决方案:始终使用 HTTPS 协议来加密传输通道,确保所有的网络通信(包括 JWT)都经过加密。
4.1.2 使用 HttpOnly Cookie
-
问题:存储在客户端的 JWT 可能被 JavaScript 代码访问,这使得它易受到 XSS(跨站脚本攻击)的影响。
-
解决方案:将 JWT 存储在 HttpOnly Cookie 中,这样客户端 JavaScript 就无法访问这个 cookie,从而减轻 XSS 攻击的风险。
Set-Cookie: jwt=<token>; HttpOnly; Secure; SameSite=Strict
-
需要注意的是,虽然这样可以防止 JavaScript 直接访问 JWT,但这并不意味着服务器端不需要处理其他安全问题。仍然需要防止 CSRF 攻击。
4.1.3 采用 短生命周期的 JWT 和 刷新令牌
-
问题:长时间有效的 JWT 会使得攻击者即使在 JWT 泄露后,仍能利用它访问系统。
-
解决方案:将 JWT 的有效期设置为较短的时间(例如 15 分钟或 1 小时),同时配合 刷新令牌(Refresh Token)来延续会话。
- 短生命周期的 JWT 限制了令牌泄露后的危害。
- 刷新令牌存储在更安全的位置,并且使用时需要进行严格的验证。
4.2 防止 JWT 被伪造
JWT 使用 签名 来确保其不可篡改,因此伪造 JWT 的唯一方法就是获取到签名的密钥。为此,我们可以采取以下措施:
4.2.1 保持签名密钥的安全
- 问题:如果签名密钥泄露,攻击者可以伪造 JWT。
- 解决方案:签名密钥必须存储在安全的地方,不应硬编码在代码中。可以通过以下几种方式来保护密钥:
- 使用 环境变量 来存储密钥。
- 使用 专门的密钥管理系统(如 AWS KMS、HashiCorp Vault 等)来管理密钥。
- 对于 JWT 的私钥,确保只有授权的服务能够访问。
4.2.2 使用 非对称加密算法(如 RSA 或 ECDSA)
-
问题:使用 对称加密算法(如 HMAC)时,签名密钥需要保密。如果服务器中的密钥泄露,JWT可能会被伪造。
-
解决方案:采用 非对称加密算法(如 RSA 或 ECDSA),这种算法使用 公钥和私钥的配对。私钥用于签名 JWT,而公钥用于验证签名。这样,即便公钥泄露,攻击者也无法伪造 JWT。
- 私钥:仅用于签发 JWT。
- 公钥:用于验证 JWT 的有效性。
例如,使用 RSA 签名生成 JWT:
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
String jwt = Jwts.builder().subject("mySubject")
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256)
.compact();
4.3 防止 重放攻击
重放攻击是指攻击者捕获有效的 JWT,并在后续请求中再次使用该 JWT。为了防止这种攻击,我们可以采取以下措施:
4.3.1 使用 JWT 的唯一 ID(jti
)
-
问题:攻击者可以捕获并重新发送合法的 JWT,从而绕过认证。
-
解决方案:在每个 JWT 中加入 唯一 ID(
jti
)。在服务器端维护一个已使用的 JWT 列表或 黑名单,确保每个 JWT 只能使用一次。生成 JWT 时,可以添加
jti
:
String jwt = Jwts.builder()
.setId(UUID.randomUUID().toString()) // 添加唯一标识符
.setSubject("mySubject")
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
-
在服务器端,维护一个已使用的 JWT ID 列表来防止 JWT 被重放。
4.3.2 短时间内有效的 Access Token
- 问题:JWT 的有效期过长会给重放攻击提供更多的机会。
- 解决方案:使用 短期有效的 Access Token 并结合 Refresh Token 来延续会话。Access Token 一旦过期,客户端需要使用 Refresh Token 获取新的 Access Token。
4.4 防止 CSRF 攻击
虽然使用 HttpOnly Cookie 可以防止 XSS 攻击,但如果客户端存储 JWT 在 Cookie 中,也可能会遭受 跨站请求伪造(CSRF)攻击。为了防止 CSRF,我们可以采取以下措施:
4.4.1 使用 SameSite Cookie 属性
-
问题:如果 JWT 存储在 Cookie 中,恶意网站可能会伪造请求,从而向服务器发送请求并自动附带该 JWT。
-
解决方案:使用 SameSite 属性来限制跨站点的请求携带 Cookie。设置
SameSite
为Strict
或Lax
可以避免外部网站的请求携带 JWT。
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
@RestController
public class AuthController {
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody UserLoginRequest loginRequest) {
// 假设用户验证成功,生成 JWT Token
String jwtToken = "your-generated-jwt-token";
// 构建 Set-Cookie 头部
HttpHeaders headers = new HttpHeaders();
headers.add("Set-Cookie", "jwt=" + jwtToken + "; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600");
return ResponseEntity.ok()
.headers(headers)
.body("Login successful");
}
}
4.4.2 使用 Bearer Token
-
问题:将 JWT 存储在 Cookie 中可能暴露于 CSRF 攻击。
-
解决方案:另一种解决方案是将 JWT 存储在 Authorization Header 中作为 Bearer Token,而不是 Cookie 中。这种方式避免了 CSRF 攻击,因为 CSRF 攻击只能通过 Cookie 自动发送请求,而不能控制
Authorization
Header。
fetch('/api/resource', {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('jwt')}`
}
});
4.5 定期 撤销 JWT
虽然 JWT 的签名保证了它的完整性和不可篡改性,但一旦泄露,攻击者就可以长期访问资源。为了应对这种情况,我们可以:
4.5.1 撤销已发放的 JWT
-
问题:JWT 没有内建的撤销机制,一旦签发,通常会在过期后才失效。
-
解决方案:你可以使用 黑名单机制,当用户登出或更改密码时,撤销某个 JWT。通过在数据库或缓存中维护一个撤销列表,来判断当前请求的 JWT 是否有效。
这种方法通常需要持久化存储(例如 Redis 或数据库)来存储黑名单中的 JWT ID(
jti
)。
小结
- JWT 泄露防护:使用 HTTPS、HttpOnly Cookie 存储 JWT,以及短生命周期的 JWT 和刷新令牌。
- JWT 伪造防护:保护签名密钥、使用非对称加密算法。
- 重放攻击防护:使用 JWT 的唯一标识符(
jti
)和短期有效的 Access Token。 - CSRF 防护:使用 SameSite Cookie 属性和 Bearer Token。
- 撤销 JWT:实现黑名单机制,确保 JWT 的有效期和撤销机制。
第四部分内容和3.4收集修正而来,有些未验证,具体开发情况和使用什么技术的前端等请自行网上搜索实现细节,内容过多。有时候也不用太关注这个安全问题,其实把能实现的都实现的差不多就已经足够安全。如果在大公司会有渗透测试人员给你提供解决方案,你得按要求修复,你开发中也不可能把所有问题都杜绝的。