diff --git a/CHANGELOG.md b/CHANGELOG.md index dc69b0a..69b1b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Unreleased] +## [2.3.3] - 2026-04-05 + +### Corrigido + +- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup) + +### Alterado + +- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados +- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`) +- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken` + ## [2.3.2] - 2026-04-04 ### Segurança diff --git a/package.json b/package.json index 71cf4ad..b678c46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "2.3.2", + "version": "2.3.3", "private": true, "packageManager": "pnpm@10.33.0", "scripts": { diff --git a/src/app/api/auth/device/refresh/route.ts b/src/app/api/auth/device/refresh/route.ts deleted file mode 100644 index e2954e9..0000000 --- a/src/app/api/auth/device/refresh/route.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { and, eq, gt, isNull } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { apiTokens } from "@/db/schema"; -import { - extractBearerToken, - refreshAccessToken, - verifyJwt, -} from "@/shared/lib/auth/api-token"; -import { db } from "@/shared/lib/db"; - -export async function POST(request: Request) { - try { - // Extrair refresh token do header - const authHeader = request.headers.get("Authorization"); - const token = extractBearerToken(authHeader); - - if (!token) { - return NextResponse.json( - { error: "Refresh token não fornecido" }, - { status: 401 }, - ); - } - - // Validar refresh token - const payload = verifyJwt(token); - - if (!payload || payload.type !== "api_refresh") { - return NextResponse.json( - { error: "Refresh token inválido ou expirado" }, - { status: 401 }, - ); - } - - // Verificar se token não foi revogado - const tokenRecord = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.id, payload.tokenId), - eq(apiTokens.userId, payload.sub), - isNull(apiTokens.revokedAt), - gt(apiTokens.expiresAt, new Date()), - ), - }); - - if (!tokenRecord) { - return NextResponse.json( - { error: "Token revogado ou não encontrado" }, - { status: 401 }, - ); - } - - // Gerar novo access token - const result = refreshAccessToken(token); - - if (!result) { - return NextResponse.json( - { error: "Não foi possível renovar o token" }, - { status: 401 }, - ); - } - - // Atualizar último uso e expiração (sem sobrescrever tokenHash, - // pois o JWT é auto-verificável por assinatura) - await db - .update(apiTokens) - .set({ - lastUsedAt: new Date(), - lastUsedIp: - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null, - expiresAt: result.expiresAt, - }) - .where(eq(apiTokens.id, payload.tokenId)); - - return NextResponse.json({ - accessToken: result.accessToken, - expiresAt: result.expiresAt.toISOString(), - }); - } catch (error) { - console.error("[API] Error refreshing device token:", error); - return NextResponse.json( - { error: "Erro ao renovar token" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/auth/device/token/route.ts b/src/app/api/auth/device/token/route.ts deleted file mode 100644 index 282aa28..0000000 --- a/src/app/api/auth/device/token/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { headers } from "next/headers"; -import { connection, NextResponse } from "next/server"; -import { z } from "zod"; -import { apiTokens } from "@/db/schema"; -import { - generateTokenPair, - getTokenPrefix, - hashToken, -} from "@/shared/lib/auth/api-token"; -import { auth } from "@/shared/lib/auth/config"; -import { db } from "@/shared/lib/db"; - -const createTokenSchema = z.object({ - name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"), - deviceId: z.string().optional(), -}); - -export async function POST(request: Request) { - await connection(); - - // Verificar autenticação via sessão web - const requestHeaders = new Headers(await headers()); - const session = await auth.api.getSession({ headers: requestHeaders }); - - if (!session?.user) { - return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); - } - - try { - // Validar body - const body = await request.json(); - const { name, deviceId } = createTokenSchema.parse(body); - - // Gerar par de tokens - const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair( - session.user.id, - deviceId, - ); - - // Salvar hash do token no banco - await db.insert(apiTokens).values({ - id: tokenId, - userId: session.user.id, - name, - tokenHash: hashToken(accessToken), - tokenPrefix: getTokenPrefix(accessToken), - expiresAt, - }); - - // Retornar tokens (mostrados apenas uma vez) - return NextResponse.json( - { - accessToken, - refreshToken, - tokenId, - name, - expiresAt: expiresAt.toISOString(), - message: - "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.", - }, - { status: 201 }, - ); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.issues[0]?.message ?? "Dados inválidos" }, - { status: 400 }, - ); - } - - console.error("[API] Error creating device token:", error); - return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 }); - } -} diff --git a/src/app/api/auth/device/verify/route.ts b/src/app/api/auth/device/verify/route.ts index c40329b..a4bff87 100644 --- a/src/app/api/auth/device/verify/route.ts +++ b/src/app/api/auth/device/verify/route.ts @@ -1,7 +1,7 @@ import { and, eq, gt, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { apiTokens } from "@/db/schema"; -import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token"; +import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token"; import { db } from "@/shared/lib/db"; export async function POST(request: Request) { @@ -17,21 +17,20 @@ export async function POST(request: Request) { ); } - // Verificar JWT (assinatura + expiração) - const payload = verifyJwt(token); - - if (!payload || payload.type !== "api_access") { + // Validar token opm_xxx via hash + if (!token.startsWith("opm_")) { return NextResponse.json( - { valid: false, error: "Token inválido ou expirado" }, + { valid: false, error: "Formato de token inválido" }, { status: 401 }, ); } - // Buscar token no banco por tokenId para checar revogação + const tokenHash = hashToken(token); + + // Buscar token no banco const tokenRecord = await db.query.apiTokens.findFirst({ where: and( - eq(apiTokens.id, payload.tokenId), - eq(apiTokens.userId, payload.sub), + eq(apiTokens.tokenHash, tokenHash), isNull(apiTokens.revokedAt), gt(apiTokens.expiresAt, new Date()), ), @@ -39,7 +38,7 @@ export async function POST(request: Request) { if (!tokenRecord) { return NextResponse.json( - { valid: false, error: "Token revogado ou não encontrado" }, + { valid: false, error: "Token inválido ou revogado" }, { status: 401 }, ); } diff --git a/src/app/api/inbox/batch/route.ts b/src/app/api/inbox/batch/route.ts index 3c065f0..ee5386a 100644 --- a/src/app/api/inbox/batch/route.ts +++ b/src/app/api/inbox/batch/route.ts @@ -48,8 +48,8 @@ export async function POST(request: Request) { ); } - // Validar token os_xxx via hash - if (!token.startsWith("os_")) { + // Validar token opm_xxx via hash + if (!token.startsWith("opm_")) { return NextResponse.json( { error: "Formato de token inválido" }, { status: 401 }, diff --git a/src/app/api/inbox/route.ts b/src/app/api/inbox/route.ts index bfd9240..ed055c7 100644 --- a/src/app/api/inbox/route.ts +++ b/src/app/api/inbox/route.ts @@ -41,8 +41,8 @@ export async function POST(request: Request) { ); } - // Validar token os_xxx via hash - if (!token.startsWith("os_")) { + // Validar token opm_xxx via hash + if (!token.startsWith("opm_")) { return NextResponse.json( { error: "Formato de token inválido" }, { status: 401 }, diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index 9942dd8..15ce1a8 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({ }); function generateSecureToken(): string { - const prefix = "os"; + const prefix = "opm"; const randomPart = randomBytes(32).toString("base64url"); return `${prefix}_${randomPart}`; } diff --git a/src/shared/lib/auth/api-token.ts b/src/shared/lib/auth/api-token.ts index d9fb940..cbad3dd 100644 --- a/src/shared/lib/auth/api-token.ts +++ b/src/shared/lib/auth/api-token.ts @@ -1,149 +1,5 @@ import crypto from "node:crypto"; -function getJwtSecret(): string { - const secret = process.env.BETTER_AUTH_SECRET; - if (!secret) { - throw new Error( - "BETTER_AUTH_SECRET is required. Set it in your .env file.", - ); - } - return secret; -} -const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds -const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds - -// ============================================================================ -// TYPES -// ============================================================================ - -export interface JwtPayload { - sub: string; // userId - type: "api_access" | "api_refresh"; - tokenId: string; - deviceId?: string; - iat: number; - exp: number; -} - -export interface TokenPair { - accessToken: string; - refreshToken: string; - tokenId: string; - expiresAt: Date; -} - -// ============================================================================ -// JWT UTILITIES -// ============================================================================ - -/** - * Base64URL encode a string - */ -function base64UrlEncode(str: string): string { - return Buffer.from(str) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -} - -/** - * Base64URL decode a string - */ -function base64UrlDecode(str: string): string { - str = str.replace(/-/g, "+").replace(/_/g, "/"); - const pad = str.length % 4; - if (pad) { - str += "=".repeat(4 - pad); - } - return Buffer.from(str, "base64").toString(); -} - -/** - * Create HMAC-SHA256 signature - */ -function createSignature(data: string): string { - return crypto - .createHmac("sha256", getJwtSecret()) - .update(data) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -} - -/** - * Create a JWT token - */ -export function createJwt( - payload: Omit, - expiresIn: number, -): string { - const header = { alg: "HS256", typ: "JWT" }; - const now = Math.floor(Date.now() / 1000); - - const fullPayload: JwtPayload = { - ...payload, - iat: now, - exp: now + expiresIn, - }; - - const headerEncoded = base64UrlEncode(JSON.stringify(header)); - const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload)); - const signature = createSignature(`${headerEncoded}.${payloadEncoded}`); - - return `${headerEncoded}.${payloadEncoded}.${signature}`; -} - -/** - * Verify and decode a JWT token - * @returns The decoded payload or null if invalid - */ -export function verifyJwt(token: string): JwtPayload | null { - try { - const parts = token.split("."); - if (parts.length !== 3) return null; - - const [headerEncoded, payloadEncoded, signature] = parts; - const expectedSignature = createSignature( - `${headerEncoded}.${payloadEncoded}`, - ); - - // Constant-time comparison to prevent timing attacks - if ( - !crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature), - ) - ) { - return null; - } - - const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded)); - - // Check expiration - const now = Math.floor(Date.now() / 1000); - if (payload.exp < now) { - return null; - } - - return payload; - } catch { - return null; - } -} - -// ============================================================================ -// TOKEN GENERATION -// ============================================================================ - -/** - * Generate a random token ID - */ -export function generateTokenId(): string { - return crypto.randomUUID(); -} - /** * Hash a token using SHA-256 */ @@ -151,74 +7,6 @@ export function hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } -/** - * Get the display prefix of a token (first 8 chars after prefix) - */ -export function getTokenPrefix(token: string): string { - // Remove "os_" prefix and get first 8 chars - const withoutPrefix = token.replace(/^os_/, ""); - return `os_${withoutPrefix.substring(0, 8)}...`; -} - -/** - * Generate a complete token pair (access + refresh) - */ -export function generateTokenPair( - userId: string, - deviceId?: string, -): TokenPair { - const tokenId = generateTokenId(); - const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000); - - const accessToken = createJwt( - { sub: userId, type: "api_access", tokenId, deviceId }, - ACCESS_TOKEN_EXPIRY, - ); - - const refreshToken = createJwt( - { sub: userId, type: "api_refresh", tokenId, deviceId }, - REFRESH_TOKEN_EXPIRY, - ); - - return { - accessToken, - refreshToken, - tokenId, - expiresAt, - }; -} - -/** - * Refresh an access token using a refresh token - */ -export function refreshAccessToken( - refreshToken: string, -): { accessToken: string; expiresAt: Date } | null { - const payload = verifyJwt(refreshToken); - - if (!payload || payload.type !== "api_refresh") { - return null; - } - - const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000); - - const accessToken = createJwt( - { - sub: payload.sub, - type: "api_access", - tokenId: payload.tokenId, - deviceId: payload.deviceId, - }, - ACCESS_TOKEN_EXPIRY, - ); - - return { accessToken, expiresAt }; -} - -// ============================================================================ -// VALIDATION HELPERS -// ============================================================================ - /** * Extract bearer token from Authorization header */