137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
import * as crypto from 'crypto';
|
|
import { AdminOperationTokenEntity, AdminOperationKind } from '../../../database/entities/admin-operation-token.entity';
|
|
import { ApiError } from '../../../common/errors/api-error';
|
|
import { ERROR_CODES } from '../../../common/errors/error-codes';
|
|
|
|
export interface IssueTokenInput {
|
|
userId: string;
|
|
operation: AdminOperationKind;
|
|
}
|
|
|
|
export interface IssueTokenResult {
|
|
token: string;
|
|
expiresAt: string;
|
|
id: string;
|
|
}
|
|
|
|
export interface ConsumeTokenInput {
|
|
userId: string;
|
|
operation: AdminOperationKind;
|
|
token: string;
|
|
stagingId?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ConfirmationTokenService {
|
|
private readonly logger = new Logger(ConfirmationTokenService.name);
|
|
private readonly ttlMs: number;
|
|
|
|
constructor(
|
|
config: ConfigService,
|
|
@InjectRepository(AdminOperationTokenEntity)
|
|
private readonly repo: Repository<AdminOperationTokenEntity>,
|
|
private readonly dataSource: DataSource,
|
|
) {
|
|
this.ttlMs = config.get<number>('SYSTEM_OP_CONFIRM_TOKEN_TTL_MS', 5 * 60_000);
|
|
}
|
|
|
|
/**
|
|
* Issue a cryptographically random single-use token for the supplied
|
|
* administrator and operation. Only the SHA-256 hash is persisted; raw
|
|
* tokens are returned exactly once and never logged.
|
|
*/
|
|
async issue(input: IssueTokenInput): Promise<IssueTokenResult> {
|
|
if (!input.userId) throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing user', 400);
|
|
if ((input.operation as string) === undefined) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing operation', 400);
|
|
}
|
|
const raw = crypto.randomBytes(32).toString('base64url');
|
|
const hash = ConfirmationTokenService.hash(raw);
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + this.ttlMs);
|
|
const id = crypto.randomUUID();
|
|
await this.repo.insert({
|
|
id,
|
|
userId: input.userId,
|
|
operation: input.operation,
|
|
tokenHash: hash,
|
|
issuedAt: now.toISOString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
consumedAt: null,
|
|
});
|
|
return { token: raw, expiresAt: expiresAt.toISOString(), id };
|
|
}
|
|
|
|
/**
|
|
* Atomically consume a token: only one caller may succeed. Tokens that
|
|
* don't match (unknown, wrong user, wrong operation, wrong stage,
|
|
* expired, or previously consumed) produce distinct stable codes.
|
|
*/
|
|
async consume(input: ConsumeTokenInput): Promise<{ id: string }> {
|
|
if (!input || !input.token || !input.userId) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Confirmation token required', 400);
|
|
}
|
|
const hash = ConfirmationTokenService.hash(input.token);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const repo = manager.getRepository(AdminOperationTokenEntity);
|
|
const row = await repo.findOne({ where: { tokenHash: hash } });
|
|
if (!row) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Unknown confirmation token', 400);
|
|
}
|
|
if (row.userId !== input.userId) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued to this administrator', 400);
|
|
}
|
|
if (row.operation !== input.operation) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued for this operation', 400);
|
|
}
|
|
if (row.consumedAt) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400);
|
|
}
|
|
const expiryMs = new Date(row.expiresAt).getTime();
|
|
if (!Number.isFinite(expiryMs) || expiryMs < Date.now()) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_EXPIRED, 'Confirmation token expired', 400);
|
|
}
|
|
const consumedAt = new Date().toISOString();
|
|
const result = await repo
|
|
.createQueryBuilder()
|
|
.update(AdminOperationTokenEntity)
|
|
.set({ consumedAt })
|
|
.where('id = :id', { id: row.id })
|
|
.andWhere('consumedAt IS NULL')
|
|
.execute();
|
|
if (!result.affected) {
|
|
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400);
|
|
}
|
|
return { id: row.id };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Best-effort expiry sweep. Safe to call frequently.
|
|
*/
|
|
async purgeExpired(): Promise<number> {
|
|
const result = await this.repo
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.from(AdminOperationTokenEntity)
|
|
.where('expiresAt < :now', { now: new Date().toISOString() })
|
|
.orWhere('consumedAt IS NOT NULL AND issued_at < :old', { old: new Date(Date.now() - 24 * 3600_000).toISOString() })
|
|
.execute()
|
|
.catch(() => ({ affected: 0 } as any));
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Constant-time SHA-256 hashing of a raw token string. Tokens are stored
|
|
* only as their hash so a leaked database row alone cannot be used as a
|
|
* bearer credential.
|
|
*/
|
|
static hash(raw: string): string {
|
|
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
}
|
|
}
|