import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { SERVER_CONFIG, type ServerConfig } from '../config/server-config'; /** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */ export interface AccessTokenClaims { sub: string; authVersion: number; iat: number; exp: number; } @Injectable() /** 统一签发和验证访问凭证,固定算法、签发方、受众和有效期。 */ export class JwtTokenService { constructor( private readonly jwt: JwtService, @Inject(SERVER_CONFIG) private readonly config: ServerConfig, ) {} /** 仅供可信登录业务调用;调用方须先验证微信身份并取得真实账户。 */ async signAccessToken( userId: string, authVersion: number, ): Promise { if ( !userId || userId.length > 128 || !Number.isSafeInteger(authVersion) || authVersion < 0 ) { throw new Error('Invalid access token identity'); } return this.jwt.signAsync( { sub: userId, authVersion }, { secret: this.config.jwtSecret, algorithm: 'HS256', issuer: this.config.jwtIssuer, audience: this.config.jwtAudience, expiresIn: this.config.jwtExpiresInSeconds, }, ); } /** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */ async verifyAccessToken(token: string): Promise { try { const claims = await this.jwt.verifyAsync( token, { secret: this.config.jwtSecret, // 不根据 token 头自由选择算法,并限制凭证用途和签发方。 algorithms: ['HS256'], issuer: this.config.jwtIssuer, audience: this.config.jwtAudience, }, ); // 签名正确仍需检查字段类型及必填时间,拒绝无期限或畸形身份载荷。 if ( typeof claims.sub !== 'string' || !claims.sub || claims.sub.length > 128 || !Number.isSafeInteger(claims.authVersion) || claims.authVersion < 0 || !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp <= claims.iat ) { throw new Error('Invalid access token claims'); } return claims; } catch { // 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。 throw new UnauthorizedException(); } } }