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:
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).*)",
|
||||
],
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user