From 11dc1266a2a5ab10dae220b8b936d31eb848de8b Mon Sep 17 00:00:00 2001 From: Antonio Andre Date: Tue, 14 Apr 2026 19:59:00 -0500 Subject: [PATCH 1/3] feat(auth): implementa endpoint de registro de usuario --- server/api/auth/register.post.ts | 9 ++++ server/routes/auth/register.post.ts | 3 ++ server/utils/auth-service.ts | 81 ++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 server/api/auth/register.post.ts create mode 100644 server/routes/auth/register.post.ts diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts new file mode 100644 index 0000000..a87c7e9 --- /dev/null +++ b/server/api/auth/register.post.ts @@ -0,0 +1,9 @@ +import { handleRegister } from '../../utils/auth-service' + +/** + * Endpoint de cadastro público do Auth Service. + * Cria usuário com email único e não gera tokens neste fluxo. + */ +export default defineEventHandler(async (event) => { + return handleRegister(event) +}) diff --git a/server/routes/auth/register.post.ts b/server/routes/auth/register.post.ts new file mode 100644 index 0000000..d1406f8 --- /dev/null +++ b/server/routes/auth/register.post.ts @@ -0,0 +1,3 @@ +import registerHandler from '../../api/auth/register.post' + +export default registerHandler diff --git a/server/utils/auth-service.ts b/server/utils/auth-service.ts index 2750eac..c8318b0 100644 --- a/server/utils/auth-service.ts +++ b/server/utils/auth-service.ts @@ -1,8 +1,9 @@ -import { createError, readBody, type H3Event } from 'h3' +import { Prisma } from '@prisma/client' +import { createError, readBody, setResponseStatus, type H3Event } from 'h3' import { signAccessToken } from './jwt' import { getAuthRuntimeConfig } from './auth-config' -import { verifyPassword } from './password' +import { hashPassword, verifyPassword } from './password' import { prisma } from './prisma' import { issueRefreshToken, rotateRefreshToken } from './refresh-token' @@ -15,6 +16,11 @@ interface RefreshBody { refresh_token?: unknown } +interface RegisterBody { + email?: unknown + password?: unknown +} + /** * Valida e normaliza email/senha enviados no login. * @@ -33,6 +39,26 @@ function parseCredentialBody(body: LoginBody) { return { email, password } } +/** + * Valida os dados de cadastro e aplica regra mínima de senha. + * + * @param body Corpo bruto da requisição de cadastro. + * @returns Email normalizado e senha pronta para persistência. + * @throws {H3Error} Quando os dados forem inválidos. + */ +function parseRegisterBody(body: RegisterBody) { + const { email, password } = parseCredentialBody(body) + + if (password.length < 6) { + throw createError({ + statusCode: 400, + statusMessage: 'password must have at least 6 characters' + }) + } + + return { email, password } +} + /** * Valida o refresh token enviado no corpo da requisição. * @@ -50,6 +76,57 @@ function parseRefreshBody(body: RefreshBody): string { return refreshToken } +/** + * Executa o fluxo de cadastro: + * valida entrada, cria usuário e retorna dados básicos sem autenticar. + * + * @param event Evento HTTP da requisição. + * @returns Usuário criado no formato público. + * @throws {H3Error} Quando houver dados inválidos ou email já cadastrado. + */ +export async function handleRegister(event: H3Event) { + const body = await readBody(event) + const { email, password } = parseRegisterBody(body ?? {}) + + try { + const createdUser = await prisma.user.create({ + data: { + email, + passwordHash: hashPassword(password) + }, + select: { + id: true, + email: true, + createdAt: true, + updatedAt: true + } + }) + + setResponseStatus(event, 201) + + return { + id: createdUser.id, + email: createdUser.email, + created_at: createdUser.createdAt.toISOString(), + updated_at: createdUser.updatedAt.toISOString() + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' && + Array.isArray(error.meta?.target) && + error.meta.target.includes('email') + ) { + throw createError({ + statusCode: 409, + statusMessage: 'Email já cadastrado' + }) + } + + throw error + } +} + /** * Executa o fluxo de login: * valida credenciais, gera access token e emite refresh token. From 5fc265f3dc1c215123f6d3533798641f9dc04c61 Mon Sep 17 00:00:00 2001 From: Antonio Andre Date: Tue, 14 Apr 2026 20:31:19 -0500 Subject: [PATCH 2/3] 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)}` + } +} From ba698de2494b3eaf008666f871f3f30016280175 Mon Sep 17 00:00:00 2001 From: Antonio Andre Date: Tue, 14 Apr 2026 20:34:51 -0500 Subject: [PATCH 3/3] docs: atualiza README com novos endpoints auth e exemplos --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8d1b29a..60c66aa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MVP de autenticação central para serviços distribuídos. ## O que este projeto entrega -- Auth Service com `login` e `refresh` +- Auth Service com `register`, `login`, `refresh` e recuperação de senha - JWT assinado em `RS256` com contrato fixo - Middleware de validação JWT local - Serviço consumidor de referência (`/profile/me`) @@ -73,6 +73,9 @@ A tabela `User` possui apenas: - `POST /auth/login` - `POST /auth/refresh` +- `POST /auth/register` +- `POST /auth/forgot-password` +- `POST /auth/reset-password` - `GET /profile/me` (protegida) - `GET /dashboard` (protegida, chama `/profile/me`) @@ -94,29 +97,65 @@ A identidade confiável do usuário é sempre o `sub`. ## Teste rápido (curl) -### 1) Login +### 1) Cadastro + +```bash +curl -X POST http://localhost:3000/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"email":"novo.usuario@example.com","password":"senha123"}' +``` + +Fluxo recomendado no cliente: `register -> login`. + +### 2) Login ```bash curl -X POST http://localhost:3000/auth/login \ -H 'Content-Type: application/json' \ - -d '{"email":"student@example.com","password":"student123"}' + -d '{"email":"novo.usuario@example.com","password":"senha123"}' ``` -### 2) Rota protegida +### 3) Forgot password (sem SMTP, modo didático) + +```bash +curl -X POST http://localhost:3000/auth/forgot-password \ + -H 'Content-Type: application/json' \ + -d '{"email":"novo.usuario@example.com"}' +``` + +Observação: neste MVP didático a resposta já traz `recovery.reset_token` e `recovery.reset_url`. + +### 4) Reset password + +```bash +curl -X POST http://localhost:3000/auth/reset-password \ + -H 'Content-Type: application/json' \ + -d '{"token":"","new_password":"novaSenha123"}' +``` + +### 5) Login com nova senha + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"novo.usuario@example.com","password":"novaSenha123"}' +``` + +### 6) Rota protegida ```bash curl http://localhost:3000/profile/me \ -H "Authorization: Bearer " ``` -### 3) Chamada entre serviços (A -> B) +### 7) Chamada entre serviços (A -> B) ```bash curl http://localhost:3000/dashboard \ -H "Authorization: Bearer " ``` -### 4) Refresh +### 8) Refresh ```bash curl -X POST http://localhost:3000/auth/refresh \