feat(auth): implementa logica de recuperacao e mudanca de senha
This commit is contained in:
@@ -13,3 +13,5 @@ JWT_PUBLIC_KEY_PEM="<replace-with-escaped-pem>"
|
||||
|
||||
# Optional hardening
|
||||
REFRESH_TOKEN_PEPPER="change-me"
|
||||
PASSWORD_RESET_TTL_SEC="900"
|
||||
PASSWORD_RESET_TOKEN_PEPPER="change-me"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
9
server/api/auth/forgot-password.post.ts
Normal file
9
server/api/auth/forgot-password.post.ts
Normal 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)
|
||||
})
|
||||
8
server/api/auth/reset-password.post.ts
Normal file
8
server/api/auth/reset-password.post.ts
Normal 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)
|
||||
})
|
||||
3
server/routes/auth/forgot-password.post.ts
Normal file
3
server/routes/auth/forgot-password.post.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import forgotPasswordHandler from '../../api/auth/forgot-password.post'
|
||||
|
||||
export default forgotPasswordHandler
|
||||
3
server/routes/auth/reset-password.post.ts
Normal file
3
server/routes/auth/reset-password.post.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import resetPasswordHandler from '../../api/auth/reset-password.post'
|
||||
|
||||
export default resetPasswordHandler
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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:
|
||||
* valida credenciais, gera access token e emite refresh token.
|
||||
|
||||
39
server/utils/password-reset-token.ts
Normal file
39
server/utils/password-reset-token.ts
Normal 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)}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user