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>
This commit is contained in:
2026-09-04 08:02:33 -03:00
parent 012c00fa4d
commit 4d23af3b23
16 changed files with 525 additions and 8 deletions

View File

@@ -7,6 +7,8 @@ import { LoginDto } from "./dto/login.dto.js";
import { RefreshDto } from "./dto/refresh.dto.js";
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
import { MfaCodeDto } from "./dto/mfa-code.dto.js";
import { ForgotPasswordDto } from "./dto/forgot-password.dto.js";
import { ResetPasswordDto } from "./dto/reset-password.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
@@ -61,6 +63,21 @@ export class AuthController {
await this.mfaService.disable(user.id, dto.code);
}
@Post("forgot-password")
@HttpCode(200)
async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
await this.auth.forgotPassword(dto.email, requestContext(req));
// Always the same response regardless of whether the email is
// registered — anti-enumeration (eden.md §4.1), mirrors login.
return { message: "Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha." };
}
@Post("reset-password")
@HttpCode(204)
async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
await this.auth.resetPassword(dto.token, dto.newPassword, requestContext(req));
}
@Post("refresh")
@HttpCode(200)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {

View File

@@ -1,5 +1,9 @@
import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
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";
@@ -37,6 +41,11 @@ function toPublicUser(user: User): PublicUser {
}
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 {
@@ -44,6 +53,7 @@ export class AuthService {
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly mfa: MfaService,
@Inject(EDEN_ENV) private readonly env: EdenEnv,
) {}
async login(
@@ -243,4 +253,71 @@ export class AuthService {
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,
});
}
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class ForgotPasswordDto {
@IsEmail()
email!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from "class-validator";
export class ResetPasswordDto {
@IsString()
token!: string;
@IsString()
@MinLength(8, { message: "A nova senha precisa ter ao menos 8 caracteres" })
newPassword!: string;
}

View File

@@ -1,6 +1,9 @@
import { randomBytes } from "node:crypto";
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, NotFoundException, 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 "../auth/password.service.js";
import { assertCanAssignRole, assertCanActOnUser } from "../auth/role-weight.js";
import type { InviteUserDto } from "./dto/invite-user.dto.js";
@@ -25,7 +28,10 @@ function generateTempPassword(): string {
@Injectable()
export class UsersService {
constructor(private readonly passwords: PasswordService) {}
constructor(
private readonly passwords: PasswordService,
@Inject(EDEN_ENV) private readonly env: EdenEnv,
) {}
list() {
return usersRepo.listUsers().then((users) => users.map(toPublicUser));
@@ -61,10 +67,22 @@ export class UsersService {
metadata: { roleKey: dto.roleKey, applications },
});
// Email delivery (packages/integrations SMTP adapter) is wired once that
// module lands — until then, the temp password is only ever returned in
// this response (same dual-delivery fallback philosophy as the legacy,
// eden.md §4.8), never logged.
// Dual delivery (eden.md §4.8): email the temp password AND still
// return it in the response, so the admin can relay it manually if
// delivery fails or SMTP isn't configured (sendMail is fail-soft —
// packages/integrations — never throws into this flow).
const loginUrl = this.env.PUBLIC_BASE_URL ?? "http://localhost:3001";
await sendMail({
to: user.email,
subject: "EDEN — Você foi convidado",
html: `
<p>Olá${user.full_name ? `, ${user.full_name}` : ""},</p>
<p>Você foi convidado para acessar o EDEN. Sua senha temporária é:</p>
<p><strong>${tempPassword}</strong></p>
<p>Acesse <a href="${loginUrl}">${loginUrl}</a> e troque essa senha assim que possível.</p>
`,
});
return { user: toPublicUser(user), tempPassword };
}