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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ build/
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
CREDENCIAIS-INICIAIS.md
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@eden/config": "workspace:*",
|
"@eden/config": "workspace:*",
|
||||||
"@eden/database": "workspace:*",
|
"@eden/database": "workspace:*",
|
||||||
"@eden/domain-shared": "workspace:*",
|
"@eden/domain-shared": "workspace:*",
|
||||||
|
"@eden/integrations": "workspace:*",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { LoginDto } from "./dto/login.dto.js";
|
|||||||
import { RefreshDto } from "./dto/refresh.dto.js";
|
import { RefreshDto } from "./dto/refresh.dto.js";
|
||||||
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
|
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
|
||||||
import { MfaCodeDto } from "./dto/mfa-code.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 { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||||
import { CurrentUser } from "./decorators/current-user.decorator.js";
|
import { CurrentUser } from "./decorators/current-user.decorator.js";
|
||||||
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
|
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
|
||||||
@@ -61,6 +63,21 @@ export class AuthController {
|
|||||||
await this.mfaService.disable(user.id, dto.code);
|
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")
|
@Post("refresh")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||||
|
|||||||
@@ -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 { 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 { PasswordService } from "./password.service.js";
|
||||||
import { TokenService } from "./token.service.js";
|
import { TokenService } from "./token.service.js";
|
||||||
import { MfaService } from "./mfa.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 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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -44,6 +53,7 @@ export class AuthService {
|
|||||||
private readonly passwords: PasswordService,
|
private readonly passwords: PasswordService,
|
||||||
private readonly tokens: TokenService,
|
private readonly tokens: TokenService,
|
||||||
private readonly mfa: MfaService,
|
private readonly mfa: MfaService,
|
||||||
|
@Inject(EDEN_ENV) private readonly env: EdenEnv,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(
|
async login(
|
||||||
@@ -243,4 +253,71 @@ export class AuthService {
|
|||||||
user: toPublicUser(user),
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsEmail } from "class-validator";
|
||||||
|
|
||||||
|
export class ForgotPasswordDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
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 { 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 { PasswordService } from "../auth/password.service.js";
|
||||||
import { assertCanAssignRole, assertCanActOnUser } from "../auth/role-weight.js";
|
import { assertCanAssignRole, assertCanActOnUser } from "../auth/role-weight.js";
|
||||||
import type { InviteUserDto } from "./dto/invite-user.dto.js";
|
import type { InviteUserDto } from "./dto/invite-user.dto.js";
|
||||||
@@ -25,7 +28,10 @@ function generateTempPassword(): string {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly passwords: PasswordService) {}
|
constructor(
|
||||||
|
private readonly passwords: PasswordService,
|
||||||
|
@Inject(EDEN_ENV) private readonly env: EdenEnv,
|
||||||
|
) {}
|
||||||
|
|
||||||
list() {
|
list() {
|
||||||
return usersRepo.listUsers().then((users) => users.map(toPublicUser));
|
return usersRepo.listUsers().then((users) => users.map(toPublicUser));
|
||||||
@@ -61,10 +67,22 @@ export class UsersService {
|
|||||||
metadata: { roleKey: dto.roleKey, applications },
|
metadata: { roleKey: dto.roleKey, applications },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Email delivery (packages/integrations SMTP adapter) is wired once that
|
// Dual delivery (eden.md §4.8): email the temp password AND still
|
||||||
// module lands — until then, the temp password is only ever returned in
|
// return it in the response, so the admin can relay it manually if
|
||||||
// this response (same dual-delivery fallback philosophy as the legacy,
|
// delivery fails or SMTP isn't configured (sendMail is fail-soft —
|
||||||
// eden.md §4.8), never logged.
|
// 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 };
|
return { user: toPublicUser(user), tempPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
apps/api/test/password-reset.spec.ts
Normal file
121
apps/api/test/password-reset.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,8 @@ import { DevicesListPage } from "./routes/hr-timeclock/DevicesListPage.js";
|
|||||||
import { DeviceFormPage } from "./routes/hr-timeclock/DeviceFormPage.js";
|
import { DeviceFormPage } from "./routes/hr-timeclock/DeviceFormPage.js";
|
||||||
import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
|
import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
|
||||||
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
|
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
|
||||||
|
import { ForgotPasswordPage } from "./routes/ForgotPasswordPage.js";
|
||||||
|
import { ResetPasswordPage } from "./routes/ResetPasswordPage.js";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +62,8 @@ export function App() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<AuthenticatedLayout />}>
|
<Route element={<AuthenticatedLayout />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
|||||||
@@ -65,3 +65,23 @@ export async function logout(refreshToken: string): Promise<void> {
|
|||||||
body: JSON.stringify({ refreshToken }),
|
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<void> {
|
||||||
|
await apiRequest<void>("/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token, newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
75
apps/core-web/src/routes/ForgotPasswordPage.tsx
Normal file
75
apps/core-web/src/routes/ForgotPasswordPage.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-light p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
|
||||||
|
<h1 className="!text-xl font-bold text-title">Esqueci minha senha</h1>
|
||||||
|
<p className="mt-1 text-sm text-default">
|
||||||
|
Informe seu e-mail — se estiver cadastrado, você receberá um link para redefinir sua senha.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha em instantes.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/login" className="mt-4 block text-center text-xs text-default hover:text-primary">
|
||||||
|
Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { FormEvent } 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 { Alert, Button, Card, Input } from "@eden/ui";
|
||||||
import { ApiError } from "../lib/api/http.js";
|
import { ApiError } from "../lib/api/http.js";
|
||||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||||
@@ -17,6 +17,8 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const passwordResetDone = (location.state as { passwordResetDone?: boolean } | null)?.passwordResetDone ?? false;
|
||||||
|
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
||||||
return <Navigate to={redirectTo} replace />;
|
return <Navigate to={redirectTo} replace />;
|
||||||
@@ -74,6 +76,12 @@ export function LoginPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{passwordResetDone && !error ? (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
Senha redefinida com sucesso. Entre com sua nova senha.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{mfaChallenge ? (
|
{mfaChallenge ? (
|
||||||
<form className="space-y-4" onSubmit={handleMfaSubmit}>
|
<form className="space-y-4" onSubmit={handleMfaSubmit}>
|
||||||
<Input
|
<Input
|
||||||
@@ -124,6 +132,12 @@ export function LoginPage() {
|
|||||||
<Button type="submit" className="w-full" loading={submitting}>
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="block text-center text-xs text-default hover:text-primary"
|
||||||
|
>
|
||||||
|
Esqueci minha senha
|
||||||
|
</Link>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
97
apps/core-web/src/routes/ResetPasswordPage.tsx
Normal file
97
apps/core-web/src/routes/ResetPasswordPage.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-light p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
|
||||||
|
<h1 className="!text-xl font-bold text-title">Redefinir senha</h1>
|
||||||
|
<p className="mt-1 text-sm text-default">Escolha uma nova senha para sua conta.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!token ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
Link inválido — falta o token de redefinição. Solicite um novo link.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
label="Nova senha"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
hint="Mínimo de 8 caracteres"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirmar nova senha"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
|
Redefinir senha
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/login" className="mt-4 block text-center text-xs text-default hover:text-primary">
|
||||||
|
Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -430,3 +430,26 @@ Ambos os agentes reconciliados manualmente em `App.tsx`/`AuthenticatedLayout.tsx
|
|||||||
### Estado do EDEN Core ao final desta sequência
|
### 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.
|
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).
|
||||||
|
|||||||
@@ -164,3 +164,33 @@ export async function disableMfa(userId: string): Promise<void> {
|
|||||||
userId,
|
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<void> {
|
||||||
|
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<User | null> {
|
||||||
|
const { rows } = await query<User>(
|
||||||
|
"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<void> {
|
||||||
|
await query("UPDATE users SET reset_token_hash = null, reset_token_expires_at = null WHERE id = $1", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@eden/domain-shared':
|
'@eden/domain-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/domain-shared
|
version: link:../../packages/domain-shared
|
||||||
|
'@eden/integrations':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/integrations
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.15
|
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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user