This commit is contained in:
David 2026-06-13 12:17:06 +02:00
parent 5aed7d81ce
commit 6b9ad95811
3 changed files with 99 additions and 10 deletions

View File

@ -22,6 +22,7 @@ import { InvitationService } from '../services/invitation.service';
import { InvitationsController } from '../controllers/invitations.controller'; import { InvitationsController } from '../controllers/invitations.controller';
import { EmailModule } from '../../infrastructure/email/email.module'; import { EmailModule } from '../../infrastructure/email/email.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { AuditModule } from '../audit/audit.module';
@Module({ @Module({
imports: [ imports: [
@ -53,6 +54,9 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
// Subscriptions module for license checks // Subscriptions module for license checks
SubscriptionsModule, SubscriptionsModule,
// Audit module for login/logout tracking
AuditModule,
], ],
controllers: [AuthController, InvitationsController], controllers: [AuthController, InvitationsController],
providers: [ providers: [

View File

@ -235,9 +235,11 @@ export class AuthService {
* The revocation list lives in Redis with a TTL matching the token's * The revocation list lives in Redis with a TTL matching the token's
* remaining lifetime, so entries clean themselves up. * remaining lifetime, so entries clean themselves up.
*/ */
async logout(refreshToken?: string): Promise<void> { async logout(
refreshToken?: string
): Promise<{ userId: string; email: string; organizationId: string } | null> {
if (!refreshToken) { if (!refreshToken) {
return; return null;
} }
try { try {
@ -248,9 +250,19 @@ export class AuthService {
await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds); await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds);
this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`); this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`);
if (payload?.sub) {
return {
userId: payload.sub,
email: payload.email,
organizationId: payload.organizationId,
};
}
return null;
} catch (error) { } catch (error) {
// Never block logout on revocation failures — log and continue // Never block logout on revocation failures — log and continue
this.logger.error(`Failed to revoke refresh token: ${error}`); this.logger.error(`Failed to revoke refresh token: ${error}`);
return null;
} }
} }

View File

@ -34,6 +34,8 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { UserMapper } from '../mappers/user.mapper'; import { UserMapper } from '../mappers/user.mapper';
import { InvitationService } from '../services/invitation.service'; import { InvitationService } from '../services/invitation.service';
import { AuditService } from '../services/audit.service';
import { AuditAction } from '@domain/entities/audit-log.entity';
import { import {
AUTH_COOKIE_NAMES, AUTH_COOKIE_NAMES,
authCookieOptions, authCookieOptions,
@ -72,9 +74,26 @@ export class AuthController {
private readonly authService: AuthService, private readonly authService: AuthService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly invitationService: InvitationService, private readonly invitationService: InvitationService,
private readonly auditService: AuditService,
@Inject(EMAIL_PORT) private readonly emailService: EmailPort @Inject(EMAIL_PORT) private readonly emailService: EmailPort
) {} ) {}
/**
* Extract the client IP and user agent from the request so we can record
* *who* connected and *from where* in the audit trail.
*/
private getClientInfo(req: Request): { ipAddress?: string; userAgent?: string } {
const forwardedFor = req.headers['x-forwarded-for'];
const ipAddress =
(Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0]?.trim()) ||
req.ip ||
req.socket?.remoteAddress;
return {
ipAddress,
userAgent: req.headers['user-agent'],
};
}
/** /**
* Deliver tokens as httpOnly cookies so they are out of reach of XSS. * Deliver tokens as httpOnly cookies so they are out of reach of XSS.
* When rememberMe is false the cookies are session-scoped (cleared when * When rememberMe is false the cookies are session-scoped (cleared when
@ -208,18 +227,59 @@ export class AuthController {
}) })
async login( async login(
@Body() dto: LoginDto, @Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
): Promise<AuthResponseDto> { ): Promise<AuthResponseDto> {
const rememberMe = dto.rememberMe === true; const rememberMe = dto.rememberMe === true;
const { ipAddress, userAgent } = this.getClientInfo(req);
try {
const result = await this.authService.login(dto.email, dto.password, rememberMe); const result = await this.authService.login(dto.email, dto.password, rememberMe);
this.setAuthCookies(res, result, rememberMe); this.setAuthCookies(res, result, rememberMe);
// Audit log: record who logged in, when and from where
await this.auditService.logSuccess(
AuditAction.USER_LOGIN,
result.user.id,
result.user.email,
result.user.organizationId,
{
resourceType: 'user',
resourceId: result.user.id,
ipAddress,
userAgent,
metadata: { rememberMe },
}
);
this.logger.log(`Login success: ${result.user.email} from ${ipAddress ?? 'unknown IP'}`);
return { return {
accessToken: result.accessToken, accessToken: result.accessToken,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
user: result.user, user: result.user,
}; };
} catch (error: any) {
// Audit log: record failed login attempts (the attempted email is the
// only identity we have — the credentials did not match a valid user)
await this.auditService.logFailure(
AuditAction.USER_LOGIN,
'unknown',
dto.email,
'unknown',
error?.message || 'Invalid credentials',
{
resourceType: 'user',
ipAddress,
userAgent,
}
);
this.logger.warn(`Login failed for ${dto.email} from ${ipAddress ?? 'unknown IP'}`);
throw error;
}
} }
/** /**
@ -302,9 +362,22 @@ export class AuthController {
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken]; const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
await this.authService.logout(refreshToken); const loggedOutUser = await this.authService.logout(refreshToken);
this.clearAuthCookies(res); this.clearAuthCookies(res);
// Audit log: record who logged out and when
if (loggedOutUser) {
const { ipAddress, userAgent } = this.getClientInfo(req);
await this.auditService.logSuccess(
AuditAction.USER_LOGOUT,
loggedOutUser.userId,
loggedOutUser.email,
loggedOutUser.organizationId,
{ resourceType: 'user', resourceId: loggedOutUser.userId, ipAddress, userAgent }
);
this.logger.log(`Logout: ${loggedOutUser.email} from ${ipAddress ?? 'unknown IP'}`);
}
return { message: 'Logout successful' }; return { message: 'Logout successful' };
} }