diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index b6149fb..9304253 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,21 @@ -import NextAuth from "next-auth" -import { authOptions } from "@/lib/auth" +import { NextResponse } from "next/server" -const handler = NextAuth(authOptions) +export const runtime = "nodejs" +export const dynamic = "force-dynamic" -export { handler as GET, handler as POST } +function disabled() { + return NextResponse.json( + { + detail: "Legacy NextAuth route is disabled. Use /api/auth/login, /api/auth/session, /api/auth/logout.", + }, + { status: 410 }, + ) +} + +export async function GET() { + return disabled() +} + +export async function POST() { + return disabled() +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..9307fa9 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server" +import { AUTH_COOKIE_MAX_AGE_SECONDS, AUTH_COOKIE_NAME } from "@/lib/auth-cookie" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") + +type MobileLoginResponse = { + accessToken: string + expiresIn?: number + user: { + id: string + email?: string | null + name?: string | null + image?: string | null + role?: string | null + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const googleIdToken = String(body?.googleIdToken || "").trim() + + if (!googleIdToken) { + return NextResponse.json({ detail: "googleIdToken is required" }, { status: 400 }) + } + + const upstream = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ googleIdToken }), + cache: "no-store", + signal: AbortSignal.timeout(10000), + }) + + if (!upstream.ok) { + const message = await upstream.text() + return NextResponse.json({ detail: message || "Authentication failed" }, { status: upstream.status }) + } + + const data = (await upstream.json()) as MobileLoginResponse + + const response = NextResponse.json( + { + user: { + id: data.user.id, + email: data.user.email || null, + name: data.user.name || null, + image: data.user.image || null, + role: data.user.role || "USER", + }, + }, + { status: 200 }, + ) + + response.cookies.set(AUTH_COOKIE_NAME, data.accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: data.expiresIn || AUTH_COOKIE_MAX_AGE_SECONDS, + }) + + return response + } catch (error) { + console.error("/api/auth/login failed", error) + return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..856481f --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function POST() { + const response = NextResponse.json({ success: true }, { status: 200 }) + response.cookies.set(AUTH_COOKIE_NAME, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }) + return response +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..6e609bf --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") + +export async function GET(req: NextRequest) { + const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || "" + if (!accessToken) { + return NextResponse.json({ user: null }, { status: 200 }) + } + + try { + const upstream = await fetch(`${readerApiOrigin}/api/auth/session`, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + cache: "no-store", + signal: AbortSignal.timeout(5000), + }) + + if (!upstream.ok) { + return NextResponse.json({ user: null }, { status: 200 }) + } + + const data = await upstream.json() + const user = data?.user || null + return NextResponse.json({ user }, { status: 200 }) + } catch (error) { + console.error("/api/auth/session failed", error) + return NextResponse.json({ user: null }, { status: 200 }) + } +} diff --git a/app/api/mod/[...path]/route.ts b/app/api/mod/[...path]/route.ts index f0c6486..1cf290b 100644 --- a/app/api/mod/[...path]/route.ts +++ b/app/api/mod/[...path]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getToken } from "next-auth/jwt" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -7,8 +7,7 @@ export const dynamic = "force-dynamic" const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") async function proxyToReaderApi(req: NextRequest, path: string[]) { - const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }) - const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null + const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null const url = new URL(req.url) const query = url.search || "" @@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) { const headers = new Headers(req.headers) headers.delete("host") + headers.delete("cookie") if (accessToken) { headers.set("authorization", `Bearer ${accessToken}`) } diff --git a/app/api/user/[...path]/route.ts b/app/api/user/[...path]/route.ts index 7d8adac..b345855 100644 --- a/app/api/user/[...path]/route.ts +++ b/app/api/user/[...path]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getToken } from "next-auth/jwt" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -7,8 +7,7 @@ export const dynamic = "force-dynamic" const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") async function proxyToReaderApi(req: NextRequest, path: string[]) { - const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }) - const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null + const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null const url = new URL(req.url) const query = url.search || "" @@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) { const headers = new Headers(req.headers) headers.delete("host") + headers.delete("cookie") if (accessToken) { headers.set("authorization", `Bearer ${accessToken}`) } diff --git a/app/dang-nhap/page.tsx b/app/dang-nhap/page.tsx index 07861b0..5caa937 100644 --- a/app/dang-nhap/page.tsx +++ b/app/dang-nhap/page.tsx @@ -1,11 +1,12 @@ "use client" -import { useEffect } from "react" +import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { BookOpen } from "lucide-react" import { Button } from "@/components/ui/button" import { useAuth } from "@/lib/auth-context" +import { toast } from "sonner" function GoogleIcon({ className }: { className?: string }) { return ( @@ -21,6 +22,7 @@ function GoogleIcon({ className }: { className?: string }) { export default function LoginPage() { const router = useRouter() const { user, loginWithGoogle } = useAuth() + const [isSubmitting, setIsSubmitting] = useState(false) useEffect(() => { if (user) { @@ -28,9 +30,19 @@ export default function LoginPage() { } }, [user, router]) - const handleGoogleLogin = () => { - loginWithGoogle() - router.push("/") + const handleGoogleLogin = async () => { + if (isSubmitting) return + + setIsSubmitting(true) + try { + await loginWithGoogle() + router.push("/") + } catch (error) { + const message = error instanceof Error ? error.message : "Đăng nhập thất bại" + toast.error(message) + } finally { + setIsSubmitting(false) + } } return ( @@ -52,9 +64,10 @@ export default function LoginPage() { variant="outline" className="flex h-12 w-full items-center justify-center gap-3 text-sm font-medium" onClick={handleGoogleLogin} + disabled={isSubmitting} > - {"Dang nhap bang Google"} + {isSubmitting ? "Dang xu ly..." : "Dang nhap bang Google"}
diff --git a/app/mod/ai-tool/page.tsx b/app/mod/ai-tool/page.tsx index 7e0f6c2..f9ac816 100644 --- a/app/mod/ai-tool/page.tsx +++ b/app/mod/ai-tool/page.tsx @@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { toast } from "sonner" +// Trang này được đặt dưới /mod layout đã kiểm soát quyền ở server side. + type NovelRow = { id: string title: string diff --git a/app/mod/chuong/[id]/page.tsx b/app/mod/chuong/[id]/page.tsx index 6a61cb4..deeb8fb 100644 --- a/app/mod/chuong/[id]/page.tsx +++ b/app/mod/chuong/[id]/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { EditorClient } from "./editor-client" export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() const resolvedParams = await params diff --git a/app/mod/chuong/page.tsx b/app/mod/chuong/page.tsx index 6baa4c5..dd40ddc 100644 --- a/app/mod/chuong/page.tsx +++ b/app/mod/chuong/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { ChapterClient } from "./chapter-client" export default async function ModChuongPage() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/app/mod/de-cu/page.tsx b/app/mod/de-cu/page.tsx index 1ce66da..f5dcf7f 100644 --- a/app/mod/de-cu/page.tsx +++ b/app/mod/de-cu/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { RecommendationClient } from "./recommendation-client" export default async function ModRecommendationPage() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/app/mod/layout.tsx b/app/mod/layout.tsx index 4acb502..e822949 100644 --- a/app/mod/layout.tsx +++ b/app/mod/layout.tsx @@ -1,6 +1,4 @@ -import { redirect } from "next/navigation" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" +import { requireModSessionUser } from "@/lib/server-auth" import { CollapsibleSidebar } from "./collapsible-sidebar" export default async function ModLayout({ @@ -8,12 +6,7 @@ export default async function ModLayout({ }: { children: React.ReactNode }) { - const session = await getServerSession(authOptions) - - // Kiểm tra quyền - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") // Không đủ quyền, đưa về trang chủ - } + await requireModSessionUser() return (
diff --git a/app/mod/page.tsx b/app/mod/page.tsx index e1fdc22..5cb3d84 100644 --- a/app/mod/page.tsx +++ b/app/mod/page.tsx @@ -1,13 +1,13 @@ -import { getServerSession } from "next-auth" -import { authOptions } from "@/lib/auth" import Link from "next/link" import { Sparkles } from "lucide-react" import { cookies } from "next/headers" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" +import { requireModSessionUser } from "@/lib/server-auth" const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") export default async function ModDashboardPage() { - const session = await getServerSession(authOptions) + const sessionUser = await requireModSessionUser() let novelCount = 0 let totalViews = 0 @@ -15,10 +15,10 @@ export default async function ModDashboardPage() { let seriesCount = 0 try { - const cookieHeader = (await cookies()).toString() + const accessToken = (await cookies()).get(AUTH_COOKIE_NAME)?.value || "" const res = await fetch(`${readerApiOrigin}/api/mod/overview`, { cache: "no-store", - headers: cookieHeader ? { cookie: cookieHeader } : undefined, + headers: accessToken ? { authorization: `Bearer ${accessToken}` } : undefined, }) if (res.ok) { const data = await res.json() @@ -33,7 +33,7 @@ export default async function ModDashboardPage() { return (
-

Xin chào, {session?.user.name}

+

Xin chào, {sessionUser?.name || "Moderator"}

Chào mừng bạn đến với trang quản trị dành cho Moderator.

diff --git a/app/mod/series/page.tsx b/app/mod/series/page.tsx index 5c46ed5..cfe17db 100644 --- a/app/mod/series/page.tsx +++ b/app/mod/series/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { SeriesClient } from "./series-client" export default async function ModSeriesPage() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/app/mod/the-loai/page.tsx b/app/mod/the-loai/page.tsx index 2372953..dff3625 100644 --- a/app/mod/the-loai/page.tsx +++ b/app/mod/the-loai/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { GenreClient } from "./genre-client" export default async function ModTheLoaiPage() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/app/mod/thieu-thong-tin/page.tsx b/app/mod/thieu-thong-tin/page.tsx index ddeccf1..17f61f0 100644 --- a/app/mod/thieu-thong-tin/page.tsx +++ b/app/mod/thieu-thong-tin/page.tsx @@ -1,14 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { MissingFieldsClient } from "./missing-fields-client" export default async function ModMissingFieldsPage() { - const session = await getServerSession(authOptions) - - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/app/mod/truyen/page.tsx b/app/mod/truyen/page.tsx index 652358c..4f3a24d 100644 --- a/app/mod/truyen/page.tsx +++ b/app/mod/truyen/page.tsx @@ -1,13 +1,8 @@ -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { redirect } from "next/navigation" +import { requireModSessionUser } from "@/lib/server-auth" import { NovelClient } from "./novel-client" export default async function ModTruyenPage() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - redirect("/") - } + await requireModSessionUser() return } diff --git a/components/header.tsx b/components/header.tsx index eb69e7d..0a5798f 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -206,7 +206,7 @@ export function Header() { - + void logout()} className="flex items-center gap-2 text-destructive"> Đăng Xuất @@ -304,7 +304,7 @@ export function Header() {

Loại tài khoản: {roleLabel(user.role)}

- diff --git a/docker-compose.yml b/docker-compose.yml index d572842..7ebc4fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,7 @@ services: - NEXTAUTH_SECRET=your-super-secret-key # Sửa thành domain name thực tế bạn đang truy cập - NEXTAUTH_URL=http://master-02:3003 - # Ép IPv4-first để tránh IPv6 Happy Eyeballs timeout khi gọi Google OAuth (OAUTH_CALLBACK_ERROR) - - NODE_OPTIONS=--dns-result-order=ipv4first + - NEXT_PUBLIC_GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com - GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com - GOOGLE_CLIENT_SECRET=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 volumes: diff --git a/instrumentation.ts b/instrumentation.ts index cd311a3..ae166c9 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,28 +1,5 @@ /** - * Next.js Instrumentation Hook — chạy một lần khi server khởi động. - * - * Mục đích: - * 1. Ép IPv4-first để tránh Happy Eyeballs timeout khi gọi Google OAuth - * (openid-client bên trong NextAuth gọi oauth2.googleapis.com). - * 2. Tắt keep-alive trên global https agent để tránh stale connection: - * Sau lần đăng nhập đầu, connection pool giữ lại TCP socket tới Google. - * NAT/firewall của Docker drop socket này sau vài phút (silently). - * Khi đăng nhập lần 2, openid-client cố reuse socket đã chết → treo 3500ms. - * keepAlive: false buộc mở connection mới mỗi request, không reuse pool cũ. + * Next.js Instrumentation Hook. + * Hiện tại không áp dụng side effects để tránh ảnh hưởng runtime khởi động. */ -export async function register() { - if (process.env.NEXT_RUNTIME === "nodejs") { - const { setDefaultResultOrder } = await import("dns") - setDefaultResultOrder("ipv4first") - - const https = await import("https") - https.globalAgent = new https.Agent({ - keepAlive: false, - }) - - const http = await import("http") - http.globalAgent = new http.Agent({ - keepAlive: false, - }) - } -} +export async function register() {} diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx index 851ea39..d9b84c0 100644 --- a/lib/auth-context.tsx +++ b/lib/auth-context.tsx @@ -1,21 +1,104 @@ "use client" -import { SessionProvider, useSession, signIn, signOut } from "next-auth/react" -import { useMemo, type ReactNode } from "react" +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import type { User } from "./types" +let googleScriptPromise: Promise | null = null + +function ensureGoogleIdentityScript() { + if (typeof window === "undefined") return Promise.resolve() + if ((window as any).google?.accounts?.id) return Promise.resolve() + if (googleScriptPromise) return googleScriptPromise + + googleScriptPromise = new Promise((resolve, reject) => { + const existing = document.querySelector('script[src="https://accounts.google.com/gsi/client"]') + if (existing) { + existing.addEventListener("load", () => resolve(), { once: true }) + existing.addEventListener("error", () => reject(new Error("Failed to load Google script")), { once: true }) + return + } + + const script = document.createElement("script") + script.src = "https://accounts.google.com/gsi/client" + script.async = true + script.defer = true + script.onload = () => resolve() + script.onerror = () => reject(new Error("Failed to load Google script")) + document.head.appendChild(script) + }) + + return googleScriptPromise +} + +async function requestGoogleIdToken(clientId: string): Promise { + await ensureGoogleIdentityScript() + + return new Promise((resolve, reject) => { + const googleApi = (window as any).google?.accounts?.id + if (!googleApi) { + reject(new Error("Google Identity API is unavailable")) + return + } + + let settled = false + + googleApi.initialize({ + client_id: clientId, + callback: (response: { credential?: string }) => { + if (settled) return + settled = true + const credential = (response?.credential || "").trim() + if (!credential) { + reject(new Error("Google did not return ID token")) + return + } + resolve(credential) + }, + auto_select: false, + cancel_on_tap_outside: true, + }) + + googleApi.prompt((notification: any) => { + if (settled) return + + if (notification?.isNotDisplayed?.() || notification?.isSkippedMoment?.()) { + settled = true + reject(new Error("Google sign-in prompt was closed or not displayed")) + } + }) + }) +} + export function AuthProvider({ children }: { children: ReactNode }) { - return {children} + return <>{children} } // Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại export function useAuth() { - const { data: session, status } = useSession() + const [sessionUser, setSessionUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) - const isLoading = status === "loading" + const fetchSession = useCallback(async () => { + try { + setIsLoading(true) + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + setSessionUser(null) + return + } - // Chuyển đổi session user thành format User của project - const sessionUser = session?.user + const data = await res.json() + setSessionUser(data?.user || null) + } catch { + setSessionUser(null) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchSession() + }, [fetchSession]) const user: User | null = useMemo(() => { if (!sessionUser) return null @@ -30,8 +113,38 @@ export function useAuth() { } }, [sessionUser]) - const loginWithGoogle = () => signIn("google", { callbackUrl: "/" }) - const logout = () => signOut({ callbackUrl: "/" }) + const loginWithGoogle = async () => { + const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim() + if (!clientId) { + throw new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID is not configured") + } + + const googleIdToken = await requestGoogleIdToken(clientId) + + const result = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ googleIdToken }), + }) + + if (!result.ok) { + const errorText = await result.text() + throw new Error(errorText || "Đăng nhập thất bại") + } + + const payload = await result.json() + setSessionUser(payload?.user || null) + + return payload + } + + const logout = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }) + } finally { + setSessionUser(null) + } + } return { user, isLoading, loginWithGoogle, logout } } diff --git a/lib/auth-cookie.ts b/lib/auth-cookie.ts new file mode 100644 index 0000000..4d313a6 --- /dev/null +++ b/lib/auth-cookie.ts @@ -0,0 +1,2 @@ +export const AUTH_COOKIE_NAME = "reader_access_token" +export const AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 diff --git a/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index 7e34c58..0000000 --- a/lib/auth.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NextAuthOptions } from "next-auth" -import GoogleProvider from "next-auth/providers/google" - -const readerApiOrigin = process.env.READER_API_ORIGIN?.replace(/\/+$/, "") -const googleClientId = process.env.GOOGLE_CLIENT_ID -const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET - -type MobileLoginResponse = { - accessToken: string - user: { - id: string - email?: string | null - name?: string | null - image?: string | null - role?: string | null - } -} - -export const authOptions: NextAuthOptions = { - providers: [ - GoogleProvider({ - clientId: googleClientId || "", - clientSecret: googleClientSecret || "", - httpOptions: { - timeout: 10000, - }, - }), - ], - session: { - strategy: "jwt", - }, - callbacks: { - async jwt({ token, account }) { - if (account?.provider === "google" && account.id_token) { - if (!readerApiOrigin) { - console.warn("READER_API_ORIGIN is not configured, skipping reader-api sync after Google login") - return token - } - - try { - const response = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ googleIdToken: account.id_token }), - signal: AbortSignal.timeout(5000), - }) - - if (!response.ok) { - console.error("reader-api sync failed", { - status: response.status, - statusText: response.statusText, - }) - return token - } - - const data = (await response.json()) as MobileLoginResponse - token.sub = data.user.id - ;(token as any).id = data.user.id - ;(token as any).role = data.user.role || "USER" - ;(token as any).name = data.user.name || token.name || null - ;(token as any).email = data.user.email || token.email || null - ;(token as any).picture = data.user.image || (token as any).picture || null - ;(token as any).accessToken = data.accessToken - } catch (error) { - console.error("Failed to sync Google login with reader-api", error) - } - } - - return token - }, - async session({ session, token }) { - if (session.user) { - session.user.id = String((token as any).id || token.sub || "") - session.user.role = String((token as any).role || "USER") - session.user.name = ((token as any).name ?? token.name ?? session.user.name ?? null) as string | null - session.user.email = ((token as any).email ?? token.email ?? session.user.email ?? null) as string | null - session.user.image = ((token as any).picture ?? (token as any).image ?? session.user.image ?? null) as string | null - ;(session as any).accessToken = (token as any).accessToken || null - } - return session - }, - }, - secret: process.env.NEXTAUTH_SECRET, - debug: process.env.NEXTAUTH_DEBUG === "true", -} diff --git a/lib/server-auth.ts b/lib/server-auth.ts new file mode 100644 index 0000000..45dfa53 --- /dev/null +++ b/lib/server-auth.ts @@ -0,0 +1,49 @@ +import { cookies } from "next/headers" +import { redirect } from "next/navigation" +import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie" + +const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") + +export type ApiSessionUser = { + id: string + email?: string | null + name?: string | null + image?: string | null + role?: string | null +} + +export async function getApiSessionUser(): Promise { + const cookieStore = await cookies() + const accessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value || "" + + if (!accessToken) { + return null + } + + try { + const response = await fetch(`${readerApiOrigin}/api/auth/session`, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + cache: "no-store", + signal: AbortSignal.timeout(5000), + }) + + if (!response.ok) { + return null + } + + const data = await response.json() + return (data?.user || null) as ApiSessionUser | null + } catch { + return null + } +} + +export async function requireModSessionUser(): Promise { + const user = await getApiSessionUser() + if (!user || (user.role !== "MOD" && user.role !== "ADMIN")) { + redirect("/") + } + + return user +} diff --git a/next.config.mjs b/next.config.mjs index 2c699ab..040796a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,13 +3,6 @@ const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000 const nextConfig = { output: "standalone", - // Tắt HTTP keep-alive để tránh stale connection tới Google OAuth. - // Sau lần đăng nhập đầu, Node.js giữ TCP socket tới oauth2.googleapis.com trong pool. - // NAT/firewall của Docker drop socket sau vài phút (silently). Khi login lần 2, - // openid-client (bên trong NextAuth) reuse socket đã chết → request treo → OAUTH_CALLBACK_ERROR 3500ms. - httpAgentOptions: { - keepAlive: false, - }, typescript: { ignoreBuildErrors: true, }, diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts deleted file mode 100644 index a99a82a..0000000 --- a/types/next-auth.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import NextAuth from "next-auth" -import { JWT } from "next-auth/jwt" - -declare module "next-auth" { - interface Session { - user: { - id: string - name?: string | null - email?: string | null - image?: string | null - role: string - } - accessToken?: string | null - } - - interface User { - role: string - } -} - -declare module "next-auth/jwt" { - interface JWT { - id?: string - role?: string - accessToken?: string | null - picture?: string | null - } -}