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
@@ -8,6 +8,7 @@ import { AuditLogService } from '../audit/audit-log.service';
|
|||||||
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
||||||
import { MfaService } from './mfa/mfa.service';
|
import { MfaService } from './mfa/mfa.service';
|
||||||
import { parseDurationToMs } from './util/duration';
|
import { parseDurationToMs } from './util/duration';
|
||||||
|
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||||
|
|
||||||
interface AuthTokens {
|
interface AuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -72,10 +73,7 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{ sub: user.id, email: user.email },
|
{ sub: user.id, email: user.email },
|
||||||
{
|
jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
|
||||||
expiresIn: expiresInSeconds,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||||
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
||||||
|
import {
|
||||||
|
TOKEN_AUDIENCE,
|
||||||
|
jwtSignOptions,
|
||||||
|
jwtVerifyOptions,
|
||||||
|
} from '../util/jwt-claims';
|
||||||
|
|
||||||
const SETUP_TOKEN_TTL = '10m';
|
const SETUP_TOKEN_TTL = '10m';
|
||||||
const CHALLENGE_TOKEN_TTL = '5m';
|
const CHALLENGE_TOKEN_TTL = '5m';
|
||||||
@@ -41,7 +46,7 @@ export class MfaService {
|
|||||||
});
|
});
|
||||||
const setupToken = this.jwtService.sign(
|
const setupToken = this.jwtService.sign(
|
||||||
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
|
{ 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 };
|
return { secret, qrCodeSvg, otpauthUri, setupToken };
|
||||||
}
|
}
|
||||||
@@ -55,9 +60,10 @@ export class MfaService {
|
|||||||
): Promise<{ encryptedSecret: string }> {
|
): Promise<{ encryptedSecret: string }> {
|
||||||
let payload: SetupTokenPayload;
|
let payload: SetupTokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = this.jwtService.verify<SetupTokenPayload>(setupToken, {
|
payload = this.jwtService.verify<SetupTokenPayload>(
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
setupToken,
|
||||||
});
|
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
||||||
@@ -76,16 +82,17 @@ export class MfaService {
|
|||||||
signChallengeToken(userId: string): string {
|
signChallengeToken(userId: string): string {
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
|
{ 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 } {
|
verifyChallengeToken(challengeToken: string): { userId: string } {
|
||||||
let payload: ChallengeTokenPayload;
|
let payload: ChallengeTokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, {
|
payload = this.jwtService.verify<ChallengeTokenPayload>(
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
challengeToken,
|
||||||
});
|
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
|
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as client from 'openid-client';
|
import * as client from 'openid-client';
|
||||||
import { UsersService } from '../../users/users.service';
|
import { UsersService } from '../../users/users.service';
|
||||||
|
import {
|
||||||
|
TOKEN_AUDIENCE,
|
||||||
|
jwtSignOptions,
|
||||||
|
jwtVerifyOptions,
|
||||||
|
} from '../util/jwt-claims';
|
||||||
|
|
||||||
const SSO_COMPLETE_TOKEN_TTL = '60s';
|
const SSO_COMPLETE_TOKEN_TTL = '60s';
|
||||||
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
|
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
|
||||||
@@ -111,10 +116,7 @@ export class SsoService {
|
|||||||
}
|
}
|
||||||
const ssoCode = this.jwtService.sign(
|
const ssoCode = this.jwtService.sign(
|
||||||
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||||
{
|
jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL),
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
|
||||||
expiresIn: SSO_COMPLETE_TOKEN_TTL,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return { status: 'success', ssoCode };
|
return { status: 'success', ssoCode };
|
||||||
}
|
}
|
||||||
@@ -122,9 +124,10 @@ export class SsoService {
|
|||||||
verifySsoCode(ssoCode: string): { userId: string } {
|
verifySsoCode(ssoCode: string): { userId: string } {
|
||||||
let payload: SsoCompleteTokenPayload;
|
let payload: SsoCompleteTokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, {
|
payload = this.jwtService.verify<SsoCompleteTokenPayload>(
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
ssoCode,
|
||||||
});
|
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||||
|
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
|
||||||
|
|
||||||
interface AccessTokenPayload {
|
interface AccessTokenPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -10,6 +11,8 @@ interface AccessTokenPayload {
|
|||||||
// reject tokens issued before the user's last password change (see
|
// reject tokens issued before the user's last password change (see
|
||||||
// PermissionResolverService.isTokenValid).
|
// PermissionResolverService.isTokenValid).
|
||||||
iat: number;
|
iat: number;
|
||||||
|
// Set only on the helper tokens (MFA setup/challenge, SSO completion).
|
||||||
|
purpose?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -19,10 +22,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
|
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
|
||||||
|
// Only access tokens: the MFA challenge token (issued after the
|
||||||
|
// password step, before the second factor) shares the secret and must
|
||||||
|
// never open a session on its own.
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience: TOKEN_AUDIENCE.access,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
||||||
|
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
|
||||||
|
* audience - not the secret - is what tells an access token apart from the
|
||||||
|
* short-lived helper tokens (MFA setup, MFA challenge, SSO completion).
|
||||||
|
* Without a pinned audience, the MFA challenge token handed out after the
|
||||||
|
* password step would pass the access-token strategy and skip the second
|
||||||
|
* factor entirely.
|
||||||
|
*
|
||||||
|
* Algorithm and issuer are pinned as well, so a token is only ever accepted
|
||||||
|
* in the exact shape ApiCore itself produces.
|
||||||
|
*/
|
||||||
|
export const JWT_ISSUER = 'apicore';
|
||||||
|
export const JWT_ALGORITHM = 'HS256';
|
||||||
|
|
||||||
|
export const TOKEN_AUDIENCE = {
|
||||||
|
access: 'apicore:access',
|
||||||
|
mfaSetup: 'apicore:mfa-setup',
|
||||||
|
mfaChallenge: 'apicore:mfa-challenge',
|
||||||
|
ssoComplete: 'apicore:sso-complete',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TokenAudience =
|
||||||
|
(typeof TOKEN_AUDIENCE)[keyof typeof TOKEN_AUDIENCE];
|
||||||
|
|
||||||
|
export function jwtSignOptions(
|
||||||
|
audience: TokenAudience,
|
||||||
|
expiresIn: JwtSignOptions['expiresIn'],
|
||||||
|
): JwtSignOptions {
|
||||||
|
return {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
algorithm: JWT_ALGORITHM,
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience,
|
||||||
|
expiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
|
||||||
|
return {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
|
||||||
|
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
|
||||||
|
interface MfaRequiredBody {
|
||||||
|
mfaRequired: true;
|
||||||
|
mfaToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All ApiCore JWTs share one secret. Only an access token may open a
|
||||||
|
* session - the helper tokens (MFA challenge after the password step, MFA
|
||||||
|
* setup, SSO completion) and hand-made tokens without the pinned claims
|
||||||
|
* must be refused everywhere.
|
||||||
|
*/
|
||||||
|
describe('Token confusion (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let userId: string;
|
||||||
|
const email = `token-confusion-${Date.now()}@example.com`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
|
||||||
|
const me = (token: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: 'MFA user',
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: encryptMfaSecret(generateTotpSecret()),
|
||||||
|
role: { connect: { key: 'ADMIN' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept the MFA challenge token as an access token', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ email, password })
|
||||||
|
.expect(200);
|
||||||
|
const body = response.body as MfaRequiredBody;
|
||||||
|
expect(body.mfaRequired).toBe(true);
|
||||||
|
|
||||||
|
await me(body.mfaToken).expect(401);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/users')
|
||||||
|
.set('Authorization', `Bearer ${body.mfaToken}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept a token without the pinned audience and issuer', async () => {
|
||||||
|
const legacy = new JwtService().sign(
|
||||||
|
{ sub: userId, email },
|
||||||
|
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: 60 },
|
||||||
|
);
|
||||||
|
await me(legacy).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept a helper token even with the access audience', async () => {
|
||||||
|
const forged = new JwtService().sign(
|
||||||
|
{ sub: userId, email, purpose: 'mfa-challenge' },
|
||||||
|
{
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
expiresIn: 60,
|
||||||
|
issuer: 'apicore',
|
||||||
|
audience: 'apicore:access',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await me(forged).expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user