Refactor authentication system: replace NextAuth with custom login/logout/session handling, improve cookie management, and enhance error handling
Build and Push Reader Image / docker (push) Successful in 39s

This commit is contained in:
2026-04-24 01:53:32 +07:00
parent 690a2fbd51
commit 7c4404ded8
26 changed files with 368 additions and 239 deletions
+19 -4
View File
@@ -1,6 +1,21 @@
import NextAuth from "next-auth" import { NextResponse } from "next/server"
import { authOptions } from "@/lib/auth"
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()
}
+71
View File
@@ -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 })
}
}
+17
View File
@@ -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
}
+34
View File
@@ -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 })
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server" 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 runtime = "nodejs"
export const dynamic = "force-dynamic" 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(/\/+$/, "") const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
async function proxyToReaderApi(req: NextRequest, path: string[]) { async function proxyToReaderApi(req: NextRequest, path: string[]) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }) const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null
const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null
const url = new URL(req.url) const url = new URL(req.url)
const query = url.search || "" const query = url.search || ""
@@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) {
const headers = new Headers(req.headers) const headers = new Headers(req.headers)
headers.delete("host") headers.delete("host")
headers.delete("cookie")
if (accessToken) { if (accessToken) {
headers.set("authorization", `Bearer ${accessToken}`) headers.set("authorization", `Bearer ${accessToken}`)
} }
+3 -3
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server" 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 runtime = "nodejs"
export const dynamic = "force-dynamic" 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(/\/+$/, "") const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
async function proxyToReaderApi(req: NextRequest, path: string[]) { async function proxyToReaderApi(req: NextRequest, path: string[]) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }) const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null
const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null
const url = new URL(req.url) const url = new URL(req.url)
const query = url.search || "" const query = url.search || ""
@@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) {
const headers = new Headers(req.headers) const headers = new Headers(req.headers)
headers.delete("host") headers.delete("host")
headers.delete("cookie")
if (accessToken) { if (accessToken) {
headers.set("authorization", `Bearer ${accessToken}`) headers.set("authorization", `Bearer ${accessToken}`)
} }
+17 -4
View File
@@ -1,11 +1,12 @@
"use client" "use client"
import { useEffect } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { BookOpen } from "lucide-react" import { BookOpen } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useAuth } from "@/lib/auth-context" import { useAuth } from "@/lib/auth-context"
import { toast } from "sonner"
function GoogleIcon({ className }: { className?: string }) { function GoogleIcon({ className }: { className?: string }) {
return ( return (
@@ -21,6 +22,7 @@ function GoogleIcon({ className }: { className?: string }) {
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const router = useRouter()
const { user, loginWithGoogle } = useAuth() const { user, loginWithGoogle } = useAuth()
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -28,9 +30,19 @@ export default function LoginPage() {
} }
}, [user, router]) }, [user, router])
const handleGoogleLogin = () => { const handleGoogleLogin = async () => {
loginWithGoogle() if (isSubmitting) return
setIsSubmitting(true)
try {
await loginWithGoogle()
router.push("/") 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 ( return (
@@ -52,9 +64,10 @@ export default function LoginPage() {
variant="outline" variant="outline"
className="flex h-12 w-full items-center justify-center gap-3 text-sm font-medium" className="flex h-12 w-full items-center justify-center gap-3 text-sm font-medium"
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
disabled={isSubmitting}
> >
<GoogleIcon className="h-5 w-5" /> <GoogleIcon className="h-5 w-5" />
{"Dang nhap bang Google"} {isSubmitting ? "Dang xu ly..." : "Dang nhap bang Google"}
</Button> </Button>
<div className="mt-4 text-center text-xs text-muted-foreground"> <div className="mt-4 text-center text-xs text-muted-foreground">
+2
View File
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { toast } from "sonner" import { toast } from "sonner"
// Trang này được đặt dưới /mod layout đã kiểm soát quyền ở server side.
type NovelRow = { type NovelRow = {
id: string id: string
title: string title: string
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { EditorClient } from "./editor-client" import { EditorClient } from "./editor-client"
export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) { export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
const resolvedParams = await params const resolvedParams = await params
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { ChapterClient } from "./chapter-client" import { ChapterClient } from "./chapter-client"
export default async function ModChuongPage() { export default async function ModChuongPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <ChapterClient /> return <ChapterClient />
} }
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { RecommendationClient } from "./recommendation-client" import { RecommendationClient } from "./recommendation-client"
export default async function ModRecommendationPage() { export default async function ModRecommendationPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <RecommendationClient /> return <RecommendationClient />
} }
+2 -9
View File
@@ -1,6 +1,4 @@
import { redirect } from "next/navigation" import { requireModSessionUser } from "@/lib/server-auth"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { CollapsibleSidebar } from "./collapsible-sidebar" import { CollapsibleSidebar } from "./collapsible-sidebar"
export default async function ModLayout({ export default async function ModLayout({
@@ -8,12 +6,7 @@ export default async function ModLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await getServerSession(authOptions) await requireModSessionUser()
// Kiểm tra quyền
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/") // Không đủ quyền, đưa về trang chủ
}
return ( return (
<div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20"> <div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20">
+6 -6
View File
@@ -1,13 +1,13 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import Link from "next/link" import Link from "next/link"
import { Sparkles } from "lucide-react" import { Sparkles } from "lucide-react"
import { cookies } from "next/headers" 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(/\/+$/, "") const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
export default async function ModDashboardPage() { export default async function ModDashboardPage() {
const session = await getServerSession(authOptions) const sessionUser = await requireModSessionUser()
let novelCount = 0 let novelCount = 0
let totalViews = 0 let totalViews = 0
@@ -15,10 +15,10 @@ export default async function ModDashboardPage() {
let seriesCount = 0 let seriesCount = 0
try { try {
const cookieHeader = (await cookies()).toString() const accessToken = (await cookies()).get(AUTH_COOKIE_NAME)?.value || ""
const res = await fetch(`${readerApiOrigin}/api/mod/overview`, { const res = await fetch(`${readerApiOrigin}/api/mod/overview`, {
cache: "no-store", cache: "no-store",
headers: cookieHeader ? { cookie: cookieHeader } : undefined, headers: accessToken ? { authorization: `Bearer ${accessToken}` } : undefined,
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -33,7 +33,7 @@ export default async function ModDashboardPage() {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1> <h1 className="text-2xl font-bold mb-4">Xin chào, {sessionUser?.name || "Moderator"}</h1>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Chào mừng bạn đến với trang quản trị dành cho Moderator. Chào mừng bạn đến với trang quản trị dành cho Moderator.
</p> </p>
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { SeriesClient } from "./series-client" import { SeriesClient } from "./series-client"
export default async function ModSeriesPage() { export default async function ModSeriesPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <SeriesClient /> return <SeriesClient />
} }
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { GenreClient } from "./genre-client" import { GenreClient } from "./genre-client"
export default async function ModTheLoaiPage() { export default async function ModTheLoaiPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <GenreClient /> return <GenreClient />
} }
+2 -8
View File
@@ -1,14 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { MissingFieldsClient } from "./missing-fields-client" import { MissingFieldsClient } from "./missing-fields-client"
export default async function ModMissingFieldsPage() { export default async function ModMissingFieldsPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <MissingFieldsClient /> return <MissingFieldsClient />
} }
+2 -7
View File
@@ -1,13 +1,8 @@
import { getServerSession } from "next-auth/next" import { requireModSessionUser } from "@/lib/server-auth"
import { authOptions } from "@/lib/auth"
import { redirect } from "next/navigation"
import { NovelClient } from "./novel-client" import { NovelClient } from "./novel-client"
export default async function ModTruyenPage() { export default async function ModTruyenPage() {
const session = await getServerSession(authOptions) await requireModSessionUser()
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
redirect("/")
}
return <NovelClient /> return <NovelClient />
} }
+2 -2
View File
@@ -206,7 +206,7 @@ export function Header() {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="flex items-center gap-2 text-destructive"> <DropdownMenuItem onClick={() => void logout()} className="flex items-center gap-2 text-destructive">
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Đăng Xuất Đăng Xuất
</DropdownMenuItem> </DropdownMenuItem>
@@ -304,7 +304,7 @@ export function Header() {
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p> <p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}> <Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { void logout(); setOpen(false) }}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Đăng Xuất Đăng Xuất
</Button> </Button>
+1 -2
View File
@@ -13,8 +13,7 @@ services:
- NEXTAUTH_SECRET=your-super-secret-key - NEXTAUTH_SECRET=your-super-secret-key
# Sửa thành domain name thực tế bạn đang truy cập # Sửa thành domain name thực tế bạn đang truy cập
- NEXTAUTH_URL=http://master-02:3003 - NEXTAUTH_URL=http://master-02:3003
# Ép IPv4-first để tránh IPv6 Happy Eyeballs timeout khi gọi Google OAuth (OAUTH_CALLBACK_ERROR) - NEXT_PUBLIC_GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
- NODE_OPTIONS=--dns-result-order=ipv4first
- GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com - GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
- GOOGLE_CLIENT_SECRET=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 - GOOGLE_CLIENT_SECRET=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
volumes: volumes:
+3 -26
View File
@@ -1,28 +1,5 @@
/** /**
* Next.js Instrumentation Hook — chạy một lần khi server khởi động. * Next.js Instrumentation Hook.
* * Hiện tại không áp dụng side effects để tránh ảnh hưởng runtime 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ũ.
*/ */
export async function register() { 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,
})
}
}
+122 -9
View File
@@ -1,21 +1,104 @@
"use client" "use client"
import { SessionProvider, useSession, signIn, signOut } from "next-auth/react" import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { useMemo, type ReactNode } from "react"
import type { User } from "./types" import type { User } from "./types"
let googleScriptPromise: Promise<void> | 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<string> {
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 }) { export function AuthProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider> return <>{children}</>
} }
// Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại // Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại
export function useAuth() { export function useAuth() {
const { data: session, status } = useSession() const [sessionUser, setSessionUser] = useState<any>(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 data = await res.json()
const sessionUser = session?.user setSessionUser(data?.user || null)
} catch {
setSessionUser(null)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchSession()
}, [fetchSession])
const user: User | null = useMemo(() => { const user: User | null = useMemo(() => {
if (!sessionUser) return null if (!sessionUser) return null
@@ -30,8 +113,38 @@ export function useAuth() {
} }
}, [sessionUser]) }, [sessionUser])
const loginWithGoogle = () => signIn("google", { callbackUrl: "/" }) const loginWithGoogle = async () => {
const logout = () => signOut({ callbackUrl: "/" }) 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 } return { user, isLoading, loginWithGoogle, logout }
} }
+2
View File
@@ -0,0 +1,2 @@
export const AUTH_COOKIE_NAME = "reader_access_token"
export const AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
-85
View File
@@ -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",
}
+49
View File
@@ -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<ApiSessionUser | null> {
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<ApiSessionUser> {
const user = await getApiSessionUser()
if (!user || (user.role !== "MOD" && user.role !== "ADMIN")) {
redirect("/")
}
return user
}
-7
View File
@@ -3,13 +3,6 @@ const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000
const nextConfig = { const nextConfig = {
output: "standalone", 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: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
-28
View File
@@ -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
}
}