import { randomBytes, createHash } from "node:crypto"; import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from "@nestjs/common"; import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database"; import { sendMail } from "@eden/integrations"; import type { EdenEnv } from "@eden/config"; import { EDEN_ENV } from "../config/config.constants.js"; import { PasswordService } from "./password.service.js"; import { TokenService } from "./token.service.js"; import { MfaService } from "./mfa.service.js"; import { resolvePermissions } from "./permissions.js"; export interface RequestContext { ipAddress?: string | null; userAgent?: string | null; } export interface AuthResult { accessToken: string; expiresIn: number; refreshToken: string; user: PublicUser; } export interface MfaRequiredResult { mfaRequired: true; mfaChallenge: string; expiresIn: number; } export type LoginResult = AuthResult | MfaRequiredResult; export interface PublicUser { id: string; email: string; fullName: string | null; roleKey: string; } function toPublicUser(user: User): PublicUser { return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key }; } const GENERIC_LOGIN_ERROR = "Credenciais inválidas"; const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — deliberately short-lived (this token grants a password change) function hashResetToken(plaintext: string): string { return createHash("sha256").update(plaintext).digest("hex"); } @Injectable() export class AuthService { constructor( private readonly passwords: PasswordService, private readonly tokens: TokenService, private readonly mfa: MfaService, @Inject(EDEN_ENV) private readonly env: EdenEnv, ) {} async login( email: string, password: string, applicationKey: "core" | "reseller" | "subscriber", ctx: RequestContext, ): Promise { const user = await usersRepo.findUserByEmail(email); // Same generic error whether the account doesn't exist or the password // is wrong — anti-enumeration, mirrors eden.md §4.1. if (!user || !user.password_hash) { throw new UnauthorizedException(GENERIC_LOGIN_ERROR); } const valid = await this.passwords.verify(user.password_hash, password); if (!valid) { throw new UnauthorizedException(GENERIC_LOGIN_ERROR); } if (this.passwords.needsRehash(user.password_algo_version)) { const { hash, algoVersion } = await this.passwords.hash(password); await usersRepo.updateUserPassword(user.id, hash, algoVersion); } const grantedApps = await usersRepo.getUserApplications(user.id); if (!grantedApps.includes(applicationKey)) { throw new ForbiddenException("Usuário sem acesso a esta aplicação"); } if (user.mfa_enabled) { const challenge = this.tokens.signMfaChallenge({ sub: user.id, app: applicationKey }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.mfa_challenge_issued", resourceType: "user", resourceId: user.id, metadata: { applicationKey }, ipAddress: ctx.ipAddress, }); return { mfaRequired: true, mfaChallenge: challenge.token, expiresIn: challenge.expiresIn }; } const result = await this.issueTokens(user, applicationKey, ctx); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.login", resourceType: "user", resourceId: user.id, metadata: { applicationKey }, ipAddress: ctx.ipAddress, }); return result; } /** Second step of login when the account has MFA enabled — exchanges a password-verified challenge + TOTP code for real tokens. */ async completeMfaLogin(challengeToken: string, code: string, ctx: RequestContext): Promise { let payload; try { payload = this.tokens.verifyMfaChallenge(challengeToken); } catch { throw new UnauthorizedException("Desafio MFA inválido ou expirado"); } const user = await usersRepo.findUserById(payload.sub); if (!user || !user.is_active || !user.mfa_enabled || !user.mfa_secret_encrypted) { throw new UnauthorizedException("Desafio MFA inválido ou expirado"); } if (!(await this.mfa.verifyCode(user.mfa_secret_encrypted, code))) { await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.mfa_challenge_failed", resourceType: "user", resourceId: user.id, ipAddress: ctx.ipAddress, }); throw new UnauthorizedException("Código inválido"); } const result = await this.issueTokens(user, payload.app, ctx); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.login", resourceType: "user", resourceId: user.id, metadata: { applicationKey: payload.app, viaMfa: true }, ipAddress: ctx.ipAddress, }); return result; } async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise { const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext); const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash); if (!active) { // Replay detection: a token that exists but is revoked/expired being // reused is a signal the refresh token chain may be compromised — // revoke every session of that user defensively (ADR-0003). const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash); if (anyState?.revoked_at) { await sessionsRepo.revokeAllUserSessions(anyState.user_id); await auditRepo.appendAuditEvent({ actorType: "system", actorId: anyState.user_id, action: "auth.refresh_replay_detected", resourceType: "session", resourceId: anyState.id, ipAddress: ctx.ipAddress, }); } throw new UnauthorizedException("Sessão inválida ou expirada"); } const user = await usersRepo.findUserById(active.user_id); if (!user || !user.is_active) { throw new UnauthorizedException("Sessão inválida ou expirada"); } await sessionsRepo.touchSession(active.id); const result = await this.issueTokens(user, active.application_key, ctx, active.id); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.refresh", resourceType: "session", resourceId: active.id, ipAddress: ctx.ipAddress, }); return result; } async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise { const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext); const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash); if (!session) return; // idempotent — already logged out await sessionsRepo.revokeSession(session.id); await auditRepo.appendAuditEvent({ actorType: "user", actorId: session.user_id, action: "auth.logout", resourceType: "session", resourceId: session.id, ipAddress: ctx.ipAddress, }); } async getProfile(userId: string) { const user = await usersRepo.findUserById(userId); if (!user || !user.is_active) throw new UnauthorizedException(); const permissions = await resolvePermissions(user.role_key); const applications = await usersRepo.getUserApplications(user.id); return { user: toPublicUser(user), permissions: permissions.superAdmin ? ("ALL" as const) : permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })), applications, }; } private async issueTokens( user: User, applicationKey: "core" | "reseller" | "subscriber", ctx: RequestContext, replacesSessionId?: string, ): Promise { const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey }); const refresh = this.tokens.generateRefreshToken(); await sessionsRepo.createSession({ userId: user.id, applicationKey, refreshTokenHash: refresh.hash, expiresAt: refresh.expiresAt, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, replacesSessionId, }); return { accessToken: access.token, expiresIn: access.expiresIn, refreshToken: refresh.plaintext, user: toPublicUser(user), }; } /** * Always resolves the same way whether or not the email is registered — * same anti-enumeration discipline as login (eden.md §4.1). If the * account exists (and is active), stages a reset token and emails a * link; the mailer is fail-soft (packages/integrations), so a delivery * failure never surfaces as an error here either. */ async forgotPassword(email: string, ctx: RequestContext): Promise { const user = await usersRepo.findUserByEmail(email); if (!user) return; const plaintextToken = randomBytes(32).toString("hex"); const tokenHash = hashResetToken(plaintextToken); const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); await usersRepo.setResetToken(user.id, tokenHash, expiresAt); const resetUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/reset-password?token=${plaintextToken}`; await sendMail({ to: user.email, subject: "EDEN — Redefinição de senha", html: `

Olá${user.full_name ? `, ${user.full_name}` : ""},

Recebemos um pedido para redefinir sua senha no EDEN. Se foi você, clique no link abaixo (válido por 1 hora):

${resetUrl}

Se você não pediu essa redefinição, pode ignorar este e-mail — sua senha continua a mesma.

`, }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.password_reset_requested", resourceType: "user", resourceId: user.id, ipAddress: ctx.ipAddress, }); } /** * Consumes a reset token issued by forgotPassword(). The token is * cleared whether or not this call succeeds past validation, so a * token can never be replayed — and every existing session is revoked * (a password change invalidates any session that might have been * established under the old, possibly-compromised credential). */ async resetPassword(plaintextToken: string, newPassword: string, ctx: RequestContext): Promise { const tokenHash = hashResetToken(plaintextToken); const user = await usersRepo.findUserByResetTokenHash(tokenHash); if (!user) { throw new BadRequestException("Token inválido ou expirado"); } const { hash, algoVersion } = await this.passwords.hash(newPassword); await usersRepo.updateUserPassword(user.id, hash, algoVersion); await usersRepo.clearResetToken(user.id); await sessionsRepo.revokeAllUserSessions(user.id); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "auth.password_reset_completed", resourceType: "user", resourceId: user.id, ipAddress: ctx.ipAddress, }); } }