Backups carried every password hash, encrypted MFA secret and webhook secret in plain JSON, and an import upserted arbitrary rows - including users - so anyone holding backup:import, or tricking an admin into importing a file, could overwrite the admin account with a known password. - Export (download and scheduled) is AES-256-GCM over the gzipped bundle, key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via HKDF; header is authenticated, decompression is capped. Scheduled files are written 0600 in a 0700 directory; run-now returns only the file name. - Import requires the current password (and TOTP when MFA is on). - Only a sealed file restores users, roles, grants, webhooks and audit entries; an old unsealed file restores business data only and reports what it skipped. - Audit entries are append-only on import. - Imported documents: entityType checked against the enum, MIME type allowlisted and matched against the file signature, path kept under the storage root (no more directory traversal via entityType). - Permission cache is cleared after an import. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
61 lines
1.8 KiB
TypeScript
61 lines
1.8 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { MfaService } from '../mfa/mfa.service';
|
|
|
|
export interface ReauthProof {
|
|
password?: string;
|
|
mfaCode?: string;
|
|
}
|
|
|
|
/**
|
|
* Step-up check for actions that are as powerful as the account itself
|
|
* (restoring a backup overwrites every user): a stolen access token alone
|
|
* must not be enough, so the caller proves they know the password - and,
|
|
* with MFA on, holds the second factor - right now.
|
|
*/
|
|
@Injectable()
|
|
export class ReauthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly mfaService: MfaService,
|
|
) {}
|
|
|
|
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
email: true,
|
|
passwordHash: true,
|
|
mfaEnabled: true,
|
|
mfaSecret: true,
|
|
isActive: true,
|
|
},
|
|
});
|
|
const passwordMatches =
|
|
Boolean(user?.isActive) &&
|
|
typeof proof.password === 'string' &&
|
|
(await bcrypt.compare(proof.password, user!.passwordHash));
|
|
if (!user || !passwordMatches) {
|
|
throw new UnauthorizedException(
|
|
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
|
);
|
|
}
|
|
if (user.mfaEnabled) {
|
|
const codeMatches =
|
|
typeof proof.mfaCode === 'string' &&
|
|
user.mfaSecret !== null &&
|
|
(await this.mfaService.verifyCode(
|
|
user.mfaSecret,
|
|
user.email,
|
|
proof.mfaCode,
|
|
));
|
|
if (!codeMatches) {
|
|
throw new UnauthorizedException(
|
|
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|