Merge branch 'dev'

This commit is contained in:
2026-04-14 20:35:01 -05:00
14 changed files with 436 additions and 11 deletions

View File

@@ -13,3 +13,5 @@ JWT_PUBLIC_KEY_PEM="<replace-with-escaped-pem>"
# Optional hardening # Optional hardening
REFRESH_TOKEN_PEPPER="change-me" REFRESH_TOKEN_PEPPER="change-me"
PASSWORD_RESET_TTL_SEC="900"
PASSWORD_RESET_TOKEN_PEPPER="change-me"

View File

@@ -4,7 +4,7 @@ MVP de autenticação central para serviços distribuídos.
## O que este projeto entrega ## 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 - JWT assinado em `RS256` com contrato fixo
- Middleware de validação JWT local - Middleware de validação JWT local
- Serviço consumidor de referência (`/profile/me`) - Serviço consumidor de referência (`/profile/me`)
@@ -73,6 +73,9 @@ A tabela `User` possui apenas:
- `POST /auth/login` - `POST /auth/login`
- `POST /auth/refresh` - `POST /auth/refresh`
- `POST /auth/register`
- `POST /auth/forgot-password`
- `POST /auth/reset-password`
- `GET /profile/me` (protegida) - `GET /profile/me` (protegida)
- `GET /dashboard` (protegida, chama `/profile/me`) - `GET /dashboard` (protegida, chama `/profile/me`)
@@ -94,29 +97,65 @@ A identidade confiável do usuário é sempre o `sub`.
## Teste rápido (curl) ## 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 ```bash
curl -X POST http://localhost:3000/auth/login \ curl -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \ -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":"<reset_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 ```bash
curl http://localhost:3000/profile/me \ curl http://localhost:3000/profile/me \
-H "Authorization: Bearer <access_token>" -H "Authorization: Bearer <access_token>"
``` ```
### 3) Chamada entre serviços (A -> B) ### 7) Chamada entre serviços (A -> B)
```bash ```bash
curl http://localhost:3000/dashboard \ curl http://localhost:3000/dashboard \
-H "Authorization: Bearer <access_token>" -H "Authorization: Bearer <access_token>"
``` ```
### 4) Refresh ### 8) Refresh
```bash ```bash
curl -X POST http://localhost:3000/auth/refresh \ curl -X POST http://localhost:3000/auth/refresh \

View File

@@ -12,7 +12,9 @@ export default defineNuxtConfig({
jwtPrivateKeyPem: process.env.JWT_PRIVATE_KEY_PEM ?? '', jwtPrivateKeyPem: process.env.JWT_PRIVATE_KEY_PEM ?? '',
jwtPublicKeyPem: process.env.JWT_PUBLIC_KEY_PEM ?? '', jwtPublicKeyPem: process.env.JWT_PUBLIC_KEY_PEM ?? '',
jwtKid: process.env.JWT_KID ?? 'auth-key-1', 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: { vite: {
optimizeDeps: { optimizeDeps: {

View File

@@ -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;

View File

@@ -15,6 +15,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
passwordResetTokens PasswordResetToken[]
@@index([email]) @@index([email])
} }
@@ -36,3 +37,18 @@ model RefreshToken {
@@index([userId]) @@index([userId])
@@index([expiresAt]) @@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])
}

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -0,0 +1,3 @@
import forgotPasswordHandler from '../../api/auth/forgot-password.post'
export default forgotPasswordHandler

View File

@@ -0,0 +1,3 @@
import registerHandler from '../../api/auth/register.post'
export default registerHandler

View File

@@ -0,0 +1,3 @@
import resetPasswordHandler from '../../api/auth/reset-password.post'
export default resetPasswordHandler

View File

@@ -5,10 +5,12 @@ export interface AuthRuntimeConfig {
audience: string audience: string
accessTtlSec: number accessTtlSec: number
refreshTtlSec: number refreshTtlSec: number
passwordResetTtlSec: number
privateKeyPem: string privateKeyPem: string
publicKeyPem: string publicKeyPem: string
kid: string kid: string
refreshTokenPepper: 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. * Lê e valida todas as configurações obrigatórias de autenticação.
* *
* @param event Evento da requisição (opcional), usado para acessar runtime config. * @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. * @throws {H3Error} Quando algum campo obrigatório estiver ausente ou inválido.
*/ */
export function getAuthRuntimeConfig(event?: H3Event): AuthRuntimeConfig { 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' }) throw createError({ statusCode: 500, statusMessage: 'JWT kid is required' })
} }
const refreshTokenPepper = String(runtimeConfig.refreshTokenPepper ?? '').trim()
const passwordResetTokenPepper =
String(runtimeConfig.passwordResetTokenPepper ?? '').trim() || refreshTokenPepper
return { return {
issuer, issuer,
audience, audience,
kid, kid,
accessTtlSec: parsePositiveInt(String(runtimeConfig.jwtAccessTtlSec), 'JWT access TTL'), accessTtlSec: parsePositiveInt(String(runtimeConfig.jwtAccessTtlSec), 'JWT access TTL'),
refreshTtlSec: parsePositiveInt(String(runtimeConfig.jwtRefreshTtlSec), 'JWT refresh 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'), privateKeyPem: normalizePem(String(runtimeConfig.jwtPrivateKeyPem ?? ''), 'JWT private key'),
publicKeyPem: normalizePem(String(runtimeConfig.jwtPublicKeyPem ?? ''), 'JWT public key'), publicKeyPem: normalizePem(String(runtimeConfig.jwtPublicKeyPem ?? ''), 'JWT public key'),
refreshTokenPepper: String(runtimeConfig.refreshTokenPepper ?? '') refreshTokenPepper,
passwordResetTokenPepper
} }
} }

View File

@@ -1,8 +1,14 @@
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 { signAccessToken } from './jwt'
import { getAuthRuntimeConfig } from './auth-config' import { getAuthRuntimeConfig } from './auth-config'
import { verifyPassword } from './password' import { hashPassword, verifyPassword } from './password'
import {
buildPasswordResetPreviewUrl,
generateRawPasswordResetToken,
hashPasswordResetToken
} from './password-reset-token'
import { prisma } from './prisma' import { prisma } from './prisma'
import { issueRefreshToken, rotateRefreshToken } from './refresh-token' import { issueRefreshToken, rotateRefreshToken } from './refresh-token'
@@ -15,6 +21,20 @@ interface RefreshBody {
refresh_token?: unknown refresh_token?: unknown
} }
interface RegisterBody {
email?: unknown
password?: unknown
}
interface ForgotPasswordBody {
email?: unknown
}
interface ResetPasswordBody {
token?: unknown
new_password?: unknown
}
/** /**
* Valida e normaliza email/senha enviados no login. * Valida e normaliza email/senha enviados no login.
* *
@@ -33,6 +53,71 @@ function parseCredentialBody(body: LoginBody) {
return { email, password } 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 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. * Valida o refresh token enviado no corpo da requisição.
* *
@@ -50,6 +135,181 @@ function parseRefreshBody(body: RefreshBody): string {
return refreshToken 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<RegisterBody>(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
}
}
/**
* 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<ForgotPasswordBody>(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<ResetPasswordBody>(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: * Executa o fluxo de login:
* valida credenciais, gera access token e emite refresh token. * valida credenciais, gera access token e emite refresh token.

View File

@@ -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)}`
}
}