From 283bfe599049ec15adabfc0ea3a3b755d65e9b5c Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 14:50:11 +0300 Subject: [PATCH] fix: bind Telegram pairing to expiring single-use account challenges --- docs/quality/uygulama-durumu.md | 8 ++++ docs/quality/yedek-geri-yukleme.md | 2 + server/index.js | 19 ++++---- server/telegram-link.mjs | 28 ++++++++++++ server/telegram.mjs | 20 ++++---- src/components/tools/TelegramBaglanti.jsx | 19 +++++--- src/services/account.js | 1 + tests/http-state.test.mjs | 7 +++ tests/telegram-link.test.mjs | 56 +++++++++++++++++++++++ 9 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 server/telegram-link.mjs create mode 100644 tests/telegram-link.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index f95ed91..4198e68 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1116,3 +1116,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Test sırasında kurum tipi seçiminin erişilebilir adının eksik olduğu bulundu; `Filtre kurum tipi` adı eklendi. Diğer seçimlerde mevcut erişilebilir adlar kullanıldı. QA verisine sentetik usul/kurum tipi alanları eklendi; resmi ihale verisi olarak değerlendirilmez. - Beş tarayıcı kontrolü geçti: ilk birleşik sonuç, yenileme sonrası yeniden uygulama, çelişen bölge boş sonucu, sıfırlama ve tekrar uygulama. Kanıt `rapor/qa-combined-saved-filter.json`. Build başarılı. Test sekmesi ve bu çalışmaya ait geçici sunucu kapatıldı. Bu dar UI değişikliği için otomatik süit tekrar çalıştırılmadı; son süit 262 testte geçmişti. - D05/D09 birleşik filtre kullanıcı akışına kanıt eklendi. Resmi kaynak alanlarının doğruluğu, gerçek firma ile beş başvuru dosyası ve diğer kabul maddeleri hâlâ açık; genel plan tamamlanmadı. + +### 2026-09-06 — Hesaba bağlı, süreli Telegram doğrulaması + +- Güvenlik/işlev hatası: eski kod tarayıcıda Math.random ile üretiliyor, herhangi bir altı haneli değer kabul ediliyor ve getUpdates mesajının rakamları içinde alt-dizi aranıyordu; süre/hesap/tek kullanım yoktu. Sunucuda 24 rastgele baytlık kod, yalnız hash saklama, 10 dakika süre, hesaba bağlı doğrulama ve tüketim eklendi. +- Özel sohbet, insan gönderici ve sohbet/gönderici kimliği eşleşmesi gerekiyor. Yalnız yeni mesajın tamamının kod veya `/start kod` olması kabul ediliyor; grup, bot, eski/gelecek tarih, düzenlenmiş mesaj ve alt-dizi eşleşmesi reddediliyor. Sağlayıcı okumasından sonra kod tekrar doğrulanıyor; bu sırada iptal/yenileme/süre dolması bağlantıyı engelliyor. Sürüm kontrollü kayıt tek kullanımı koruyor. +- Genel veri yazma API'siyle doğrudan Telegram chatId atama yolu kapatıldı. UI sunucudan yeni kod oluşturuyor, son geçerliliği gösteriyor, kod oluşmadan doğrulama sunmuyor. Mevcut bağlı hesaplar korunuyor; otomatik bağlantı-onay mesajı yerine uygulama sonucu gösteriliyor. +- Doğrulama: 266 test ve build başarılı. Gerçek kullanıcı deposunda hesap ayrımı, hash saklama, yenileme, süre sınırı, tüketim ve devam eden doğrulamanın iptali; sahte Telegram yanıtlarında özel/taze/tam eşleşme; HTTP üzerinden doğrudan yazma ve eski kod reddi test edildi. Gerçek Telegram ağına test mesajı gönderilmedi. +- Açık kabul: yeni kod ekranının tarayıcı testi, izinli gerçek hesapla bağlama; getUpdates son 100 güncelleme penceresinin yoğun botta yetersiz kalması; genel Telegram bağlantı sürüm/geri dönüş akışları. Genel plan henüz tamamlanmadı. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index 891219c..7c06d66 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -93,3 +93,5 @@ Eski `server/data/brief-son.json` silinmez: eski sürümün aynı hafta için i Haftalık belirsiz kayıtların karar akışı artık Yönetim → Mobil PWA → **Haftalık özet gönderim incelemesi** bölümündedir. `/api/admin/brief/outbox` yalnız yöneticiye açıktır. Gönderildi kararı sağlayıcı kimliği, açık kontrol onayı ve 20–2000 karakterlik açıklama ister; doğrulanamayan mesaj yeni gönderim yapmadan kapatılabilir. `brief_reviews` tablosu karar sahibini, türünü, açıklamasını ve tarihini aynı transaction içinde saklar. İkinci karar 409 ile reddedilir. Süresi dolmuş gönderim sahipliği liste yenilendiğinde belirsiz olarak görünür; etkin gönderim için karar verilemez. Tam volume yedeği karar tablosunu da kapsar. Bu bölüm önceki nottaki eksik yönetici karar ekranını tamamlar; sağlayıcı teslimat kanıtını otomatik olarak doğrulamaz. Yeni haftalık Telegram kabulleri `brief_messages.receipt` alanında `chat_id:message_id` olarak saklanır. Eski `telegram-accepted` değerleri gerçek mesaj kimliği değildir ve geriye dönük kimlik üretilmez. Sağlayıcı incelemesinde mesajın ait olduğu sohbet de kontrol edilmelidir; mesaj numarası tek başına bütün sohbetlerde tekil kabul edilmez. + +Telegram bağlama protokolü değişti: tarayıcının ürettiği altı haneli kod kabul edilmez. Oturumlu POST `/api/me/telegram` `{islem:"kod"}` sunucuda 24 rastgele baytlık kod üretir; kullanıcı deposunda yalnız hash, oluşturulma ve 10 dakikalık son geçerlilik saklanır. Yeni kod önceki kodu geçersiz kılar. Doğrulama yalnız hesapta aktif olan kodu tüketir; bağlantı kaldırma veya kod yenileme sürmekteki doğrulamayı da geçersiz kılar. Ön yüz ve sunucu birlikte güncellenmelidir. Mevcut bağlı sohbetler korunur. Genel `/api/me/data` yolu Telegram verisini yazamaz; bağlantı ekranı kullanılmalıdır. Bağlama sonrası otomatik Telegram onay mesajı kaldırıldı; bağlantı sonucu uygulamada gösterilir. Ayrı test mesajı düğmesi yine açık kullanıcı eylemidir. diff --git a/server/index.js b/server/index.js index 4a71568..fa78721 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,7 @@ import { kalemleriCikar } from './kalem-cikar.mjs'; import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs'; import { iknCikar } from './kalem-cikar.mjs'; import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs'; +import { createTelegramLinker } from './telegram-link.mjs'; import { telegramConfigured, botAdi, kodIleBagla, sendTelegram } from './telegram.mjs'; import { bildirimleriOku, okunduIsaretle, zeyilnameDurumu, zeyilnameTara, sonucTara } from './zeyilname.mjs'; @@ -897,7 +898,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { success: true, ...gommeDurumu() }); } // --- Telegram bağlantısı (kullanıcı kodu bota yazar, biz getUpdates'te görürüz) --- - if (url.pathname.startsWith('/api/me/telegram')) { + if (url.pathname === '/api/me/telegram') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); const tg = store.getData(user.email, 'telegram'); @@ -913,15 +914,16 @@ const server = http.createServer(async (req, res) => { await sendTelegram(tg.chatId, '✅ İhaleist bağlantı testi — bildirimler bu sohbete gelecek.'); return json(res, 200, { success: true }); } - const kod = String(body.kod || '').replace(/\D/g, ''); - if (kod.length !== 6) return json(res, 400, { success: false, error: '6 haneli kod gerekli' }); - const bulunan = await kodIleBagla(kod); - if (!bulunan) return json(res, 404, { success: false, error: 'Kod bota gönderilmiş görünmüyor. Önce bota yazın, sonra doğrulayın.' }); - store.setData(user.email, 'telegram', { ...bulunan, baglanma: new Date().toISOString() }); - await sendTelegram(bulunan.chatId, `✅ İhaleist hesabınız (${user.email}) bu sohbete bağlandı.`).catch(() => {}); + const linker=createTelegramLinker(store); + if(body.islem==='kod'){ + if(!telegramConfigured())return json(res,503,{success:false,error:'Telegram hizmeti etkin değil.'}); + return json(res,200,{success:true,...linker.issue(user.email)}); + } + const bulunan=await linker.link(user.email,body.kod,kodIleBagla); return json(res, 200, { success: true, ad: bulunan.ad }); - } catch (e) { return json(res, 502, { success: false, error: e.message }); } + } catch (e) { return json(res,e.kod==='VERSION_CONFLICT'?409:e.status||502,{success:false,error:e.message}); } } + return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); } // --- Takip bildirimleri (zeyilname / sonuç) --- if (url.pathname === '/api/me/bildirimler') { @@ -1217,6 +1219,7 @@ const server = http.createServer(async (req, res) => { const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder','brief'].includes(k); if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision))))) return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'}); + if(k==='telegram')return json(res,403,{success:false,error:'Telegram bağlantısını doğrulama ekranından yönetin.'}); const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined); return json(res, 200, { success: true, ...(protectedKey ? {revision} : {}) }); } catch (e) { diff --git a/server/telegram-link.mjs b/server/telegram-link.mjs new file mode 100644 index 0000000..dc219e1 --- /dev/null +++ b/server/telegram-link.mjs @@ -0,0 +1,28 @@ +import { randomBytes, createHash } from 'node:crypto'; +const digest = code => createHash('sha256').update(code).digest('hex'); +export function createTelegramLinker(store,clock=Date.now) { + function validate(email,code) { + const snapshot=store.getDataSnapshot(email),value=snapshot.veri.telegram; + if(typeof code!=='string'||!/^[a-f0-9]{48}$/.test(code)||value?.challengeHash!==digest(code)|| + !Number.isFinite(value.expiresAt)||clock()>=value.expiresAt) + throw Object.assign(new Error('Bağlama kodu geçersiz veya süresi dolmuş. Yeni kod oluşturun.'),{status:400}); + return {value,revision:snapshot.revisions.telegram ?? null}; + } + return { + issue(email) { + const snapshot=store.getDataSnapshot(email); + if(snapshot.veri.telegram?.chatId)throw Object.assign(new Error('Önce mevcut Telegram bağlantısını kaldırın.'),{status:409}); + const code=randomBytes(24).toString('hex'),issuedAt=clock(),expiresAt=issuedAt+10*60000; + store.setData(email,'telegram',{challengeHash:digest(code),issuedAt,expiresAt},snapshot.revisions.telegram ?? null); + return {kod:code,expiresAt}; + }, + async link(email,code,find) { + const {value}=validate(email,code); + const found=await find(code,{issuedAt:value.issuedAt,expiresAt:value.expiresAt,now:clock()}); + if(!found)throw Object.assign(new Error('Kod özel sohbette bulunamadı. Yeni kodu bota gönderip tekrar doğrulayın.'),{status:404}); + const current=validate(email,code); + store.setData(email,'telegram',{chatId:found.chatId,ad:found.ad,baglanma:new Date(clock()).toISOString()},current.revision); + return found; + }, + }; +} diff --git a/server/telegram.mjs b/server/telegram.mjs index ab24312..526bd77 100644 --- a/server/telegram.mjs +++ b/server/telegram.mjs @@ -2,7 +2,7 @@ * Telegram bildirim kanalı — Bot API, bağımlılık yok. * * KURULUM (kullanıcı/yönetici): BotFather'da bot oluşturulur, tokenı - * TELEGRAM_BOT_TOKEN env'ine girilir. Her kullanıcı uygulamadan aldığı 6 haneli + * TELEGRAM_BOT_TOKEN env'ine girilir. Her kullanıcı uygulamadan aldığı süreli * kodu bota yazar; sunucu getUpdates ile kodu görüp chat id'yi kullanıcıya * bağlar. Token bizde tutulmaz, sohbet kimliği kullanıcı verisinde durur. */ @@ -48,22 +48,24 @@ export async function sendTelegram(chatId, text, { parseMode = 'HTML', returnRec } /** - * Kullanıcının bota yazdığı 6 haneli kodu son güncellemelerde arar. + * Kullanıcının bota yazdığı süreli bağlama kodunu son güncellemelerde arar. * Bulursa { chatId, ad } döner. Long-polling yok; kullanıcı "doğrula" dediğinde * tek getUpdates çağrısı yeterli (Telegram son 24 saati tutar). */ -export async function kodIleBagla(kod) { +export async function kodIleBagla(kod, {issuedAt,expiresAt,now=Date.now()} = {}) { if (!telegramConfigured()) throw new Error('Sunucuda TELEGRAM_BOT_TOKEN tanımlı değil'); + if(!/^[a-f0-9]{48}$/.test(String(kod))||!Number.isFinite(issuedAt)||!Number.isFinite(expiresAt)||now>=expiresAt)return null; const r = await fetch(`${API()}/getUpdates?limit=100`, { signal: AbortSignal.timeout(15000) }); const d = await r.json().catch(() => ({})); if (!d.ok) throw new Error(d.description || 'getUpdates başarısız'); - const aranan = String(kod).trim(); + const aranan = String(kod); for (const u of [...(d.result || [])].reverse()) { - const m = u.message || u.edited_message; - if (!m?.text) continue; - if (m.text.replace(/\D/g, '').includes(aranan)) { - return { chatId: String(m.chat.id), ad: [m.chat.first_name, m.chat.last_name].filter(Boolean).join(' ') || m.chat.username || null }; - } + const m = u.message; + if(typeof m?.text!=='string'||m.chat?.type!=='private'||m.from?.is_bot!==false|| + !Number.isSafeInteger(m.chat.id)||m.chat.id<=0||m.from.id!==m.chat.id|| + !Number.isFinite(m.date)||m.dateMath.floor(now/1000))continue; + if(m.text.trim()===aranan || m.text.trim()===`/start ${aranan}`) + return {chatId:String(m.chat.id),ad:[m.chat.first_name,m.chat.last_name].filter(Boolean).join(' ')||m.chat.username||null}; } return null; } diff --git a/src/components/tools/TelegramBaglanti.jsx b/src/components/tools/TelegramBaglanti.jsx index 7d3b847..f251e95 100644 --- a/src/components/tools/TelegramBaglanti.jsx +++ b/src/components/tools/TelegramBaglanti.jsx @@ -2,14 +2,14 @@ import {useApp} from '../../context/AppContext'; import {getToken} from '../../services/auth'; import React, { useEffect, useState, useRef } from 'react'; import { Send, CheckCircle2, AlertTriangle, Loader2, Unplug } from 'lucide-react'; -import { telegramDurum, telegramBagla, telegramTest, telegramSil } from '../../services/account'; +import { telegramDurum, telegramKodOlustur, telegramBagla, telegramTest, telegramSil } from '../../services/account'; /** * Telegram bildirim bağlantısı — kullanıcı kendi kurar. - * Bot tokenı sunucuda (TELEGRAM_BOT_TOKEN); kullanıcı 6 haneli kodu bota yazar, + * Bot tokenı sunucuda (TELEGRAM_BOT_TOKEN); kullanıcı süreli bağlama kodunu bota yazar, * "Doğrula" deyince sunucu kodu görüp sohbeti hesaba bağlar. Token bizden geçmez. */ -const kodUret = () => String(Math.floor(100000 + Math.random() * 900000)); + export const TelegramBaglanti = () => { const {authUser,isAdmin}=useApp(); @@ -17,7 +17,8 @@ export const TelegramBaglanti = () => { }; const TelegramPanel = ({isAdmin}) => { const [d, setD] = useState(null); - const [kod] = useState(kodUret); + const [challenge,setChallenge]=useState(null); + const kod=challenge?.kod; const [busy, setBusy] = useState(null); const [mesaj, setMesaj] = useState(null); @@ -90,10 +91,16 @@ const TelegramPanel = ({isAdmin}) => { : (bot adı alınamadı — yöneticiye sorun)} {' '}ve Başlat'a basın. -
  • Bota şu kodu yazıp gönderin: {kod}
  • +
  • Yeni kod oluşturun ve bota kodun tamamını gönderin: {kod || 'Henüz kod oluşturulmadı'}
  • Sonra buraya dönüp Doğrula'ya basın.
  • - + {challenge&&

    Kod 10 dakika geçerlidir. Son geçerlilik: {new Date(challenge.expiresAt).toLocaleTimeString('tr-TR')}. Yeni kod oluşturmak önceki kodu geçersiz kılar.

    } + diff --git a/src/services/account.js b/src/services/account.js index 970fb91..d31ed16 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -111,6 +111,7 @@ export const updateDocumentMeta = (id, { sonKullanma, kategori } = {}) => // --- Telegram bağlantısı --- export const telegramDurum = () => iste('/api/me/telegram'); +export const telegramKodOlustur = () => iste('/api/me/telegram', {method:'POST',body:JSON.stringify({islem:'kod'})}); export const telegramBagla = (kod) => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ kod }) }); export const telegramTest = () => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ islem: 'test' }) }); export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' }); diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index 22314b3..e30180f 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -287,3 +287,10 @@ test('weekly brief review is admin-only and persists evidence without sending me assert.equal(saved.review.actor,'weekly-admin@example.test');assert.equal(saved.providerId,input.providerId); assert.equal(saved.state,'sent_manual'); }); + +test('Telegram cannot be linked through generic data writes or legacy client codes',async()=>{ + assert.equal((await request('/api/me/data','PUT',{anahtar:'telegram',deger:{chatId:'123'}})).status,403); + assert.equal((await request('/api/me/telegram','POST',{kod:'123456'})).status,400); + assert.equal((await request('/api/me/telegram','PATCH',{})).status,405); + assert.equal((await request('/api/me/telegram','POST',{islem:'kod'},null)).status,401); +}); diff --git a/tests/telegram-link.test.mjs b/tests/telegram-link.test.mjs new file mode 100644 index 0000000..6e3d53e --- /dev/null +++ b/tests/telegram-link.test.mjs @@ -0,0 +1,56 @@ +import {test,after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {createTelegramLinker} from '../server/telegram-link.mjs'; +import {kodIleBagla} from '../server/telegram.mjs'; +const directory=mkdtempSync(join(tmpdir(),'telegram-link-')); +process.env.IHALEIST_USER_DATA_DIR=directory; +const store=await import('../server/user-store.mjs'); +after(()=>rmSync(directory,{recursive:true,force:true})); +const found={chatId:'123',ad:'Test'}; + +test('server challenge belongs to its account, expires, rotates and is consumed once',async()=>{ + let time=1000000;const linker=createTelegramLinker(store,()=>time),email='link@example.test'; + const first=linker.issue(email);assert.match(first.kod,/^[a-f0-9]{48}$/); + assert.ok(!JSON.stringify(store.getData(email,'telegram')).includes(first.kod)); + await assert.rejects(linker.link('other@example.test',first.kod,async()=>found),{status:400}); + const second=linker.issue(email);assert.notEqual(second.kod,first.kod); + await assert.rejects(linker.link(email,first.kod,async()=>found),{status:400}); + time=second.expiresAt; + await assert.rejects(linker.link(email,second.kod,async()=>found),{status:400}); + const current=linker.issue(email); + assert.deepEqual(await linker.link(email,current.kod,async()=>found),found); + await assert.rejects(linker.link(email,current.kod,async()=>found),{status:400}); + assert.equal(store.getData(email,'telegram').chatId,'123'); + assert.throws(()=>linker.issue(email),{status:409}); +}); + +test('disconnect or expiration during provider lookup cannot restore a removed link',async()=>{ + let time=1000000;const linker=createTelegramLinker(store,()=>time),email='disconnect@example.test'; + const challenge=linker.issue(email); + await assert.rejects(linker.link(email,challenge.kod,async()=>{store.setData(email,'telegram',null);return found;}),{status:400}); + assert.equal(store.getData(email,'telegram'),null); + const next=linker.issue(email); + await assert.rejects(linker.link(email,next.kod,async()=>{time=next.expiresAt;return found;}),{status:400}); +}); + +test('matching accepts only a fresh exact code from a private non-bot sender',async()=>{ + const oldFetch=globalThis.fetch,oldToken=process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN='synthetic-token'; + const code='a'.repeat(48),window={issuedAt:1000000,expiresAt:1600000,now:1100000}; + const good={text:code,date:1050,chat:{id:123,type:'private',first_name:'QA'},from:{id:123,is_bot:false}}; + let updates=[]; + globalThis.fetch=async()=>({ok:true,json:async()=>({ok:true,result:updates})}); + try { + for(const message of [{...good,text:'prefix '+code},{...good,text:code+' suffix'},{...good,date:999}, + {...good,date:1101},{...good,chat:{id:123,type:'group'}},{...good,from:{id:123,is_bot:true}}, + {...good,from:{id:999,is_bot:false}}]) { + updates=[{message}];assert.equal(await kodIleBagla(code,window),null); + } + updates=[{edited_message:good}];assert.equal(await kodIleBagla(code,window),null); + updates=[{message:good}];assert.equal((await kodIleBagla(code,window)).chatId,'123'); + updates=[{message:{...good,text:'/start '+code}}];assert.equal((await kodIleBagla(code,window)).ad,'QA'); + } finally {globalThis.fetch=oldFetch;if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=oldToken;} +});