Files
dh-inspeccion-v2/api-v3/src/auth/auth.service.ts
T

497 lines
15 KiB
TypeScript

import { isIP } from 'node:net';
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { Response } from 'express';
import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository';
import { RolesRepository } from '../core-data/repositories/roles.repository';
import { UsersRepository } from '../core-data/repositories/users.repository';
import {
AuditAction,
AuditSource,
AuthSession,
User,
UserStatus,
} from '../database/entities';
import type { ChangePasswordDto } from './dto/change-password.dto';
import type { LoginDto } from './dto/login.dto';
import { CookieService } from './services/cookie.service';
import { PasswordService } from './services/password.service';
import {
IssuedRefreshToken,
TokenService,
} from './services/token.service';
interface RequestMetadata {
ip: string | null;
userAgent: string | null;
}
export interface PublicUser {
id: string;
username: string;
firstName: string;
lastName: string;
email: string | null;
mustChangePassword: boolean;
roles: string[];
permissions: string[];
}
interface SuccessfulAuthentication {
user: User;
accessToken: string;
refreshToken: IssuedRefreshToken;
}
type LoginOutcome = SuccessfulAuthentication | null;
type RefreshOutcome =
| ({ kind: 'ok' } & SuccessfulAuthentication)
| { kind: 'reuse' }
| { kind: 'invalid' };
function invalidCredentials(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_CREDENTIALS',
message: 'Credenciales inválidas',
});
}
function invalidSession(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_SESSION',
message: 'Sesión inválida o vencida',
});
}
function requestMetadata(request: RequestWithContext): RequestMetadata {
const candidateIp = request.ip || request.socket.remoteAddress || '';
const ip = isIP(candidateIp) ? candidateIp : null;
const rawUserAgent = request.header('user-agent')?.trim();
return {
ip,
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
};
}
@Injectable()
export class AuthService {
constructor(
private readonly dataSource: DataSource,
private readonly users: UsersRepository,
private readonly sessions: AuthSessionsRepository,
private readonly roles: RolesRepository,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly cookies: CookieService,
private readonly audit: AuditService,
) {}
async login(
dto: LoginDto,
request: RequestWithContext,
response: Response,
) {
const identifier = dto.identifier.trim().toLowerCase();
const candidate = await this.users.findForAuthentication(identifier);
const metadata = requestMetadata(request);
if (!candidate) {
await this.passwords.verifyUnknown(dto.password);
await this.audit.record({
action: AuditAction.AUTH_LOGIN_FAILED,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
metadata: {
identifier,
reason: 'UNKNOWN_IDENTIFIER',
},
});
throw invalidCredentials();
}
const outcome = await this.dataSource.transaction<LoginOutcome>(
async (manager) => {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: candidate.id })
.setLock('pessimistic_write')
.getOne();
if (!user) return null;
const now = new Date();
const passwordMatches = await this.passwords.verify(
user.passwordHash,
dto.password,
);
const locked = Boolean(user.lockedUntil && user.lockedUntil > now);
const active = user.status === UserStatus.ACTIVE;
if (!passwordMatches || locked || !active) {
if (!passwordMatches && active && !locked) {
await manager.query(
`
UPDATE users
SET
failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= $2
THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second')
ELSE locked_until
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`,
[
user.id,
this.tokens.maxLoginAttempts,
this.tokens.lockoutSeconds,
],
);
}
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_FAILED,
entityType: 'user',
entityId: user.id,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
metadata: {
reason: !active
? 'INACTIVE_USER'
: locked
? 'LOCKED_USER'
: 'INVALID_PASSWORD',
},
},
manager,
);
return null;
}
if (this.passwords.needsRehash(user.passwordHash)) {
user.passwordHash = await this.passwords.hash(dto.password);
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.lastLoginAt = now;
await manager.getRepository(User).save(user);
const refreshToken = this.tokens.issueRefreshToken();
const session = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: dto.deviceLabel?.trim() || null,
});
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: session.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_SUCCESS,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
metadata: { deviceLabel: session.deviceLabel },
},
manager,
);
return { user, accessToken, refreshToken };
},
);
if (!outcome) throw invalidCredentials();
return this.completeAuthentication(outcome, response);
}
async refresh(request: RequestWithContext, response: Response) {
const rawToken = this.cookies.getRefreshToken(request);
const parsed = rawToken ? this.tokens.parseRefreshToken(rawToken) : null;
if (!rawToken || !parsed) {
this.cookies.clearAuthCookies(response);
throw invalidSession();
}
const metadata = requestMetadata(request);
const outcome = await this.dataSource.transaction<RefreshOutcome>(
async (manager) => {
const session = await manager
.getRepository(AuthSession)
.createQueryBuilder('session')
.addSelect('session.refreshTokenHash')
.where('session.id = :id', { id: parsed.sessionId })
.setLock('pessimistic_write')
.getOne();
if (
!session ||
!this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash)
) {
return { kind: 'invalid' };
}
const user = await manager.getRepository(User).findOne({
where: { id: session.userId },
});
if (session.revokedAt) {
await this.sessions.revokeSessionFamily(session.id, manager);
await this.audit.record(
{
actorUserId: user?.id ?? null,
actorUsername: user?.username ?? null,
action: AuditAction.AUTH_REFRESH_REUSE_DETECTED,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
},
manager,
);
return { kind: 'reuse' };
}
const now = new Date();
if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) {
session.revokedAt = now;
session.lastUsedAt = now;
await manager.getRepository(AuthSession).save(session);
if (user?.status === UserStatus.INACTIVE) {
await this.sessions.revokeUserSessions(user.id, undefined, manager);
}
return { kind: 'invalid' };
}
const refreshToken = this.tokens.issueRefreshToken();
const replacement = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: session.deviceLabel,
});
await manager.getRepository(AuthSession).save(replacement);
session.revokedAt = now;
session.lastUsedAt = now;
session.replacedBySessionId = replacement.id;
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: replacement.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_REFRESH,
entityType: 'auth_session',
entityId: replacement.id,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
metadata: { replacedSessionId: session.id },
},
manager,
);
return { kind: 'ok', user, accessToken, refreshToken };
},
);
if (outcome.kind !== 'ok') {
this.cookies.clearAuthCookies(response);
throw invalidSession();
}
return this.completeAuthentication(outcome, response);
}
async logout(
principal: AuthPrincipal,
request: RequestWithContext,
response: Response,
) {
const metadata = requestMetadata(request);
await this.dataSource.transaction(async (manager) => {
await this.sessions.revokeSession(
principal.sessionId,
principal.userId,
manager,
);
await this.audit.record(
{
actorUserId: principal.userId,
actorUsername: principal.username,
action: AuditAction.AUTH_LOGOUT,
entityType: 'auth_session',
entityId: principal.sessionId,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
},
manager,
);
});
this.cookies.clearAuthCookies(response);
return { status: 'ok' };
}
me(principal: AuthPrincipal) {
return { user: this.publicPrincipal(principal) };
}
async changePassword(
principal: AuthPrincipal,
dto: ChangePasswordDto,
request: RequestWithContext,
) {
const metadata = requestMetadata(request);
await this.dataSource.transaction(async (manager) => {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: principal.userId })
.setLock('pessimistic_write')
.getOne();
if (
!user ||
!(await this.passwords.verify(user.passwordHash, dto.currentPassword))
) {
throw invalidCredentials();
}
if (await this.passwords.verify(user.passwordHash, dto.newPassword)) {
throw new BadRequestException({
code: 'PASSWORD_UNCHANGED',
message: 'La nueva contraseña debe ser diferente',
});
}
const now = new Date();
user.passwordHash = await this.passwords.hash(dto.newPassword);
user.passwordChangedAt = now;
user.mustChangePassword = false;
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.updatedBy = user.id;
await manager.getRepository(User).save(user);
await this.sessions.revokeUserSessions(
user.id,
principal.sessionId,
manager,
);
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_PASSWORD_CHANGED,
entityType: 'user',
entityId: user.id,
requestId: request.requestId,
source: AuditSource.WEB,
...metadata,
metadata: { otherSessionsRevoked: true },
},
manager,
);
});
return { status: 'ok', mustChangePassword: false };
}
private async completeAuthentication(
authentication: SuccessfulAuthentication,
response: Response,
) {
const [roleCodes, permissionCodes] = await Promise.all([
this.roles.findRoleCodesForUser(authentication.user.id),
this.roles.findPermissionCodesForUser(authentication.user.id),
]);
const csrfToken = this.cookies.setAuthCookies(
response,
authentication.accessToken,
authentication.refreshToken.token,
);
return {
user: this.publicUser(authentication.user, roleCodes, permissionCodes),
csrfToken,
accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds,
};
}
private publicUser(
user: User,
roles: string[],
permissions: string[],
): PublicUser {
return {
id: user.id,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
mustChangePassword: user.mustChangePassword,
roles,
permissions,
};
}
private publicPrincipal(principal: AuthPrincipal): PublicUser {
return {
id: principal.userId,
username: principal.username,
firstName: principal.firstName,
lastName: principal.lastName,
email: principal.email,
mustChangePassword: principal.mustChangePassword,
roles: principal.roles,
permissions: principal.permissions,
};
}
}