fix: harden auth against brute force, add auth audit logging, fix cookie/TLS gaps

Security review findings (HIGH-1/2/3, MEDIUM):

- Rate limiting via @nestjs/throttler: global 100 req/min default, stricter
  10 req/min on login and refresh. Also closes the MEDIUM finding (public
  batch-lookup enumeration) since it now sits behind the global limit too.
  Trust the K8s ingress's single proxy hop so throttling keys off the real
  client IP, not the ingress pod's IP.
- Auth events (login success/failure, logout, token refresh) are now
  audit-logged via new AuditAction values, closing the gap where the
  project's own audit-log principle never actually covered the Auth module.
- Refresh-token cookie now defaults to Secure=true (opt out only via
  COOKIE_SECURE=false for local HTTP dev), instead of keying off NODE_ENV
  which was never set in the K8s deploy templates. Added a TLS block +
  cert-manager annotation to both the backend and frontend ingress
  templates, which previously had no HTTPS termination configured at all.
This commit is contained in:
Nick Szaunig committed 2026-08-25 10:49:25 +02:00
1 parent 1a7331b6b2
commit f1298eff08
16 files changed
+224 -15

No files matched your search

+40 -2
View File
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AuthenticatedUser } from './types/authenticated-user';
import { parseDurationToMs } from './util/duration';
@@ -20,6 +21,7 @@ export class AuthService {
private readonly usersService: UsersService,
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
) {}
private hashRefreshToken(token: string): string {
@@ -54,10 +56,22 @@ export class AuthService {
async login(email: string, password: string): Promise<AuthTokens> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user?.id ?? email,
action: 'LOGIN_FAILED',
userId: user?.id,
});
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
}
const authenticatedUser: AuthenticatedUser = {
@@ -67,6 +81,12 @@ export class AuthService {
};
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 { accessToken, refreshToken };
}
@@ -93,14 +113,32 @@ export class AuthService {
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'TOKEN_REFRESHED',
userId: user.id,
});
return { accessToken, refreshToken: newRefreshToken };
}
async logout(refreshToken: string): Promise<void> {
const tokenHash = this.hashRefreshToken(refreshToken);
await this.prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt) {
return;
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'AuthSession',
entityId: stored.userId,
action: 'LOGOUT',
userId: stored.userId,
});
}
}