Files
lcbp3.np-dms.work/Bearer-Token.patch.diff
2025-10-05 09:21:04 +07:00

484 lines
19 KiB
Diff

diff --git a/backend/src/middleware/requireBearer.js b/backend/src/middleware/requireBearer.js
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/backend/src/middleware/requireBearer.js
@@ -0,0 +1,44 @@
+// backend/src/middleware/requireBearer.js
+import jwt from "jsonwebtoken";
+import { findUserById } from "../db/models/users.js";
+
+export async function requireBearer(req, res, next) {
+ const hdr = req.get("Authorization") || "";
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
+ if (!m) return res.status(401).json({ error: "Unauthenticated" });
+ try {
+ const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, {
+ issuer: "dms-backend",
+ });
+ const user = await findUserById(payload.user_id);
+ if (!user) return res.status(401).json({ error: "Unauthenticated" });
+ req.user = {
+ user_id: user.user_id,
+ username: user.username,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ };
+ next();
+ } catch {
+ return res.status(401).json({ error: "Unauthenticated" });
+ }
+}
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 2222222..3333333 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -1,99 +1,109 @@
-// (เดิม) ผูกกับคุกกี้ / ส่ง ok:true ฯลฯ
+// backend/src/routes/auth.js — Bearer Token ล้วน
import { Router } from "express";
import jwt from "jsonwebtoken";
-import { findUserByUsername } from "../db/models/users.js";
+import { findUserByUsername, findUserById } from "../db/models/users.js";
import { verifyPassword } from "../utils/passwords.js";
-// NOTE: ลบการใช้งาน res.cookie(...) ทั้งหมด
+// NOTE: ไม่มีการใช้ res.cookie(...) อีกต่อไป
const router = Router();
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
process.env.JWT_ACCESS_SECRET,
- { issuer: "dms-backend", expiresIn: "30m" } // ปรับได้
+ { issuer: "dms-backend", expiresIn: "30m" }
);
}
function signRefreshToken(user) {
return jwt.sign(
- { user_id: user.user_id, username: user.username },
+ { user_id: user.user_id, username: user.username, t: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ issuer: "dms-backend", expiresIn: "30d" }
);
}
router.post("/login", async (req, res) => {
const { username, password } = req.body || {};
const user = await findUserByUsername(username);
if (!user || !(await verifyPassword(password, user.password_hash))) {
return res.status(401).json({ error: "INVALID_CREDENTIALS" });
}
const token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
return res.json({
token,
refresh_token,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
});
+router.post("/refresh", async (req, res) => {
+ const hdr = req.get("Authorization") || "";
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
+ const r = m?.[1];
+ if (!r) return res.status(401).json({ error: "NO_REFRESH_TOKEN" });
+ try {
+ const payload = jwt.verify(r, process.env.JWT_REFRESH_SECRET, {
+ issuer: "dms-backend",
+ });
+ const user = await findUserById(payload.user_id);
+ if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
+ const token = signAccessToken(user);
+ return res.json({ token });
+ } catch {
+ return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
+ }
+});
+
export default router;
diff --git a/backend/src/index.js b/backend/src/index.js
index 4444444..5555555 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -1,60 +1,69 @@
import express from "express";
import cors from "cors";
import authRouter from "./routes/auth.js";
+import { requireBearer } from "./middleware/requireBearer.js";
-// import routers อื่น ๆ ตามจริง เช่น rfasRouter, transmittalsRouter
const app = express();
-// CORS เดิม (อาจมี credentials)
-app.use(cors({
- origin: true,
- credentials: true,
-}));
+// ✅ CORS สำหรับ Bearer: ไม่ต้อง credentials, อนุญาต Authorization header
+app.use(cors({
+ origin: [
+ "https://lcbp3.np-dms.work",
+ "http://localhost:3000"
+ ],
+ methods: ["GET","POST","PUT","PATCH","DELETE","OPTIONS"],
+ allowedHeaders: ["Authorization","Content-Type","Accept","Origin","Referer","User-Agent","X-Requested-With","Cache-Control","Pragma"],
+ exposedHeaders: ["Content-Disposition","Content-Length"]
+}));
app.use(express.json());
-// routes เดิม
-app.use("/api/auth", authRouter);
-// app.use("/api/rfas", rfasRouter);
-// app.use("/api/transmittals", transmittalsRouter);
+// ✅ เส้นทาง auth (ไม่ต้องมี token)
+app.use("/api/auth", authRouter);
+
+// ✅ ตั้ง guard สำหรับเส้นทางที่เหลือต้องล็อกอิน
+app.use("/api", requireBearer);
+// แล้วค่อย mount routers protected ใต้ /api
+// app.use("/api/rfas", rfasRouter);
+// app.use("/api/transmittals", transmittalsRouter);
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: "INTERNAL_SERVER_ERROR" });
});
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`backend listening on :${port}`));
diff --git a/frontend/app/(auth)/login/page.jsx b/frontend/app/(auth)/login/page.jsx
index 6666666..7777777 100644
--- a/frontend/app/(auth)/login/page.jsx
+++ b/frontend/app/(auth)/login/page.jsx
@@ -1,200 +1,236 @@
// File: frontend/app/(auth)/login/page.jsx
"use client";
-// เวอร์ชันเดิม
+// ✅ Bearer-only + Debug toggle (NEXT_PUBLIC_DEBUG_AUTH)
import { useState, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
-const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const DEBUG =
+ String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
+function dlog(...args) {
+ if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
+}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState("");
async function onSubmit(e) {
e.preventDefault();
setErr("");
if (!username.trim() || !password) {
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
return;
}
try {
setSubmitting(true);
+ dlog("API_BASE =", API_BASE || "(empty → relative)");
+ dlog("nextPath =", nextPath, "remember =", remember);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
cache: "no-store",
});
- const data = await res.json().catch(() => ({}));
+ dlog("response.status =", res.status);
+ dlog("response.headers.content-type =", res.headers.get("content-type"));
+ let data = {};
+ try { data = await res.json(); } catch (e) { dlog("response.json() error =", e); }
+ dlog("response.body =", data);
if (!res.ok) {
- setErr(data?.error || "เข้าสู่ระบบไม่สำเร็จ");
+ const msg =
+ data?.error === "INVALID_CREDENTIALS"
+ ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
+ : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
+ dlog("login FAILED →", msg);
+ setErr(msg);
return;
}
+ if (!data?.token) {
+ dlog("login FAILED → data.token not found");
+ setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)");
+ return;
+ }
const storage = remember ? window.localStorage : window.sessionStorage;
storage.setItem("dms.token", data.token);
storage.setItem("dms.refresh_token", data.refresh_token);
storage.setItem("dms.user", JSON.stringify(data.user || {}));
+ dlog("token stored in", remember ? "localStorage" : "sessionStorage");
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
- router.replace(nextPath);
+ dlog("navigating →", nextPath);
+ router.replace(nextPath);
} catch (e) {
+ dlog("exception =", e);
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
} finally {
setSubmitting(false);
+ dlog("done");
}
}
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
{err ? (
<Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
) : null}
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">ชื่อผู้ใช้</Label>
<Input id="username" autoFocus autoComplete="username" value={username}
onChange={(e) => setUsername(e.target.value)} placeholder="เช่น superadmin" disabled={submitting}/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">รหัสผ่าน</Label>
<div className="relative">
<Input id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
disabled={submitting} className="pr-10"/>
<button type="button" onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" className="size-4 accent-sky-700"
checked={remember} onChange={(e) => setRemember(e.target.checked)} disabled={submitting}/>
จดจำฉันไว้ในเครื่องนี้
</label>
<a href="/forgot-password" className="text-sm text-sky-700 hover:text-sky-900 hover:underline">ลืมรหัสผ่าน?</a>
</div>
<Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
{submitting ? (<span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span>) : ("เข้าสู่ระบบ")}
</Button>
+ {DEBUG ? (
+ <p className="mt-2 text-xs text-slate-500">
+ DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
+ </p>
+ ) : null}
</form>
</CardContent>
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginPageSkeleton />}>
<LoginForm />
</Suspense>
);
}
function LoginPageSkeleton() {
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 animate-pulse">
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
</div>
</CardContent>
</Card>
</div>
);
}
function Spinner() {
return (
<svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
}
diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx
new file mode 100644
index 0000000..8888888
--- /dev/null
+++ b/frontend/app/(protected)/layout.jsx
@@ -0,0 +1,38 @@
+"use client";
+import { useEffect, useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+
+export default function ProtectedLayout({ children }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ try {
+ const token =
+ (typeof window !== "undefined" &&
+ (localStorage.getItem("dms.token") ||
+ sessionStorage.getItem("dms.token"))) ||
+ null;
+ if (!token) {
+ const next = encodeURIComponent(pathname || "/dashboard");
+ router.replace(`/login?next=${next}`);
+ return;
+ }
+ } finally {
+ setReady(true);
+ }
+ }, [pathname, router]);
+
+ if (!ready) {
+ return (
+ <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-6 text-slate-600">
+ กำลังตรวจสิทธิ์…
+ </div>
+ );
+ }
+ return <>{children}</>;
+}
diff --git a/frontend/lib/api.js b/frontend/lib/api.js
new file mode 100644
index 0000000..9999999
--- /dev/null
+++ b/frontend/lib/api.js
@@ -0,0 +1,45 @@
+// frontend/lib/api.js
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+
+function getToken() {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("dms.token") || sessionStorage.getItem("dms.token");
+}
+
+export async function apiFetch(path, options = {}) {
+ const token = getToken();
+ const headers = new Headers(options.headers || {});
+ headers.set("Accept", "application/json");
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers, cache: "no-store" });
+
+ if (res.status === 401) {
+ const refresh =
+ localStorage.getItem("dms.refresh_token") || sessionStorage.getItem("dms.refresh_token");
+ if (refresh) {
+ const r = await fetch(`${API_BASE}/api/auth/refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${refresh}` },
+ });
+ if (r.ok) {
+ const { token: newToken } = await r.json();
+ const store = localStorage.getItem("dms.refresh_token") ? localStorage : sessionStorage;
+ store.setItem("dms.token", newToken);
+ const headers2 = new Headers(headers);
+ headers2.set("Authorization", `Bearer ${newToken}`);
+ return fetch(`${API_BASE}${path}`, { ...options, headers: headers2, cache: "no-store" });
+ }
+ }
+ }
+ return res;
+}
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index aaaaaaa..bbbbbbb 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -1,15 +1,14 @@
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-
-// เดิม: ตรวจคุกกี้แล้ว redirect /dashboard
-export function middleware(req: NextRequest) {
- // ... logic เดิมที่ใช้คุกกี้
- return NextResponse.next();
-}
-
-export const config = {
- matcher: ["/(protected/:path*)","/dashboard","/users/:path*","/api/:path*"],
-};
+import { NextResponse } from "next/server";
+// ✅ ไม่บล็อกเพจอีกต่อไป (Bearer อยู่ใน storage ฝั่ง client)
+export function middleware() {
+ return NextResponse.next();
+}
+// จำกัดให้ทำงานเฉพาะ /api ถ้าต้องการใช้ในอนาคต (ตอนนี้ผ่านเฉย ๆ)
+export const config = { matcher: ["/api/:path*"] };
diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx
new file mode 100644
index 0000000..ccccccc
--- /dev/null
+++ b/frontend/app/(protected)/dashboard/page.jsx
@@ -0,0 +1,11 @@
+"use client";
+export default function DashboardPage() {
+ return (
+ <main className="p-6">
+ <h1 className="text-2xl font-semibold text-sky-800">Dashboard</h1>
+ <p className="text-slate-600 mt-2">
+ ยินดีต้อนรับสู่ DMS
+ </p>
+ </main>
+ );
+}