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]>
This commit is contained in:
1 parent
fc6af8737b
commit
45efb08369
16 files changed
+166
-23
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
|
||||||
|
-- used; a second use of the same jti is refused.
|
||||||
|
CREATE TABLE "ConsumedToken" (
|
||||||
|
"jti" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
|
||||||
|
);
|
||||||
|
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
|
||||||
@@ -157,6 +157,15 @@ model ApiKey {
|
|||||||
@@index([revokedAt])
|
@@index([revokedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt
|
||||||
|
// wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt.
|
||||||
|
model ConsumedToken {
|
||||||
|
jti String @id
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
import { MfaModule } from './mfa/mfa.module';
|
import { MfaModule } from './mfa/mfa.module';
|
||||||
import { SsoModule } from './sso/sso.module';
|
import { SsoModule } from './sso/sso.module';
|
||||||
import { SsoController } from './sso/sso.controller';
|
import { SsoController } from './sso/sso.controller';
|
||||||
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
|
|||||||
SsoModule,
|
SsoModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController, SsoController],
|
controllers: [AuthController, SsoController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy, OneTimeTokenService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -6,6 +6,7 @@ import { UsersService } from '../users/users.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { MfaService } from './mfa/mfa.service';
|
import { MfaService } from './mfa/mfa.service';
|
||||||
import { AccountGuardService } from './mfa/account-guard.service';
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
@@ -28,6 +29,7 @@ describe('AuthService', () => {
|
|||||||
signChallengeToken: jest.Mock;
|
signChallengeToken: jest.Mock;
|
||||||
verifyChallengeToken: jest.Mock;
|
verifyChallengeToken: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let oneTimeTokens: { consume: jest.Mock };
|
||||||
let accountGuard: {
|
let accountGuard: {
|
||||||
isLocked: jest.Mock;
|
isLocked: jest.Mock;
|
||||||
recordFailure: jest.Mock;
|
recordFailure: jest.Mock;
|
||||||
@@ -67,6 +69,7 @@ describe('AuthService', () => {
|
|||||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||||
verifyChallengeToken: jest.fn(),
|
verifyChallengeToken: jest.fn(),
|
||||||
};
|
};
|
||||||
|
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
|
||||||
accountGuard = {
|
accountGuard = {
|
||||||
isLocked: jest.fn().mockReturnValue(false),
|
isLocked: jest.fn().mockReturnValue(false),
|
||||||
recordFailure: jest.fn().mockResolvedValue(undefined),
|
recordFailure: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -82,6 +85,7 @@ describe('AuthService', () => {
|
|||||||
permissionResolver as never,
|
permissionResolver as never,
|
||||||
mfaService as unknown as MfaService,
|
mfaService as unknown as MfaService,
|
||||||
accountGuard as unknown as AccountGuardService,
|
accountGuard as unknown as AccountGuardService,
|
||||||
|
oneTimeTokens as unknown as OneTimeTokenService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +218,10 @@ describe('AuthService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('issues tokens and records LOGIN when the code is valid', async () => {
|
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
mfaService.verifyChallengeToken.mockReturnValue({
|
||||||
|
userId: mfaUser.id,
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
usersService.findById.mockResolvedValue(mfaUser);
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
accountGuard.consumeTotp.mockResolvedValue(true);
|
accountGuard.consumeTotp.mockResolvedValue(true);
|
||||||
prisma.refreshToken.create.mockResolvedValue({});
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
@@ -233,7 +240,10 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
||||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
mfaService.verifyChallengeToken.mockReturnValue({
|
||||||
|
userId: mfaUser.id,
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
usersService.findById.mockResolvedValue(mfaUser);
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
accountGuard.consumeTotp.mockResolvedValue(false);
|
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { AccountGuardService } from './mfa/account-guard.service';
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
import { parseDurationToMs } from './util/duration';
|
import { parseDurationToMs } from './util/duration';
|
||||||
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export class AuthService {
|
|||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
private readonly accountGuard: AccountGuardService,
|
private readonly accountGuard: AccountGuardService,
|
||||||
|
private readonly oneTimeTokens: OneTimeTokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
||||||
@@ -208,7 +210,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||||
const user = await this.usersService.findById(userId);
|
const user = await this.usersService.findById(userId);
|
||||||
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
||||||
throw new UnauthorizedException('MFA nicht verfügbar');
|
throw new UnauthorizedException('MFA nicht verfügbar');
|
||||||
@@ -230,6 +232,8 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
throw new UnauthorizedException('Code ungültig');
|
throw new UnauthorizedException('Code ungültig');
|
||||||
}
|
}
|
||||||
|
// A challenge that already opened a session cannot open another one.
|
||||||
|
await this.oneTimeTokens.consume(claims);
|
||||||
await this.accountGuard.recordSuccess(user.id);
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
const authenticatedUser = {
|
const authenticatedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -139,10 +139,13 @@ describe('MfaService', () => {
|
|||||||
jwtService.verify.mockReturnValue({
|
jwtService.verify.mockReturnValue({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
purpose: 'mfa-challenge',
|
purpose: 'mfa-challenge',
|
||||||
|
jti: 'jti-1',
|
||||||
|
exp: 1_900_000_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mfaService.verifyChallengeToken('token')).toEqual({
|
expect(mfaService.verifyChallengeToken('token')).toEqual({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
verifyTotpStep,
|
verifyTotpStep,
|
||||||
} from './totp.util';
|
} from './totp.util';
|
||||||
import {
|
import {
|
||||||
|
OneTimeTokenClaims,
|
||||||
TOKEN_AUDIENCE,
|
TOKEN_AUDIENCE,
|
||||||
jwtSignOptions,
|
jwtSignOptions,
|
||||||
jwtVerifyOptions,
|
jwtVerifyOptions,
|
||||||
@@ -91,13 +92,15 @@ export class MfaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyChallengeToken(challengeToken: string): { userId: string } {
|
verifyChallengeToken(challengeToken: string): {
|
||||||
let payload: ChallengeTokenPayload;
|
userId: string;
|
||||||
|
claims: OneTimeTokenClaims;
|
||||||
|
} {
|
||||||
|
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
|
||||||
try {
|
try {
|
||||||
payload = this.jwtService.verify<ChallengeTokenPayload>(
|
payload = this.jwtService.verify<
|
||||||
challengeToken,
|
ChallengeTokenPayload & OneTimeTokenClaims
|
||||||
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge),
|
>(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',
|
||||||
@@ -106,7 +109,10 @@ export class MfaService {
|
|||||||
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
|
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
|
||||||
throw new UnauthorizedException('Ungültiger Token');
|
throw new UnauthorizedException('Ungültiger Token');
|
||||||
}
|
}
|
||||||
return { userId: payload.sub };
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
claims: { jti: payload.jti, exp: payload.exp },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
|
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { OneTimeTokenClaims } from './util/jwt-claims';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a short-lived helper token usable exactly once (SSO completion,
|
||||||
|
* MFA challenge after a successful code): its id is recorded, and a
|
||||||
|
* second presentation - a replayed request, a copied cookie - is refused.
|
||||||
|
* Works across replicas because the record lives in the database.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OneTimeTokenService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async consume(claims: OneTimeTokenClaims): Promise<void> {
|
||||||
|
if (!claims.jti || !claims.exp) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Anmeldung abgelaufen, bitte erneut anmelden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.prisma.consumedToken.create({
|
||||||
|
data: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) },
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Diese Anmeldung wurde bereits abgeschlossen, bitte erneut anmelden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,15 +165,34 @@ describe('PermissionResolverService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shares the cached entry with getPermissions instead of re-querying', async () => {
|
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue(
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
activeUserWithPermissions('colonies:read'),
|
activeUserWithPermissions('colonies:read'),
|
||||||
);
|
);
|
||||||
|
|
||||||
await resolver.getPermissions('user-1');
|
await resolver.getPermissions('user-1');
|
||||||
await resolver.isTokenValid('user-1', nowSeconds);
|
await resolver.isTokenValid('user-1', nowSeconds);
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
// One read for the first getPermissions, one fresh read for the
|
||||||
|
// validity check - the second getPermissions uses that fresh entry.
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees a deactivation made elsewhere on the next request', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
...activeUserWithPermissions('colonies:read'),
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,18 @@ export class PermissionResolverService {
|
|||||||
return entry.permissions;
|
return entry.permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always read fresh (one primary-key lookup) and stored as the cache
|
||||||
|
* entry the PermissionsGuard then uses: a deactivation, role change or
|
||||||
|
* password change made on ANOTHER replica takes effect on the very next
|
||||||
|
* request here, not after the cache window.
|
||||||
|
*/
|
||||||
async isTokenValid(
|
async isTokenValid(
|
||||||
userId: string,
|
userId: string,
|
||||||
tokenIssuedAtSeconds: number,
|
tokenIssuedAtSeconds: number,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const entry = await this.getEntry(userId);
|
const entry = await this.loadEntry(userId);
|
||||||
|
this.cache.set(userId, entry);
|
||||||
if (!entry.isActive) {
|
if (!entry.isActive) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
|
|||||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||||
|
import { OneTimeTokenService } from '../one-time-token.service';
|
||||||
import { SsoService } from './sso.service';
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
const PKCE_COOKIE = 'oidcPkceVerifier';
|
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||||
@@ -40,6 +41,7 @@ export class SsoController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly ssoService: SsoService,
|
private readonly ssoService: SsoService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly oneTimeTokens: OneTimeTokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private frontendLoginUrl(): string {
|
private frontendLoginUrl(): string {
|
||||||
@@ -135,7 +137,8 @@ export class SsoController {
|
|||||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
|
||||||
|
await this.oneTimeTokens.consume(claims);
|
||||||
const result = await this.authService.issueTokensForSsoUser(userId);
|
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||||
if (result.mfaRequired) {
|
if (result.mfaRequired) {
|
||||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
|
|||||||
@@ -263,8 +263,13 @@ describe('SsoService', () => {
|
|||||||
jwtService.verify.mockReturnValue({
|
jwtService.verify.mockReturnValue({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
purpose: 'sso-complete',
|
purpose: 'sso-complete',
|
||||||
|
jti: 'jti-1',
|
||||||
|
exp: 1_900_000_000,
|
||||||
|
});
|
||||||
|
expect(ssoService.verifySsoCode('token')).toEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
});
|
});
|
||||||
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException when verification fails', () => {
|
it('throws UnauthorizedException when verification fails', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 {
|
import {
|
||||||
|
OneTimeTokenClaims,
|
||||||
TOKEN_AUDIENCE,
|
TOKEN_AUDIENCE,
|
||||||
jwtSignOptions,
|
jwtSignOptions,
|
||||||
jwtVerifyOptions,
|
jwtVerifyOptions,
|
||||||
@@ -178,13 +179,15 @@ export class SsoService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
verifySsoCode(ssoCode: string): { userId: string } {
|
verifySsoCode(ssoCode: string): {
|
||||||
let payload: SsoCompleteTokenPayload;
|
userId: string;
|
||||||
|
claims: OneTimeTokenClaims;
|
||||||
|
} {
|
||||||
|
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
|
||||||
try {
|
try {
|
||||||
payload = this.jwtService.verify<SsoCompleteTokenPayload>(
|
payload = this.jwtService.verify<
|
||||||
ssoCode,
|
SsoCompleteTokenPayload & OneTimeTokenClaims
|
||||||
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete),
|
>(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',
|
||||||
@@ -193,6 +196,9 @@ export class SsoService {
|
|||||||
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||||
throw new UnauthorizedException('Ungültiger Token');
|
throw new UnauthorizedException('Ungültiger Token');
|
||||||
}
|
}
|
||||||
return { userId: payload.sub };
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
claims: { jti: payload.jti, exp: payload.exp },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
|
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
|
||||||
@@ -34,9 +35,17 @@ export function jwtSignOptions(
|
|||||||
issuer: JWT_ISSUER,
|
issuer: JWT_ISSUER,
|
||||||
audience,
|
audience,
|
||||||
expiresIn,
|
expiresIn,
|
||||||
|
// Unique id, so a one-time token can be marked as used.
|
||||||
|
jwtid: randomUUID(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a verified one-time token carries for OneTimeTokenService. */
|
||||||
|
export interface OneTimeTokenClaims {
|
||||||
|
jti?: string;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
|
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
|
||||||
return {
|
return {
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export class IdempotencyCleanupService {
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
this.logger.log(`Removed ${count} expired idempotency record(s)`);
|
this.logger.log(`Removed ${count} expired idempotency record(s)`);
|
||||||
}
|
}
|
||||||
|
// Used one-time tokens only matter until the token itself expires.
|
||||||
|
await this.prisma.consumedToken.deleteMany({
|
||||||
|
where: { expiresAt: { lt: now } },
|
||||||
|
});
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
|
|||||||
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
|
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
|
||||||
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
|
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { OneTimeTokenService } from '../src/auth/one-time-token.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account-level brute-force protection and one-time TOTP codes, against a
|
* Account-level brute-force protection and one-time TOTP codes, against a
|
||||||
@@ -91,4 +92,14 @@ describe('Account guard (e2e)', () => {
|
|||||||
.send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
|
.send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets a one-time token be used exactly once', async () => {
|
||||||
|
const oneTimeTokens = app.get(OneTimeTokenService);
|
||||||
|
const claims = {
|
||||||
|
jti: `jti-${suffix}`,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
|
};
|
||||||
|
await expect(oneTimeTokens.consume(claims)).resolves.toBeUndefined();
|
||||||
|
await expect(oneTimeTokens.consume(claims)).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user