Merge branch 'dev'
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -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 \
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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())
|
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])
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
9
server/api/auth/register.post.ts
Normal file
9
server/api/auth/register.post.ts
Normal 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)
|
||||||
|
})
|
||||||
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/register.post.ts
Normal file
3
server/routes/auth/register.post.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import registerHandler from '../../api/auth/register.post'
|
||||||
|
|
||||||
|
export default registerHandler
|
||||||
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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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