first commit
This commit is contained in:
187
.codex/plans/IMPLEMENTACAO_INICIAL.md
Normal file
187
.codex/plans/IMPLEMENTACAO_INICIAL.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Plano minimo de implementacao — comunicacao entre servicos com JWT
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar comunicacao minima entre servicos distribuidos onde:
|
||||
|
||||
- o **Auth Service** autentica o usuario
|
||||
- o **Auth Service** emite o **JWT**
|
||||
- os outros servicos **validam o JWT localmente**
|
||||
- um servico pode chamar outro **em nome do usuario**, repassando o mesmo token
|
||||
- cada servico identifica o usuario autenticado pelo `sub`
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura minima
|
||||
|
||||
### Auth Service
|
||||
Responsavel por:
|
||||
|
||||
- login
|
||||
- refresh token
|
||||
- emissao do JWT
|
||||
- distribuicao da chave publica para validacao nos demais servicos
|
||||
|
||||
### Outros servicos
|
||||
Responsaveis por:
|
||||
|
||||
- receber `Authorization: Bearer <token>`
|
||||
- validar JWT localmente
|
||||
- extrair claims
|
||||
- montar contexto do usuario autenticado
|
||||
|
||||
### Comunicacao entre servicos
|
||||
Quando um servico chamar outro em nome do usuario:
|
||||
|
||||
- repassar o mesmo header `Authorization`
|
||||
|
||||
---
|
||||
|
||||
## Contrato minimo do JWT
|
||||
|
||||
Exemplo:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "uuid-do-usuario",
|
||||
"iss": "https://auth.seusistema.com",
|
||||
"aud": "internal-apis",
|
||||
"iat": 1776110000,
|
||||
"exp": 1776110900
|
||||
}
|
||||
```
|
||||
|
||||
### Claims minimas
|
||||
|
||||
- `sub`: identificador do usuario
|
||||
- `iss`: emissor
|
||||
- `aud`: audiencia
|
||||
- `iat`: emissao
|
||||
- `exp`: expiracao
|
||||
|
||||
### Regra importante
|
||||
|
||||
O identificador confiavel do usuario deve ser sempre o **`sub`**.
|
||||
|
||||
Nao confiar em:
|
||||
|
||||
- `user_id` no body
|
||||
- `x-user-id` em header customizado
|
||||
- qualquer identificador separado do token
|
||||
|
||||
---
|
||||
|
||||
## Escolhas tecnicas minimas
|
||||
|
||||
### Algoritmo
|
||||
- `RS256`
|
||||
|
||||
### Assinatura
|
||||
- Auth assina com chave privada
|
||||
- demais servicos validam com chave publica
|
||||
|
||||
### Tempo de vida (MVP)
|
||||
- access token: 15 minutos
|
||||
- refresh token: 7 dias
|
||||
|
||||
---
|
||||
|
||||
## Endpoints minimos do Auth
|
||||
|
||||
```http
|
||||
POST /auth/login
|
||||
POST /auth/refresh
|
||||
```
|
||||
|
||||
### Login
|
||||
Retorna:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "jwt_aqui",
|
||||
"refresh_token": "refresh_aqui",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh
|
||||
Recebe refresh token e retorna novo access token + novo refresh token.
|
||||
|
||||
## Middleware minimo em cada servico
|
||||
|
||||
Passos:
|
||||
|
||||
1. ler `Authorization`
|
||||
2. extrair bearer token
|
||||
3. validar assinatura
|
||||
4. validar `exp`
|
||||
5. validar `iss`
|
||||
6. validar `aud`
|
||||
7. extrair claims
|
||||
8. montar usuario autenticado no contexto da requisicao
|
||||
|
||||
Resultado esperado no contexto:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-do-usuario"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rotas protegidas minimas para teste
|
||||
|
||||
- `GET /profile/me`
|
||||
- `GET /dashboard`
|
||||
|
||||
Cada rota deve:
|
||||
|
||||
- exigir token valido
|
||||
- extrair `sub`
|
||||
- retornar dados do usuario autenticado
|
||||
|
||||
---
|
||||
|
||||
## Ordem minima de implementacao
|
||||
|
||||
1. definir contrato do token
|
||||
2. gerar chaves
|
||||
3. implementar `login`, `refresh`
|
||||
4. emitir JWT corretamente
|
||||
5. implementar middleware JWT em 1 servico consumidor
|
||||
6. proteger 1 rota simples
|
||||
7. replicar middleware nos demais servicos
|
||||
8. implementar propagacao de `Authorization` entre servicos
|
||||
|
||||
---
|
||||
|
||||
## Criterios de sucesso do MVP
|
||||
|
||||
1. login retorna `access_token`
|
||||
2. acesso direto a rota protegida funciona
|
||||
3. token invalido retorna `401`
|
||||
4. token expirado retorna `401`
|
||||
5. chamada A -> B preserva o mesmo `sub`
|
||||
|
||||
---
|
||||
|
||||
## Fora de escopo agora
|
||||
|
||||
- blacklist de tokens
|
||||
- revogacao imediata global
|
||||
- introspection por request
|
||||
- machine-to-machine token
|
||||
- gateway avancado
|
||||
|
||||
---
|
||||
|
||||
## Resumo executivo
|
||||
|
||||
O minimo para funcionar:
|
||||
|
||||
1. Auth emitir JWT assinado
|
||||
2. servicos validarem localmente
|
||||
3. `sub` como identificador confiavel
|
||||
4. propagacao do mesmo bearer token entre servicos
|
||||
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Postgres
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sistema_auth?schema=public"
|
||||
DIRECT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sistema_auth?schema=public"
|
||||
|
||||
# JWT contract
|
||||
JWT_ISSUER="https://auth.local"
|
||||
JWT_AUDIENCE="internal-apis"
|
||||
JWT_ACCESS_TTL_SEC="900"
|
||||
JWT_REFRESH_TTL_SEC="604800"
|
||||
JWT_KID="auth-key-1"
|
||||
JWT_PRIVATE_KEY_PEM="<replace-with-escaped-pem>"
|
||||
JWT_PUBLIC_KEY_PEM="<replace-with-escaped-pem>"
|
||||
|
||||
# Optional hardening
|
||||
REFRESH_TOKEN_PEPPER="change-me"
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
132
README.md
Normal file
132
README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Sistema Auth Central (Nuxt + Prisma)
|
||||
|
||||
MVP de autenticação central para serviços distribuídos.
|
||||
|
||||
## O que este projeto entrega
|
||||
|
||||
- Auth Service com `login` e `refresh`
|
||||
- JWT assinado em `RS256` com contrato fixo
|
||||
- Middleware de validação JWT local
|
||||
- Serviço consumidor de referência (`/profile/me`)
|
||||
- Fluxo A -> B (`/dashboard` chama `/profile/me` com o mesmo Bearer token)
|
||||
|
||||
## Stack
|
||||
|
||||
- Nuxt 4 (Nitro server routes)
|
||||
- Prisma + Postgres
|
||||
- JOSE (JWT)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Instale dependências:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Gere chaves RSA para JWT:
|
||||
|
||||
```bash
|
||||
npm run jwt:keys
|
||||
```
|
||||
|
||||
Distribua o valor de `JWT_PUBLIC_KEY_PEM` para todos os serviços consumidores.
|
||||
|
||||
3. Crie `.env` a partir de `.env.example` e preencha os valores.
|
||||
Se usar Postgres remoto com pool/proxy (ex.: Railway), use `DIRECT_DATABASE_URL` com conexão direta para migrations.
|
||||
|
||||
4. Execute migrações do Prisma:
|
||||
|
||||
```bash
|
||||
npm run prisma:migrate:dev -- --name init
|
||||
```
|
||||
|
||||
5. Rode seed para usuários de teste:
|
||||
|
||||
```bash
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
6. Inicie a aplicação:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Usuários de seed
|
||||
|
||||
- `student@example.com` / `student123`
|
||||
- `admin@example.com` / `admin123`
|
||||
- `limited@example.com` / `limited123`
|
||||
|
||||
## Estrutura da tabela `User`
|
||||
|
||||
A tabela `User` possui apenas:
|
||||
|
||||
- `id`
|
||||
- `email`
|
||||
- `passwordHash`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/refresh`
|
||||
- `GET /profile/me` (protegida)
|
||||
- `GET /dashboard` (protegida, chama `/profile/me`)
|
||||
|
||||
## Guia para serviços consumidores
|
||||
|
||||
- `docs/GUIA_VALIDACAO_JWT_SERVICOS_CONSUMIDORES.md`
|
||||
|
||||
## Contrato do Access Token
|
||||
|
||||
Claims obrigatórias:
|
||||
|
||||
- `sub`
|
||||
- `iss`
|
||||
- `aud`
|
||||
- `iat`
|
||||
- `exp`
|
||||
|
||||
A identidade confiável do usuário é sempre o `sub`.
|
||||
|
||||
## Teste rápido (curl)
|
||||
|
||||
### 1) Login
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"student@example.com","password":"student123"}'
|
||||
```
|
||||
|
||||
### 2) Rota protegida
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/profile/me \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
### 3) Chamada entre serviços (A -> B)
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/dashboard \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
### 4) Refresh
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/refresh \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"refresh_token":"<refresh_token>"}'
|
||||
```
|
||||
|
||||
## Scripts úteis
|
||||
|
||||
- `npm run prisma:generate`
|
||||
- `npm run prisma:migrate:dev`
|
||||
- `npm run prisma:seed`
|
||||
- `npm run jwt:keys`
|
||||
6
app/app.vue
Normal file
6
app/app.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtWelcome />
|
||||
</div>
|
||||
</template>
|
||||
134
docs/GUIA_VALIDACAO_JWT_SERVICOS_CONSUMIDORES.md
Normal file
134
docs/GUIA_VALIDACAO_JWT_SERVICOS_CONSUMIDORES.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Guia didatico: validar JWT nos servicos consumidores (sem JWKS)
|
||||
|
||||
Este guia mostra a forma mais simples de integrar os servicos:
|
||||
|
||||
1. Auth assina o token com `JWT_PRIVATE_KEY_PEM`
|
||||
2. cada servico consumidor valida localmente com `JWT_PUBLIC_KEY_PEM`
|
||||
|
||||
## 1) Configuracao em cada servico consumidor
|
||||
|
||||
Cada servico precisa ter no `.env`:
|
||||
|
||||
```env
|
||||
JWT_ISSUER="https://auth.local"
|
||||
JWT_AUDIENCE="internal-apis"
|
||||
JWT_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
```
|
||||
|
||||
Importante:
|
||||
|
||||
- o `JWT_PUBLIC_KEY_PEM` deve ser exatamente a chave publica do Auth
|
||||
- mantenha os `\n` literais no `.env`
|
||||
|
||||
## 2) O que validar
|
||||
|
||||
Para toda rota protegida:
|
||||
|
||||
1. ler `Authorization`
|
||||
2. extrair `Bearer <token>`
|
||||
3. validar assinatura RS256 com `JWT_PUBLIC_KEY_PEM`
|
||||
4. validar `iss`, `aud` e `exp`
|
||||
5. validar se `sub` existe
|
||||
6. montar `request.auth = { id: sub, token }`
|
||||
|
||||
Se algo falhar, retornar `401`.
|
||||
|
||||
## 3) Exemplo pratico (Node/Express + jose)
|
||||
|
||||
```ts
|
||||
import express from 'express'
|
||||
import { importSPKI, jwtVerify } from 'jose'
|
||||
|
||||
const app = express()
|
||||
|
||||
const ISSUER = process.env.JWT_ISSUER!
|
||||
const AUDIENCE = process.env.JWT_AUDIENCE!
|
||||
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY_PEM!.replace(/\\n/g, '\n')
|
||||
|
||||
let publicKeyPromise: ReturnType<typeof importSPKI> | null = null
|
||||
|
||||
function getPublicKey() {
|
||||
if (!publicKeyPromise) {
|
||||
publicKeyPromise = importSPKI(PUBLIC_KEY_PEM, 'RS256')
|
||||
}
|
||||
|
||||
return publicKeyPromise
|
||||
}
|
||||
|
||||
function authMiddleware() {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('authorization')
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ message: 'Missing Authorization header' })
|
||||
}
|
||||
|
||||
const [scheme, token] = authHeader.split(' ')
|
||||
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
|
||||
return res.status(401).json({ message: 'Authorization must be Bearer token' })
|
||||
}
|
||||
|
||||
const publicKey = await getPublicKey()
|
||||
|
||||
const { payload } = await jwtVerify(token, publicKey, {
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
algorithms: ['RS256']
|
||||
})
|
||||
|
||||
if (typeof payload.sub !== 'string' || !payload.sub.trim()) {
|
||||
return res.status(401).json({ message: 'Invalid token claims' })
|
||||
}
|
||||
|
||||
req.auth = {
|
||||
id: payload.sub,
|
||||
token
|
||||
}
|
||||
|
||||
return next()
|
||||
} catch {
|
||||
return res.status(401).json({ message: 'Invalid or expired access token' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/profile/me', authMiddleware(), (req, res) => {
|
||||
return res.json({ userId: req.auth.id })
|
||||
})
|
||||
```
|
||||
|
||||
## 4) Propagacao entre servicos (A -> B)
|
||||
|
||||
Quando API A chamar API B em nome do usuario:
|
||||
|
||||
- repasse o mesmo `Authorization: Bearer <token>`
|
||||
- API B valida esse token com `JWT_PUBLIC_KEY_PEM`
|
||||
- API B usa `sub` como identidade confiavel
|
||||
|
||||
```ts
|
||||
const authHeader = req.header('authorization')
|
||||
|
||||
const response = await fetch('http://courses-service/courses/me', {
|
||||
headers: { Authorization: authHeader! }
|
||||
})
|
||||
```
|
||||
|
||||
## 5) Checklist rapido
|
||||
|
||||
- todos os servicos com o mesmo `JWT_PUBLIC_KEY_PEM`
|
||||
- middleware valida assinatura + `iss` + `aud` + `exp`
|
||||
- `sub` obrigatorio
|
||||
- retorna `401` para token ausente/invalido/expirado
|
||||
- usa `sub` como ID confiavel
|
||||
|
||||
## 6) Trade-off dessa abordagem
|
||||
|
||||
Vantagem:
|
||||
|
||||
- muito simples para implementar e explicar
|
||||
|
||||
Desvantagem:
|
||||
|
||||
- quando trocar a chave publica, precisa atualizar o `.env` de todos os servicos consumidores
|
||||
9
h3.d.ts
vendored
Normal file
9
h3.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { AuthContext } from './server/types/auth'
|
||||
|
||||
declare module 'h3' {
|
||||
interface H3EventContext {
|
||||
auth?: AuthContext
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
15
lib/prisma.ts
Normal file
15
lib/prisma.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient()
|
||||
}
|
||||
|
||||
declare const globalThis: {
|
||||
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
|
||||
} & typeof global;
|
||||
|
||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||
|
||||
export default prisma
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||
22
nuxt.config.ts
Normal file
22
nuxt.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@prisma/nuxt'],
|
||||
runtimeConfig: {
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
jwtIssuer: process.env.JWT_ISSUER ?? 'https://auth.local',
|
||||
jwtAudience: process.env.JWT_AUDIENCE ?? 'internal-apis',
|
||||
jwtAccessTtlSec: process.env.JWT_ACCESS_TTL_SEC ?? '900',
|
||||
jwtRefreshTtlSec: process.env.JWT_REFRESH_TTL_SEC ?? '604800',
|
||||
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 ?? ''
|
||||
},
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
include: ['@vue/devtools-core', '@vue/devtools-kit']
|
||||
}
|
||||
}
|
||||
})
|
||||
12207
package-lock.json
generated
Normal file
12207
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "sistema",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.mjs",
|
||||
"jwt:keys": "node scripts/generate-jwt-keys.mjs"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@prisma/nuxt": "^0.3.0",
|
||||
"jose": "^6.2.2",
|
||||
"nuxt": "^4.4.2",
|
||||
"prisma": "^6.16.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0"
|
||||
}
|
||||
}
|
||||
1
prisma/migrations/.gitkeep
Normal file
1
prisma/migrations/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Keep Prisma migrations directory in VCS
|
||||
45
prisma/migrations/20260414031916_init/migration.sql
Normal file
45
prisma/migrations/20260414031916_init/migration.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."RefreshToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"replacedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "public"."User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "public"."RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "public"."RefreshToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_expiresAt_idx" ON "public"."RefreshToken"("expiresAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RefreshToken" ADD CONSTRAINT "RefreshToken_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "public"."RefreshToken"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
38
prisma/schema.prisma
Normal file
38
prisma/schema.prisma
Normal file
@@ -0,0 +1,38 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DIRECT_DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
replacedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
replacedBy RefreshToken? @relation("RefreshRotation", fields: [replacedById], references: [id])
|
||||
replaces RefreshToken[] @relation("RefreshRotation")
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
52
prisma/seed.mjs
Normal file
52
prisma/seed.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { randomBytes, scryptSync } from 'node:crypto'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('base64url')
|
||||
const derivedKey = scryptSync(password, salt, 64).toString('base64url')
|
||||
return `scrypt$${salt}$${derivedKey}`
|
||||
}
|
||||
|
||||
async function upsertUser({ email, password }) {
|
||||
const passwordHash = hashPassword(password)
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
passwordHash
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
passwordHash
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await upsertUser({
|
||||
email: 'student@example.com',
|
||||
password: 'student123'
|
||||
})
|
||||
|
||||
await upsertUser({
|
||||
email: 'admin@example.com',
|
||||
password: 'admin123'
|
||||
})
|
||||
|
||||
await upsertUser({
|
||||
email: 'limited@example.com',
|
||||
password: 'limited123'
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error('Seed failed:', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
24
scripts/generate-jwt-keys.mjs
Normal file
24
scripts/generate-jwt-keys.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { generateKeyPairSync, randomUUID } from 'node:crypto'
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
})
|
||||
|
||||
const kid = `auth-${randomUUID()}`
|
||||
|
||||
function toEnvValue(pem) {
|
||||
return pem.trim().replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
console.log('# Copy these values to your .env file')
|
||||
console.log(`JWT_KID="${kid}"`)
|
||||
console.log(`JWT_PRIVATE_KEY_PEM="${toEnvValue(privateKey)}"`)
|
||||
console.log(`JWT_PUBLIC_KEY_PEM="${toEnvValue(publicKey)}"`)
|
||||
9
server/api/auth/login.post.ts
Normal file
9
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { handleLogin } from '../../utils/auth-service'
|
||||
|
||||
/**
|
||||
* Endpoint de login do Auth Service.
|
||||
* Recebe credenciais e retorna access/refresh token.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
return handleLogin(event)
|
||||
})
|
||||
9
server/api/auth/refresh.post.ts
Normal file
9
server/api/auth/refresh.post.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { handleRefresh } from '../../utils/auth-service'
|
||||
|
||||
/**
|
||||
* Endpoint para renovar sessão com refresh token.
|
||||
* Retorna novo access token e novo refresh token rotacionado.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
return handleRefresh(event)
|
||||
})
|
||||
57
server/middleware/auth.ts
Normal file
57
server/middleware/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createError, getRequestHeader, type H3Event } from 'h3'
|
||||
|
||||
import { getRouteRequirement } from '../utils/auth-rules'
|
||||
import { verifyAccessToken } from '../utils/jwt'
|
||||
|
||||
/**
|
||||
* Lê o header Authorization e extrai o token Bearer.
|
||||
*
|
||||
* @param event Evento HTTP atual.
|
||||
* @returns Token JWT sem o prefixo `Bearer`.
|
||||
* @throws {H3Error} Quando o header estiver ausente ou em formato inválido.
|
||||
*/
|
||||
function extractBearerToken(event: H3Event): string {
|
||||
const authHeader = getRequestHeader(event, 'authorization')
|
||||
|
||||
if (!authHeader) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Missing Authorization header' })
|
||||
}
|
||||
|
||||
const [scheme, token] = authHeader.split(' ')
|
||||
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authorization must be Bearer token' })
|
||||
}
|
||||
|
||||
return token.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware global de autenticação.
|
||||
* Em rotas protegidas, valida o JWT e preenche `event.context.auth`.
|
||||
*
|
||||
* @param event Evento HTTP atual.
|
||||
* @throws {H3Error} Quando houver falha de autenticação.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const routeRequirement = getRouteRequirement(event.method, event.path)
|
||||
|
||||
if (!routeRequirement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getRequestHeader(event, 'x-user-id')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Custom identity headers are not allowed. Use JWT claims instead.'
|
||||
})
|
||||
}
|
||||
|
||||
const token = extractBearerToken(event)
|
||||
const payload = await verifyAccessToken(event, token)
|
||||
|
||||
event.context.auth = {
|
||||
id: payload.sub,
|
||||
token
|
||||
}
|
||||
})
|
||||
3
server/routes/auth/login.post.ts
Normal file
3
server/routes/auth/login.post.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import loginHandler from '../../api/auth/login.post'
|
||||
|
||||
export default loginHandler
|
||||
3
server/routes/auth/refresh.post.ts
Normal file
3
server/routes/auth/refresh.post.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import refreshHandler from '../../api/auth/refresh.post'
|
||||
|
||||
export default refreshHandler
|
||||
30
server/routes/dashboard.get.ts
Normal file
30
server/routes/dashboard.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createError, getRequestHeader } from 'h3'
|
||||
|
||||
import { requireAuthContext } from '../utils/require-auth'
|
||||
|
||||
/**
|
||||
* Exemplo de orquestração A -> B.
|
||||
* Reaproveita o mesmo Authorization para chamar `/profile/me`.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = requireAuthContext(event)
|
||||
|
||||
const authorization = getRequestHeader(event, 'authorization')
|
||||
|
||||
if (!authorization) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Missing Authorization header' })
|
||||
}
|
||||
|
||||
const profileFromService = await $fetch('/profile/me', {
|
||||
headers: {
|
||||
Authorization: authorization
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
sub_from_api_a: auth.id,
|
||||
sub_from_api_b: profileFromService.id,
|
||||
same_subject: profileFromService.id === auth.id,
|
||||
profile: profileFromService
|
||||
}
|
||||
})
|
||||
32
server/routes/profile/me.get.ts
Normal file
32
server/routes/profile/me.get.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createError } from 'h3'
|
||||
|
||||
import { prisma } from '../../utils/prisma'
|
||||
import { requireAuthContext } from '../../utils/require-auth'
|
||||
|
||||
/**
|
||||
* Retorna os dados do usuário autenticado com base no `sub` do JWT.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = requireAuthContext(event)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: auth.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Authenticated user not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
updated_at: user.updatedAt.toISOString()
|
||||
}
|
||||
})
|
||||
17
server/types/auth.ts
Normal file
17
server/types/auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface AuthContext {
|
||||
id: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AccessTokenClaims {
|
||||
sub: string
|
||||
iss: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
export interface AuthRouteRequirement {
|
||||
method: string
|
||||
path: string
|
||||
}
|
||||
93
server/utils/auth-config.ts
Normal file
93
server/utils/auth-config.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createError, type H3Event } from 'h3'
|
||||
|
||||
export interface AuthRuntimeConfig {
|
||||
issuer: string
|
||||
audience: string
|
||||
accessTtlSec: number
|
||||
refreshTtlSec: number
|
||||
privateKeyPem: string
|
||||
publicKeyPem: string
|
||||
kid: string
|
||||
refreshTokenPepper: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte uma string para inteiro positivo e valida o resultado.
|
||||
*
|
||||
* @param value Valor recebido do runtime config.
|
||||
* @param label Nome amigável do campo para mensagens de erro.
|
||||
* @returns Número inteiro positivo.
|
||||
* @throws {H3Error} Quando o valor não for um inteiro positivo.
|
||||
*/
|
||||
function parsePositiveInt(value: string, label: string): number {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `${label} must be a positive integer`
|
||||
})
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza uma chave PEM salva em variável de ambiente.
|
||||
* Troca `\\n` por quebra de linha real e remove espaços nas pontas.
|
||||
*
|
||||
* @param rawValue Valor bruto vindo do ambiente.
|
||||
* @param label Nome amigável do campo para mensagens de erro.
|
||||
* @returns Chave PEM pronta para uso.
|
||||
* @throws {H3Error} Quando a chave estiver vazia.
|
||||
*/
|
||||
function normalizePem(rawValue: string, label: string): string {
|
||||
const normalized = rawValue.replace(/\\n/g, '\n').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `${label} is required`
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @throws {H3Error} Quando algum campo obrigatório estiver ausente ou inválido.
|
||||
*/
|
||||
export function getAuthRuntimeConfig(event?: H3Event): AuthRuntimeConfig {
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
|
||||
const issuer = String(runtimeConfig.jwtIssuer ?? '').trim()
|
||||
const audience = String(runtimeConfig.jwtAudience ?? '').trim()
|
||||
const kid = String(runtimeConfig.jwtKid ?? '').trim()
|
||||
|
||||
if (!issuer) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'JWT issuer is required' })
|
||||
}
|
||||
|
||||
if (!audience) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'JWT audience is required' })
|
||||
}
|
||||
|
||||
if (!kid) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'JWT kid is required' })
|
||||
}
|
||||
|
||||
return {
|
||||
issuer,
|
||||
audience,
|
||||
kid,
|
||||
accessTtlSec: parsePositiveInt(String(runtimeConfig.jwtAccessTtlSec), 'JWT access TTL'),
|
||||
refreshTtlSec: parsePositiveInt(String(runtimeConfig.jwtRefreshTtlSec), 'JWT refresh TTL'),
|
||||
privateKeyPem: normalizePem(String(runtimeConfig.jwtPrivateKeyPem ?? ''), 'JWT private key'),
|
||||
publicKeyPem: normalizePem(String(runtimeConfig.jwtPublicKeyPem ?? ''), 'JWT public key'),
|
||||
refreshTokenPepper: String(runtimeConfig.refreshTokenPepper ?? '')
|
||||
}
|
||||
}
|
||||
42
server/utils/auth-rules.ts
Normal file
42
server/utils/auth-rules.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AuthRouteRequirement } from '../types/auth'
|
||||
|
||||
const PROTECTED_ROUTES: AuthRouteRequirement[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/profile/me'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/dashboard'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Normaliza o caminho da requisição para facilitar a comparação de rotas.
|
||||
* Remove query string e barra final extra.
|
||||
*
|
||||
* @param path Caminho original recebido na requisição.
|
||||
* @returns Caminho em formato padronizado.
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
const withoutQuery = path.split('?')[0] ?? '/'
|
||||
const normalized = withoutQuery.replace(/\/+$/, '')
|
||||
|
||||
return normalized || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a regra de autenticação da rota atual, quando existir.
|
||||
*
|
||||
* @param method Método HTTP da requisição.
|
||||
* @param path Caminho da requisição.
|
||||
* @returns Regra da rota protegida ou `null` quando a rota é pública.
|
||||
*/
|
||||
export function getRouteRequirement(method: string, path: string): AuthRouteRequirement | null {
|
||||
const normalizedMethod = method.toUpperCase()
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
return (
|
||||
PROTECTED_ROUTES.find((route) => route.method === normalizedMethod && route.path === normalizedPath) ?? null
|
||||
)
|
||||
}
|
||||
107
server/utils/auth-service.ts
Normal file
107
server/utils/auth-service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createError, readBody, type H3Event } from 'h3'
|
||||
|
||||
import { signAccessToken } from './jwt'
|
||||
import { getAuthRuntimeConfig } from './auth-config'
|
||||
import { verifyPassword } from './password'
|
||||
import { prisma } from './prisma'
|
||||
import { issueRefreshToken, rotateRefreshToken } from './refresh-token'
|
||||
|
||||
interface LoginBody {
|
||||
email?: unknown
|
||||
password?: unknown
|
||||
}
|
||||
|
||||
interface RefreshBody {
|
||||
refresh_token?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida e normaliza email/senha enviados no login.
|
||||
*
|
||||
* @param body Corpo bruto da requisição de login.
|
||||
* @returns Email normalizado e senha pronta para validação.
|
||||
* @throws {H3Error} Quando email ou senha não forem informados.
|
||||
*/
|
||||
function parseCredentialBody(body: LoginBody) {
|
||||
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
|
||||
if (!email || !password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'email and password are required' })
|
||||
}
|
||||
|
||||
return { email, password }
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida o refresh token enviado no corpo da requisição.
|
||||
*
|
||||
* @param body Corpo bruto da requisição de refresh.
|
||||
* @returns Refresh token em formato de string.
|
||||
* @throws {H3Error} Quando `refresh_token` não for informado.
|
||||
*/
|
||||
function parseRefreshBody(body: RefreshBody): string {
|
||||
const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token.trim() : ''
|
||||
|
||||
if (!refreshToken) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'refresh_token is required' })
|
||||
}
|
||||
|
||||
return refreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o fluxo de login:
|
||||
* valida credenciais, gera access token e emite refresh token.
|
||||
*
|
||||
* @param event Evento HTTP da requisição.
|
||||
* @returns Resposta padrão de autenticação para o cliente.
|
||||
* @throws {H3Error} Quando as credenciais forem inválidas ou faltarem dados.
|
||||
*/
|
||||
export async function handleLogin(event: H3Event) {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const { email, password } = parseCredentialBody(body ?? {})
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
|
||||
if (!user || !verifyPassword(password, user.passwordHash)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const accessToken = await signAccessToken(event, { sub: user.id })
|
||||
|
||||
const refreshToken = await issueRefreshToken(event, user.id)
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: config.accessTtlSec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o fluxo de refresh:
|
||||
* valida o refresh token, rotaciona e retorna novo access token.
|
||||
*
|
||||
* @param event Evento HTTP da requisição.
|
||||
* @returns Novo par de tokens para continuar a sessão.
|
||||
* @throws {H3Error} Quando o refresh token for inválido ou ausente.
|
||||
*/
|
||||
export async function handleRefresh(event: H3Event) {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const body = await readBody<RefreshBody>(event)
|
||||
const incomingRefreshToken = parseRefreshBody(body ?? {})
|
||||
|
||||
const rotated = await rotateRefreshToken(event, incomingRefreshToken)
|
||||
|
||||
const accessToken = await signAccessToken(event, { sub: rotated.user.id })
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: rotated.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: config.accessTtlSec
|
||||
}
|
||||
}
|
||||
137
server/utils/jwt.ts
Normal file
137
server/utils/jwt.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createError, type H3Event } from 'h3'
|
||||
import {
|
||||
SignJWT,
|
||||
importPKCS8,
|
||||
importSPKI,
|
||||
jwtVerify,
|
||||
type JWTPayload
|
||||
} from 'jose'
|
||||
|
||||
import type { AccessTokenClaims } from '../types/auth'
|
||||
import { getAuthRuntimeConfig } from './auth-config'
|
||||
|
||||
interface LoadedSigningKeys {
|
||||
privateKey: CryptoKey
|
||||
publicKey: CryptoKey
|
||||
keyFingerprint: string
|
||||
}
|
||||
|
||||
let cachedKeys: LoadedSigningKeys | null = null
|
||||
|
||||
/**
|
||||
* Gera uma assinatura simples para identificar quando as chaves mudaram.
|
||||
* Isso evita reutilizar chaves antigas em memória.
|
||||
*
|
||||
* @param privateKeyPem Chave privada em PEM.
|
||||
* @param publicKeyPem Chave pública em PEM.
|
||||
* @param kid Identificador da chave.
|
||||
* @returns Texto usado para comparar cache de chaves.
|
||||
*/
|
||||
function keysFingerprint(privateKeyPem: string, publicKeyPem: string, kid: string): string {
|
||||
return `${kid}:${privateKeyPem.length}:${publicKeyPem.length}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega as chaves JWT e mantém cache em memória para performance.
|
||||
*
|
||||
* @param event Evento HTTP usado para ler config de runtime.
|
||||
* @returns Chaves de assinatura/verificação prontas para uso.
|
||||
* @throws {H3Error} Quando as chaves PEM não puderem ser carregadas.
|
||||
*/
|
||||
async function loadSigningKeys(event: H3Event): Promise<LoadedSigningKeys> {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const fingerprint = keysFingerprint(config.privateKeyPem, config.publicKeyPem, config.kid)
|
||||
|
||||
if (cachedKeys && cachedKeys.keyFingerprint === fingerprint) {
|
||||
return cachedKeys
|
||||
}
|
||||
|
||||
try {
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
importPKCS8(config.privateKeyPem, 'RS256'),
|
||||
importSPKI(config.publicKeyPem, 'RS256')
|
||||
])
|
||||
|
||||
cachedKeys = {
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyFingerprint: fingerprint
|
||||
}
|
||||
|
||||
return cachedKeys
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to load JWT signing keys'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina um novo access token RS256 para o usuário autenticado.
|
||||
*
|
||||
* @param event Evento HTTP usado para ler config de runtime.
|
||||
* @param payload Dados mínimos do token (apenas `sub`).
|
||||
* @returns JWT assinado em formato string.
|
||||
*/
|
||||
export async function signAccessToken(event: H3Event, payload: Pick<AccessTokenClaims, 'sub'>): Promise<string> {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const keys = await loadSigningKeys(event)
|
||||
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'RS256', kid: config.kid, typ: 'JWT' })
|
||||
.setSubject(payload.sub)
|
||||
.setIssuer(config.issuer)
|
||||
.setAudience(config.audience)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${config.accessTtlSec}s`)
|
||||
.sign(keys.privateKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida um access token recebido em rota protegida.
|
||||
* Confere assinatura, issuer, audience, exp e formato de claims.
|
||||
*
|
||||
* @param event Evento HTTP usado para ler config de runtime.
|
||||
* @param token JWT recebido no header Authorization.
|
||||
* @returns Claims normalizadas do token válido.
|
||||
* @throws {H3Error} Quando o token for inválido ou expirado.
|
||||
*/
|
||||
export async function verifyAccessToken(event: H3Event, token: string): Promise<AccessTokenClaims> {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const keys = await loadSigningKeys(event)
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, keys.publicKey, {
|
||||
issuer: config.issuer,
|
||||
audience: config.audience,
|
||||
algorithms: ['RS256']
|
||||
})
|
||||
|
||||
const claims = payload as JWTPayload & {
|
||||
sub?: unknown
|
||||
iss?: unknown
|
||||
aud?: unknown
|
||||
}
|
||||
|
||||
if (typeof claims.sub !== 'string' || !claims.sub.trim()) {
|
||||
throw new Error('Invalid sub claim')
|
||||
}
|
||||
|
||||
if (typeof claims.iat !== 'number' || typeof claims.exp !== 'number') {
|
||||
throw new Error('Invalid temporal claims')
|
||||
}
|
||||
|
||||
const audience = Array.isArray(claims.aud) ? claims.aud[0] : claims.aud
|
||||
|
||||
return {
|
||||
sub: claims.sub,
|
||||
iss: String(claims.iss),
|
||||
aud: String(audience),
|
||||
iat: claims.iat,
|
||||
exp: claims.exp
|
||||
}
|
||||
} catch {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired access token' })
|
||||
}
|
||||
}
|
||||
41
server/utils/password.ts
Normal file
41
server/utils/password.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
const PASSWORD_HASH_PREFIX = 'scrypt'
|
||||
const DERIVED_KEY_LENGTH = 64
|
||||
|
||||
/**
|
||||
* Gera hash de senha com scrypt e salt aleatório.
|
||||
*
|
||||
* @param password Senha em texto puro.
|
||||
* @returns Hash no formato `scrypt$salt$hash`.
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('base64url')
|
||||
const derivedKey = scryptSync(password, salt, DERIVED_KEY_LENGTH).toString('base64url')
|
||||
|
||||
return `${PASSWORD_HASH_PREFIX}$${salt}$${derivedKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compara senha em texto puro com hash salvo no banco.
|
||||
*
|
||||
* @param password Senha informada no login.
|
||||
* @param encodedHash Hash armazenado no banco.
|
||||
* @returns `true` quando a senha confere; caso contrário, `false`.
|
||||
*/
|
||||
export function verifyPassword(password: string, encodedHash: string): boolean {
|
||||
const [prefix, salt, storedHash] = encodedHash.split('$')
|
||||
|
||||
if (prefix !== PASSWORD_HASH_PREFIX || !salt || !storedHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
const derivedKey = scryptSync(password, salt, DERIVED_KEY_LENGTH)
|
||||
const storedKeyBuffer = Buffer.from(storedHash, 'base64url')
|
||||
|
||||
if (derivedKey.length !== storedKeyBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(derivedKey, storedKeyBuffer)
|
||||
}
|
||||
11
server/utils/prisma.ts
Normal file
11
server/utils/prisma.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
type PrismaGlobal = typeof globalThis & { prisma?: PrismaClient }
|
||||
|
||||
const globalForPrisma = globalThis as PrismaGlobal
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
118
server/utils/refresh-token.ts
Normal file
118
server/utils/refresh-token.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { createError, type H3Event } from 'h3'
|
||||
|
||||
import { getAuthRuntimeConfig } from './auth-config'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export interface IssuedRefreshToken {
|
||||
token: string
|
||||
expiresAt: Date
|
||||
expiresInSec: number
|
||||
recordId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera um hash seguro do refresh token usando pepper do ambiente.
|
||||
* O token bruto nunca é salvo no banco.
|
||||
*
|
||||
* @param rawToken Refresh token em texto puro.
|
||||
* @param pepper Segredo adicional do servidor.
|
||||
* @returns Hash SHA-256 do token.
|
||||
*/
|
||||
export function hashRefreshToken(rawToken: string, pepper: string): string {
|
||||
return createHash('sha256').update(`${rawToken}${pepper}`).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um refresh token aleatório para entregar ao cliente.
|
||||
*
|
||||
* @returns Token aleatório em formato URL-safe.
|
||||
*/
|
||||
function generateRawRefreshToken(): string {
|
||||
return randomBytes(48).toString('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Emite e persiste um novo refresh token para um usuário.
|
||||
*
|
||||
* @param event Evento HTTP usado para ler config.
|
||||
* @param userId ID do usuário dono do token.
|
||||
* @returns Metadados do refresh token emitido.
|
||||
*/
|
||||
export async function issueRefreshToken(event: H3Event, userId: string): Promise<IssuedRefreshToken> {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const token = generateRawRefreshToken()
|
||||
const tokenHash = hashRefreshToken(token, config.refreshTokenPepper)
|
||||
const expiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
|
||||
|
||||
const created = await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
expiresInSec: config.refreshTtlSec,
|
||||
recordId: created.id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotaciona um refresh token válido:
|
||||
* cria um novo token e marca o antigo como revogado.
|
||||
*
|
||||
* @param event Evento HTTP usado para ler config.
|
||||
* @param rawRefreshToken Token atual enviado pelo cliente.
|
||||
* @returns Dados do novo refresh token e usuário autenticado.
|
||||
* @throws {H3Error} Quando o token for inválido, revogado ou expirado.
|
||||
*/
|
||||
export async function rotateRefreshToken(event: H3Event, rawRefreshToken: string) {
|
||||
const config = getAuthRuntimeConfig(event)
|
||||
const tokenHash = hashRefreshToken(rawRefreshToken, config.refreshTokenPepper)
|
||||
|
||||
const existing = await prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { user: true }
|
||||
})
|
||||
|
||||
if (!existing || existing.revokedAt || existing.expiresAt.getTime() <= Date.now()) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid refresh token' })
|
||||
}
|
||||
|
||||
const newRawRefreshToken = generateRawRefreshToken()
|
||||
const newTokenHash = hashRefreshToken(newRawRefreshToken, config.refreshTokenPepper)
|
||||
const newExpiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
|
||||
|
||||
const rotated = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.refreshToken.create({
|
||||
data: {
|
||||
userId: existing.userId,
|
||||
tokenHash: newTokenHash,
|
||||
expiresAt: newExpiresAt
|
||||
}
|
||||
})
|
||||
|
||||
await tx.refreshToken.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
replacedById: created.id
|
||||
}
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return {
|
||||
user: existing.user,
|
||||
token: newRawRefreshToken,
|
||||
expiresAt: rotated.expiresAt,
|
||||
expiresInSec: config.refreshTtlSec,
|
||||
recordId: rotated.id,
|
||||
replacedTokenId: existing.id
|
||||
}
|
||||
}
|
||||
18
server/utils/require-auth.ts
Normal file
18
server/utils/require-auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createError, type H3Event } from 'h3'
|
||||
|
||||
import type { AuthContext } from '../types/auth'
|
||||
|
||||
/**
|
||||
* Garante que o middleware de autenticação já preencheu `event.context.auth`.
|
||||
*
|
||||
* @param event Evento HTTP atual.
|
||||
* @returns Contexto autenticado com ID do usuário e token.
|
||||
* @throws {H3Error} Quando a requisição não estiver autenticada.
|
||||
*/
|
||||
export function requireAuthContext(event: H3Event): AuthContext {
|
||||
if (!event.context.auth) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
}
|
||||
|
||||
return event.context.auth
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user