251004 backend restore /routes/auth.js
This commit is contained in:
@@ -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 <refresh_token>
|
|
||||||
* 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://<frontend-domain>/reset-password?token=<raw>
|
|
||||||
// คุณสามารถต่อ 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 แทน
|
|
||||||
137
backend/src/routes/auth พัง.js
Normal file
137
backend/src/routes/auth พัง.js
Normal file
@@ -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;
|
||||||
@@ -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 { Router } from "express";
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { config } from "../config.js";
|
import sql from "../db/index.js";
|
||||||
import { User } from "../db/sequelize.js";
|
import { cookieOpts } from "../utils/cookie.js";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
import {
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
signAccessToken,
|
import crypto from "node:crypto";
|
||||||
signRefreshToken,
|
|
||||||
requireAuth,
|
|
||||||
} from "../middleware/auth.js";
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่)
|
/* =========================
|
||||||
function cookieOpts(maxAgeMs) {
|
* CONFIG & HELPERS
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
* ========================= */
|
||||||
const opts = {
|
// ใช้ค่าเดียวกับ middleware authJwt()
|
||||||
httpOnly: true,
|
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
|
||||||
secure: true, // หลัง Nginx/HTTPS
|
const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret";
|
||||||
sameSite: "none", // ส่งข้าม subdomain ได้
|
const ACCESS_TTL = process.env.ACCESS_TTL || "30m";
|
||||||
path: "/",
|
const REFRESH_TTL = process.env.REFRESH_TTL || "30d";
|
||||||
maxAge: maxAgeMs,
|
// อายุของ reset token (นาที)
|
||||||
};
|
const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30);
|
||||||
if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work
|
|
||||||
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
|
function signAccessToken(user) {
|
||||||
opts.secure = false;
|
return jwt.sign(
|
||||||
opts.sameSite = "lax";
|
{ user_id: user.user_id, username: user.username },
|
||||||
}
|
JWT_SECRET,
|
||||||
return opts;
|
{ expiresIn: ACCESS_TTL, issuer: "dms-backend" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper TTL จาก config เดิม
|
function signRefreshToken(user) {
|
||||||
const ACCESS_TTL_MS = (() => {
|
return jwt.sign(
|
||||||
// รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds
|
{ user_id: user.user_id, username: user.username, t: "refresh" },
|
||||||
// ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย
|
REFRESH_SECRET,
|
||||||
if (/^\d+$/.test(String(config.JWT.EXPIRES_IN)))
|
{ expiresIn: REFRESH_TTL, issuer: "dms-backend" }
|
||||||
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 ==
|
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) => {
|
r.post("/login", async (req, res) => {
|
||||||
const { username, password } = req.body || {};
|
const { username, password } = req.body || {};
|
||||||
if (!username || !password)
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
|
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" });
|
if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.password_hash || "");
|
const ok = await bcrypt.compare(password, user.password_hash || "");
|
||||||
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
// NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ
|
const token = signAccessToken(user);
|
||||||
const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว)
|
const refresh_token = signRefreshToken(user);
|
||||||
const payload = {
|
|
||||||
|
// 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,
|
user_id: user.user_id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
permissions,
|
email: user.email,
|
||||||
};
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
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 ==
|
* 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 <refresh_token>
|
||||||
|
* 2) req.body.refresh_token
|
||||||
|
* - ออก token ใหม่ + refresh ใหม่ (rotation)
|
||||||
|
* ========================= */
|
||||||
r.post("/refresh", async (req, res) => {
|
r.post("/refresh", async (req, res) => {
|
||||||
// รับจากคุกกี้ก่อน แล้วค่อย Authorization
|
const fromHeader = getBearer(req);
|
||||||
const bearer = req.headers.authorization?.startsWith("Bearer ")
|
const fromBody = (req.body || {}).refresh_token;
|
||||||
? req.headers.authorization.slice(7)
|
const refreshToken = fromHeader || fromBody;
|
||||||
: null;
|
if (!refreshToken) {
|
||||||
const rt = req.cookies?.refresh_token || bearer;
|
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
|
||||||
if (!rt) return res.status(401).json({ error: "Unauthenticated" });
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม)
|
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
|
||||||
const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 });
|
issuer: "dms-backend",
|
||||||
// โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน
|
});
|
||||||
const permissions = [];
|
if (payload.t !== "refresh") throw new Error("bad token type");
|
||||||
const access = signAccessToken({ user_id: p.user_id, permissions });
|
|
||||||
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
|
const [[user]] = await sql.query(
|
||||||
return res.json({ ok: true, token: access });
|
`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 {
|
} catch {
|
||||||
return res.status(401).json({ error: "Unauthenticated" });
|
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://<frontend-domain>/reset-password?token=<raw>
|
||||||
|
// คุณสามารถต่อ 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) => {
|
r.post("/logout", (_req, res) => {
|
||||||
res.clearCookie("access_token", { path: "/" });
|
res.clearCookie("access_token", { path: "/" });
|
||||||
res.clearCookie("refresh_token", { path: "/" });
|
res.clearCookie("refresh_token", { path: "/" });
|
||||||
@@ -135,3 +274,6 @@ r.post("/logout", (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|
||||||
|
// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ
|
||||||
|
// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน
|
||||||
|
|||||||
Reference in New Issue
Block a user