feat(auth): restrict admin access to verified Google email
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { LoginCard } from "@/components/admin/login-card";
|
||||||
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
|
||||||
|
export default async function AdminLoginPage() {
|
||||||
|
await connection();
|
||||||
|
if (await getAdminSession()) redirect("/admin");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh">
|
||||||
|
<SiteHeader />
|
||||||
|
<main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12">
|
||||||
|
<LoginCard />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
import { getAuth } from "@/lib/auth/server";
|
||||||
|
|
||||||
|
const handlers = toNextJsHandler((request) => getAuth().handler(request));
|
||||||
|
|
||||||
|
export const GET = handlers.GET;
|
||||||
|
export const POST = handlers.POST;
|
||||||
|
export const PATCH = handlers.PATCH;
|
||||||
|
export const PUT = handlers.PUT;
|
||||||
|
export const DELETE = handlers.DELETE;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { KeyRoundIcon, LogInIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
|
||||||
|
export function LoginCard() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await authClient.signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
callbackURL: "/admin",
|
||||||
|
errorCallbackURL: "/admin/login?error=oauth",
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError("เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-3 grid size-11 place-items-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<LogInIcon aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">เข้าสู่ระบบผู้ดูแล</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
ใช้บัญชี Google ที่ได้รับอนุญาตให้จัดการคู่มือ
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button className="w-full" size="lg" onClick={signIn} disabled={loading}>
|
||||||
|
{loading ? <Spinner /> : <KeyRoundIcon aria-hidden="true" />}
|
||||||
|
{loading ? "กำลังเชื่อมต่อ…" : "ดำเนินการต่อด้วย Google"}
|
||||||
|
</Button>
|
||||||
|
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isAuthorizedAdmin } from "./authorization";
|
||||||
|
|
||||||
|
const verified = {
|
||||||
|
id: "admin",
|
||||||
|
email: "[email protected]",
|
||||||
|
emailVerified: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("admin authorization", () => {
|
||||||
|
it("requires an exact verified email match", () => {
|
||||||
|
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(true);
|
||||||
|
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAuthorizedAdmin({ ...verified, emailVerified: false }, verified.email),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies missing users and missing configuration", () => {
|
||||||
|
expect(isAuthorizedAdmin(null, verified.email)).toBe(false);
|
||||||
|
expect(isAuthorizedAdmin(verified, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface SessionUserLike {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
name?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthorizedAdmin(
|
||||||
|
user: SessionUserLike | null | undefined,
|
||||||
|
adminEmail: string | null | undefined,
|
||||||
|
): user is SessionUserLike {
|
||||||
|
return Boolean(
|
||||||
|
user &&
|
||||||
|
user.emailVerified === true &&
|
||||||
|
adminEmail &&
|
||||||
|
user.email === adminEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { getDb } from "@/db";
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
sessions,
|
||||||
|
users,
|
||||||
|
verifications,
|
||||||
|
} from "@/db/schema";
|
||||||
|
|
||||||
|
import { isAuthorizedAdmin, type SessionUserLike } from "./authorization";
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`${name} is required for authentication.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuth() {
|
||||||
|
return betterAuth({
|
||||||
|
appName: "Buzz Sheet",
|
||||||
|
database: drizzleAdapter(getDb(), {
|
||||||
|
provider: "pg",
|
||||||
|
schema: {
|
||||||
|
user: users,
|
||||||
|
session: sessions,
|
||||||
|
account: accounts,
|
||||||
|
verification: verifications,
|
||||||
|
},
|
||||||
|
transaction: true,
|
||||||
|
}),
|
||||||
|
baseURL: required("BETTER_AUTH_URL"),
|
||||||
|
secret: required("BETTER_AUTH_SECRET"),
|
||||||
|
socialProviders: {
|
||||||
|
google: {
|
||||||
|
clientId: required("GOOGLE_CLIENT_ID"),
|
||||||
|
clientSecret: required("GOOGLE_CLIENT_SECRET"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [nextCookies()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthInstance = ReturnType<typeof createAuth>;
|
||||||
|
let authInstance: AuthInstance | undefined;
|
||||||
|
|
||||||
|
export function getAuth(): AuthInstance {
|
||||||
|
if (!authInstance) authInstance = createAuth();
|
||||||
|
return authInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSession {
|
||||||
|
user: SessionUserLike;
|
||||||
|
session: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminSession(): Promise<AdminSession | null> {
|
||||||
|
if (process.env.BUZZ_DEMO_MODE === "true") {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: "demo-admin",
|
||||||
|
email: "[email protected]",
|
||||||
|
emailVerified: true,
|
||||||
|
name: "Demo Admin",
|
||||||
|
},
|
||||||
|
session: { id: "demo-session" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getAuth().api.getSession({ headers: await headers() });
|
||||||
|
if (
|
||||||
|
!session?.session ||
|
||||||
|
!isAuthorizedAdmin(session.user, process.env.ADMIN_EMAIL)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: session.user,
|
||||||
|
session: { id: session.session.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuthorizationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Admin authorization required.");
|
||||||
|
this.name = "AdminAuthorizationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(): Promise<AdminSession> {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) throw new AdminAuthorizationError();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user