- 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]>
142 lines
3.9 KiB
TypeScript
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);
|
|
}
|
|
}
|