feat: add better auth sqlite runtime and server-side session flow
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import "server-only";
|
||||
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import * as schema from "@/server/db/schema";
|
||||
import {
|
||||
completeFirstFreelancerSetup,
|
||||
recordAuthAuditEvent,
|
||||
reserveFirstFreelancerSetup,
|
||||
} from "@/server/auth/setup";
|
||||
|
||||
const config = getServerConfig();
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Neta",
|
||||
baseURL: config.appUrl,
|
||||
trustedOrigins: config.trustedOrigins,
|
||||
secret: config.betterAuthSecret,
|
||||
database: drizzleAdapter(getSqliteConnection().db, {
|
||||
provider: "sqlite",
|
||||
schema,
|
||||
transaction: true,
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 60,
|
||||
customRules: {
|
||||
"/sign-in/email": {
|
||||
window: 60,
|
||||
max: 10,
|
||||
},
|
||||
"/sign-up/email": {
|
||||
window: 300,
|
||||
max: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
useSecureCookies: config.nodeEnv === "production",
|
||||
cookiePrefix: "neta",
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.nodeEnv === "production",
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const isReserved = await reserveFirstFreelancerSetup(user.email);
|
||||
return isReserved;
|
||||
},
|
||||
after: async (user) => {
|
||||
await completeFirstFreelancerSetup(user);
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
after: async (session) => {
|
||||
await recordAuthAuditEvent({
|
||||
type: "login_succeeded",
|
||||
authUserId: session.userId,
|
||||
metadata: { source: "session_create" },
|
||||
});
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
after: async (session) => {
|
||||
await recordAuthAuditEvent({
|
||||
type: "logout_succeeded",
|
||||
authUserId: session.userId,
|
||||
metadata: { source: "session_delete" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies()],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SessionContext } from "@/server/auth/session";
|
||||
import type { UserRole } from "@/server/auth/types";
|
||||
|
||||
export class AuthorizationError extends Error {
|
||||
constructor(
|
||||
message = "Bu işlem için yetkiniz yok.",
|
||||
public readonly code: "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" = "FORBIDDEN",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthorizationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRole(context: SessionContext | null, allowedRoles: readonly UserRole[]): void {
|
||||
if (!context) {
|
||||
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(context.profile.role)) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSameOwner(context: SessionContext | null, ownerAuthUserId: string): void {
|
||||
if (!context) {
|
||||
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
if (context.user.id !== ownerAuthUserId) {
|
||||
throw new AuthorizationError("Kaynak bulunamadı.", "NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEnabledUser(context: SessionContext | null): void {
|
||||
if (!context) {
|
||||
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
if (context.profile.disabled) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/server/auth/auth";
|
||||
import type { UserRole } from "@/server/auth/types";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { appProfiles } from "@/server/db/schema";
|
||||
|
||||
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||
|
||||
export type SessionContext = {
|
||||
session: BetterAuthSession["session"];
|
||||
user: BetterAuthSession["user"];
|
||||
profile: {
|
||||
id: number;
|
||||
authUserId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = getProfileByAuthUserId(session.user.id);
|
||||
|
||||
if (!profile || profile.disabled || profile.authUserId !== session.user.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
session: session.session,
|
||||
user: session.user,
|
||||
profile,
|
||||
};
|
||||
});
|
||||
|
||||
export async function requireSession(): Promise<SessionContext> {
|
||||
const context = await getSessionContext();
|
||||
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireFreelancer(): Promise<SessionContext> {
|
||||
const context = await requireSession();
|
||||
|
||||
if (context.profile.role !== "freelancer") {
|
||||
redirect("/portal");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireClientUser(): Promise<SessionContext> {
|
||||
const context = await requireSession();
|
||||
|
||||
if (context.profile.role !== "client") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function getProfileByAuthUserId(authUserId: string): SessionContext["profile"] | null {
|
||||
const { db } = getSqliteConnection();
|
||||
const [profile] = db
|
||||
.select({
|
||||
id: appProfiles.id,
|
||||
authUserId: appProfiles.authUserId,
|
||||
email: appProfiles.email,
|
||||
displayName: appProfiles.displayName,
|
||||
role: appProfiles.role,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
return profile ?? null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { appProfiles, appSetupState, authAuditEvents } from "@/server/db/schema";
|
||||
import type { AuthAuditEventType } from "@/server/auth/types";
|
||||
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
|
||||
|
||||
const FIRST_FREELANCER_SETUP_KEY = "first_freelancer";
|
||||
const SETUP_LOCK_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type FirstFreelancerSetupState = {
|
||||
available: boolean;
|
||||
locked: boolean;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export async function getFirstFreelancerSetupState(): Promise<FirstFreelancerSetupState> {
|
||||
try {
|
||||
return readFirstFreelancerSetupState();
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
locked: false,
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "İlk kurulum durumu okunamadı.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
|
||||
const { db } = getSqliteConnection();
|
||||
const [{ value: freelancerCount }] = db
|
||||
.select({ value: count() })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.role, "freelancer"))
|
||||
.all();
|
||||
|
||||
if (freelancerCount > 0) {
|
||||
return { available: false, locked: false };
|
||||
}
|
||||
|
||||
const [setupState] = db
|
||||
.select()
|
||||
.from(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!setupState) {
|
||||
return { available: true, locked: false };
|
||||
}
|
||||
|
||||
if (setupState.status === "completed") {
|
||||
return { available: false, locked: false };
|
||||
}
|
||||
|
||||
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
|
||||
const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS;
|
||||
|
||||
return {
|
||||
available: isStale,
|
||||
locked: !isStale,
|
||||
errorMessage: isStale ? undefined : "İlk kurulum şu anda başka bir istek tarafından işleniyor.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> {
|
||||
const normalizedEmail = normalizeAuthEmail(email);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
return db.transaction((tx) => {
|
||||
const [{ value: freelancerCount }] = tx
|
||||
.select({ value: count() })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.role, "freelancer"))
|
||||
.all();
|
||||
|
||||
if (freelancerCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [setupState] = tx
|
||||
.select()
|
||||
.from(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (setupState?.status === "completed") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setupState?.status === "pending") {
|
||||
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
|
||||
|
||||
if (Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
tx.insert(appSetupState)
|
||||
.values({
|
||||
key: FIRST_FREELANCER_SETUP_KEY,
|
||||
status: "pending",
|
||||
lockedBy: normalizedEmail,
|
||||
lockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSetupState.key,
|
||||
set: {
|
||||
status: "pending",
|
||||
lockedBy: normalizedEmail,
|
||||
lockedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "setup_started",
|
||||
email: normalizedEmail,
|
||||
metadata: { source: "better_auth_user_create" },
|
||||
})
|
||||
.run();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeFirstFreelancerSetup(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
}): Promise<void> {
|
||||
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||
const now = new Date();
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(appProfiles)
|
||||
.values({
|
||||
authUserId: user.id,
|
||||
email: normalizedEmail,
|
||||
displayName: user.name || getDefaultDisplayName(normalizedEmail),
|
||||
role: "freelancer",
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
||||
tx.insert(appSetupState)
|
||||
.values({
|
||||
key: FIRST_FREELANCER_SETUP_KEY,
|
||||
status: "completed",
|
||||
lockedBy: normalizedEmail,
|
||||
lockedAt: now,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSetupState.key,
|
||||
set: {
|
||||
status: "completed",
|
||||
lockedBy: normalizedEmail,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "setup_completed",
|
||||
authUserId: user.id,
|
||||
email: normalizedEmail,
|
||||
metadata: { role: "freelancer" },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAuthAuditEvent(input: {
|
||||
type: AuthAuditEventType;
|
||||
authUserId?: string | null;
|
||||
email?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): Promise<void> {
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.insert(authAuditEvents)
|
||||
.values({
|
||||
type: input.type,
|
||||
authUserId: input.authUserId ?? null,
|
||||
email: input.email ? normalizeAuthEmail(input.email) : null,
|
||||
metadata: input.metadata ?? null,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const userRoles = ["freelancer", "client"] as const;
|
||||
|
||||
export type UserRole = (typeof userRoles)[number];
|
||||
|
||||
export type SetupStatus = "pending" | "completed";
|
||||
|
||||
export type AuthAuditEventType =
|
||||
| "setup_started"
|
||||
| "setup_completed"
|
||||
| "login_succeeded"
|
||||
| "login_failed"
|
||||
| "logout_succeeded";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const authCredentialsSchema = z.object({
|
||||
email: z.email().transform((value) => normalizeAuthEmail(value)),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export type AuthCredentials = z.infer<typeof authCredentialsSchema>;
|
||||
|
||||
export function parseAuthCredentials(formData: FormData): AuthCredentials {
|
||||
return authCredentialsSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuthEmail(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getDefaultDisplayName(email: string): string {
|
||||
return email.split("@")[0] || "Neta Kullanıcısı";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user