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