Files
eden/apps/api/src/auth/auth.service.ts
Matheus (Handix) 4d23af3b23 feat(auth): e-mail transacional real + recuperação de senha
Liga o adapter SMTP existente (packages/integrations, já era fail-soft)
a um consumidor de verdade e adiciona o fluxo completo de recuperação
de senha, faltante desde a Fase 1.

- UsersService.invite() agora envia e-mail com a senha temporária
  (mantém o retorno na resposta também — dupla entrega, eden.md §4.8).
- POST /v1/auth/forgot-password + POST /v1/auth/reset-password: token
  de 256 bits, só o hash SHA-256 persistido (users.reset_token_hash/
  reset_token_expires_at — colunas que já existiam desde a baseline da
  Fase 1, nunca usadas até agora), validade de 1h, uso único, revoga
  todas as sessões ativas do usuário ao concluir. Mesma disciplina
  anti-enumeração do login: resposta idêntica para e-mail cadastrado
  ou não.
- Frontend: link "Esqueci minha senha" no LoginPage, novas
  ForgotPasswordPage/ResetPasswordPage.

Teste de integração novo (password-reset.spec.ts, 3 testes) usa
vi.mock para interceptar sendMail e recuperar o token real do corpo do
e-mail — única forma de testar o caminho feliz de ponta a ponta, já
que o token nunca é devolvido na resposta da API. Suíte completa:
33/33 (30→33). pnpm turbo run typecheck build (28/28). Testado ao vivo
através do proxy same-origin: anti-enumeração confirmada com curl
real, token gravado no Postgres, token bogus rejeitado (400).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:02:33 -03:00

324 lines
11 KiB
TypeScript

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<LoginResult> {
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<AuthResult> {
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<AuthResult> {
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<void> {
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<AuthResult> {
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<void> {
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: `
<p>Olá${user.full_name ? `, ${user.full_name}` : ""},</p>
<p>Recebemos um pedido para redefinir sua senha no EDEN. Se foi você, clique no link abaixo (válido por 1 hora):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>Se você não pediu essa redefinição, pode ignorar este e-mail — sua senha continua a mesma.</p>
`,
});
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<void> {
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,
});
}
}