// 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 แทน