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 { 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()
}
+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 { 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}`)
}
+3 -3
View File
@@ -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}`)
}
+18 -5
View File
@@ -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}
>
<GoogleIcon className="h-5 w-5" />
{"Dang nhap bang Google"}
{isSubmitting ? "Dang xu ly..." : "Dang nhap bang Google"}
</Button>
<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 { 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
+2 -7
View File
@@ -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
+2 -7
View File
@@ -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 <ChapterClient />
}
+2 -7
View File
@@ -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 <RecommendationClient />
}
+2 -9
View File
@@ -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 (
<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 { 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 (
<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">
Chào mừng bạn đến với trang quản trị dành cho Moderator.
</p>
+2 -7
View File
@@ -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 <SeriesClient />
}
+2 -7
View File
@@ -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 <GenreClient />
}
+2 -8
View File
@@ -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 <MissingFieldsClient />
}
+2 -7
View File
@@ -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 <NovelClient />
}