diff --git a/.gitignore b/.gitignore index c86452f..d46993b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ build/ .env.local *.pem *.key +CREDENCIAIS-INICIAIS.md # Logs *.log diff --git a/apps/api/package.json b/apps/api/package.json index a29801a..f86f8cb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "@eden/config": "workspace:*", "@eden/database": "workspace:*", "@eden/domain-shared": "workspace:*", + "@eden/integrations": "workspace:*", "@nestjs/common": "^10.4.15", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 4547447..f52c5fd 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index a525441..d715d58 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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, + }); + } } diff --git a/apps/api/src/auth/dto/forgot-password.dto.ts b/apps/api/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..7073e16 --- /dev/null +++ b/apps/api/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from "class-validator"; + +export class ForgotPasswordDto { + @IsEmail() + email!: string; +} diff --git a/apps/api/src/auth/dto/reset-password.dto.ts b/apps/api/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..6854a4b --- /dev/null +++ b/apps/api/src/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 3ef144e..18ed9cc 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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: ` +

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

+

Você foi convidado para acessar o EDEN. Sua senha temporária é:

+

${tempPassword}

+

Acesse ${loginUrl} e troque essa senha assim que possível.

+ `, + }); + return { user: toPublicUser(user), tempPassword }; } diff --git a/apps/api/test/password-reset.spec.ts b/apps/api/test/password-reset.spec.ts new file mode 100644 index 0000000..a51d699 --- /dev/null +++ b/apps/api/test/password-reset.spec.ts @@ -0,0 +1,121 @@ +import { INestApplication } from "@nestjs/common"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agent, + bearer, + closeTestApp, + createTestApp, + loginSuperAdmin, + qaEmail, + type AuthedTokens, +} from "./setup.js"; + +/** + * `sendMail` is mocked (not stubbed-out — the real call path through + * auth.service.ts still runs) so this test can recover the plaintext + * reset token, which by design is NEVER returned in any API response — + * only emailed. This mirrors how a real user would get it: from the + * email body. `packages/integrations`' own mailer is fail-soft when SMTP + * isn't configured (confirmed by the other specs' harmless console + * logs), but that's irrelevant here since we intercept before it runs. + */ +const sendMailMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@eden/integrations", () => ({ + sendMail: (...args: unknown[]) => sendMailMock(...args), +})); + +function extractTokenFromLastEmail(): string { + const lastCall = sendMailMock.mock.calls.at(-1)?.[0] as { html: string } | undefined; + const match = lastCall?.html.match(/token=([a-f0-9]{64})/); + if (!match?.[1]) throw new Error("Reset token not found in the last mocked email"); + return match[1]; +} + +/** + * Protects the password-reset invariants added in auth.service.ts + * (forgotPassword/resetPassword) — anti-enumeration on request, token + * single-use, expiry, and session revocation on completion. + */ +describe("auth — password reset", () => { + let app: INestApplication; + let admin: AuthedTokens; + let fixtureEmail: string; + let fixtureUserId: string; + let fixturePassword: string; + + beforeAll(async () => { + app = await createTestApp(); + admin = await loginSuperAdmin(app); + + fixtureEmail = qaEmail("pwreset"); + const invite = await agent(app) + .post("/api/v1/users/invite") + .set(...bearer(admin.accessToken)) + .send({ email: fixtureEmail, roleKey: "user", applications: ["core"] }) + .expect(201); + fixtureUserId = invite.body.user.id; + fixturePassword = invite.body.tempPassword; + sendMailMock.mockClear(); // discard the invite email capture, not relevant here + }); + + afterAll(async () => { + await agent(app) + .patch(`/api/v1/users/${fixtureUserId}`) + .set(...bearer(admin.accessToken)) + .send({ isActive: false }) + .expect(200); + await closeTestApp(app); + }); + + it("forgot-password returns the same generic response for a registered and an unregistered email", async () => { + const known = await agent(app).post("/api/v1/auth/forgot-password").send({ email: fixtureEmail }).expect(200); + const unknown = await agent(app) + .post("/api/v1/auth/forgot-password") + .send({ email: qaEmail("nobody") }) + .expect(200); + expect(known.body.message).toBe(unknown.body.message); + + // Only the registered email actually triggers a send. + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(sendMailMock.mock.calls[0]?.[0].to).toBe(fixtureEmail); + }); + + it("a bogus token is rejected", async () => { + await agent(app) + .post("/api/v1/auth/reset-password") + .send({ token: "0".repeat(64), newPassword: "brandNewPassword123" }) + .expect(400); + }); + + it("completing the reset changes the password, revokes existing sessions, and the token is single-use", async () => { + // A real active session, established BEFORE the reset — must not survive it. + const sessionBeforeReset = await agent(app) + .post("/api/v1/auth/login") + .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) + .expect(200); + const oldRefreshToken = sessionBeforeReset.body.refreshToken as string; + + sendMailMock.mockClear(); + await agent(app).post("/api/v1/auth/forgot-password").send({ email: fixtureEmail }).expect(200); + const token = extractTokenFromLastEmail(); + + const newPassword = "brandNewPassword123"; + await agent(app).post("/api/v1/auth/reset-password").send({ token, newPassword }).expect(204); + + // Old session is dead — reset revokes every existing session. + await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: oldRefreshToken }).expect(401); + + // Old password no longer works; the new one does. + await agent(app) + .post("/api/v1/auth/login") + .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) + .expect(401); + await agent(app) + .post("/api/v1/auth/login") + .send({ email: fixtureEmail, password: newPassword, applicationKey: "core" }) + .expect(200); + + // The same token cannot be replayed. + await agent(app).post("/api/v1/auth/reset-password").send({ token, newPassword: "yetAnotherPassword456" }).expect(400); + }); +}); diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index 09a6cbc..a486a08 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -53,6 +53,8 @@ import { DevicesListPage } from "./routes/hr-timeclock/DevicesListPage.js"; import { DeviceFormPage } from "./routes/hr-timeclock/DeviceFormPage.js"; import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js"; import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js"; +import { ForgotPasswordPage } from "./routes/ForgotPasswordPage.js"; +import { ResetPasswordPage } from "./routes/ResetPasswordPage.js"; export function App() { return ( @@ -60,6 +62,8 @@ export function App() { } /> + } /> + } /> }> }> } /> diff --git a/apps/core-web/src/lib/api/auth.ts b/apps/core-web/src/lib/api/auth.ts index 5423d87..a3d8177 100644 --- a/apps/core-web/src/lib/api/auth.ts +++ b/apps/core-web/src/lib/api/auth.ts @@ -65,3 +65,23 @@ export async function logout(refreshToken: string): Promise { body: JSON.stringify({ refreshToken }), }); } + +/** + * Always resolves the same way whether or not the email is registered — + * the API returns the same generic message either way (anti-enumeration, + * see auth.service.ts::forgotPassword) — never treat this as confirmation + * the account exists. + */ +export async function forgotPassword(email: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>("/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email }), + }); +} + +export async function resetPassword(token: string, newPassword: string): Promise { + await apiRequest("/auth/reset-password", { + method: "POST", + body: JSON.stringify({ token, newPassword }), + }); +} diff --git a/apps/core-web/src/routes/ForgotPasswordPage.tsx b/apps/core-web/src/routes/ForgotPasswordPage.tsx new file mode 100644 index 0000000..c77a922 --- /dev/null +++ b/apps/core-web/src/routes/ForgotPasswordPage.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Link } from "react-router-dom"; +import { Alert, Button, Card, Input } from "@eden/ui"; +import { ApiError } from "../lib/api/http.js"; +import { forgotPassword } from "../lib/api/auth.js"; + +export function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [sent, setSent] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + // The API always returns the same generic message whether or not the + // email is registered (anti-enumeration) — the UI must not add any + // hint of its own (e.g. never say "e-mail não encontrado"). + await forgotPassword(email); + setSent(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Não foi possível processar o pedido. Tente novamente."); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ EDEN +

Esqueci minha senha

+

+ Informe seu e-mail — se estiver cadastrado, você receberá um link para redefinir sua senha. +

+
+ + {error ? ( + + {error} + + ) : null} + + {sent ? ( + + Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha em instantes. + + ) : ( +
+ setEmail(e.target.value)} + required + autoFocus + /> + +
+ )} + + + Voltar para o login + +
+
+ ); +} diff --git a/apps/core-web/src/routes/LoginPage.tsx b/apps/core-web/src/routes/LoginPage.tsx index aa32dca..d85885d 100644 --- a/apps/core-web/src/routes/LoginPage.tsx +++ b/apps/core-web/src/routes/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { FormEvent } from "react"; -import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { Alert, Button, Card, Input } from "@eden/ui"; import { ApiError } from "../lib/api/http.js"; import { useAuth } from "../lib/auth/AuthContext.js"; @@ -17,6 +17,8 @@ export function LoginPage() { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const passwordResetDone = (location.state as { passwordResetDone?: boolean } | null)?.passwordResetDone ?? false; + if (status === "authenticated") { const redirectTo = (location.state as { from?: string } | null)?.from ?? "/"; return ; @@ -74,6 +76,12 @@ export function LoginPage() { ) : null} + {passwordResetDone && !error ? ( + + Senha redefinida com sucesso. Entre com sua nova senha. + + ) : null} + {mfaChallenge ? (
Entrar + + Esqueci minha senha +
)} diff --git a/apps/core-web/src/routes/ResetPasswordPage.tsx b/apps/core-web/src/routes/ResetPasswordPage.tsx new file mode 100644 index 0000000..09063a4 --- /dev/null +++ b/apps/core-web/src/routes/ResetPasswordPage.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Alert, Button, Card, Input } from "@eden/ui"; +import { ApiError } from "../lib/api/http.js"; +import { resetPassword } from "../lib/api/auth.js"; + +export function ResetPasswordPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get("token"); + + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!token) return; + setError(null); + + if (newPassword !== confirmPassword) { + setError("As senhas não coincidem."); + return; + } + + setSubmitting(true); + try { + await resetPassword(token, newPassword); + navigate("/login", { replace: true, state: { passwordResetDone: true } }); + } catch (err) { + setError( + err instanceof ApiError + ? err.message + : "Não foi possível redefinir sua senha. O link pode ter expirado — solicite um novo.", + ); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ EDEN +

Redefinir senha

+

Escolha uma nova senha para sua conta.

+
+ + {!token ? ( + + Link inválido — falta o token de redefinição. Solicite um novo link. + + ) : ( + <> + {error ? ( + + {error} + + ) : null} +
+ setNewPassword(e.target.value)} + hint="Mínimo de 8 caracteres" + minLength={8} + required + autoFocus + /> + setConfirmPassword(e.target.value)} + minLength={8} + required + /> + +
+ + )} + + + Voltar para o login + +
+
+ ); +} diff --git a/docs/progress.md b/docs/progress.md index 33b4ae9..39288ba 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -430,3 +430,26 @@ Ambos os agentes reconciliados manualmente em `App.tsx`/`AuthenticatedLayout.tsx ### Estado do EDEN Core ao final desta sequência UI real para: Empresas, Produtos (+ perfil fiscal), Revendas, CRM (Leads/Oportunidades), Ofertas, Cadastro de Cliente, Contratos, Estoque, Faturamento, Suporte, Fiscal, Documentos (fundação), Ponto Eletrônico (fundação). Cobertura completa de todo o backend construído nesta sessão, exceto Assinatura Eletrônica (deliberadamente não construída — módulo próprio, ainda não existe no backend) e apuração/fechamento de ponto (idem). `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela de negócio própria ainda. + +--- + +## E-mail transacional: convite de usuário + recuperação de senha + +Data: 2026-09-04. Usuário perguntou onde configurar as credenciais de e-mail (`noreply@handix.com.br`) — confirmado que é via `.env` (`SMTP_HOST/PORT/USER/PASS/FROM`, já existiam desde a Fase 1, sem consumidor real). Pedido em seguida: ligar o envio de verdade e construir recuperação de senha na tela de login. + +### Backend (`apps/api/src/auth/`, `apps/api/src/users/`) — implementado diretamente (fluxo de autenticação, não delegado) + +- `@eden/integrations` adicionado como dependência real de `apps/api` (só existia como biblioteca solta até agora). +- `UsersService.invite()` agora envia e-mail de verdade com a senha temporária (mantendo o retorno na resposta da API também — dupla entrega, eden.md §4.8) via o adapter SMTP existente (`packages/integrations/src/smtp/mailer.ts`, já era fail-soft: nunca quebra o fluxo de convite se o SMTP falhar ou não estiver configurado). +- Novo fluxo de recuperação de senha: `POST /v1/auth/forgot-password` (`{email}` → sempre a mesma resposta genérica, cadastrado ou não — mesma disciplina anti-enumeração do login) e `POST /v1/auth/reset-password` (`{token, newPassword}`). Token de 256 bits, só o hash SHA-256 é persistido (`users.reset_token_hash`/`reset_token_expires_at`, colunas que já existiam desde a migration baseline da Fase 1 mas nunca tinham sido usadas), validade de 1h, uso único (limpo após consumido), e **revoga todas as sessões ativas do usuário** ao completar a redefinição (senha mudou → toda sessão anterior morre). + +### Frontend (`apps/core-web`) + +Link "Esqueci minha senha" no `LoginPage`, nova `ForgotPasswordPage` (mensagem genérica de sucesso, nunca confirma se o e-mail existe) e `ResetPasswordPage` (token vem da query string do link do e-mail, confirmação de senha, redireciona pro login com banner de sucesso ao concluir). + +### Verificação + +Teste de integração novo (`test/password-reset.spec.ts`, 3 testes) — usa `vi.mock` para interceptar `sendMail` e extrair o token real do corpo do e-mail (a única forma de testar o caminho feliz de ponta a ponta, já que o token nunca é devolvido na resposta da API, só por e-mail): confirma resposta genérica idêntica para e-mail cadastrado/não-cadastrado, token bogus rejeitado, e o ciclo completo — sessão anterior revogada, senha antiga para de funcionar, nova senha funciona, token não pode ser reaproveitado. Suíte completa: 33/33 verdes (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), telas novas confirmadas no bundle de produção. + +### Pendências +SMTP ainda não configurado no `.env` real (aguardando credenciais do usuário) — o fluxo todo já funciona corretamente sem SMTP configurado (fail-soft), só não envia e-mail de verdade ainda. Sem rate-limiting no `forgot-password` (mesma lacuna geral já registrada para outros endpoints de auth). diff --git a/packages/database/src/repositories/users.ts b/packages/database/src/repositories/users.ts index aa7cd1b..5fbd8f1 100644 --- a/packages/database/src/repositories/users.ts +++ b/packages/database/src/repositories/users.ts @@ -164,3 +164,33 @@ export async function disableMfa(userId: string): Promise { userId, ]); } + +/** + * Stages a password-reset token — only the SHA-256 hash is stored (same + * pattern as sessions.refresh_token_hash), never the plaintext. A new + * request overwrites any previous token for the user (only one active + * reset token at a time — no separate token table needed for this MVP). + */ +export async function setResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise { + await query("UPDATE users SET reset_token_hash = $2, reset_token_expires_at = $3 WHERE id = $1", [ + userId, + tokenHash, + expiresAt, + ]); +} + +/** Only matches a token that hasn't expired — an expired-but-still-stored hash is treated as no match. */ +export async function findUserByResetTokenHash(tokenHash: string): Promise { + const { rows } = await query( + "SELECT * FROM users WHERE reset_token_hash = $1 AND reset_token_expires_at > now() AND is_active = true", + [tokenHash], + ); + return rows[0] ?? null; +} + +/** Called after a successful reset (or to invalidate a stale request) — a used/expired token must never be consumable twice. */ +export async function clearResetToken(userId: string): Promise { + await query("UPDATE users SET reset_token_hash = null, reset_token_expires_at = null WHERE id = $1", [ + userId, + ]); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4233045..42c8d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@eden/domain-shared': specifier: workspace:* version: link:../../packages/domain-shared + '@eden/integrations': + specifier: workspace:* + version: link:../../packages/integrations '@nestjs/common': specifier: ^10.4.15 version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)