feat(api): public API POC — API-key auth + /api/v1 decode & OEM xref
Kapalı-beta public API temeli (rapor: api-widget-analizi-2026-07-03): - better-auth apiKey plugin (defaultPrefix sase_, enableMetadata, anahtar başına 120 istek/dk); enableSessionForAPIKeys KAPALI — anahtar dashboard oturumu yerine geçmez, cookie AuthGuard davranışı değişmez - apikeys tablosu (migration 0023, plugin şemasının birebir karşılığı) - ApiKeyGuard: Authorization Bearer / x-api-key → verifyApiKey → kullanıcı yüklenir (status kontrolü), request.user + request.apiKey doldurulur - /api/v1/vin/decode: mevcut slim kontrat + aday akışı; günlük başarılı-decode kotası (Redis, TR günü, aynı VIN idempotent; limit: key metadata dailyDecodeLimit → PUBLIC_API_DAILY_DECODE_LIMIT → 100) ve X-Decode-Quota-* header'ları - /api/v1/oem/:code: P çapraz-referans (kotasız) - internal/admin/api-keys: x-internal-token ile anahtar üret/listele/kapat (düz metin yalnız create cevabında) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type ApiKeyRecord, getApiKeyApi } from "../../auth/auth";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { users } from "../../database/schema/core";
|
||||
|
||||
export type VerifiedApiKey = ApiKeyRecord;
|
||||
|
||||
/**
|
||||
* Public API (/api/v1) kimlik doğrulaması. Cookie tabanlı AuthGuard'dan
|
||||
* tamamen ayrı: anahtar `Authorization: Bearer sase_...` veya `x-api-key`
|
||||
* header'ında gelir, better-auth apiKey plugin'i ile doğrulanır (hash
|
||||
* karşılaştırma + enabled/expiry/anahtar-başına rate-limit). Geçerliyse
|
||||
* anahtarın sahibi kullanıcı yüklenir ve `request.user` + `request.apiKey`
|
||||
* doldurulur — böylece mevcut servisler (decodeVin'in abonelik/marka
|
||||
* yetki kontrolü dahil) hiçbir değişiklik olmadan çalışır.
|
||||
*
|
||||
* Controller'lar bu guard'ı `@Public()` ile birlikte kullanmalı; @Public
|
||||
* global AuthGuard'ı atlatır, kimlik işini bu guard devralır.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const rawKey = this.extractKey(request.headers);
|
||||
if (!rawKey) {
|
||||
throw new UnauthorizedException(
|
||||
"API anahtarı gerekli. Anahtarınızı 'Authorization: Bearer <anahtar>' header'ında gönderin.",
|
||||
);
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
|
||||
try {
|
||||
result = await getApiKeyApi().verifyApiKey({ body: { key: rawKey } });
|
||||
} catch {
|
||||
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||
}
|
||||
|
||||
if (!result.valid || !result.key) {
|
||||
const code = result.error?.code ?? "INVALID_API_KEY";
|
||||
if (code === "RATE_LIMITED" || code === "USAGE_EXCEEDED" || code === "TOO_MANY_REQUESTS") {
|
||||
throw new HttpException(
|
||||
"İstek limiti aşıldı. Lütfen bekleyip tekrar deneyin.",
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
if (code === "KEY_EXPIRED") {
|
||||
throw new UnauthorizedException("API anahtarının süresi dolmuş.");
|
||||
}
|
||||
if (code === "KEY_DISABLED") {
|
||||
throw new UnauthorizedException("API anahtarı devre dışı bırakılmış.");
|
||||
}
|
||||
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||
}
|
||||
|
||||
const key = result.key;
|
||||
const [user] = await this.db.select().from(users).where(eq(users.id, key.userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("API anahtarının sahibi bulunamadı.");
|
||||
}
|
||||
if (user.status !== "active") {
|
||||
throw new UnauthorizedException(
|
||||
"Hesabınız askıya alınmış durumda. Destek ile iletişime geçin.",
|
||||
);
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
request.apiKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Authorization: Bearer <key> (tercih edilen) veya x-api-key header'ı. */
|
||||
private extractKey(headers: Record<string, unknown>): string | null {
|
||||
const authHeader = headers.authorization;
|
||||
if (typeof authHeader === "string" && authHeader.length > 0) {
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
const value = (match ? match[1] : authHeader).trim();
|
||||
if (value.length > 0) return value;
|
||||
}
|
||||
const apiKeyHeader = headers["x-api-key"];
|
||||
if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) {
|
||||
return apiKeyHeader.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user