diff --git a/api-v3/src/auth/auth.module.ts b/api-v3/src/auth/auth.module.ts index 5875058..fa83cfa 100644 --- a/api-v3/src/auth/auth.module.ts +++ b/api-v3/src/auth/auth.module.ts @@ -5,6 +5,8 @@ import { AuthorizationModule } from '../authorization/authorization.module'; import { PhaseADataModule } from '../core-data/phase-a-data.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { MobileAuthController } from './mobile-auth.controller'; +import { MobileAuthService } from './mobile-auth.service'; import { AccessTokenGuard } from './guards/access-token.guard'; import { CsrfGuard } from './guards/csrf.guard'; import { AuthConfigService } from '../common/config/auth-config.service'; @@ -18,6 +20,7 @@ const providers = [ PasswordService, TokenService, AuthService, + MobileAuthService, AccessTokenGuard, CsrfGuard, ]; @@ -29,7 +32,7 @@ const providers = [ AuditModule, AuthorizationModule, ], - controllers: [AuthController], + controllers: [AuthController, MobileAuthController], providers, exports: [ AuthConfigService, diff --git a/api-v3/src/auth/dto/mobile-refresh.dto.ts b/api-v3/src/auth/dto/mobile-refresh.dto.ts new file mode 100644 index 0000000..a3794e7 --- /dev/null +++ b/api-v3/src/auth/dto/mobile-refresh.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class MobileRefreshDto { + @IsString() + @MinLength(32) + @MaxLength(256) + refreshToken!: string; +} diff --git a/api-v3/src/auth/mobile-auth.controller.ts b/api-v3/src/auth/mobile-auth.controller.ts new file mode 100644 index 0000000..05cad07 --- /dev/null +++ b/api-v3/src/auth/mobile-auth.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + HttpCode, + Post, + Req, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { + AuthPrincipal, + RequestWithContext, +} from '../common/http/request-context'; +import { CurrentAuth } from './decorators/current-auth.decorator'; +import { Public } from './decorators/public.decorator'; +import { SkipCsrf } from './decorators/skip-csrf.decorator'; +import { LoginDto } from './dto/login.dto'; +import { MobileRefreshDto } from './dto/mobile-refresh.dto'; +import { MobileAuthService } from './mobile-auth.service'; + +@Controller('auth/mobile') +export class MobileAuthController { + constructor(private readonly mobileAuth: MobileAuthService) {} + + @Post('login') + @HttpCode(200) + @Public() + @SkipCsrf() + @Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } }) + login( + @Body() dto: LoginDto, + @Req() request: RequestWithContext, + ) { + return this.mobileAuth.login(dto, request); + } + + @Post('refresh') + @HttpCode(200) + @Public() + @SkipCsrf() + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + refresh( + @Body() dto: MobileRefreshDto, + @Req() request: RequestWithContext, + ) { + return this.mobileAuth.refresh(dto.refreshToken, request); + } + + @Post('logout') + @HttpCode(200) + logout( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.mobileAuth.logout(principal, request); + } +} diff --git a/api-v3/src/auth/mobile-auth.service.ts b/api-v3/src/auth/mobile-auth.service.ts new file mode 100644 index 0000000..7f97689 --- /dev/null +++ b/api-v3/src/auth/mobile-auth.service.ts @@ -0,0 +1,386 @@ +import { isIP } from 'node:net'; +import { + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { DataSource } 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 { LoginDto } from './dto/login.dto'; +import { PasswordService } from './services/password.service'; +import { + IssuedRefreshToken, + TokenService, +} from './services/token.service'; + +interface RequestMetadata { + ip: string | null; + userAgent: string | null; +} + +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 móvil inválida o vencida', + }); +} + +function inspectorRequired(): ForbiddenException { + return new ForbiddenException({ + code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED', + message: 'Sólo un usuario con rol inspector puede ingresar a la aplicación móvil', + }); +} + +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 MobileAuthService { + 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 audit: AuditService, + ) {} + + async login(dto: LoginDto, request: RequestWithContext) { + 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.ANDROID, + ...metadata, + metadata: { identifier, reason: 'UNKNOWN_IDENTIFIER' }, + }); + throw invalidCredentials(); + } + + const outcome = await this.dataSource.transaction( + 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.ANDROID, + ...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() || 'DH Android', + }); + 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.ANDROID, + ...metadata, + metadata: { deviceLabel: session.deviceLabel }, + }, + manager, + ); + + return { user, accessToken, refreshToken }; + }, + ); + + if (!outcome) throw invalidCredentials(); + return this.complete(outcome); + } + + async refresh(rawToken: string, request: RequestWithContext) { + const parsed = this.tokens.parseRefreshToken(rawToken); + if (!parsed) throw invalidSession(); + + const metadata = requestMetadata(request); + const outcome = await this.dataSource.transaction( + 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.ANDROID, + ...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.ANDROID, + ...metadata, + metadata: { replacedSessionId: session.id }, + }, + manager, + ); + + return { kind: 'ok', user, accessToken, refreshToken }; + }, + ); + + if (outcome.kind !== 'ok') throw invalidSession(); + return this.complete(outcome); + } + + async logout( + principal: AuthPrincipal, + request: RequestWithContext, + ) { + 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.ANDROID, + ...metadata, + }, + manager, + ); + }); + return { status: 'ok' }; + } + + private async complete(authentication: SuccessfulAuthentication) { + const [roleCodes, permissionCodes] = await Promise.all([ + this.roles.findRoleCodesForUser(authentication.user.id), + this.roles.findPermissionCodesForUser(authentication.user.id), + ]); + + if (!roleCodes.includes('inspector')) { + await this.sessions.revokeSession( + authentication.refreshToken.sessionId, + authentication.user.id, + ); + throw inspectorRequired(); + } + + return { + user: { + id: authentication.user.id, + username: authentication.user.username, + firstName: authentication.user.firstName, + lastName: authentication.user.lastName, + email: authentication.user.email, + mustChangePassword: authentication.user.mustChangePassword, + roles: roleCodes, + permissions: permissionCodes, + }, + accessToken: authentication.accessToken, + refreshToken: authentication.refreshToken.token, + accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds, + }; + } +}