109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
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 解析失败');
|
||
}
|
||
}
|
||
}
|