feat(auth): Better Auth, kullanıcı adı ile giriş, DAL ve proxy

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:02:00 +03:00
parent 381214c2cb
commit 5191a5cce1
14 changed files with 319 additions and 4 deletions
+11
View File
@@ -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);
}
+4
View File
@@ -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",
+9
View File
@@ -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()],
});
+8
View File
@@ -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;
}
+81
View File
@@ -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<typeof createAuth>;
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;
}
+68
View File
@@ -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<SessionUser | null> => {
// 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<SessionUser> {
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<SessionUser> {
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<SessionUser | null> {
const user = await getCurrentUser();
if (!user) return null;
if (role && user.role !== role) return null;
return user;
}
View File
+36
View File
@@ -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<boolean> {
try {
await getDb().command({ ping: 1 }, { timeoutMS: timeoutMs });
return true;
} catch {
return false;
}
}
+19
View File
@@ -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<typeof mongoose> };
export function connectMongoose(): Promise<typeof mongoose> {
globalForMongoose.__butcemMongoose ??= mongoose.connect(env().MONGODB_URI, {
appName: "butcem-odm",
maxPoolSize: 20,
serverSelectionTimeoutMS: 5_000,
autoIndex: env().NODE_ENV !== "production",
});
return globalForMongoose.__butcemMongoose;
}
+22
View File
@@ -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).*)",
],
};
+20
View File
@@ -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");
});
});
+24
View File
@@ -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;
}
+9 -3
View File
@@ -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));
+8 -1
View File
@@ -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);