Files
ApiCore/ApiCore-Backend/src/auth/mfa/mfa.service.ts
T
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00

142 lines
3.9 KiB
TypeScript

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as QRCode from 'qrcode';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import {
buildTotpUri,
generateTotpSecret,
verifyTotpCode,
verifyTotpStep,
} from './totp.util';
import {
OneTimeTokenClaims,
TOKEN_AUDIENCE,
jwtSignOptions,
jwtVerifyOptions,
} from '../util/jwt-claims';
const SETUP_TOKEN_TTL = '10m';
const CHALLENGE_TOKEN_TTL = '5m';
const SETUP_TOKEN_PURPOSE = 'mfa-setup';
const CHALLENGE_TOKEN_PURPOSE = 'mfa-challenge';
interface SetupTokenPayload {
sub: string;
secret: string;
purpose: typeof SETUP_TOKEN_PURPOSE;
}
interface ChallengeTokenPayload {
sub: string;
purpose: typeof CHALLENGE_TOKEN_PURPOSE;
}
export interface MfaEnrollment {
secret: string;
qrCodeSvg: string;
otpauthUri: string;
setupToken: string;
}
@Injectable()
export class MfaService {
constructor(private readonly jwtService: JwtService) {}
async startEnrollment(userId: string, email: string): Promise<MfaEnrollment> {
const secret = generateTotpSecret();
const otpauthUri = buildTotpUri(secret, email);
const qrCodeSvg = await QRCode.toString(otpauthUri, {
type: 'svg',
margin: 1,
width: 256,
});
const setupToken = this.jwtService.sign(
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
);
return { secret, qrCodeSvg, otpauthUri, setupToken };
}
/** Returns the plaintext secret from a setup token once the enrollment code checks out. */
async confirmEnrollment(
userId: string,
setupToken: string,
email: string,
code: string,
): Promise<{ encryptedSecret: string }> {
let payload: SetupTokenPayload;
try {
payload = this.jwtService.verify<SetupTokenPayload>(
setupToken,
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
);
} catch {
throw new UnauthorizedException(
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
);
}
if (payload.purpose !== SETUP_TOKEN_PURPOSE || payload.sub !== userId) {
throw new UnauthorizedException('Setup-Token ungültig');
}
const isValid = await verifyTotpCode(payload.secret, email, code);
if (!isValid) {
throw new UnauthorizedException('Code ungültig');
}
return { encryptedSecret: encryptMfaSecret(payload.secret) };
}
signChallengeToken(userId: string): string {
return this.jwtService.sign(
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
);
}
verifyChallengeToken(challengeToken: string): {
userId: string;
claims: OneTimeTokenClaims;
} {
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
try {
payload = this.jwtService.verify<
ChallengeTokenPayload & OneTimeTokenClaims
>(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
} catch {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
);
}
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
}
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
async verifyCodeStep(
encryptedSecret: string,
email: string,
code: string,
afterTimeStep?: number,
): Promise<number | null> {
return verifyTotpStep(
decryptMfaSecret(encryptedSecret),
email,
code,
afterTimeStep,
);
}
async verifyCode(
encryptedSecret: string,
email: string,
code: string,
): Promise<boolean> {
const secret = decryptMfaSecret(encryptedSecret);
return verifyTotpCode(secret, email, code);
}
}