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>
324 lines
11 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|