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 签发与校验。 * *
核心变更:
*本类**不访问数据库**。
*/ @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(); } }