diff --git a/app/api/auth/device/verify/route.ts b/app/api/auth/device/verify/route.ts index a862542..8af7767 100644 --- a/app/api/auth/device/verify/route.ts +++ b/app/api/auth/device/verify/route.ts @@ -3,9 +3,11 @@ * * Valida se um token de API é válido. * Usado pelo app Android durante o setup. + * + * Aceita tokens no formato os_xxx (hash-based, sem expiração). */ -import { validateApiToken, extractBearerToken } from "@/lib/auth/api-token"; +import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; import { apiTokens } from "@/db/schema"; import { eq, and, isNull } from "drizzle-orm"; @@ -24,47 +26,50 @@ export async function POST(request: Request) { ); } - // Validar JWT - const payload = validateApiToken(token); - - if (!payload) { + // Validar token os_xxx via hash lookup + if (!token.startsWith("os_")) { return NextResponse.json( - { valid: false, error: "Token inválido ou expirado" }, + { valid: false, error: "Formato de token inválido" }, { status: 401 } ); } - // Verificar se token não foi revogado + // Hash do token para buscar no DB + 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) ), }); if (!tokenRecord) { return NextResponse.json( - { valid: false, error: "Token revogado ou não encontrado" }, + { valid: false, error: "Token inválido ou revogado" }, { status: 401 } ); } // Atualizar último uso + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || null; + await db .update(apiTokens) .set({ lastUsedAt: new Date(), - lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), + lastUsedIp: clientIp, }) - .where(eq(apiTokens.id, payload.tokenId)); + .where(eq(apiTokens.id, tokenRecord.id)); return NextResponse.json({ valid: true, - userId: payload.sub, - tokenId: payload.tokenId, + userId: tokenRecord.userId, + tokenId: tokenRecord.id, tokenName: tokenRecord.name, - expiresAt: new Date(payload.exp * 1000).toISOString(), }); } catch (error) { console.error("[API] Error verifying device token:", error); diff --git a/app/api/inbox/batch/route.ts b/app/api/inbox/batch/route.ts index f84c0f3..57ed8d4 100644 --- a/app/api/inbox/batch/route.ts +++ b/app/api/inbox/batch/route.ts @@ -2,10 +2,10 @@ * POST /api/inbox/batch * * Recebe múltiplas notificações do app Android (sync offline). - * Requer autenticação via API token. + * Requer autenticação via API token (formato os_xxx). */ -import { validateApiToken, extractBearerToken } from "@/lib/auth/api-token"; +import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; import { apiTokens, inboxItems } from "@/db/schema"; import { eq, and, isNull } from "drizzle-orm"; @@ -55,34 +55,33 @@ export async function POST(request: Request) { ); } - // Validar JWT - const payload = validateApiToken(token); - - if (!payload) { + // Validar token os_xxx via hash + if (!token.startsWith("os_")) { return NextResponse.json( - { error: "Token inválido ou expirado" }, + { error: "Formato de token inválido" }, { status: 401 } ); } - // Verificar se token não foi revogado + 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) ), }); if (!tokenRecord) { return NextResponse.json( - { error: "Token revogado ou não encontrado" }, + { error: "Token inválido ou revogado" }, { status: 401 } ); } // Rate limiting - if (!checkRateLimit(payload.sub)) { + if (!checkRateLimit(tokenRecord.userId)) { return NextResponse.json( { error: "Limite de requisições excedido", retryAfter: 60 }, { status: 429 } @@ -101,10 +100,10 @@ export async function POST(request: Request) { const [inserted] = await db .insert(inboxItems) .values({ - userId: payload.sub, + userId: tokenRecord.userId, sourceApp: item.sourceApp, sourceAppName: item.sourceAppName, - deviceId: item.deviceId || payload.deviceId, + deviceId: item.deviceId, originalTitle: item.originalTitle, originalText: item.originalText, notificationTimestamp: item.notificationTimestamp, @@ -132,13 +131,17 @@ export async function POST(request: Request) { } // Atualizar último uso do token + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || null; + await db .update(apiTokens) .set({ lastUsedAt: new Date(), - lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), + lastUsedIp: clientIp, }) - .where(eq(apiTokens.id, payload.tokenId)); + .where(eq(apiTokens.id, tokenRecord.id)); const successCount = results.filter((r) => r.success).length; const failCount = results.filter((r) => !r.success).length; diff --git a/app/api/inbox/route.ts b/app/api/inbox/route.ts index 3b11afa..32dff0a 100644 --- a/app/api/inbox/route.ts +++ b/app/api/inbox/route.ts @@ -2,10 +2,10 @@ * POST /api/inbox * * Recebe uma notificação do app Android. - * Requer autenticação via API token. + * Requer autenticação via API token (formato os_xxx). */ -import { validateApiToken, extractBearerToken } from "@/lib/auth/api-token"; +import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; import { apiTokens, inboxItems } from "@/db/schema"; import { eq, and, isNull } from "drizzle-orm"; @@ -48,34 +48,33 @@ export async function POST(request: Request) { ); } - // Validar JWT - const payload = validateApiToken(token); - - if (!payload) { + // Validar token os_xxx via hash + if (!token.startsWith("os_")) { return NextResponse.json( - { error: "Token inválido ou expirado" }, + { error: "Formato de token inválido" }, { status: 401 } ); } - // Verificar se token não foi revogado + 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) ), }); if (!tokenRecord) { return NextResponse.json( - { error: "Token revogado ou não encontrado" }, + { error: "Token inválido ou revogado" }, { status: 401 } ); } // Rate limiting - if (!checkRateLimit(payload.sub)) { + if (!checkRateLimit(tokenRecord.userId)) { return NextResponse.json( { error: "Limite de requisições excedido", retryAfter: 60 }, { status: 429 } @@ -90,10 +89,10 @@ export async function POST(request: Request) { const [inserted] = await db .insert(inboxItems) .values({ - userId: payload.sub, + userId: tokenRecord.userId, sourceApp: data.sourceApp, sourceAppName: data.sourceAppName, - deviceId: data.deviceId || payload.deviceId, + deviceId: data.deviceId, originalTitle: data.originalTitle, originalText: data.originalText, notificationTimestamp: data.notificationTimestamp, @@ -107,13 +106,17 @@ export async function POST(request: Request) { .returning({ id: inboxItems.id }); // Atualizar último uso do token + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || null; + await db .update(apiTokens) .set({ lastUsedAt: new Date(), - lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), + lastUsedIp: clientIp, }) - .where(eq(apiTokens.id, payload.tokenId)); + .where(eq(apiTokens.id, tokenRecord.id)); return NextResponse.json( { diff --git a/lib/auth/api-token.ts b/lib/auth/api-token.ts index 977dc42..5fcd277 100644 --- a/lib/auth/api-token.ts +++ b/lib/auth/api-token.ts @@ -216,6 +216,7 @@ export function extractBearerToken(authHeader: string | null): string | null { /** * Validate an API token and return the payload + * @deprecated Use validateHashToken for os_xxx tokens */ export function validateApiToken(token: string): JwtPayload | null { const payload = verifyJwt(token); @@ -224,3 +225,14 @@ export function validateApiToken(token: string): JwtPayload | null { } return payload; } + +/** + * Validate a hash-based API token (os_xxx format) + * Returns the token hash for database lookup + */ +export function validateHashToken(token: string): { valid: boolean; tokenHash?: string } { + if (!token || !token.startsWith("os_")) { + return { valid: false }; + } + return { valid: true, tokenHash: hashToken(token) }; +}