From d3844aec717601205e1d7692b406f2e40587563b Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 4 Oct 2025 11:24:01 +0700 Subject: [PATCH] 251004 backend restore /routes/auth.js --- backend/src/routes/auth backup.js | 279 ------------------------ backend/src/routes/auth พัง.js | 137 ++++++++++++ backend/src/routes/auth.js | 344 +++++++++++++++++++++--------- 3 files changed, 380 insertions(+), 380 deletions(-) delete mode 100644 backend/src/routes/auth backup.js create mode 100644 backend/src/routes/auth พัง.js diff --git a/backend/src/routes/auth backup.js b/backend/src/routes/auth backup.js deleted file mode 100644 index 35fbbebf..00000000 --- a/backend/src/routes/auth backup.js +++ /dev/null @@ -1,279 +0,0 @@ -// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password -import { Router } from "express"; -import jwt from "jsonwebtoken"; -import sql from "../db/index.js"; -import { cookieOpts } from "../utils/cookie.js"; -import bcrypt from "bcryptjs"; -import { requireAuth } from "../middleware/auth.js"; -import crypto from "node:crypto"; - -const r = Router(); - -/* ========================= - * CONFIG & HELPERS - * ========================= */ -// ใช้ค่าเดียวกับ middleware authJwt() -const JWT_SECRET = process.env.JWT_SECRET || "dev-secret"; -const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret"; -const ACCESS_TTL = process.env.ACCESS_TTL || "30m"; -const REFRESH_TTL = process.env.REFRESH_TTL || "30d"; -// อายุของ reset token (นาที) -const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30); - -function signAccessToken(user) { - return jwt.sign( - { user_id: user.user_id, username: user.username }, - JWT_SECRET, - { expiresIn: ACCESS_TTL, issuer: "dms-backend" } - ); -} - -function signRefreshToken(user) { - return jwt.sign( - { user_id: user.user_id, username: user.username, t: "refresh" }, - REFRESH_SECRET, - { expiresIn: REFRESH_TTL, issuer: "dms-backend" } - ); -} - -function getBearer(req) { - const h = req.headers.authorization || ""; - if (!h.startsWith("Bearer ")) return null; - const token = h.slice(7).trim(); - return token || null; -} - -async function findUserByUsername(username) { - const [rows] = await sql.query( - `SELECT user_id, username, email, first_name, last_name, password_hash - FROM users WHERE username=? LIMIT 1`, - [username] - ); - return rows?.[0] || null; -} - -async function findUserByEmail(email) { - const [rows] = await sql.query( - `SELECT user_id, username, email, first_name, last_name, password_hash - FROM users WHERE email=? LIMIT 1`, - [email] - ); - return rows?.[0] || null; -} - -/* ========================= - * POST /api/auth/login - * - รับ username/password - * - ตรวจ bcrypt แล้วออก token+refresh_token (JSON) - * ========================= */ -r.post("/login", async (req, res) => { - const { username, password } = req.body || {}; - if (!username || !password) { - return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); - } - - const user = await findUserByUsername(username); - if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); - - const ok = await bcrypt.compare(password, user.password_hash || ""); - if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); - - const token = signAccessToken(user); - const refresh_token = signRefreshToken(user); - - // set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม) - res.cookie( - "access_token", - token, - cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10)) - ); - res.cookie( - "refresh_token", - refresh_token, - cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10)) - ); - - 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, - }, - }); -}); -/* ========================= - * GET /api/auth/me (cookie or bearer) - * ========================= */ -r.get("/me", requireAuth, async (req, res) => { - return res.json({ - ok: true, - user: { user_id: req.user.user_id, username: req.user.username }, - }); -}); -+( - /* ========================= - * POST /api/auth/refresh - * - รองรับ refresh token จาก: - * 1) Authorization: Bearer - * 2) req.body.refresh_token - * - ออก token ใหม่ + refresh ใหม่ (rotation) - * ========================= */ - r.post("/refresh", async (req, res) => { - const fromHeader = getBearer(req); - const fromBody = (req.body || {}).refresh_token; - const refreshToken = fromHeader || fromBody; - if (!refreshToken) { - return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" }); - } - - try { - const payload = jwt.verify(refreshToken, REFRESH_SECRET, { - issuer: "dms-backend", - }); - if (payload.t !== "refresh") throw new Error("bad token type"); - - const [[user]] = await sql.query( - `SELECT user_id, username, email, first_name, last_name - FROM users WHERE user_id=? LIMIT 1`, - [payload.user_id] - ); - if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" }); - - // rotation - const token = signAccessToken(user); - const new_refresh = signRefreshToken(user); - - // rotate cookies - res.cookie( - "access_token", - token, - cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10)) - ); - res.cookie( - "refresh_token", - new_refresh, - cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10)) - ); - - return res.json({ - token, - refresh_token: new_refresh, - user: { - user_id: user.user_id, - username: user.username, - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - }, - }); - } catch { - return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" }); - } - }) -); - -/* ========================= - * POST /api/auth/forgot-password - * - รับ username หรือ email อย่างใดอย่างหนึ่ง - * - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ - * - ส่งเสมอ {ok:true} เพื่อลด user enumeration - * - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n) - * ========================= */ -r.post("/forgot-password", async (req, res) => { - const { username, email } = req.body || {}; - // หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน) - let user = null; - if (username) user = await findUserByUsername(username); - if (!user && email) user = await findUserByEmail(email); - - // สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก) - if (user) { - const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล) - const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB - const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000); - - // ทำ invalid เก่า ๆ ของ user นี้ (optional) - await sql.query( - `UPDATE password_resets SET used_at=NOW() - WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`, - [user.user_id] - ); - - // บันทึก token ใหม่ - await sql.query( - `INSERT INTO password_resets (user_id, token_hash, expires_at) - VALUES (?,?,?)`, - [user.user_id, hash, expires] - ); - - // TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล) - // ตัวอย่างลิงก์ที่ frontend จะใช้: - // https:///reset-password?token= - // คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ - } - - // ไม่บอกว่าเจอหรือไม่เจอ user - return res.json({ ok: true }); -}); - -/* ========================= - * POST /api/auth/reset-password - * - รับ token (จากลิงก์ในอีเมล) + new_password - * - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง - * - เปลี่ยนรหัสผ่าน/ปิดใช้ token - * ========================= */ -r.post("/reset-password", async (req, res) => { - const { token, new_password } = req.body || {}; - if (!token || !new_password) { - return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" }); - } - - const token_hash = crypto.createHash("sha256").update(token).digest("hex"); - - const [[row]] = await sql.query( - `SELECT id, user_id, expires_at, used_at - FROM password_resets - WHERE token_hash=? LIMIT 1`, - [token_hash] - ); - - if (!row) return res.status(400).json({ error: "INVALID_TOKEN" }); - if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" }); - if (new Date(row.expires_at).getTime() < Date.now()) { - return res.status(400).json({ error: "TOKEN_EXPIRED" }); - } - - // เปลี่ยนรหัสผ่าน - const salt = await bcrypt.genSalt(10); - const hash = await bcrypt.hash(new_password, salt); - await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [ - hash, - row.user_id, - ]); - - // ปิดใช้ token นี้ - await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [ - row.id, - ]); - - return res.json({ ok: true }); -}); - -/* ========================= - * POST /api/auth/logout — stateless - * - frontend ลบ token เอง - * ========================= */ -r.post("/logout", (_req, res) => { - res.clearCookie("access_token", { path: "/" }); - res.clearCookie("refresh_token", { path: "/" }); - return res.json({ ok: true }); -}); - -export default r; - -// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ -// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน diff --git a/backend/src/routes/auth พัง.js b/backend/src/routes/auth พัง.js new file mode 100644 index 00000000..149b3382 --- /dev/null +++ b/backend/src/routes/auth พัง.js @@ -0,0 +1,137 @@ +// backend/src/routes/auth.js +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { config } from "../config.js"; +import { User } from "../db/sequelize.js"; + +import { + signAccessToken, + signRefreshToken, + requireAuth, +} from "../middleware/auth.js"; + +const r = Router(); + +// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่) +function cookieOpts(maxAgeMs) { + const isProd = process.env.NODE_ENV === "production"; + const opts = { + httpOnly: true, + secure: true, // หลัง Nginx/HTTPS + sameSite: "none", // ส่งข้าม subdomain ได้ + path: "/", + maxAge: maxAgeMs, + }; + if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work + if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { + opts.secure = false; + opts.sameSite = "lax"; + } + return opts; +} + +// helper TTL จาก config เดิม +const ACCESS_TTL_MS = (() => { + // รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds + // ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย + if (/^\d+$/.test(String(config.JWT.EXPIRES_IN))) + return Number(config.JWT.EXPIRES_IN); + // แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ + const s = String(config.JWT.EXPIRES_IN || "15m"); + const n = parseInt(s, 10); + if (s.endsWith("h")) return n * 60 * 60 * 1000; + if (s.endsWith("m")) return n * 60 * 1000; + if (s.endsWith("s")) return n * 1000; + return 15 * 60 * 1000; +})(); +const REFRESH_TTL_MS = (() => { + if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN))) + return Number(config.JWT.REFRESH_EXPIRES_IN); + const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d"); + const n = parseInt(s, 10); + if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000; + if (s.endsWith("h")) return n * 60 * 60 * 1000; + if (s.endsWith("m")) return n * 60 * 1000; + if (s.endsWith("s")) return n * 1000; + return 7 * 24 * 60 * 60 * 1000; +})(); + +// == POST /api/auth/login == +r.post("/login", async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); + + const user = await User.findOne({ where: { username }, raw: true }); + if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); + + const ok = await bcrypt.compare(password, user.password_hash || ""); + if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); + + // NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ + const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว) + const payload = { + user_id: user.user_id, + username: user.username, + permissions, + }; + + const access = signAccessToken(payload); + const refresh = signRefreshToken({ user_id: user.user_id }); + + // ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่) + res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); + res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS)); + + return res.json({ + ok: true, + token: access, + refresh_token: refresh, + user: { user_id: user.user_id, username: user.username, email: user.email }, + }); +}); + +// == GET /api/auth/me == +r.get("/me", requireAuth, async (req, res) => { + // enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token + return res.json({ + ok: true, + user: { + user_id: req.user.user_id, + username: req.user.username, + permissions: req.user.permissions || [], + }, + }); +}); + +// == POST /api/auth/refresh == +r.post("/refresh", async (req, res) => { + // รับจากคุกกี้ก่อน แล้วค่อย Authorization + const bearer = req.headers.authorization?.startsWith("Bearer ") + ? req.headers.authorization.slice(7) + : null; + const rt = req.cookies?.refresh_token || bearer; + if (!rt) return res.status(401).json({ error: "Unauthenticated" }); + + try { + // verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม) + const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 }); + // โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน + const permissions = []; + const access = signAccessToken({ user_id: p.user_id, permissions }); + res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); + return res.json({ ok: true, token: access }); + } catch { + return res.status(401).json({ error: "Unauthenticated" }); + } +}); + +// == POST /api/auth/logout == +r.post("/logout", (_req, res) => { + res.clearCookie("access_token", { path: "/" }); + res.clearCookie("refresh_token", { path: "/" }); + return res.json({ ok: true }); +}); + +export default r; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 149b3382..35fbbebf 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,133 +1,272 @@ -// backend/src/routes/auth.js +// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password import { Router } from "express"; -import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; -import { config } from "../config.js"; -import { User } from "../db/sequelize.js"; - -import { - signAccessToken, - signRefreshToken, - requireAuth, -} from "../middleware/auth.js"; +import sql from "../db/index.js"; +import { cookieOpts } from "../utils/cookie.js"; +import bcrypt from "bcryptjs"; +import { requireAuth } from "../middleware/auth.js"; +import crypto from "node:crypto"; const r = Router(); -// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่) -function cookieOpts(maxAgeMs) { - const isProd = process.env.NODE_ENV === "production"; - const opts = { - httpOnly: true, - secure: true, // หลัง Nginx/HTTPS - sameSite: "none", // ส่งข้าม subdomain ได้ - path: "/", - maxAge: maxAgeMs, - }; - if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work - if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { - opts.secure = false; - opts.sameSite = "lax"; - } - return opts; +/* ========================= + * CONFIG & HELPERS + * ========================= */ +// ใช้ค่าเดียวกับ middleware authJwt() +const JWT_SECRET = process.env.JWT_SECRET || "dev-secret"; +const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret"; +const ACCESS_TTL = process.env.ACCESS_TTL || "30m"; +const REFRESH_TTL = process.env.REFRESH_TTL || "30d"; +// อายุของ reset token (นาที) +const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30); + +function signAccessToken(user) { + return jwt.sign( + { user_id: user.user_id, username: user.username }, + JWT_SECRET, + { expiresIn: ACCESS_TTL, issuer: "dms-backend" } + ); } -// helper TTL จาก config เดิม -const ACCESS_TTL_MS = (() => { - // รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds - // ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย - if (/^\d+$/.test(String(config.JWT.EXPIRES_IN))) - return Number(config.JWT.EXPIRES_IN); - // แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ - const s = String(config.JWT.EXPIRES_IN || "15m"); - const n = parseInt(s, 10); - if (s.endsWith("h")) return n * 60 * 60 * 1000; - if (s.endsWith("m")) return n * 60 * 1000; - if (s.endsWith("s")) return n * 1000; - return 15 * 60 * 1000; -})(); -const REFRESH_TTL_MS = (() => { - if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN))) - return Number(config.JWT.REFRESH_EXPIRES_IN); - const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d"); - const n = parseInt(s, 10); - if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000; - if (s.endsWith("h")) return n * 60 * 60 * 1000; - if (s.endsWith("m")) return n * 60 * 1000; - if (s.endsWith("s")) return n * 1000; - return 7 * 24 * 60 * 60 * 1000; -})(); +function signRefreshToken(user) { + return jwt.sign( + { user_id: user.user_id, username: user.username, t: "refresh" }, + REFRESH_SECRET, + { expiresIn: REFRESH_TTL, issuer: "dms-backend" } + ); +} -// == POST /api/auth/login == +function getBearer(req) { + const h = req.headers.authorization || ""; + if (!h.startsWith("Bearer ")) return null; + const token = h.slice(7).trim(); + return token || null; +} + +async function findUserByUsername(username) { + const [rows] = await sql.query( + `SELECT user_id, username, email, first_name, last_name, password_hash + FROM users WHERE username=? LIMIT 1`, + [username] + ); + return rows?.[0] || null; +} + +async function findUserByEmail(email) { + const [rows] = await sql.query( + `SELECT user_id, username, email, first_name, last_name, password_hash + FROM users WHERE email=? LIMIT 1`, + [email] + ); + return rows?.[0] || null; +} + +/* ========================= + * POST /api/auth/login + * - รับ username/password + * - ตรวจ bcrypt แล้วออก token+refresh_token (JSON) + * ========================= */ r.post("/login", async (req, res) => { const { username, password } = req.body || {}; - if (!username || !password) + if (!username || !password) { return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); + } - const user = await User.findOne({ where: { username }, raw: true }); + const user = await findUserByUsername(username); if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); const ok = await bcrypt.compare(password, user.password_hash || ""); if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); - // NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ - const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว) - const payload = { - user_id: user.user_id, - username: user.username, - permissions, - }; + const token = signAccessToken(user); + const refresh_token = signRefreshToken(user); - const access = signAccessToken(payload); - const refresh = signRefreshToken({ user_id: user.user_id }); - - // ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่) - res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); - res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS)); + // set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม) + res.cookie( + "access_token", + token, + cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10)) + ); + res.cookie( + "refresh_token", + refresh_token, + cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10)) + ); return res.json({ - ok: true, - token: access, - refresh_token: refresh, - user: { user_id: user.user_id, username: user.username, email: user.email }, - }); -}); - -// == GET /api/auth/me == -r.get("/me", requireAuth, async (req, res) => { - // enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token - return res.json({ - ok: true, + token, + refresh_token, user: { - user_id: req.user.user_id, - username: req.user.username, - permissions: req.user.permissions || [], + user_id: user.user_id, + username: user.username, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, }, }); }); +/* ========================= + * GET /api/auth/me (cookie or bearer) + * ========================= */ +r.get("/me", requireAuth, async (req, res) => { + return res.json({ + ok: true, + user: { user_id: req.user.user_id, username: req.user.username }, + }); +}); ++( + /* ========================= + * POST /api/auth/refresh + * - รองรับ refresh token จาก: + * 1) Authorization: Bearer + * 2) req.body.refresh_token + * - ออก token ใหม่ + refresh ใหม่ (rotation) + * ========================= */ + r.post("/refresh", async (req, res) => { + const fromHeader = getBearer(req); + const fromBody = (req.body || {}).refresh_token; + const refreshToken = fromHeader || fromBody; + if (!refreshToken) { + return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" }); + } -// == POST /api/auth/refresh == -r.post("/refresh", async (req, res) => { - // รับจากคุกกี้ก่อน แล้วค่อย Authorization - const bearer = req.headers.authorization?.startsWith("Bearer ") - ? req.headers.authorization.slice(7) - : null; - const rt = req.cookies?.refresh_token || bearer; - if (!rt) return res.status(401).json({ error: "Unauthenticated" }); + try { + const payload = jwt.verify(refreshToken, REFRESH_SECRET, { + issuer: "dms-backend", + }); + if (payload.t !== "refresh") throw new Error("bad token type"); - try { - // verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม) - const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 }); - // โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน - const permissions = []; - const access = signAccessToken({ user_id: p.user_id, permissions }); - res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); - return res.json({ ok: true, token: access }); - } catch { - return res.status(401).json({ error: "Unauthenticated" }); + const [[user]] = await sql.query( + `SELECT user_id, username, email, first_name, last_name + FROM users WHERE user_id=? LIMIT 1`, + [payload.user_id] + ); + if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" }); + + // rotation + const token = signAccessToken(user); + const new_refresh = signRefreshToken(user); + + // rotate cookies + res.cookie( + "access_token", + token, + cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10)) + ); + res.cookie( + "refresh_token", + new_refresh, + cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10)) + ); + + return res.json({ + token, + refresh_token: new_refresh, + user: { + user_id: user.user_id, + username: user.username, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + }, + }); + } catch { + return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" }); + } + }) +); + +/* ========================= + * POST /api/auth/forgot-password + * - รับ username หรือ email อย่างใดอย่างหนึ่ง + * - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ + * - ส่งเสมอ {ok:true} เพื่อลด user enumeration + * - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n) + * ========================= */ +r.post("/forgot-password", async (req, res) => { + const { username, email } = req.body || {}; + // หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน) + let user = null; + if (username) user = await findUserByUsername(username); + if (!user && email) user = await findUserByEmail(email); + + // สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก) + if (user) { + const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล) + const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB + const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000); + + // ทำ invalid เก่า ๆ ของ user นี้ (optional) + await sql.query( + `UPDATE password_resets SET used_at=NOW() + WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`, + [user.user_id] + ); + + // บันทึก token ใหม่ + await sql.query( + `INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES (?,?,?)`, + [user.user_id, hash, expires] + ); + + // TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล) + // ตัวอย่างลิงก์ที่ frontend จะใช้: + // https:///reset-password?token= + // คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ } + + // ไม่บอกว่าเจอหรือไม่เจอ user + return res.json({ ok: true }); }); -// == POST /api/auth/logout == +/* ========================= + * POST /api/auth/reset-password + * - รับ token (จากลิงก์ในอีเมล) + new_password + * - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง + * - เปลี่ยนรหัสผ่าน/ปิดใช้ token + * ========================= */ +r.post("/reset-password", async (req, res) => { + const { token, new_password } = req.body || {}; + if (!token || !new_password) { + return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" }); + } + + const token_hash = crypto.createHash("sha256").update(token).digest("hex"); + + const [[row]] = await sql.query( + `SELECT id, user_id, expires_at, used_at + FROM password_resets + WHERE token_hash=? LIMIT 1`, + [token_hash] + ); + + if (!row) return res.status(400).json({ error: "INVALID_TOKEN" }); + if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" }); + if (new Date(row.expires_at).getTime() < Date.now()) { + return res.status(400).json({ error: "TOKEN_EXPIRED" }); + } + + // เปลี่ยนรหัสผ่าน + const salt = await bcrypt.genSalt(10); + const hash = await bcrypt.hash(new_password, salt); + await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [ + hash, + row.user_id, + ]); + + // ปิดใช้ token นี้ + await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [ + row.id, + ]); + + return res.json({ ok: true }); +}); + +/* ========================= + * POST /api/auth/logout — stateless + * - frontend ลบ token เอง + * ========================= */ r.post("/logout", (_req, res) => { res.clearCookie("access_token", { path: "/" }); res.clearCookie("refresh_token", { path: "/" }); @@ -135,3 +274,6 @@ r.post("/logout", (_req, res) => { }); export default r; + +// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ +// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน