From 5191a5cce1398ea80daa53057b68ee77d4506047 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:02:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Better=20Auth,=20kullan=C4=B1c?= =?UTF-8?q?=C4=B1=20ad=C4=B1=20ile=20giri=C5=9F,=20DAL=20ve=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MongoDB/Mongoose bağlantıları, kayıt kapalı, admin + username plugin, rate limit - DAL: getCurrentUser, requireSession, requireRole, getActionUser - Kullanıcı adı normalizasyonu: İ/I/ı → i (tr-TR 'ADMIN' → 'admın' hatası) Co-Authored-By: Claude Opus 5.5 --- src/app/api/auth/[...all]/route.ts | 11 ++++ src/i18n/tr.ts | 4 ++ src/lib/auth-client.ts | 9 ++++ src/lib/auth-errors.ts | 8 +++ src/lib/auth.ts | 81 ++++++++++++++++++++++++++++++ src/lib/dal.ts | 68 +++++++++++++++++++++++++ src/lib/db/.gitkeep | 0 src/lib/db/mongo.ts | 36 +++++++++++++ src/lib/db/mongoose.ts | 19 +++++++ src/proxy.ts | 22 ++++++++ src/shared/account-email.test.ts | 20 ++++++++ src/shared/account-email.ts | 24 +++++++++ src/shared/schemas/common.ts | 12 +++-- src/shared/schemas/user.test.ts | 9 +++- 14 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/auth-errors.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/dal.ts delete mode 100644 src/lib/db/.gitkeep create mode 100644 src/lib/db/mongo.ts create mode 100644 src/lib/db/mongoose.ts create mode 100644 src/proxy.ts create mode 100644 src/shared/account-email.test.ts create mode 100644 src/shared/account-email.ts diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..50c5361 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,11 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { getAuth } from "@/lib/auth"; + +/** Better Auth uç noktaları (/api/auth/*). Kayıt (sign-up) yapılandırmada kapalıdır. */ +export function GET(request: Request) { + return toNextJsHandler(getAuth()).GET(request); +} + +export function POST(request: Request) { + return toNextJsHandler(getAuth()).POST(request); +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 9b583a2..e76d8ee 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -31,6 +31,8 @@ export const tr = { showPassword: "Şifreyi göster", hidePassword: "Şifreyi gizle", genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + unauthorized: "Bu işlem için yetkiniz yok.", + notFound: "Kayıt bulunamadı.", }, theme: { label: "Tema", @@ -90,6 +92,8 @@ export const tr = { submit: "Giriş yap", submitting: "Giriş yapılıyor…", invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.", + banned: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.", + tooManyAttempts: "Çok fazla deneme yapıldı. Lütfen bir dakika sonra tekrar deneyin.", noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.", identifierRequired: "Kullanıcı adı veya e-posta girin", passwordRequired: "Şifre girin", diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..cf80318 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,9 @@ +"use client"; + +import { adminClient, usernameClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; + +/** Tarayıcı tarafı Better Auth istemcisi (aynı origin: /api/auth). */ +export const authClient = createAuthClient({ + plugins: [usernameClient(), adminClient()], +}); diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 0000000..8989af7 --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,8 @@ +import { isAPIError } from "better-auth/api"; + +/** Better Auth hatasından makine okunur kodu çıkarır (ör. "USERNAME_IS_ALREADY_TAKEN"). */ +export function authErrorCode(error: unknown): string | undefined { + if (!isAPIError(error)) return undefined; + const body = error.body as { code?: unknown } | undefined; + return typeof body?.code === "string" ? body.code : undefined; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d53d67f --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,81 @@ +import "server-only"; +import { betterAuth } from "better-auth"; +import { mongodbAdapter } from "better-auth/adapters/mongodb"; +import { nextCookies } from "better-auth/next-js"; +import { admin, username } from "better-auth/plugins"; +import { getMongoClient } from "@/lib/db/mongo"; +import { env } from "@/lib/env"; +import { normalizeUsername, USERNAME_PATTERN } from "@/shared/schemas/common"; + +function createAuth() { + const e = env(); + const client = getMongoClient(); + const isProd = e.NODE_ENV === "production"; + + return betterAuth({ + appName: "BÜTÇEM", + baseURL: e.BETTER_AUTH_URL, + secret: e.BETTER_AUTH_SECRET, + // client verildiği için çok adımlı auth yazmaları transaction içinde yapılır (replica set). + database: mongodbAdapter(client.db(), { client }), + emailAndPassword: { + enabled: true, + // Kullanıcılar kendi hesabını açamaz; hesaplar yalnızca admin panelinden oluşturulur. + disableSignUp: true, + minPasswordLength: 8, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }, + session: { + expiresIn: 60 * 60 * 24 * 30, // 30 gün + updateAge: 60 * 60 * 24, // günde bir uzat + }, + rateLimit: { + enabled: true, + // Yeniden başlatmalarda sıfırlanmasın; tek örnek çalıştığı için yeterli. + storage: "database", + window: 60, + max: 100, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-in/username": { window: 60, max: 5 }, + }, + }, + advanced: { + useSecureCookies: isProd, + cookiePrefix: "butcem", + // Rate limit istemci IP'sini X-Forwarded-For'dan okur. Canlıda Traefik güvenilmeyen + // istemcilerden gelen X-Forwarded-* başlıklarını silip kendisi yazmalı + // (forwardedHeaders.insecure=false, varsayılan) — docs/DEPLOY.md (Faz 15). + }, + telemetry: { enabled: false }, + plugins: [ + username({ + minUsernameLength: 3, + maxUsernameLength: 30, + // shared/schemas/common.ts ile aynı kural (İ/I/ı → i, ASCII küçük harf). + usernameNormalization: normalizeUsername, + // Giriş uç noktası normalizasyondan ÖNCE doğrular; büyük harfli girişler ("ADMIN") + // reddedilmesin diye doğrulama her zaman normalize edilmiş değer üzerinde yapılır. + usernameValidator: (value) => USERNAME_PATTERN.test(normalizeUsername(value)), + validationOrder: { username: "post-normalization" }, + }), + admin({ + defaultRole: "user", + adminRoles: ["admin"], + bannedUserMessage: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.", + }), + nextCookies(), // Server Action'larda cookie ayarlamak için en sonda olmalı + ], + }); +} + +export type Auth = ReturnType; + +const globalForAuth = globalThis as unknown as { __butcemAuth?: Auth }; + +/** Better Auth örneği (tembel: build sırasında ortam değişkeni gerektirmez). */ +export function getAuth(): Auth { + globalForAuth.__butcemAuth ??= createAuth(); + return globalForAuth.__butcemAuth; +} diff --git a/src/lib/dal.ts b/src/lib/dal.ts new file mode 100644 index 0000000..f648cc4 --- /dev/null +++ b/src/lib/dal.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; +import type { SessionUser } from "@/features/auth/types"; +import { getAuth } from "@/lib/auth"; +import { visibleEmail } from "@/shared/account-email"; +import type { SystemRole } from "@/shared/schemas/common"; + +/** + * Data Access Layer — her korumalı sayfa, layout, Server Action ve Route Handler oturumu + * BURADAN okur. proxy.ts yalnızca iyimser (cookie var mı?) kontrol yapar; güvenlik bu katmandadır. + */ + +type RawUser = { + id: string; + name: string; + email: string; + username?: string | null; + role?: string | null; + banned?: boolean | null; + image?: string | null; +}; + +export function toSessionUser(user: RawUser): SessionUser { + return { + id: user.id, + name: user.name, + username: user.username ?? "", + email: visibleEmail(user.email), + role: user.role === "admin" ? "admin" : "user", + image: user.image ?? undefined, + }; +} + +/** İstek başına bir kez çalışır (React cache). Oturum yoksa veya kullanıcı askıdaysa null. */ +export const getCurrentUser = cache(async (): Promise => { + // headers() önce çağrılır: Cache Components prerender'ı burada durur, auth hiç oluşturulmaz. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); + if (!session || session.user.banned) return null; + return toSessionUser(session.user); +}); + +/** Sayfa/layout: oturum yoksa /login'e yönlendirir. */ +export async function requireSession(): Promise { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + return user; +} + +/** Sayfa/layout: rol yoksa 404 (sayfanın varlığı bile gösterilmez). */ +export async function requireRole(role: SystemRole): Promise { + const user = await requireSession(); + if (user.role !== role) notFound(); + return user; +} + +/** + * Server Action / Route Handler: yönlendirme yapmaz, yetkisizse null döner + * (çağıran `fail(...)` veya 401/403 döndürür). + */ +export async function getActionUser(role?: SystemRole): Promise { + const user = await getCurrentUser(); + if (!user) return null; + if (role && user.role !== role) return null; + return user; +} diff --git a/src/lib/db/.gitkeep b/src/lib/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/db/mongo.ts b/src/lib/db/mongo.ts new file mode 100644 index 0000000..20f8d5f --- /dev/null +++ b/src/lib/db/mongo.ts @@ -0,0 +1,36 @@ +import "server-only"; +import { MongoClient, type Db } from "mongodb"; +import { env } from "@/lib/env"; + +/** + * Uygulama genelinde tek MongoClient. Geliştirmede HMR yeniden yüklemelerinde bağlantı + * çoğalmasın diye globalThis üzerinde saklanır. İstemci tembel oluşturulur: `next build` + * sırasında ortam değişkeni veya bağlantı gerektirmez (sürücü ilk işlemde bağlanır). + */ +const globalForMongo = globalThis as unknown as { __butcemMongo?: MongoClient }; + +export function getMongoClient(): MongoClient { + if (!globalForMongo.__butcemMongo) { + globalForMongo.__butcemMongo = new MongoClient(env().MONGODB_URI, { + appName: "butcem", + maxPoolSize: 20, + serverSelectionTimeoutMS: 5_000, + }); + } + return globalForMongo.__butcemMongo; +} + +/** Bağlantı URI'sindeki veritabanı (ör. /butcem). */ +export function getDb(): Db { + return getMongoClient().db(); +} + +/** Sağlık kontrolü için hızlı ping. */ +export async function pingDb(timeoutMs = 2_000): Promise { + try { + await getDb().command({ ping: 1 }, { timeoutMS: timeoutMs }); + return true; + } catch { + return false; + } +} diff --git a/src/lib/db/mongoose.ts b/src/lib/db/mongoose.ts new file mode 100644 index 0000000..b36cc51 --- /dev/null +++ b/src/lib/db/mongoose.ts @@ -0,0 +1,19 @@ +import "server-only"; +import mongoose from "mongoose"; +import { env } from "@/lib/env"; + +/** + * Alan (domain) modelleri için Mongoose bağlantısı; MongoClient ile aynı önbellekleme deseni. + * Kimlik doğrulama koleksiyonları Better Auth tarafından native sürücüyle yönetilir. + */ +const globalForMongoose = globalThis as unknown as { __butcemMongoose?: Promise }; + +export function connectMongoose(): Promise { + globalForMongoose.__butcemMongoose ??= mongoose.connect(env().MONGODB_URI, { + appName: "butcem-odm", + maxPoolSize: 20, + serverSelectionTimeoutMS: 5_000, + autoIndex: env().NODE_ENV !== "production", + }); + return globalForMongoose.__butcemMongoose; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..a292ad9 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,22 @@ +import { getSessionCookie } from "better-auth/cookies"; +import { NextResponse, type NextRequest } from "next/server"; + +/** + * İYİMSER kontrol: yalnızca oturum cookie'si var mı diye bakar (veritabanına gitmez). + * Cookie yoksa korumalı sayfaları /login'e yönlendirir. Asıl yetki kontrolü lib/dal.ts'de; + * bu katman tek başına güvenlik sağlamaz. + */ +export function proxy(request: NextRequest) { + const hasSessionCookie = Boolean(getSessionCookie(request, { cookiePrefix: "butcem" })); + if (!hasSessionCookie) { + return NextResponse.redirect(new URL("/login", request.url)); + } + return NextResponse.next(); +} + +export const config = { + // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest. + matcher: [ + "/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", + ], +}; diff --git a/src/shared/account-email.test.ts b/src/shared/account-email.test.ts new file mode 100644 index 0000000..f4a4a65 --- /dev/null +++ b/src/shared/account-email.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isPlaceholderEmail, placeholderEmail, storedEmail, visibleEmail } from "./account-email"; + +describe("yer tutucu e-posta", () => { + it("kullanıcı adından .invalid adres üretir ve tanır", () => { + const e = placeholderEmail("emre"); + expect(e).toBe("emre@kullanici.butcem.invalid"); + expect(isPlaceholderEmail(e)).toBe(true); + expect(isPlaceholderEmail("EMRE@KULLANICI.BUTCEM.INVALID")).toBe(true); + expect(isPlaceholderEmail("emre@ornek.com")).toBe(false); + expect(isPlaceholderEmail(undefined)).toBe(false); + }); + + it("kaydedilen ve gösterilen e-posta", () => { + expect(storedEmail("emre", undefined)).toBe("emre@kullanici.butcem.invalid"); + expect(storedEmail("ayse", "ayse@ornek.com")).toBe("ayse@ornek.com"); + expect(visibleEmail("emre@kullanici.butcem.invalid")).toBeUndefined(); + expect(visibleEmail("ayse@ornek.com")).toBe("ayse@ornek.com"); + }); +}); diff --git a/src/shared/account-email.ts b/src/shared/account-email.ts new file mode 100644 index 0000000..47a7b8b --- /dev/null +++ b/src/shared/account-email.ts @@ -0,0 +1,24 @@ +/** + * E-postası olmayan kullanıcılar (ör. çocuklar) kullanıcı adıyla giriş yapar. Better Auth + * e-postayı zorunlu tuttuğu için bunlara ayrılmış `.invalid` alan adında (RFC 2606) + * yer tutucu bir adres verilir; arayüzde "e-posta yok" olarak gösterilir. + */ +export const PLACEHOLDER_EMAIL_DOMAIN = "kullanici.butcem.invalid"; + +export function placeholderEmail(username: string): string { + return `${username}@${PLACEHOLDER_EMAIL_DOMAIN}`; +} + +export function isPlaceholderEmail(email: string | null | undefined): boolean { + return Boolean(email?.toLowerCase().endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`)); +} + +/** Kaydedilecek e-posta: girilmişse o, yoksa kullanıcı adından yer tutucu. */ +export function storedEmail(username: string, email: string | undefined): string { + return email ?? placeholderEmail(username); +} + +/** Gösterilecek e-posta: yer tutucuysa undefined. */ +export function visibleEmail(email: string | null | undefined): string | undefined { + return email && !isPlaceholderEmail(email) ? email : undefined; +} diff --git a/src/shared/schemas/common.ts b/src/shared/schemas/common.ts index 6b90e49..377dd4e 100644 --- a/src/shared/schemas/common.ts +++ b/src/shared/schemas/common.ts @@ -12,11 +12,17 @@ export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/; export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax); -/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */ +/** + * Kullanıcı adı normalizasyonu (ASCII). Türkçe yerel ayarı KULLANILMAZ: tr-TR'de "I" → "ı" olur + * ve "ADMIN" "admın"e dönüşür. Bunun yerine İ/I/ı hepsi "i" yapılır, sonra küçük harfe çevrilir. + */ +export function normalizeUsername(value: string): string { + return value.trim().replace(/[İIı]/g, "i").toLowerCase(); +} + export const usernameSchema = z .string() - .trim() - .transform((s) => s.toLocaleLowerCase("tr-TR")) + .transform(normalizeUsername) .pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat)); export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email)); diff --git a/src/shared/schemas/user.test.ts b/src/shared/schemas/user.test.ts index a1d2630..d5c6f61 100644 --- a/src/shared/schemas/user.test.ts +++ b/src/shared/schemas/user.test.ts @@ -12,11 +12,18 @@ const base = { }; describe("usernameSchema", () => { - it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => { + it("küçük harfe çevirir ve boşlukları kırpar", () => { expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k"); expect(usernameSchema.parse("Ali_2")).toBe("ali_2"); }); + it("I/İ/ı harflerini i yapar (tr-TR'deki I → ı tuzağı)", () => { + expect(usernameSchema.parse("ADMIN")).toBe("admin"); + expect(usernameSchema.parse("ALI")).toBe("ali"); + expect(usernameSchema.parse("İREM")).toBe("irem"); + expect(usernameSchema.parse("ırmak")).toBe("irmak"); + }); + it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => { for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) { expect(usernameSchema.safeParse(bad).success, bad).toBe(false);