From 5fc265f3dc1c215123f6d3533798641f9dc04c61 Mon Sep 17 00:00:00 2001 From: Antonio Andre Date: Tue, 14 Apr 2026 20:31:19 -0500 Subject: [PATCH] feat(auth): implementa logica de recuperacao e mudanca de senha --- .env.example | 2 + nuxt.config.ts | 4 +- .../migration.sql | 24 +++ prisma/schema.prisma | 16 ++ server/api/auth/forgot-password.post.ts | 9 + server/api/auth/reset-password.post.ts | 8 + server/routes/auth/forgot-password.post.ts | 3 + server/routes/auth/reset-password.post.ts | 3 + server/utils/auth-config.ts | 12 +- server/utils/auth-service.ts | 183 ++++++++++++++++++ server/utils/password-reset-token.ts | 39 ++++ 11 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260414193000_password_reset_tokens/migration.sql create mode 100644 server/api/auth/forgot-password.post.ts create mode 100644 server/api/auth/reset-password.post.ts create mode 100644 server/routes/auth/forgot-password.post.ts create mode 100644 server/routes/auth/reset-password.post.ts create mode 100644 server/utils/password-reset-token.ts diff --git a/.env.example b/.env.example index 2519213..c26d80e 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,5 @@ JWT_PUBLIC_KEY_PEM="" # Optional hardening REFRESH_TOKEN_PEPPER="change-me" +PASSWORD_RESET_TTL_SEC="900" +PASSWORD_RESET_TOKEN_PEPPER="change-me" diff --git a/nuxt.config.ts b/nuxt.config.ts index 023f1e9..94d6ee3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -12,7 +12,9 @@ export default defineNuxtConfig({ jwtPrivateKeyPem: process.env.JWT_PRIVATE_KEY_PEM ?? '', jwtPublicKeyPem: process.env.JWT_PUBLIC_KEY_PEM ?? '', jwtKid: process.env.JWT_KID ?? 'auth-key-1', - refreshTokenPepper: process.env.REFRESH_TOKEN_PEPPER ?? '' + refreshTokenPepper: process.env.REFRESH_TOKEN_PEPPER ?? '', + passwordResetTtlSec: process.env.PASSWORD_RESET_TTL_SEC ?? '900', + passwordResetTokenPepper: process.env.PASSWORD_RESET_TOKEN_PEPPER ?? '' }, vite: { optimizeDeps: { diff --git a/prisma/migrations/20260414193000_password_reset_tokens/migration.sql b/prisma/migrations/20260414193000_password_reset_tokens/migration.sql new file mode 100644 index 0000000..3642228 --- /dev/null +++ b/prisma/migrations/20260414193000_password_reset_tokens/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "public"."PasswordResetToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "public"."PasswordResetToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_userId_idx" ON "public"."PasswordResetToken"("userId"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "public"."PasswordResetToken"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "public"."PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9040c19..6089490 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt refreshTokens RefreshToken[] + passwordResetTokens PasswordResetToken[] @@index([email]) } @@ -36,3 +37,18 @@ model RefreshToken { @@index([userId]) @@index([expiresAt]) } + +model PasswordResetToken { + id String @id @default(uuid()) + userId String + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) +} diff --git a/server/api/auth/forgot-password.post.ts b/server/api/auth/forgot-password.post.ts new file mode 100644 index 0000000..9cc6546 --- /dev/null +++ b/server/api/auth/forgot-password.post.ts @@ -0,0 +1,9 @@ +import { handleForgotPassword } from '../../utils/auth-service' + +/** + * Endpoint para iniciar recuperação de senha. + * Retorna resposta genérica e dados de recuperação para ambiente didático. + */ +export default defineEventHandler(async (event) => { + return handleForgotPassword(event) +}) diff --git a/server/api/auth/reset-password.post.ts b/server/api/auth/reset-password.post.ts new file mode 100644 index 0000000..fbc60d9 --- /dev/null +++ b/server/api/auth/reset-password.post.ts @@ -0,0 +1,8 @@ +import { handleResetPassword } from '../../utils/auth-service' + +/** + * Endpoint para finalizar recuperação de senha com token válido. + */ +export default defineEventHandler(async (event) => { + return handleResetPassword(event) +}) diff --git a/server/routes/auth/forgot-password.post.ts b/server/routes/auth/forgot-password.post.ts new file mode 100644 index 0000000..ba7bcc5 --- /dev/null +++ b/server/routes/auth/forgot-password.post.ts @@ -0,0 +1,3 @@ +import forgotPasswordHandler from '../../api/auth/forgot-password.post' + +export default forgotPasswordHandler diff --git a/server/routes/auth/reset-password.post.ts b/server/routes/auth/reset-password.post.ts new file mode 100644 index 0000000..0c60aef --- /dev/null +++ b/server/routes/auth/reset-password.post.ts @@ -0,0 +1,3 @@ +import resetPasswordHandler from '../../api/auth/reset-password.post' + +export default resetPasswordHandler diff --git a/server/utils/auth-config.ts b/server/utils/auth-config.ts index 1c4cce6..d0b258a 100644 --- a/server/utils/auth-config.ts +++ b/server/utils/auth-config.ts @@ -5,10 +5,12 @@ export interface AuthRuntimeConfig { audience: string accessTtlSec: number refreshTtlSec: number + passwordResetTtlSec: number privateKeyPem: string publicKeyPem: string kid: string refreshTokenPepper: string + passwordResetTokenPepper: string } /** @@ -58,7 +60,7 @@ function normalizePem(rawValue: string, label: string): string { * Lê e valida todas as configurações obrigatórias de autenticação. * * @param event Evento da requisição (opcional), usado para acessar runtime config. - * @returns Objeto com configurações de JWT e refresh token já validadas. + * @returns Objeto com configurações de JWT, refresh token e recuperação de senha já validadas. * @throws {H3Error} Quando algum campo obrigatório estiver ausente ou inválido. */ export function getAuthRuntimeConfig(event?: H3Event): AuthRuntimeConfig { @@ -80,14 +82,20 @@ export function getAuthRuntimeConfig(event?: H3Event): AuthRuntimeConfig { throw createError({ statusCode: 500, statusMessage: 'JWT kid is required' }) } + const refreshTokenPepper = String(runtimeConfig.refreshTokenPepper ?? '').trim() + const passwordResetTokenPepper = + String(runtimeConfig.passwordResetTokenPepper ?? '').trim() || refreshTokenPepper + return { issuer, audience, kid, accessTtlSec: parsePositiveInt(String(runtimeConfig.jwtAccessTtlSec), 'JWT access TTL'), refreshTtlSec: parsePositiveInt(String(runtimeConfig.jwtRefreshTtlSec), 'JWT refresh TTL'), + passwordResetTtlSec: parsePositiveInt(String(runtimeConfig.passwordResetTtlSec), 'Password reset TTL'), privateKeyPem: normalizePem(String(runtimeConfig.jwtPrivateKeyPem ?? ''), 'JWT private key'), publicKeyPem: normalizePem(String(runtimeConfig.jwtPublicKeyPem ?? ''), 'JWT public key'), - refreshTokenPepper: String(runtimeConfig.refreshTokenPepper ?? '') + refreshTokenPepper, + passwordResetTokenPepper } } diff --git a/server/utils/auth-service.ts b/server/utils/auth-service.ts index c8318b0..76832d5 100644 --- a/server/utils/auth-service.ts +++ b/server/utils/auth-service.ts @@ -4,6 +4,11 @@ import { createError, readBody, setResponseStatus, type H3Event } from 'h3' import { signAccessToken } from './jwt' import { getAuthRuntimeConfig } from './auth-config' import { hashPassword, verifyPassword } from './password' +import { + buildPasswordResetPreviewUrl, + generateRawPasswordResetToken, + hashPasswordResetToken +} from './password-reset-token' import { prisma } from './prisma' import { issueRefreshToken, rotateRefreshToken } from './refresh-token' @@ -21,6 +26,15 @@ interface RegisterBody { password?: unknown } +interface ForgotPasswordBody { + email?: unknown +} + +interface ResetPasswordBody { + token?: unknown + new_password?: unknown +} + /** * Valida e normaliza email/senha enviados no login. * @@ -59,6 +73,51 @@ function parseRegisterBody(body: RegisterBody) { return { email, password } } +/** + * Valida o payload de solicitação da recuperação de senha. + * + * @param body Corpo bruto da requisição de forgot password. + * @returns Email normalizado para busca do usuário. + * @throws {H3Error} Quando o email não for informado. + */ +function parseForgotPasswordBody(body: ForgotPasswordBody): string { + const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '' + + if (!email) { + throw createError({ statusCode: 400, statusMessage: 'email is required' }) + } + + return email +} + +/** + * Valida token e nova senha no fluxo de redefinição. + * + * @param body Corpo bruto da requisição de reset. + * @returns Token de recuperação e nova senha. + * @throws {H3Error} Quando dados obrigatórios faltarem ou senha for curta. + */ +function parseResetPasswordBody(body: ResetPasswordBody) { + const token = typeof body.token === 'string' ? body.token.trim() : '' + const newPassword = typeof body.new_password === 'string' ? body.new_password : '' + + if (!token || !newPassword) { + throw createError({ + statusCode: 400, + statusMessage: 'token and new_password are required' + }) + } + + if (newPassword.length < 6) { + throw createError({ + statusCode: 400, + statusMessage: 'password must have at least 6 characters' + }) + } + + return { token, newPassword } +} + /** * Valida o refresh token enviado no corpo da requisição. * @@ -127,6 +186,130 @@ export async function handleRegister(event: H3Event) { } } +/** + * Inicia a recuperação de senha e retorna preview didático do token/link. + * A resposta é sempre genérica para não expor se o email existe. + * + * @param event Evento HTTP da requisição. + * @returns Mensagem genérica e dados de recuperação para testes. + * @throws {H3Error} Quando o email não for informado. + */ +export async function handleForgotPassword(event: H3Event) { + const config = getAuthRuntimeConfig(event) + const body = await readBody(event) + const email = parseForgotPasswordBody(body ?? {}) + const now = new Date() + const expiresAt = new Date(now.getTime() + config.passwordResetTtlSec * 1000) + const rawResetToken = generateRawPasswordResetToken() + const tokenHash = hashPasswordResetToken(rawResetToken, config.passwordResetTokenPepper) + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true } + }) + + if (user) { + await prisma.$transaction([ + prisma.passwordResetToken.updateMany({ + where: { + userId: user.id, + usedAt: null + }, + data: { + usedAt: now + } + }), + prisma.passwordResetToken.create({ + data: { + userId: user.id, + tokenHash, + expiresAt + } + }) + ]) + } + + return { + message: 'If the email exists, recovery instructions were generated', + recovery: { + reset_token: rawResetToken, + reset_url: buildPasswordResetPreviewUrl(config.issuer, rawResetToken), + expires_in: config.passwordResetTtlSec + } + } +} + +/** + * Finaliza a recuperação de senha com token de uso único. + * Também revoga todos os refresh tokens ativos do usuário. + * + * @param event Evento HTTP da requisição. + * @returns Mensagem de sucesso após redefinir senha. + * @throws {H3Error} Quando token for inválido/expirado/usado ou payload inválido. + */ +export async function handleResetPassword(event: H3Event) { + const config = getAuthRuntimeConfig(event) + const body = await readBody(event) + const { token, newPassword } = parseResetPasswordBody(body ?? {}) + const tokenHash = hashPasswordResetToken(token, config.passwordResetTokenPepper) + + const existingToken = await prisma.passwordResetToken.findUnique({ + where: { tokenHash }, + select: { + id: true, + userId: true, + expiresAt: true, + usedAt: true + } + }) + + if (!existingToken || existingToken.usedAt || existingToken.expiresAt.getTime() <= Date.now()) { + throw createError({ statusCode: 401, statusMessage: 'Invalid or expired reset token' }) + } + + const now = new Date() + + await prisma.$transaction(async (tx) => { + const consumeResult = await tx.passwordResetToken.updateMany({ + where: { + id: existingToken.id, + usedAt: null, + expiresAt: { + gt: now + } + }, + data: { + usedAt: now + } + }) + + if (consumeResult.count !== 1) { + throw createError({ statusCode: 401, statusMessage: 'Invalid or expired reset token' }) + } + + await tx.user.update({ + where: { id: existingToken.userId }, + data: { + passwordHash: hashPassword(newPassword) + } + }) + + await tx.refreshToken.updateMany({ + where: { + userId: existingToken.userId, + revokedAt: null + }, + data: { + revokedAt: now + } + }) + }) + + return { + message: 'Password updated successfully' + } +} + /** * Executa o fluxo de login: * valida credenciais, gera access token e emite refresh token. diff --git a/server/utils/password-reset-token.ts b/server/utils/password-reset-token.ts new file mode 100644 index 0000000..460f1af --- /dev/null +++ b/server/utils/password-reset-token.ts @@ -0,0 +1,39 @@ +import { createHash, randomBytes } from 'node:crypto' + +/** + * Gera hash seguro para token de recuperação usando pepper do servidor. + * + * @param rawToken Token bruto entregue ao cliente. + * @param pepper Segredo adicional do ambiente. + * @returns Hash SHA-256 do token. + */ +export function hashPasswordResetToken(rawToken: string, pepper: string): string { + return createHash('sha256').update(`${rawToken}${pepper}`).digest('hex') +} + +/** + * Gera token aleatório para o fluxo de recuperação de senha. + * + * @returns Token em formato URL-safe. + */ +export function generateRawPasswordResetToken(): string { + return randomBytes(32).toString('base64url') +} + +/** + * Monta uma URL de preview para facilitar testes locais sem SMTP. + * + * @param issuer Base do serviço de auth. + * @param token Token bruto de recuperação. + * @returns URL completa (ou fallback relativo) com o token. + */ +export function buildPasswordResetPreviewUrl(issuer: string, token: string): string { + try { + const url = new URL('/auth/reset-password', issuer) + url.searchParams.set('token', token) + + return url.toString() + } catch { + return `/auth/reset-password?token=${encodeURIComponent(token)}` + } +}