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
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();
|
|
}
|
|
}
|
|
|