You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

207 lines
8.8 KiB

package com.mydreamjob.auth;
import com.mydreamjob.config.AppJwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
/**
* S02 Access/Refresh Token 签发与校验。
*
* <p>核心变更:</p>
* <ul>
* <li>Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;</li>
* <li>强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;</li>
* <li>新 Token 不再包含手机号;</li>
* <li>{@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;</li>
* <li>过期边界固定为 {@code exp <= capturedNow} 视为过期;</li>
* <li>对外只返回强类型 record,不暴露 JJWT {@code Claims};</li>
* <li>异常消息固定为安全文本,不含 Token 或 claim 原值。</li>
* </ul>
*
* <p>本类**不访问数据库**。</p>
*/
@Slf4j
@Component
public class JwtTokenProvider {
private static final String CLAIM_TYPE = "type";
private static final String CLAIM_SID = "sid";
private static final String CLAIM_ROLE = "role";
private static final String TYPE_ACCESS = "access";
private static final String TYPE_REFRESH = "refresh";
private final SecretKey accessKey;
private final SecretKey refreshKey;
private final long expirationMs;
private final long refreshExpirationMs;
private final String issuer;
private final String accessAudience;
private final String refreshAudience;
public JwtTokenProvider(AppJwtProperties properties) {
this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8));
this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8));
this.expirationMs = properties.getExpirationMs();
this.refreshExpirationMs = properties.getRefreshExpirationMs();
this.issuer = properties.getIssuer();
this.accessAudience = properties.getAccessAudience();
this.refreshAudience = properties.getRefreshAudience();
}
// ── 签发 ────────────────────────────────────────────────────
public IssuedToken generateAccessToken(Long userId, String role, String sessionId,
String tokenId, Instant capturedNow) {
Instant expiresAt = capturedNow.plusMillis(expirationMs);
String token = Jwts.builder()
.subject(String.valueOf(userId))
.id(tokenId)
.claim(CLAIM_TYPE, TYPE_ACCESS)
.claim(CLAIM_SID, sessionId)
.claim(CLAIM_ROLE, role)
.issuer(issuer)
.audience().add(accessAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(accessKey)
.compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
public IssuedToken generateRefreshToken(Long userId, String sessionId,
String tokenId, Instant capturedNow) {
Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs);
String token = Jwts.builder()
.subject(String.valueOf(userId))
.id(tokenId)
.claim(CLAIM_TYPE, TYPE_REFRESH)
.claim(CLAIM_SID, sessionId)
.issuer(issuer)
.audience().add(refreshAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(refreshKey)
.compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
// ── 校验 ────────────────────────────────────────────────────
/**
* 校验并解析 Access Token。任何失败(签名、类型、issuer、audience、subject、
* sid、jti、role、过期)统一抛出 {@link InvalidTokenException}。
*/
public AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow) {
Claims claims = parse(rawToken, accessKey, TYPE_ACCESS, accessAudience, capturedNow);
Long userId = requirePositiveSubject(claims);
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class));
String jti = requireCanonicalUuid(claims.getId());
String role = claims.get(CLAIM_ROLE, String.class);
if (role == null || role.isBlank()) {
throw new InvalidTokenException();
}
return new AccessTokenClaims(userId, sid, jti, role,
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration()));
}
/**
* 校验并解析 Refresh Token。Refresh **不含** role,也不接受 role 参与判断。
*/
public RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow) {
Claims claims = parse(rawToken, refreshKey, TYPE_REFRESH, refreshAudience, capturedNow);
Long userId = requirePositiveSubject(claims);
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class));
String jti = requireCanonicalUuid(claims.getId());
return new RefreshTokenClaims(userId, sid, jti,
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration()));
}
private Claims parse(String rawToken, SecretKey key, String expectedType,
String expectedAudience, Instant capturedNow) {
if (rawToken == null || rawToken.isBlank()) {
throw new InvalidTokenException();
}
Claims claims;
try {
claims = Jwts.parser()
.verifyWith(key)
// 过期判定由下面显式完成:这里把解析时钟固定在很早的时间,
// 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。
.clock(() -> Date.from(Instant.EPOCH))
.require(CLAIM_TYPE, expectedType)
.requireIssuer(issuer)
.requireAudience(expectedAudience)
.build()
.parseSignedClaims(rawToken)
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
// 固定安全文本:不回显 Token、claim 原值或异常消息。
throw new InvalidTokenException();
}
// 时间契约(设计第七节):iat <= capturedNow < exp。
//
// 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。
Date expiration = claims.getExpiration();
if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) {
throw new InvalidTokenException();
}
// 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。
// 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。
Date issuedAt = claims.getIssuedAt();
if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) {
throw new InvalidTokenException();
}
return claims;
}
private Long requirePositiveSubject(Claims claims) {
String subject = claims.getSubject();
if (subject == null || subject.isBlank()) {
throw new InvalidTokenException();
}
long userId;
try {
userId = Long.parseLong(subject);
} catch (NumberFormatException e) {
throw new InvalidTokenException();
}
if (userId <= 0) {
throw new InvalidTokenException();
}
return userId;
}
private String requireCanonicalUuid(String value) {
if (value == null || value.isBlank()) {
throw new InvalidTokenException();
}
UUID parsed;
try {
parsed = UUID.fromString(value);
} catch (IllegalArgumentException e) {
throw new InvalidTokenException();
}
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
if (!parsed.toString().equals(value)) {
throw new InvalidTokenException();
}
return value;
}
private static Instant toInstant(Date date) {
return date == null ? null : date.toInstant();
}
}