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:
1 parent
1a7331b6b2
commit
f1298eff08
16 files changed
+224
-15
No files matched your search
@@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
@@ -16,6 +17,7 @@ describe('AuthService', () => {
|
||||
};
|
||||
};
|
||||
let jwtService: { sign: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const activeUser = {
|
||||
id: 'user-1',
|
||||
@@ -37,11 +39,13 @@ describe('AuthService', () => {
|
||||
},
|
||||
};
|
||||
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
authService = new AuthService(
|
||||
usersService as unknown as UsersService,
|
||||
prisma as unknown as PrismaService,
|
||||
jwtService as never,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,6 +89,36 @@ describe('AuthService', () => {
|
||||
[{ data: { userId: string } }],
|
||||
];
|
||||
expect(createArg.data.userId).toBe(activeUser.id);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||
await expect(
|
||||
authService.login(activeUser.email, 'wrong-password'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: activeUser.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
await expect(
|
||||
authService.login('[email protected]', 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'LOGIN_FAILED',
|
||||
entityId: '[email protected]',
|
||||
userId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +171,39 @@ describe('AuthService', () => {
|
||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'TOKEN_REFRESHED',
|
||||
userId: activeUser.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('revokes the matching refresh token', async () => {
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||
it('revokes the matching refresh token and records a LOGOUT audit entry', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: null,
|
||||
});
|
||||
prisma.refreshToken.update.mockResolvedValue({});
|
||||
|
||||
await authService.logout('some-token');
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||
|
||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGOUT', userId: activeUser.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the token is unknown or already revoked', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
||||
await authService.logout('unknown-token');
|
||||
expect(prisma.refreshToken.update).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user