Hesap Güvenliği ve Oturum Yönetimi
Hesap paylaşımı engelleme, eşzamanlı oturum limiti, review bombing koruması, iade suistimali önleme ve 401 hata sayfası.
Bu sayfa Achidemy’de hesap paylaşımı engelleme (Account Sharing Prevention), review bombing koruması, iade suistimali önleme ve 401 Unauthorized hata sayfası implementasyonlarını açıklar.
B2B (Company) — Lokal Subdomain Oturum Notları
Section titled “B2B (Company) — Lokal Subdomain Oturum Notları”B2B paneli (/company) subdomain üzerinde çalışır. Lokal geliştirmede:
localhostüzerinde login olmak, cookie’yilocalhosthost’una yazar.quaflow.localhostgibi tenant host’larına geçince tarayıcı cookie’yi paylaşmayabilir.
Bu nedenle lokalde test için login’i tenant host’unda yapın:
http://quaflow.localhost:8787/en/login
Better Auth trustedOrigins
Section titled “Better Auth trustedOrigins”Dosya: app/lib/auth.ts
Better Auth, güvenlik gereği origin kontrolü yapar. Lokal geliştirmede trustedOrigins listesi *.localhost origin’lerini kabul edecek şekilde ayarlanmalıdır.
Cross-subdomain cookies (lokal)
Section titled “Cross-subdomain cookies (lokal)”Tarayıcılar .localhost için Domain=.localhost cookie’lerini tutarlı şekilde kabul etmeyebilir. Bu repo’da bu yüzden cross-subdomain cookie’ler:
- Lokal (localhost / *.localhost): kapalı
- Prod (gerçek domain:
*.achidemy.netve apex): açık
Lokal subdomain geliştirme akışı için Yerelde Subdomain/Tenant Geliştirme sayfasına bakın.
Hesap Paylaşımı Engelleme (Account Sharing Prevention)
Section titled “Hesap Paylaşımı Engelleme (Account Sharing Prevention)”SaaS platformlarının hesap paylaşımını ve korsan kullanımı engellemek için kullandığı en katı ve etkili güvenlik standardıdır.
Nasıl Çalışır?
Section titled “Nasıl Çalışır?”| Senaryo | Sonuç |
|---|---|
| Kullanıcı aynı tarayıcıda 10 sekme açar | ✅ Tek oturum sayılır |
| Bilgisayar (1. oturum) + Telefon (2. oturum) | ✅ İkisi de aktif kalır |
| Bilgisayar + Telefon + Arkadaşın cihazı (3. oturum) | ⚠️ İlk 2 cihaz otomatik çıkış yapar |
Teknik Implementasyon
Section titled “Teknik Implementasyon”Dosya: app/lib/auth.ts
Better Auth’un databaseHooks özelliği kullanılarak veritabanı seviyesinde koruma sağlanır:
databaseHooks: { session: { create: { after: async (newSession: any) => { try { // 1. Kullanıcının tüm aktif oturumlarını getir const userSessions = await db .select({ id: schema.sessions.id }) .from(schema.sessions) .where(eq(schema.sessions.userId, newSession.userId)) .orderBy(desc(schema.sessions.createdAt));
// 2. Maksimum 2 oturum izni - 3. cihazdan giriş yapılırsa if (userSessions.length > 2) { // 3. Yeni oturum HARİÇ diğer tüm oturumları sil await db .delete(schema.sessions) .where( and( eq(schema.sessions.userId, newSession.userId), not(eq(schema.sessions.id, newSession.id)) ) );
console.log( `[SECURITY] Hesap paylaşımı engellendi. Kullanıcı (${newSession.userId}) için diğer tüm cihazların oturumu kapatıldı.` ); } } catch (error) { console.error("[SECURITY] Session hook hatası:", error); } }, }, }, // ... user hooks}Avantajları
Section titled “Avantajları”- Sıfır Performans Kaybı — Sadece login anında çalışır, her sayfa yüklemesinde değil
- Aşılamaz Güvenlik — Session veritabanından silindiği için çerez kopyalama işe yaramaz
- Otomatik UX — Eski cihazlar sayfayı yenileyince
/login’e yönlendirilir - Frontend Değişikliği Gereksiz — Better Auth + React Router otomatik yönetir
Frontend Davranışı
Section titled “Frontend Davranışı”Eski cihazlardaki oturumlar veritabanından fiziksel olarak silindiği için:
- Kullanıcı sayfayı değiştirdiğinde veya yenilediğinde
- Sistem oturumun veritabanından silindiğini anlar
- Kullanıcı
401 Unauthorizedyanıtı alır /loginsayfasına veya/401hata sayfasına yönlendirilir
Review Bombing Koruması (%10 İlerleme Kuralı)
Section titled “Review Bombing Koruması (%10 İlerleme Kuralı)”Sahte hesapların veya rakip eğitmenlerin kursu satın alır almaz (hiç izlemeden) 1 yıldız verip iade etmesini engeller.
Kullanıcı bir kursa yorum yapabilmek için en az %10 ilerleme kaydetmiş olmalıdır.
Backend Güvenliği (GraphQL)
Section titled “Backend Güvenliği (GraphQL)”Dosya: app/graphql/schema.ts — createReview mutation
createReview: async (_, { courseId, rating, comment }, { db, user }) => { if (!user) throw new Error("Giriş yapmalısınız");
// 1. Kullanıcının kursa kayıtlı olup olmadığını kontrol et const enrollment = await db .select() .from(enrollments) .where( and( eq(enrollments.userId, user.id), eq(enrollments.courseId, courseId) ) ) .limit(1);
if (!enrollment.length) { throw new Error("Bu kursa kayıtlı değilsiniz"); }
// 2. İlerleme yüzdesini hesapla const totalLessons = await db .select({ count: sql`count(*)` }) .from(lessons) .innerJoin(sections, eq(lessons.sectionId, sections.id)) .where(eq(sections.courseId, courseId));
const completedLessons = await db .select({ count: sql`count(*)` }) .from(progress) .where( and( eq(progress.userId, user.id), eq(progress.courseId, courseId), eq(progress.completed, true) ) );
const progressPercentage = totalLessons[0].count > 0 ? (Number(completedLessons[0].count) / Number(totalLessons[0].count)) * 100 : 0;
// 3. %10 kuralını uygula if (progressPercentage < 10) { throw new Error( `Yorum yapabilmek için kursun en az %10'unu tamamlamalısınız. Mevcut ilerlemeniz: %${progressPercentage.toFixed(0)}` ); }
// 4. Daha önce yorum yapılmış mı kontrol et const existingReview = await db .select() .from(reviews) .where( and( eq(reviews.userId, user.id), eq(reviews.courseId, courseId) ) ) .limit(1);
if (existingReview.length) { throw new Error("Bu kursa zaten yorum yapmışsınız"); }
// 5. Yorumu kaydet // ...}Frontend UX
Section titled “Frontend UX”Dosya: app/routes/my-courses.learning.tsx
progressPercentage >= 10ise “Puan verin” butonu aktifprogressPercentage < 10ise buton devre dışı,Lockikonu ve tooltip gösterilir
Dosya: app/components/courses/CourseReviewModal.tsx
canReviewfalse ise modal içinde “Değerlendirme Kilitli” ekranı gösterilir- İlerleme çubuğu ve açıklama metni ile kullanıcı bilgilendirilir
İade Suistimali Önleme (%25 / Sertifika Kuralı)
Section titled “İade Suistimali Önleme (%25 / Sertifika Kuralı)”Kötü niyetli kullanıcıların kursu alıp, hızlıca tüketip iade alarak eğitmenin emeğini sömürmesini engeller.
Kullanıcı aşağıdaki durumlarda iade talep edemez:
- Kursun %25’inden fazlasını tamamlamışsa (ulaştığı en yüksek ilerleme yüzdesi kalıcı olarak saklanır)
- Kurs için sertifika almışsa
Backend Güvenliği (GraphQL)
Section titled “Backend Güvenliği (GraphQL)”Dosya: app/db/schema.ts — enrollments.maxProgressPercentage
enrollmentstablosundamaxProgressPercentagealanı, öğrencinin o kursta ulaştığı en yüksek ilerleme yüzdesini (0–100) kalıcı olarak saklar.- Bu alan asla düşmez; sadece yeni ilerleme yüzdesi daha yüksekse güncellenir.
Dosya: app/graphql/schema.ts — toggleLessonCompletion ve requestRefund mutations
// Ders tamamlanma toggle'ı: ilerleme kaydını güncelle + maksimum ilerlemeyi kilitletoggleLessonCompletion: async (_, { lessonId, courseId, completed, currentProgress }, context) => { const { db, user } = context; if (!user) throw new Error("Yetkisiz işlem.");
// 1. İlerlemeyi normal şekilde kaydet veya geri al if (completed) { await db.insert(userLessonsProgress).values({ userId: user.id, lessonId, courseId, completed: true, }).onConflictDoUpdate({ target: [userLessonsProgress.userId, userLessonsProgress.lessonId], set: { completed: true, lastWatchedAt: new Date() }, }); } else { await db.update(userLessonsProgress) .set({ completed: false }) .where( and( eq(userLessonsProgress.userId, user.id), eq(userLessonsProgress.lessonId, lessonId), ), ); }
// 2. Maksimum ilerleme yüzdesini sadece yukarı yönlü güncelle if (currentProgress !== undefined && currentProgress !== null) { const progressInt = Math.floor(currentProgress);
const [enrollment] = await db .select() .from(enrollments) .where( and( eq(enrollments.userId, user.id), eq(enrollments.courseId, courseId), ), ) .limit(1);
if (enrollment && progressInt > (enrollment.maxProgressPercentage || 0)) { await db .update(enrollments) .set({ maxProgressPercentage: progressInt }) .where(eq(enrollments.id, enrollment.id)); } }
return true;};
// İade talebi: maxProgressPercentage ve sertifika kuralırequestRefund: async (_, { enrollmentId, refundReason, refundReasonOther }, context) => { const { db, user } = context; if (!user) throw new Error("Giriş yapmalısınız.");
// ... enrollment ve 30 gün + Stripe kontrolleri ...
const [enrollment] = await db .select() .from(enrollments) .where(and(eq(enrollments.id, enrollmentId), eq(enrollments.userId, user.id))) .limit(1);
if (!enrollment) { throw new Error("Satın alma kaydı bulunamadı."); }
// 🚀 Suistimal Kalkanı: maxProgressPercentage ve sertifika kontrolü if (enrollment.maxProgressPercentage > 25) { throw new Error( "Kursun %25'inden fazlasını görüntülediğiniz için iade politikalarımız gereği bu kurs iade edilemez. (Ders işaretlerini kaldırmak bu durumu değiştirmez).", ); }
const courseId = enrollment.courseId; if (courseId) { const [hasCert] = await db .select({ id: certificates.id }) .from(certificates) .where( and( eq(certificates.userId, user.id), eq(certificates.courseId, courseId), ), ) .limit(1);
if (hasCert) { throw new Error( "Bu kurs için sertifika aldığınız için iade politikalarımız gereği bu kurs iade edilemez.", ); } }
// İade işlemi...};Frontend UX
Section titled “Frontend UX”Dosya: app/routes/learn.$slug.tsx
toggleCompletefonksiyonu, her checkbox tıklamasında o anki yeni ilerleme yüzdesini hesaplar (newProgressPercentage) ve GraphQLtoggleLessonCompletionmutation’ınacurrentProgressolarak gönderir.- Böylece öğrenci ders tiklerini sonradan kaldırsa bile, sistem o kurs için ulaşılan en yüksek ilerlemeyi bilir.
Dosya: app/routes/account.purchase-history.tsx
- Loader, her satın alma için
enrollments.maxProgressPercentagealanını da yükleyippurchasesiçinemaxProgressPercentageolarak ekler. - UI’da iade butonu:
maxProgressPercentage <= 25ve 30 gün/Stripe koşulları sağlanıyorsa aktif “İade Et” butonu gösterir.maxProgressPercentage > 25ise deaktif görünümlü bir iade butonu gösterilir (cursor-not-allowed, soluk renk); butona tıklandığındatoast.error(refund.abuseBlocked)mesajı ile politika açıklanır.
Çeviri Anahtarları
Section titled “Çeviri Anahtarları”| Anahtar | TR | EN |
|---|---|---|
refund.abuseBlocked | Kursun %25’inden fazlasını görüntülediğiniz için iade politikalarımız gereği bu kurs iade edilemez. (Ders işaretlerini kaldırmak bu durumu değiştirmez). | Refund policy violation: you have consumed more than 25% of this course. Automatic refund is not available. |
401 Unauthorized Hata Sayfası
Section titled “401 Unauthorized Hata Sayfası”Oturum sonlandırıldığında kullanıcıya gösterilen profesyonel hata sayfası.
Route Tanımlamaları
Section titled “Route Tanımlamaları”Dosya: app/routes.ts
// Lang'sizroute("401", "routes/401.tsx", { id: "error-401" }),
// Lang'liroute("401", "routes/401.tsx", { id: "lang-401" }),Sayfa Bileşeni
Section titled “Sayfa Bileşeni”Dosya: app/routes/401.tsx
Özellikler:
- 404 ve 500 sayfalarıyla aynı tasarım dili
- Amber/sarı renk teması (güvenlik uyarısı hissi)
ShieldAlertikonu ile güvenlik vurgusu- “Tekrar giriş yap” ve “Ana sayfaya dön” butonları
- Güvenlik uyarı kutusu (şifre değiştirme önerisi)
- Çoklu dil desteği (tr/en/es/de/fr/ja)
export default function UnauthorizedPage() { const { t } = useTranslation(); const { lang } = useParams<{ lang?: string }>();
const homeHref = lang ? `/${lang}` : "/"; const loginHref = lang ? `/${lang}/login` : "/login";
return ( <div className="relative min-h-screen overflow-hidden bg-slate-950"> {/* Full-screen background image */} <img src="/images/401.webp" alt="" onError={(e) => { (e.target as HTMLImageElement).src = "/images/404.webp"; }} />
{/* Amber overlay */} <div className="bg-gradient-to-br from-slate-950/85 via-amber-950/70 to-slate-950/85" />
{/* Content */} <h1>{t("errors.unauthorizedTitle")}</h1> <p>{t("errors.unauthorizedDescription")}</p>
{/* Security warning box */} <div className="border-amber-500/20 bg-amber-500/10"> <ShieldAlert /> <p>{t("errors.securityNote")}</p> </div>
{/* Buttons */} <Link to={loginHref}>{t("errors.loginAgain")}</Link> <Link to={homeHref}>{t("errors.backHome")}</Link> </div> );}Navbar/Footer Gizleme
Section titled “Navbar/Footer Gizleme”Dosya: app/root.tsx
const isErrorRoute = matches.some((m) => [ "error-401", // ✅ Eklendi "error-404", "error-500", "lang-401", // ✅ Eklendi "lang-404", "lang-500", "lang-catchall-404", "catchall-404", ].includes(m.id));
// Navbar ve Footer gizlenir{!isErrorRoute && <Navbar />}{!isErrorRoute && <Footer />}ErrorBoundary Desteği
Section titled “ErrorBoundary Desteği”Dosya: app/root.tsx
import UnauthorizedPage from "./routes/401";
export function ErrorBoundary() { const status = isRouteErrorResponse(error) ? error.status : 500;
const getErrorPage = () => { switch (status) { case 401: return UnauthorizedPage; case 404: return NotFoundPage; default: return ServerErrorPage; } };
const ErrorPage = getErrorPage(); return <ErrorPage />;}Çeviri Anahtarları
Section titled “Çeviri Anahtarları”| Anahtar | TR | EN |
|---|---|---|
errors.unauthorizedTitle | Oturum sonlandırıldı | Session ended |
errors.unauthorizedDescription | Hesabınıza başka bir cihazdan giriş yapıldı… | Someone logged into your account… |
errors.securityNote | Bu sizin değilse, şifrenizi hemen değiştirin… | If this wasn’t you, change your password… |
errors.loginAgain | Tekrar giriş yap | Log in again |
errors.sessionTip | Hesabınız en fazla 2 cihazda aktif olabilir… | Your account can be active on up to 2 devices… |
| Özellik | Açıklama | Dosya(lar) |
|---|---|---|
| Hesap Paylaşımı Engelleme | Max 2 eşzamanlı oturum, 3. cihazda diğerleri düşer | app/lib/auth.ts |
| Review Bombing Koruması | %10 ilerleme olmadan yorum yapılamaz | app/graphql/schema.ts, my-courses.learning.tsx |
| İade Suistimali Önleme | %25 ilerleme veya sertifika varsa iade yok | app/graphql/schema.ts, account.purchase-history.tsx |
| 401 Hata Sayfası | Oturum sonlandırıldığında gösterilen sayfa | app/routes/401.tsx, app/root.tsx |
İlgili Dosyalar
Section titled “İlgili Dosyalar”app/lib/auth.ts— Better Auth yapılandırması ve session hook’larıapp/graphql/schema.ts— GraphQL mutations (createReview, requestRefund)app/routes/401.tsx— 401 Unauthorized hata sayfasıapp/routes/my-courses.learning.tsx— Öğrenme sayfası (review butonu)app/routes/account.purchase-history.tsx— Satın alma geçmişi (iade butonu)app/root.tsx— Error boundary ve navbar/footer kontrolüapp/locales/*.json— Çeviri dosyaları