fix(auth): accept only access tokens as bearer credentials (MFA bypass)
All JWTs (access, MFA setup, MFA challenge, SSO completion) were signed with the same secret and the access-token strategy checked only signature and expiry. The MFA challenge token returned after the password step therefore worked as a full access token: a password alone was enough to use the API, skipping the second factor. Every token now carries a pinned issuer, algorithm (HS256) and a per-purpose audience; the strategy accepts only the access audience and rejects any payload carrying a helper-token purpose. Existing access tokens (no audience) stop working; clients renew them via the refresh cookie. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fac66882fa
commit
e0bd7b2095
6 files changed
+176
-20
No files matched your search
@@ -3,6 +3,11 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
||||
import {
|
||||
TOKEN_AUDIENCE,
|
||||
jwtSignOptions,
|
||||
jwtVerifyOptions,
|
||||
} from '../util/jwt-claims';
|
||||
|
||||
const SETUP_TOKEN_TTL = '10m';
|
||||
const CHALLENGE_TOKEN_TTL = '5m';
|
||||
@@ -41,7 +46,7 @@ export class MfaService {
|
||||
});
|
||||
const setupToken = this.jwtService.sign(
|
||||
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
|
||||
);
|
||||
return { secret, qrCodeSvg, otpauthUri, setupToken };
|
||||
}
|
||||
@@ -55,9 +60,10 @@ export class MfaService {
|
||||
): Promise<{ encryptedSecret: string }> {
|
||||
let payload: SetupTokenPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(setupToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(
|
||||
setupToken,
|
||||
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
||||
@@ -76,16 +82,17 @@ export class MfaService {
|
||||
signChallengeToken(userId: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
|
||||
);
|
||||
}
|
||||
|
||||
verifyChallengeToken(challengeToken: string): { userId: string } {
|
||||
let payload: ChallengeTokenPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<ChallengeTokenPayload>(
|
||||
challengeToken,
|
||||
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge),
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
|
||||
|
||||
Reference in new issue
Block a user