import { Injectable, UnauthorizedException } from '@nestjs/common'; import { createHmac, timingSafeEqual } from 'crypto'; import type { UserRole } from '../generated/prisma/enums.js'; interface JwtHeader { alg: 'HS256'; typ: 'JWT'; } interface SignTokenInput { sub: number; role: UserRole; hospitalId: number | null; } export interface TokenPayload extends SignTokenInput { iat: number; exp: number; } @Injectable() export class TokenService { // 建议在生产环境通过环境变量覆盖,且长度不少于 32 位。 private readonly secret = process.env.JWT_SECRET ?? 'local-dev-insecure-secret-change-me-please-1234567890'; // 默认 24 小时过期,可通过环境变量调节。 readonly expiresInSeconds = Number.parseInt( process.env.JWT_EXPIRES_IN_SECONDS ?? '86400', 10, ); constructor() { // 启动时校验配置,避免线上运行才暴露错误。 if (this.secret.length < 32) { throw new Error('JWT_SECRET 长度至少32位'); } if (!Number.isFinite(this.expiresInSeconds) || this.expiresInSeconds <= 0) { throw new Error('JWT_EXPIRES_IN_SECONDS 必须是正整数'); } } sign(input: SignTokenInput): string { // 生成 iat/exp,形成完整 payload。 const now = Math.floor(Date.now() / 1000); const payload: TokenPayload = { ...input, iat: now, exp: now + this.expiresInSeconds, }; const header: JwtHeader = { alg: 'HS256', typ: 'JWT', }; // HMAC-SHA256 签名,输出标准三段式 token。 const encodedHeader = this.encodeObject(header); const encodedPayload = this.encodeObject(payload); const unsignedToken = `${encodedHeader}.${encodedPayload}`; const signature = this.signRaw(unsignedToken); return `${unsignedToken}.${signature}`; } verify(token: string): TokenPayload { // 必须是 header.payload.signature 三段。 const parts = token.split('.'); if (parts.length !== 3) { throw new UnauthorizedException('无效 token'); } // 重新计算签名并做常量时间比较,防止签名篡改。 const [encodedHeader, encodedPayload, signature] = parts; const unsignedToken = `${encodedHeader}.${encodedPayload}`; const expectedSignature = this.signRaw(unsignedToken); if ( signature.length !== expectedSignature.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)) ) { throw new UnauthorizedException('token 签名错误'); } // 解析 payload 并做过期检查。 const payload = this.decodeObject(encodedPayload); const now = Math.floor(Date.now() / 1000); if (!payload.sub || !payload.role || !payload.exp || payload.exp <= now) { throw new UnauthorizedException('token 已过期'); } return payload; } private signRaw(content: string): string { // 统一签名算法,便于后续切换实现。 return createHmac('sha256', this.secret).update(content).digest('base64url'); } private encodeObject(value: object): string { return Buffer.from(JSON.stringify(value)).toString('base64url'); } private decodeObject(encoded: string): T { try { return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T; } catch { // 解析异常统一转成鉴权异常,避免泄露内部细节。 throw new UnauthorizedException('token 解析失败'); } } }