feat: Google Drive backup OAuth (Faz 1 — backend)

PRD "AyrisLegal Google Drive Backup & Recovery" kapsamında sadece
backend altyapısı: OAuth connect/callback/status/disconnect/refresh
uç noktaları, AES-256-GCM token şifreleme (tokenCrypto.ts), tek
kullanımlık CSRF state (oauthState.ts), drive.file scope ile
AyrisLegal/Davalar klasör oluşturma (googleDriveClient.ts). Electron
tarafı (dosya tarama/kuyruk/UI) ayrı bir aşamada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mstfyldz
2026-08-10 03:51:02 +03:00
co-authored by Claude Sonnet 5
parent 9618ff4513
commit 328aef91bd
6 changed files with 430 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// Google OAuth + Drive API v3 çağrıları — bilerek googleapis SDK'sı yerine
// düz fetch kullanılıyor (bu birkaç REST çağrısı için ağır bir bağımlılık
// gerekmiyor, projenin geri kalanındaki aiClient.ts ile aynı yaklaşım).
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
const DRIVE_API_URL = 'https://www.googleapis.com/drive/v3';
// PRD §7: mümkün olan en düşük scope. drive.file, uygulamanın SADECE kendi
// oluşturduğu/açtığı dosyalara erişmesini sağlar — kullanıcının tüm Drive'ına
// erişim istemiyoruz.
const SCOPE = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/userinfo.email',
].join(' ');
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
scope: string;
token_type: string;
}
function getOAuthConfig() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new Error('GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET veya GOOGLE_REDIRECT_URI tanımlı değil.');
}
return { clientId, clientSecret, redirectUri };
}
export function buildAuthUrl(state: string): string {
const { clientId, redirectUri } = getOAuthConfig();
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline', // refresh_token almak için gerekli
prompt: 'consent', // refresh_token'ın HER bağlantıda dönmesini garantiler
scope: SCOPE,
state,
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
}
export async function exchangeCodeForTokens(code: string): Promise<TokenResponse> {
const { clientId, clientSecret, redirectUri } = getOAuthConfig();
const res = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error_description || data.error || 'Google token exchange başarısız.');
}
return data;
}
export async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number; scope: string }> {
const { clientId, clientSecret } = getOAuthConfig();
const res = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
}),
});
const data = await res.json();
if (!res.ok) {
const err: any = new Error(data.error_description || data.error || 'Google token yenileme başarısız.');
err.code = data.error; // 'invalid_grant' → refresh token artık geçersiz
throw err;
}
return data;
}
// En iyi çaba — Google tarafında revoke başarısız olsa bile disconnect akışı
// yerel kaydı yine de siler (bkz. googleDrive.controller.ts).
export async function revokeToken(token: string): Promise<void> {
try {
await fetch(`${GOOGLE_REVOKE_URL}?token=${encodeURIComponent(token)}`, { method: 'POST' });
} catch (e: any) {
console.error('[DRIVE] Token revoke isteği başarısız:', e.message);
}
}
export async function getUserEmail(accessToken: string): Promise<string> {
const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
const data = await res.json();
if (!res.ok || !data.email) {
throw new Error('Google kullanıcı bilgisi alınamadı.');
}
return data.email;
}
// drive.file scope'u sadece uygulamanın oluşturduğu/açtığı dosyaları görmesine
// izin verir — bu yüzden klasörü her zaman biz oluşturuyoruz/arıyoruz, kullanıcının
// genel Drive'ında serbest arama yapamayız (zaten istemiyoruz, bkz. PRD §7).
async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise<string> {
const conditions = [`name='${name.replace(/'/g, "\\'")}'`, "mimeType='application/vnd.google-apps.folder'", 'trashed=false'];
if (parentId) conditions.push(`'${parentId}' in parents`);
const listRes = await fetch(`${DRIVE_API_URL}/files?q=${encodeURIComponent(conditions.join(' and '))}&fields=files(id,name)`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const listData = await listRes.json();
if (listRes.ok && Array.isArray(listData.files) && listData.files.length > 0) {
return listData.files[0].id;
}
const createRes = await fetch(`${DRIVE_API_URL}/files?fields=id`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
mimeType: 'application/vnd.google-apps.folder',
parents: parentId ? [parentId] : undefined,
}),
});
const createData = await createRes.json();
if (!createRes.ok) {
throw new Error(`Google Drive klasörü oluşturulamadı: ${name}`);
}
return createData.id;
}
// İlk bağlantıda AyrisLegal/Davalar klasör yapısını kurar (PRD §8). Sonraki
// bağlantılarda (aynı kullanıcı tekrar bağlanırsa) mevcut klasörler bulunup
// tekrar kullanılır, kopya oluşturulmaz.
export async function ensureAyrisLegalFolders(accessToken: string): Promise<{ rootFolderId: string; casesFolderId: string }> {
const rootFolderId = await findOrCreateFolder(accessToken, 'AyrisLegal');
const casesFolderId = await findOrCreateFolder(accessToken, 'Davalar', rootFolderId);
return { rootFolderId, casesFolderId };
}
+36
View File
@@ -0,0 +1,36 @@
import crypto from 'crypto';
// Google OAuth "state" parametresi: CSRF'e karşı kriptografik olarak rastgele,
// kısa ömürlü, tek kullanımlık (bkz. PRD §20). Bellek-içi Map yeterli — OAuth
// akışı saniyeler/dakikalar içinde tamamlanıyor, kalıcı depolamaya gerek yok.
// NOT: backend birden fazla instance'la (load balancer arkasında) çalışırsa bu
// state farklı bir instance'a düşebilir — o zaman paylaşımlı bir store (Redis
// vb.) gerekir. Şu an tek instance varsayımıyla yazıldı.
interface StateEntry { userId: string; expiresAt: number; }
const STATE_TTL_MS = 10 * 60 * 1000; // 10 dakika
const stateStore = new Map<string, StateEntry>();
function cleanupExpired() {
const now = Date.now();
for (const [key, entry] of stateStore.entries()) {
if (entry.expiresAt < now) stateStore.delete(key);
}
}
export function createState(userId: string): string {
cleanupExpired();
const state = crypto.randomBytes(32).toString('hex');
stateStore.set(state, { userId, expiresAt: Date.now() + STATE_TTL_MS });
return state;
}
// Tek kullanımlık — çağrıldığı anda store'dan siliniyor, ikinci kullanım (replay)
// her zaman null döner.
export function consumeState(state: string): string | null {
const entry = stateStore.get(state);
if (!entry) return null;
stateStore.delete(state);
if (entry.expiresAt < Date.now()) return null;
return entry.userId;
}
+42
View File
@@ -0,0 +1,42 @@
import crypto from 'crypto';
// Google OAuth tokenları asla plaintext saklanmaz (bkz. PRD §6) — AES-256-GCM
// ile şifrelenip tek bir string olarak (iv:authTag:ciphertext, hepsi base64)
// veritabanına yazılır. Node'un yerleşik crypto modülü kullanılıyor, ek
// bağımlılık gerekmiyor.
const ALGORITHM = 'aes-256-gcm';
function getKey(): Buffer {
const raw = process.env.GOOGLE_TOKEN_ENCRYPTION_KEY;
if (!raw) {
throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY ortam değişkeni tanımlı değil.');
}
const buf = raw.length === 64 ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
if (buf.length !== 32) {
throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY 32 byte olmalı (64 karakterlik hex ya da base64 olarak sağlayın).');
}
return buf;
}
export function encryptToken(plaintext: string): string {
const key = getKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted].map((b) => b.toString('base64')).join(':');
}
export function decryptToken(stored: string): string {
const key = getKey();
const [ivB64, tagB64, dataB64] = stored.split(':');
if (!ivB64 || !tagB64 || !dataB64) {
throw new Error('Şifrelenmiş token formatı geçersiz.');
}
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(tagB64, 'base64');
const data = Buffer.from(dataB64, 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}