Skip to content

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’yi localhost host’una yazar.
  • quaflow.localhost gibi 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

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.

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.net ve 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.

SenaryoSonuç
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

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
}
  1. Sıfır Performans Kaybı — Sadece login anında çalışır, her sayfa yüklemesinde değil
  2. Aşılamaz Güvenlik — Session veritabanından silindiği için çerez kopyalama işe yaramaz
  3. Otomatik UX — Eski cihazlar sayfayı yenileyince /login’e yönlendirilir
  4. Frontend Değişikliği Gereksiz — Better Auth + React Router otomatik yönetir

Eski cihazlardaki oturumlar veritabanından fiziksel olarak silindiği için:

  1. Kullanıcı sayfayı değiştirdiğinde veya yenilediğinde
  2. Sistem oturumun veritabanından silindiğini anlar
  3. Kullanıcı 401 Unauthorized yanıtı alır
  4. /login sayfasına veya /401 hata 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.

Dosya: app/graphql/schema.tscreateReview 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
// ...
}

Dosya: app/routes/my-courses.learning.tsx

  • progressPercentage >= 10 ise “Puan verin” butonu aktif
  • progressPercentage < 10 ise buton devre dışı, Lock ikonu ve tooltip gösterilir

Dosya: app/components/courses/CourseReviewModal.tsx

  • canReview false 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:

  1. Kursun %25’inden fazlasını tamamlamışsa (ulaştığı en yüksek ilerleme yüzdesi kalıcı olarak saklanır)
  2. Kurs için sertifika almışsa

Dosya: app/db/schema.tsenrollments.maxProgressPercentage

  • enrollments tablosunda maxProgressPercentage alanı, öğ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.tstoggleLessonCompletion ve requestRefund mutations

// Ders tamamlanma toggle'ı: ilerleme kaydını güncelle + maksimum ilerlemeyi kilitle
toggleLessonCompletion: 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...
};

Dosya: app/routes/learn.$slug.tsx

  • toggleComplete fonksiyonu, her checkbox tıklamasında o anki yeni ilerleme yüzdesini hesaplar (newProgressPercentage) ve GraphQL toggleLessonCompletion mutation’ına currentProgress olarak 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.maxProgressPercentage alanını da yükleyip purchases içine maxProgressPercentage olarak ekler.
  • UI’da iade butonu:
    • maxProgressPercentage <= 25 ve 30 gün/Stripe koşulları sağlanıyorsa aktif “İade Et” butonu gösterir.
    • maxProgressPercentage > 25 ise deaktif görünümlü bir iade butonu gösterilir (cursor-not-allowed, soluk renk); butona tıklandığında toast.error(refund.abuseBlocked) mesajı ile politika açıklanır.
AnahtarTREN
refund.abuseBlockedKursun %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.

Oturum sonlandırıldığında kullanıcıya gösterilen profesyonel hata sayfası.

Dosya: app/routes.ts

// Lang'siz
route("401", "routes/401.tsx", { id: "error-401" }),
// Lang'li
route("401", "routes/401.tsx", { id: "lang-401" }),

Dosya: app/routes/401.tsx

Özellikler:

  • 404 ve 500 sayfalarıyla aynı tasarım dili
  • Amber/sarı renk teması (güvenlik uyarısı hissi)
  • ShieldAlert ikonu 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>
);
}

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 />}

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 />;
}
AnahtarTREN
errors.unauthorizedTitleOturum sonlandırıldıSession ended
errors.unauthorizedDescriptionHesabınıza başka bir cihazdan giriş yapıldı…Someone logged into your account…
errors.securityNoteBu sizin değilse, şifrenizi hemen değiştirin…If this wasn’t you, change your password…
errors.loginAgainTekrar giriş yapLog in again
errors.sessionTipHesabınız en fazla 2 cihazda aktif olabilir…Your account can be active on up to 2 devices…

ÖzellikAçıklamaDosya(lar)
Hesap Paylaşımı EngellemeMax 2 eşzamanlı oturum, 3. cihazda diğerleri düşerapp/lib/auth.ts
Review Bombing Koruması%10 ilerleme olmadan yorum yapılamazapp/graphql/schema.ts, my-courses.learning.tsx
İade Suistimali Önleme%25 ilerleme veya sertifika varsa iade yokapp/graphql/schema.ts, account.purchase-history.tsx
401 Hata SayfasıOturum sonlandırıldığında gösterilen sayfaapp/routes/401.tsx, app/root.tsx

  • 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ı