first commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user