feat: MFA (TOTP) und Self-Service-Profilseite
Benutzer koennen jetzt selbst Zwei-Faktor-Authentifizierung einrichten
und ihr Profil verwalten (Name, E-Mail, Passwort, Profilbild).
Backend:
- Neues MfaModule (src/auth/mfa/): AES-256-GCM-verschluesselte
TOTP-Secrets (otplib), QR-Code-Erzeugung, kurzlebige Setup-/
Challenge-JWTs fuer den 2-Schritt-Login-Flow
- Neues ProfileModule (src/profile/): PATCH /profile,
POST /profile/password, POST/DELETE/GET /profile/avatar,
POST /profile/mfa/enroll(/confirm), POST /profile/mfa/disable -
alle self-scoped ueber actor.id, ohne @RequirePermission (siehe
ALLOWLIST in authorization-coverage.e2e-spec.ts)
- AuthService.login() zweistufig: bei aktivem MFA erst
{ mfaRequired, mfaToken } statt Tokens, neuer Endpunkt
POST /auth/mfa/verify schliesst den Flow ab
- Admin-Recovery: POST /users/:id/mfa/reset (users:update) fuer
verlorene MFA-Geraete, da es keinen E-Mail-Reset-Flow gibt
Frontend:
- Login-Seite mit zweitem Schritt fuer den TOTP-Code
- Neue Profilseite (/profile, ausserhalb des admin-only
Settings-Bereichs): Profilbild, Name/E-Mail, Passwort, MFA-Ein-/
Ausrichtung mit QR-Code-Dialog
- Header zeigt jetzt Avatar mit Dropdown (Profil/Abmelden) statt
nur E-Mail-Text
- Admin-Users-Tabelle zeigt MFA-Status + Reset-Button
Haertung nach Security-Review (Backend/Frontend-Diff, kein CRITICAL,
mehrere MEDIUM behoben):
- Access-Token wird bei Passwortwechsel sofort ungueltig
(User.passwordChangedAt + JWT-iat-Vergleich in
PermissionResolverService.isTokenValid, vorher isUserActive) statt
erst nach bis zu 15 Minuten
- E-Mail-Aenderung verlangt jetzt aktuelles Passwort
- POST /profile/password und /profile/mfa/disable haben jetzt das
gleiche strikte Throttling wie Login (SENSITIVE_ACTION_THROTTLE)
- Kein stiller leerer Verschluesselungs-Key-Fallback mehr (wirft
jetzt explizit)
Bewusst zurueckgestellt (dokumentiert, kein aktiver Exploit-Pfad):
dedizierter Verschluesselungs-Key fuer MFA-Secrets statt
JWT_ACCESS_SECRET-Ableitung, dedizierte users:mfa-reset-Permission
statt users:update, separate JWT-Secrets pro Token-Zweck.
Verifiziert: tsc/eslint clean, 297/297 Backend- + 31/31
Frontend-Unit-Tests, Build clean auf beiden Projekten. e2e weiterhin
durch bekanntes WSL2/Postgres-Problem blockiert. Unabhaengiges
Security- und Code-Quality-Review durchgefuehrt (0 CRITICAL, 0 HIGH).
This commit is contained in:
1 parent
6776bdfe73
commit
62004483cf
55 files changed
+2876
-129
No files matched your search
@@ -17,14 +17,18 @@ import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from './types/authenticated-user';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AccessTokenDto } from './dto/access-token.dto';
|
||||
import { MfaVerifyDto } from './dto/mfa-verify.dto';
|
||||
import { AuthService, CurrentUserProfile } from './auth.service';
|
||||
import { parseDurationToMs } from './util/duration';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
|
||||
|
||||
interface MfaRequiredResponse {
|
||||
mfaRequired: true;
|
||||
mfaToken: string;
|
||||
}
|
||||
|
||||
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
// Stricter than the global default: bounds brute-force/credential-stuffing
|
||||
// attempts against login and refresh-token guessing specifically.
|
||||
const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } };
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -59,23 +63,37 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(AUTH_THROTTLE)
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto | MfaRequiredResponse> {
|
||||
const result = await this.authService.login(dto.email, dto.password);
|
||||
if (result.mfaRequired) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
this.setRefreshTokenCookie(res, result.refreshToken);
|
||||
return { accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('mfa/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyMfa(
|
||||
@Body() dto: MfaVerifyDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto> {
|
||||
const { accessToken, refreshToken } = await this.authService.login(
|
||||
dto.email,
|
||||
dto.password,
|
||||
);
|
||||
const { accessToken, refreshToken } =
|
||||
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(AUTH_THROTTLE)
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(
|
||||
|
||||
@@ -5,9 +5,10 @@ import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { MfaModule } from './mfa/mfa.module';
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule, JwtModule.register({}), UsersModule],
|
||||
imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService } from './mfa/mfa.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
@@ -19,16 +20,25 @@ describe('AuthService', () => {
|
||||
let jwtService: { sign: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { getPermissions: jest.Mock };
|
||||
let mfaService: {
|
||||
signChallengeToken: jest.Mock;
|
||||
verifyChallengeToken: jest.Mock;
|
||||
verifyCode: jest.Mock;
|
||||
};
|
||||
|
||||
const activeUser = {
|
||||
id: 'user-1',
|
||||
email: '[email protected]',
|
||||
isActive: true,
|
||||
passwordHash: '',
|
||||
mfaEnabled: false,
|
||||
mfaSecret: null as string | null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
||||
activeUser.mfaEnabled = false;
|
||||
activeUser.mfaSecret = null;
|
||||
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
|
||||
prisma = {
|
||||
refreshToken: {
|
||||
@@ -43,6 +53,11 @@ describe('AuthService', () => {
|
||||
permissionResolver = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set()),
|
||||
};
|
||||
mfaService = {
|
||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||
verifyChallengeToken: jest.fn(),
|
||||
verifyCode: jest.fn(),
|
||||
};
|
||||
|
||||
authService = new AuthService(
|
||||
usersService as unknown as UsersService,
|
||||
@@ -50,6 +65,7 @@ describe('AuthService', () => {
|
||||
jwtService as never,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as never,
|
||||
mfaService as unknown as MfaService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,6 +103,9 @@ describe('AuthService', () => {
|
||||
'correct-password',
|
||||
);
|
||||
|
||||
if (result.mfaRequired) {
|
||||
throw new Error('expected mfaRequired to be false');
|
||||
}
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(result.refreshToken).toEqual(expect.any(String));
|
||||
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
||||
@@ -98,6 +117,29 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an MFA challenge instead of tokens when MFA is enabled', async () => {
|
||||
usersService.findByEmail.mockResolvedValue({
|
||||
...activeUser,
|
||||
mfaEnabled: true,
|
||||
mfaSecret: 'encrypted-secret',
|
||||
});
|
||||
|
||||
const result = await authService.login(
|
||||
activeUser.email,
|
||||
'correct-password',
|
||||
);
|
||||
|
||||
if (!result.mfaRequired) {
|
||||
throw new Error('expected mfaRequired to be true');
|
||||
}
|
||||
expect(result.mfaToken).toBe('mfa-challenge-token');
|
||||
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
|
||||
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGIN' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||
await expect(
|
||||
@@ -126,6 +168,64 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMfaAndLogin', () => {
|
||||
const mfaUser = {
|
||||
...activeUser,
|
||||
mfaEnabled: true,
|
||||
mfaSecret: 'encrypted-secret',
|
||||
};
|
||||
|
||||
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(true);
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.verifyMfaAndLogin(
|
||||
'mfa-challenge-token',
|
||||
'123456',
|
||||
);
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(mfaService.verifyCode).toHaveBeenCalledWith(
|
||||
mfaUser.mfaSecret,
|
||||
mfaUser.email,
|
||||
'123456',
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: mfaUser.id,
|
||||
}),
|
||||
);
|
||||
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates an expired/invalid challenge token as UnauthorizedException', async () => {
|
||||
mfaService.verifyChallengeToken.mockImplementation(() => {
|
||||
throw new UnauthorizedException('expired');
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.verifyMfaAndLogin('bad-token', '123456'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(usersService.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('throws when the refresh token is unknown', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from './types/authenticated-user';
|
||||
import { MfaService } from './mfa/mfa.service';
|
||||
import { parseDurationToMs } from './util/duration';
|
||||
|
||||
interface AuthTokens {
|
||||
@@ -14,6 +14,10 @@ interface AuthTokens {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| ({ mfaRequired: false } & AuthTokens)
|
||||
| { mfaRequired: true; mfaToken: string };
|
||||
|
||||
export interface CurrentUserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -21,6 +25,8 @@ export interface CurrentUserProfile {
|
||||
roleKey: string;
|
||||
roleName: string;
|
||||
permissions: string[];
|
||||
mfaEnabled: boolean;
|
||||
hasAvatar: boolean;
|
||||
}
|
||||
|
||||
const REFRESH_TOKEN_BYTES = 40;
|
||||
@@ -33,6 +39,7 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly mfaService: MfaService,
|
||||
) {}
|
||||
|
||||
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
||||
@@ -48,6 +55,8 @@ export class AuthService {
|
||||
roleKey: user.role.key,
|
||||
roleName: user.role.name,
|
||||
permissions: Array.from(permissions),
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
hasAvatar: user.avatarPath !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +64,7 @@ export class AuthService {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private signAccessToken(user: AuthenticatedUser): string {
|
||||
private signAccessToken(user: { id: string; email: string }): string {
|
||||
const expiresInSeconds = Math.floor(
|
||||
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
|
||||
);
|
||||
@@ -80,7 +89,7 @@ export class AuthService {
|
||||
return token;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthTokens> {
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user || !user.isActive) {
|
||||
await this.auditLog.record({
|
||||
@@ -101,7 +110,48 @@ export class AuthService {
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const authenticatedUser: AuthenticatedUser = {
|
||||
if (user.mfaEnabled) {
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: this.mfaService.signChallengeToken(user.id),
|
||||
};
|
||||
}
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
const accessToken = this.signAccessToken(authenticatedUser);
|
||||
const refreshToken = await this.issueRefreshToken(user.id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN',
|
||||
userId: user.id,
|
||||
});
|
||||
return { mfaRequired: false, accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
||||
throw new UnauthorizedException('MFA nicht verfügbar');
|
||||
}
|
||||
const isValid = await this.mfaService.verifyCode(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
code,
|
||||
);
|
||||
if (!isValid) {
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: user.id,
|
||||
});
|
||||
throw new UnauthorizedException('Code ungültig');
|
||||
}
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
@@ -132,7 +182,7 @@ export class AuthService {
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const authenticatedUser: AuthenticatedUser = {
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class MfaVerifyDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
mfaToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { PermissionResolverService } from '../permissions/permission-resolver.se
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let reflector: { getAllAndOverride: jest.Mock };
|
||||
let permissionResolver: { isUserActive: jest.Mock };
|
||||
let permissionResolver: { isTokenValid: jest.Mock };
|
||||
|
||||
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
|
||||
function contextWithUser(
|
||||
user: { id: string; tokenIssuedAt?: number } | undefined,
|
||||
): ExecutionContext {
|
||||
const request = { user };
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
@@ -19,7 +21,7 @@ describe('JwtAuthGuard', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
|
||||
permissionResolver = { isUserActive: jest.fn() };
|
||||
permissionResolver = { isTokenValid: jest.fn() };
|
||||
guard = new JwtAuthGuard(
|
||||
reflector as unknown as Reflector,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
@@ -32,11 +34,11 @@ describe('JwtAuthGuard', () => {
|
||||
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(permissionResolver.isUserActive).not.toHaveBeenCalled();
|
||||
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the request when the JWT is valid and the user is active', async () => {
|
||||
const context = contextWithUser({ id: 'user-1' });
|
||||
it('allows the request when the JWT is valid and the token is still valid for the user', async () => {
|
||||
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
|
||||
jest
|
||||
.spyOn(
|
||||
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||
@@ -45,14 +47,17 @@ describe('JwtAuthGuard', () => {
|
||||
'canActivate',
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
permissionResolver.isUserActive.mockResolvedValue(true);
|
||||
permissionResolver.isTokenValid.mockResolvedValue(true);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
expect(permissionResolver.isUserActive).toHaveBeenCalledWith('user-1');
|
||||
expect(permissionResolver.isTokenValid).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when the authenticated user has been deactivated', async () => {
|
||||
const context = contextWithUser({ id: 'user-1' });
|
||||
it('throws UnauthorizedException when the user is deactivated or the token predates a password change', async () => {
|
||||
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
|
||||
jest
|
||||
.spyOn(
|
||||
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||
@@ -61,7 +66,7 @@ describe('JwtAuthGuard', () => {
|
||||
'canActivate',
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
permissionResolver.isUserActive.mockResolvedValue(false);
|
||||
permissionResolver.isTokenValid.mockResolvedValue(false);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
|
||||
@@ -14,13 +14,14 @@ interface RequestWithUser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces User.isActive on every authenticated request, not just ones
|
||||
* gated by @RequirePermission. Without this, a deactivated user's
|
||||
* still-valid access token would keep working on any route that carries no
|
||||
* permission decorator until the token's own expiry (up to
|
||||
* JWT_ACCESS_EXPIRES_IN). isUserActive shares PermissionsGuard's cache, so
|
||||
* this check costs no extra query beyond what permission resolution already
|
||||
* does.
|
||||
* Enforces User.isActive and token freshness (issued after the last
|
||||
* password change) on every authenticated request, not just ones gated by
|
||||
* @RequirePermission. Without this, a deactivated user's still-valid access
|
||||
* token - or one stolen before a password change - would keep working on
|
||||
* any route that carries no permission decorator until the token's own
|
||||
* expiry (up to JWT_ACCESS_EXPIRES_IN). isTokenValid shares
|
||||
* PermissionsGuard's cache, so this check costs no extra query beyond what
|
||||
* permission resolution already does.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
@@ -45,7 +46,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (!user || !(await this.permissionResolver.isUserActive(user.id))) {
|
||||
if (
|
||||
!user ||
|
||||
!(await this.permissionResolver.isTokenValid(user.id, user.tokenIssuedAt))
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||
|
||||
describe('mfa-crypto.util', () => {
|
||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
||||
const ciphertext = encryptMfaSecret(plaintext);
|
||||
expect(ciphertext).not.toContain(plaintext);
|
||||
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext every time (random IV)', () => {
|
||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
||||
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
|
||||
});
|
||||
|
||||
it('fails to decrypt with the wrong key', () => {
|
||||
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
||||
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
||||
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a malformed ciphertext', () => {
|
||||
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||
'Invalid MFA secret ciphertext',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.JWT_ACCESS_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
||||
}
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||
* are one-way hashed) so the server can compute the expected code on every
|
||||
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
|
||||
* out of the DB in plaintext without introducing a new secret to manage.
|
||||
*/
|
||||
export function encryptMfaSecret(plaintext: string): string {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return [iv, authTag, encrypted]
|
||||
.map((buffer) => buffer.toString('base64'))
|
||||
.join('.');
|
||||
}
|
||||
|
||||
export function decryptMfaSecret(ciphertext: string): string {
|
||||
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
||||
if (!ivB64 || !authTagB64 || !dataB64) {
|
||||
throw new Error('Invalid MFA secret ciphertext');
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
ALGORITHM,
|
||||
getKey(),
|
||||
Buffer.from(ivB64, 'base64'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB64, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MfaService } from './mfa.service';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [MfaService],
|
||||
exports: [MfaService],
|
||||
})
|
||||
export class MfaModule {}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib';
|
||||
import { MfaService } from './mfa.service';
|
||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||
import { generateTotpSecret } from './totp.util';
|
||||
|
||||
const crypto = new NobleCryptoPlugin();
|
||||
const base32 = new ScureBase32Plugin();
|
||||
|
||||
describe('MfaService', () => {
|
||||
let mfaService: MfaService;
|
||||
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||
jwtService = { sign: jest.fn(), verify: jest.fn() };
|
||||
mfaService = new MfaService(jwtService as unknown as JwtService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
describe('startEnrollment', () => {
|
||||
it('returns a secret, QR SVG, otpauth URI, and a signed setup token', async () => {
|
||||
jwtService.sign.mockReturnValue('signed-setup-token');
|
||||
|
||||
const enrollment = await mfaService.startEnrollment(
|
||||
'user-1',
|
||||
'[email protected]',
|
||||
);
|
||||
|
||||
expect(enrollment.secret).toMatch(/^[A-Z2-7]+$/);
|
||||
expect(enrollment.qrCodeSvg).toContain('<svg');
|
||||
expect(enrollment.otpauthUri).toContain('otpauth://totp/');
|
||||
expect(enrollment.setupToken).toBe('signed-setup-token');
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sub: 'user-1',
|
||||
secret: enrollment.secret,
|
||||
purpose: 'mfa-setup',
|
||||
}),
|
||||
expect.objectContaining({ expiresIn: '10m' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmEnrollment', () => {
|
||||
async function totpCodeFor(secret: string): Promise<string> {
|
||||
return new TOTP({ secret, crypto, base32 }).generate();
|
||||
}
|
||||
|
||||
it('encrypts and returns the secret when the code is valid', async () => {
|
||||
const secret = generateTotpSecret();
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
secret,
|
||||
purpose: 'mfa-setup',
|
||||
});
|
||||
const code = await totpCodeFor(secret);
|
||||
|
||||
const { encryptedSecret } = await mfaService.confirmEnrollment(
|
||||
'user-1',
|
||||
'setup-token',
|
||||
'[email protected]',
|
||||
code,
|
||||
);
|
||||
|
||||
expect(decryptMfaSecret(encryptedSecret)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when the code does not match the secret', async () => {
|
||||
const secret = generateTotpSecret();
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
secret,
|
||||
purpose: 'mfa-setup',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mfaService.confirmEnrollment(
|
||||
'user-1',
|
||||
'setup-token',
|
||||
'[email protected]',
|
||||
'000000',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('throws when the setup token belongs to a different user', async () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'someone-else',
|
||||
secret: generateTotpSecret(),
|
||||
purpose: 'mfa-setup',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mfaService.confirmEnrollment(
|
||||
'user-1',
|
||||
'setup-token',
|
||||
'[email protected]',
|
||||
'123456',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('throws when the token is expired or invalid', async () => {
|
||||
jwtService.verify.mockImplementation(() => {
|
||||
throw new Error('jwt expired');
|
||||
});
|
||||
|
||||
await expect(
|
||||
mfaService.confirmEnrollment(
|
||||
'user-1',
|
||||
'expired-token',
|
||||
'[email protected]',
|
||||
'123456',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('challenge tokens', () => {
|
||||
it('signs a challenge token for login-time MFA verification', () => {
|
||||
jwtService.sign.mockReturnValue('signed-challenge-token');
|
||||
|
||||
const token = mfaService.signChallengeToken('user-1');
|
||||
|
||||
expect(token).toBe('signed-challenge-token');
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: 'user-1', purpose: 'mfa-challenge' }),
|
||||
expect.objectContaining({ expiresIn: '5m' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies a valid challenge token', () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
purpose: 'mfa-challenge',
|
||||
});
|
||||
|
||||
expect(mfaService.verifyChallengeToken('token')).toEqual({
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on an expired/invalid challenge token', () => {
|
||||
jwtService.verify.mockImplementation(() => {
|
||||
throw new Error('jwt expired');
|
||||
});
|
||||
|
||||
expect(() => mfaService.verifyChallengeToken('bad-token')).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the token purpose does not match', () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
purpose: 'mfa-setup',
|
||||
});
|
||||
|
||||
expect(() => mfaService.verifyChallengeToken('token')).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyCode', () => {
|
||||
it('decrypts the stored secret and verifies the code', async () => {
|
||||
const secret = generateTotpSecret();
|
||||
const encryptedSecret = encryptMfaSecret(secret);
|
||||
const code = await new TOTP({ secret, crypto, base32 }).generate();
|
||||
|
||||
await expect(
|
||||
mfaService.verifyCode(encryptedSecret, '[email protected]', code),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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 } from './totp.util';
|
||||
|
||||
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 },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: 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, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
} 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 },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL },
|
||||
);
|
||||
}
|
||||
|
||||
verifyChallengeToken(challengeToken: string): { userId: string } {
|
||||
let payload: ChallengeTokenPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
} 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 };
|
||||
}
|
||||
|
||||
async verifyCode(
|
||||
encryptedSecret: string,
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const secret = decryptMfaSecret(encryptedSecret);
|
||||
return verifyTotpCode(secret, email, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
||||
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
||||
|
||||
const crypto = new NobleCryptoPlugin();
|
||||
const base32 = new ScureBase32Plugin();
|
||||
|
||||
describe('totp.util', () => {
|
||||
it('generates a base32 secret', () => {
|
||||
const secret = generateTotpSecret();
|
||||
expect(secret).toMatch(/^[A-Z2-7]+$/);
|
||||
});
|
||||
|
||||
it('builds an otpauth:// URI carrying the issuer and label', () => {
|
||||
const uri = buildTotpUri('JBSWY3DPEHPK3PXP', '[email protected]');
|
||||
expect(uri).toContain('otpauth://totp/');
|
||||
expect(uri).toContain('ApiCore');
|
||||
expect(uri).toContain('secret=JBSWY3DPEHPK3PXP');
|
||||
});
|
||||
|
||||
it('verifies a code generated for the same secret', async () => {
|
||||
const secret = generateTotpSecret();
|
||||
const totp = new TOTP({ secret, crypto, base32 });
|
||||
const code = await totp.generate();
|
||||
await expect(
|
||||
verifyTotpCode(secret, '[email protected]', code),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an incorrect code', async () => {
|
||||
const secret = generateTotpSecret();
|
||||
const totp = new TOTP({ secret, crypto, base32 });
|
||||
const validCode = await totp.generate();
|
||||
const wrongCode = validCode === '000000' ? '111111' : '000000';
|
||||
await expect(
|
||||
verifyTotpCode(secret, '[email protected]', wrongCode),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a code generated for a different secret', async () => {
|
||||
const secretA = generateTotpSecret();
|
||||
const secretB = generateTotpSecret();
|
||||
const totpB = new TOTP({ secret: secretB, crypto, base32 });
|
||||
const codeFromB = await totpB.generate();
|
||||
await expect(
|
||||
verifyTotpCode(secretA, '[email protected]', codeFromB),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
||||
|
||||
const ISSUER = 'ApiCore';
|
||||
// Allows one 30s step of clock drift in either direction between the
|
||||
// authenticator app and the server, matching common 2FA implementations.
|
||||
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||
|
||||
const crypto = new NobleCryptoPlugin();
|
||||
const base32 = new ScureBase32Plugin();
|
||||
|
||||
function createTotp(secret: string, label: string): TOTP {
|
||||
return new TOTP({ secret, label, issuer: ISSUER, crypto, base32 });
|
||||
}
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return new TOTP({ crypto, base32 }).generateSecret();
|
||||
}
|
||||
|
||||
export function buildTotpUri(secret: string, label: string): string {
|
||||
return createTotp(secret, label).toURI();
|
||||
}
|
||||
|
||||
export async function verifyTotpCode(
|
||||
secret: string,
|
||||
label: string,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const result = await createTotp(secret, label).verify(code, {
|
||||
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||
});
|
||||
return result.valid;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ describe('PermissionResolverService', () => {
|
||||
function activeUserWithPermissions(...keys: string[]) {
|
||||
return {
|
||||
isActive: true,
|
||||
passwordChangedAt: null as Date | null,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: keys.map((key) => ({ permission: { key } })),
|
||||
@@ -93,43 +94,75 @@ describe('PermissionResolverService', () => {
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('isUserActive', () => {
|
||||
it('returns true for an active user with an active role', async () => {
|
||||
describe('isTokenValid', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
it('returns true for an active user with no recorded password change', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(
|
||||
activeUserWithPermissions('colonies:read'),
|
||||
);
|
||||
|
||||
await expect(resolver.isUserActive('user-1')).resolves.toBe(true);
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for a deactivated user', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
isActive: false,
|
||||
passwordChangedAt: null,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the role is inactive', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
isActive: true,
|
||||
passwordChangedAt: null,
|
||||
role: {
|
||||
status: 'INACTIVE',
|
||||
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the user does not exist', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(resolver.isUserActive('missing')).resolves.toBe(false);
|
||||
await expect(resolver.isTokenValid('missing', nowSeconds)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the token was issued before the last password change', async () => {
|
||||
const entry = activeUserWithPermissions('colonies:read');
|
||||
entry.passwordChangedAt = new Date((nowSeconds + 60) * 1000);
|
||||
prisma.user.findUnique.mockResolvedValue(entry);
|
||||
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true when the token was issued after the last password change', async () => {
|
||||
const entry = activeUserWithPermissions('colonies:read');
|
||||
entry.passwordChangedAt = new Date((nowSeconds - 60) * 1000);
|
||||
prisma.user.findUnique.mockResolvedValue(entry);
|
||||
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('shares the cached entry with getPermissions instead of re-querying', async () => {
|
||||
@@ -138,7 +171,7 @@ describe('PermissionResolverService', () => {
|
||||
);
|
||||
|
||||
await resolver.getPermissions('user-1');
|
||||
await resolver.isUserActive('user-1');
|
||||
await resolver.isTokenValid('user-1', nowSeconds);
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PermissionKey } from './permission-catalog';
|
||||
|
||||
interface CacheEntry {
|
||||
isActive: boolean;
|
||||
passwordChangedAt: Date | null;
|
||||
permissions: ReadonlySet<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -16,13 +17,14 @@ const CACHE_TTL_MS = 30_000;
|
||||
* role's grants takes effect without forcing a re-login), with a short TTL
|
||||
* cache to keep that from becoming a query-per-request cost. Call
|
||||
* invalidateUser/invalidateAll after any write to Role, Permission,
|
||||
* RolePermission, or User.roleId so the change is visible immediately
|
||||
* instead of waiting out the cache window.
|
||||
* RolePermission, User.roleId, or User.passwordChangedAt so the change is
|
||||
* visible immediately instead of waiting out the cache window.
|
||||
*
|
||||
* isUserActive shares the same cached entry: JwtAuthGuard calls it on every
|
||||
* isTokenValid shares the same cached entry: JwtAuthGuard calls it on every
|
||||
* authenticated request (including routes with no @RequirePermission) so a
|
||||
* deactivated user's still-valid access token stops working within the
|
||||
* cache TTL instead of staying usable until the token expires.
|
||||
* cache TTL instead of staying usable until the token expires, and so does
|
||||
* a token issued before the user's last password change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionResolverService {
|
||||
@@ -35,9 +37,18 @@ export class PermissionResolverService {
|
||||
return entry.permissions;
|
||||
}
|
||||
|
||||
async isUserActive(userId: string): Promise<boolean> {
|
||||
async isTokenValid(
|
||||
userId: string,
|
||||
tokenIssuedAtSeconds: number,
|
||||
): Promise<boolean> {
|
||||
const entry = await this.getEntry(userId);
|
||||
return entry.isActive;
|
||||
if (!entry.isActive) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.passwordChangedAt) {
|
||||
return true;
|
||||
}
|
||||
return tokenIssuedAtSeconds * 1000 >= entry.passwordChangedAt.getTime();
|
||||
}
|
||||
|
||||
async hasAny(
|
||||
@@ -74,6 +85,7 @@ export class PermissionResolverService {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
isActive: true,
|
||||
passwordChangedAt: true,
|
||||
role: {
|
||||
select: {
|
||||
status: true,
|
||||
@@ -92,6 +104,7 @@ export class PermissionResolverService {
|
||||
: new Set<string>();
|
||||
return {
|
||||
isActive: isEligible,
|
||||
passwordChangedAt: user?.passwordChangedAt ?? null,
|
||||
permissions,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@ import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
interface AccessTokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
// Standard JWT claim, added automatically by jsonwebtoken.sign(); used to
|
||||
// reject tokens issued before the user's last password change (see
|
||||
// PermissionResolverService.isTokenValid).
|
||||
iat: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -19,6 +23,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
||||
return { id: payload.sub, email: payload.email };
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenIssuedAt: payload.iat,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
/** JWT `iat` claim (seconds since epoch) - see PermissionResolverService.isTokenValid. */
|
||||
tokenIssuedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Stricter than the global default: bounds brute-force/credential-stuffing
|
||||
// attempts against any endpoint that verifies a password or MFA code
|
||||
// (login, refresh, MFA verify, change-password, MFA disable).
|
||||
export const SENSITIVE_ACTION_THROTTLE = { default: { limit: 10, ttl: 60000 } };
|
||||
Reference in new issue
Block a user