feat(auth): Google ile Giriş + landing/login + çalışan çıkış + admin rolü
- server/auth.mjs: ID token'ı oauth2.googleapis.com/tokeninfo ile doğrula (ek bağımlılık yok), HMAC imzalı app token, ADMIN_EMAILS ile admin - POST /api/auth/google, GET /api/auth/google/config, GET /api/auth/me - LoginPage: GIS butonlu landing/giriş; giriş yapılmadan uygulama açılmaz - Header çıkış butonu artık çalışıyor; kullanıcı adı/avatar Google'dan - AI Model Ayarları sekmesi sadece admin'e görünür - Env: GOOGLE_CLIENT_ID + ADMIN_EMAILS (Coolify'dan)
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Google ile Giriş — backend doğrulama (ek bağımlılık yok).
|
||||
* GIS ID-token akışı: frontend Google'dan ID token alır, buraya gönderir;
|
||||
* biz oauth2.googleapis.com/tokeninfo ile doğrular, aud=GOOGLE_CLIENT_ID +
|
||||
* email_verified kontrol eder, HMAC imzalı kısa bir uygulama token'ı veririz.
|
||||
* Admin: ADMIN_EMAILS listesindeki e-postalar.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import https from 'node:https';
|
||||
|
||||
const CLIENT_ID = () => process.env.GOOGLE_CLIENT_ID || null;
|
||||
const AUTH_SECRET = () =>
|
||||
process.env.AUTH_SECRET || process.env.GOOGLE_CLIENT_ID || 'ihaleist-dev-secret';
|
||||
const TOKEN_TTL_SEC = 30 * 24 * 60 * 60; // 30 gün
|
||||
|
||||
export function adminEmails() {
|
||||
return (process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
export function isAdminEmail(email) {
|
||||
return adminEmails().includes(String(email || '').toLowerCase());
|
||||
}
|
||||
|
||||
// --- Uygulama token'ı (HMAC imzalı, kompakt) ---
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function b64urlJson(obj) {
|
||||
return b64url(JSON.stringify(obj));
|
||||
}
|
||||
export function signToken(payload) {
|
||||
const body = { ...payload, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC };
|
||||
const data = b64urlJson(body);
|
||||
const sig = b64url(crypto.createHmac('sha256', AUTH_SECRET()).update(data).digest());
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
export function verifyToken(token) {
|
||||
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
|
||||
const [data, sig] = token.split('.');
|
||||
const expected = b64url(crypto.createHmac('sha256', AUTH_SECRET()).update(data).digest());
|
||||
if (
|
||||
sig.length !== expected.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
|
||||
if (body.exp && body.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return body;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Google ID token doğrulama (tokeninfo) ---
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { timeout: 15000 }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (res.statusCode !== 200) return reject(new Error(`tokeninfo HTTP ${res.statusCode}`));
|
||||
try {
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new Error('tokeninfo geçersiz JSON'));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('timeout', function () {
|
||||
this.destroy(new Error('tokeninfo zaman aşımı'));
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginWithGoogle(credential) {
|
||||
const clientId = CLIENT_ID();
|
||||
if (!clientId) throw new Error('Google girişi kapalı (GOOGLE_CLIENT_ID yok)');
|
||||
if (!credential) throw new Error('credential yok');
|
||||
const p = await fetchJson(
|
||||
`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`,
|
||||
);
|
||||
if (p.aud !== clientId) throw new Error('aud eşleşmiyor');
|
||||
if (p.email_verified !== 'true' && p.email_verified !== true) throw new Error('e-posta doğrulanmamış');
|
||||
const email = String(p.email).toLowerCase();
|
||||
const isAdmin = isAdminEmail(email);
|
||||
const user = {
|
||||
email,
|
||||
name: p.name || p.given_name || email.split('@')[0],
|
||||
picture: p.picture || null,
|
||||
isAdmin,
|
||||
roles: isAdmin ? ['USER', 'ADMIN'] : ['USER'],
|
||||
};
|
||||
const token = signToken({ email: user.email, name: user.name, isAdmin });
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
export function userFromAuthHeader(authHeader) {
|
||||
const m = /^Bearer\s+(.+)$/i.exec(authHeader || '');
|
||||
if (!m) return null;
|
||||
const claims = verifyToken(m[1]);
|
||||
if (!claims) return null;
|
||||
return {
|
||||
email: claims.email,
|
||||
name: claims.name,
|
||||
isAdmin: Boolean(claims.isAdmin),
|
||||
roles: claims.isAdmin ? ['USER', 'ADMIN'] : ['USER'],
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readDtTenders } from './dt-store.mjs';
|
||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||
|
||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||
@@ -295,9 +296,46 @@ function json(res, code, body) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req, limit = 100 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => {
|
||||
data += c;
|
||||
if (data.length > limit) reject(new Error('gövde çok büyük'));
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
reject(new Error('geçersiz JSON gövdesi'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
try {
|
||||
// --- Google ile Giriş ---
|
||||
if (url.pathname === '/api/auth/google/config') {
|
||||
return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null });
|
||||
}
|
||||
if (url.pathname === '/api/auth/google' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const { token, user } = await loginWithGoogle(body.credential);
|
||||
return json(res, 200, { success: true, token, user });
|
||||
} catch (e) {
|
||||
return json(res, 401, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/api/auth/me') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
return json(res, 200, { success: true, user });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tenders') {
|
||||
const force = url.searchParams.get('refresh') === '1';
|
||||
const c = await getTenders(force);
|
||||
|
||||
Reference in New Issue
Block a user