tyt-api-nest/src/auth/token.service.ts

109 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TokenPayload>(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<T>(encoded: string): T {
try {
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T;
} catch {
// 解析异常统一转成鉴权异常,避免泄露内部细节。
throw new UnauthorizedException('token 解析失败');
}
}
}