AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61)
This commit was merged in pull request #61.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user