Commit aba86181 by tdgiang

Fix critical/important findings from final whole-branch review

parent 5342bbfe
...@@ -206,7 +206,7 @@ exports.listTransactions = function (req, res) { ...@@ -206,7 +206,7 @@ exports.listTransactions = function (req, res) {
if (!isAdmin) { if (!isAdmin) {
filter.createdByUsername = req.session.username; filter.createdByUsername = req.session.username;
} else if (req.query.staff) { } else if (req.query.staff) {
filter.createdByUsername = req.query.staff; filter.createdByUsername = String(req.query.staff);
} }
function render(staffOptions) { function render(staffOptions) {
......
...@@ -2,6 +2,14 @@ ...@@ -2,6 +2,14 @@
var AdminUser = require("../models/AdminUser"); var AdminUser = require("../models/AdminUser");
var passwordHash = require("../libs/passwordHash"); var passwordHash = require("../libs/passwordHash");
var MIN_PASSWORD_LENGTH = 8;
// Fixed, precomputed bcrypt hash of a value nobody will ever type as a real password.
// Used only to make the "unknown username" path cost the same time as "wrong password" —
// comparing against this constant takes the same ~70ms as a real compare, so an attacker
// timing responses cannot tell "no such user" apart from "wrong password".
var DUMMY_HASH = "$2b$10$v5huvbowopyU7O11oK7ykeCMpvpEJCt4.y9cv8kZ.7liCwdYEY8sS";
exports.loginForm = function (req, res) { exports.loginForm = function (req, res) {
res.render("admin/login", { error: null }); res.render("admin/login", { error: null });
}; };
...@@ -15,16 +23,37 @@ exports.login = function (req, res) { ...@@ -15,16 +23,37 @@ exports.login = function (req, res) {
console.error("login: DB error:", err.message); console.error("login: DB error:", err.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" }); return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
} }
if (!user || !passwordHash.compare(password, user.passwordHash)) {
return res.render("admin/login", { error: "Sai tên đăng nhập hoặc mật khẩu" }); var hashToCompare = user ? user.passwordHash : DUMMY_HASH;
}
if (!user.active) { passwordHash.compare(password, hashToCompare, function (compareErr, isMatch) {
return res.render("admin/login", { error: "Tài khoản đã bị khoá, liên hệ admin" }); if (compareErr) {
} console.error("login: compare error:", compareErr.message);
req.session.userId = user._id.toString(); return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
req.session.username = user.username; }
req.session.role = user.role; if (!user || !isMatch) {
return res.redirect("/admin/transactions"); return res.render("admin/login", { error: "Sai tên đăng nhập hoặc mật khẩu" });
}
if (!user.active) {
return res.render("admin/login", { error: "Tài khoản đã bị khoá, liên hệ admin" });
}
req.session.regenerate(function (regenErr) {
if (regenErr) {
console.error("login: session regenerate error:", regenErr.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
req.session.userId = user._id.toString();
req.session.username = user.username;
req.session.role = user.role;
req.session.save(function (saveErr) {
if (saveErr) {
console.error("login: session save error:", saveErr.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
return res.redirect("/admin/transactions");
});
});
});
}); });
}; };
...@@ -58,28 +87,41 @@ exports.createAccount = function (req, res) { ...@@ -58,28 +87,41 @@ exports.createAccount = function (req, res) {
if (!username || !password) { if (!username || !password) {
return res.redirect("/admin/accounts?error=MISSING_FIELDS"); return res.redirect("/admin/accounts?error=MISSING_FIELDS");
} }
if (password.length < MIN_PASSWORD_LENGTH) {
return res.redirect("/admin/accounts?error=PASSWORD_TOO_SHORT");
}
AdminUser.create( passwordHash.hash(password, function (hashErr, hashed) {
{ if (hashErr) {
username: username, console.error("createAccount: hash error:", hashErr.message);
passwordHash: passwordHash.hash(password), return res.redirect("/admin/accounts?error=DB_ERROR");
role: role, }
active: true, AdminUser.create(
}, {
function (err) { username: username,
if (err) { passwordHash: hashed,
if (err.code === 11000) { role: role,
return res.redirect("/admin/accounts?error=DUPLICATE_USERNAME"); active: true,
},
function (err) {
if (err) {
if (err.code === 11000) {
return res.redirect("/admin/accounts?error=DUPLICATE_USERNAME");
}
console.error("createAccount: DB error:", err.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
} }
console.error("createAccount: DB error:", err.message); return res.redirect("/admin/accounts?created=1");
return res.redirect("/admin/accounts?error=DB_ERROR");
} }
return res.redirect("/admin/accounts?created=1"); );
} });
);
}; };
exports.toggleAccount = function (req, res) { exports.toggleAccount = function (req, res) {
if (req.params.id === req.session.userId) {
return res.redirect("/admin/accounts?error=CANNOT_LOCK_SELF");
}
AdminUser.findById(req.params.id, function (err, user) { AdminUser.findById(req.params.id, function (err, user) {
if (err) { if (err) {
console.error("toggleAccount: DB error:", err.message); console.error("toggleAccount: DB error:", err.message);
...@@ -88,14 +130,37 @@ exports.toggleAccount = function (req, res) { ...@@ -88,14 +130,37 @@ exports.toggleAccount = function (req, res) {
if (!user) { if (!user) {
return res.status(404).send("Không tìm thấy tài khoản"); return res.status(404).send("Không tìm thấy tài khoản");
} }
user.active = !user.active;
user.save(function (saveErr) { var nextActive = !user.active;
if (saveErr) {
console.error("toggleAccount: DB error saving:", saveErr.message); function applyToggle() {
return res.redirect("/admin/accounts?error=DB_ERROR"); user.active = nextActive;
} user.save(function (saveErr) {
return res.redirect("/admin/accounts"); if (saveErr) {
}); console.error("toggleAccount: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts");
});
}
if (!nextActive && user.role === "admin") {
AdminUser.countDocuments(
{ role: "admin", active: true, _id: { $ne: user._id } },
function (countErr, remaining) {
if (countErr) {
console.error("toggleAccount: DB error counting admins:", countErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
if (remaining < 1) {
return res.redirect("/admin/accounts?error=LAST_ADMIN");
}
applyToggle();
}
);
} else {
applyToggle();
}
}); });
}; };
...@@ -104,6 +169,9 @@ exports.resetAccountPassword = function (req, res) { ...@@ -104,6 +169,9 @@ exports.resetAccountPassword = function (req, res) {
if (!newPassword) { if (!newPassword) {
return res.redirect("/admin/accounts?error=MISSING_FIELDS"); return res.redirect("/admin/accounts?error=MISSING_FIELDS");
} }
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.redirect("/admin/accounts?error=PASSWORD_TOO_SHORT");
}
AdminUser.findById(req.params.id, function (err, user) { AdminUser.findById(req.params.id, function (err, user) {
if (err) { if (err) {
console.error("resetAccountPassword: DB error:", err.message); console.error("resetAccountPassword: DB error:", err.message);
...@@ -112,13 +180,19 @@ exports.resetAccountPassword = function (req, res) { ...@@ -112,13 +180,19 @@ exports.resetAccountPassword = function (req, res) {
if (!user) { if (!user) {
return res.status(404).send("Không tìm thấy tài khoản"); return res.status(404).send("Không tìm thấy tài khoản");
} }
user.passwordHash = passwordHash.hash(newPassword); passwordHash.hash(newPassword, function (hashErr, hashed) {
user.save(function (saveErr) { if (hashErr) {
if (saveErr) { console.error("resetAccountPassword: hash error:", hashErr.message);
console.error("resetAccountPassword: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR"); return res.redirect("/admin/accounts?error=DB_ERROR");
} }
return res.redirect("/admin/accounts?reset=1"); user.passwordHash = hashed;
user.save(function (saveErr) {
if (saveErr) {
console.error("resetAccountPassword: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts?reset=1");
});
}); });
}); });
}; };
...@@ -136,19 +210,37 @@ exports.changePassword = function (req, res) { ...@@ -136,19 +210,37 @@ exports.changePassword = function (req, res) {
console.error("changePassword: DB error:", err.message); console.error("changePassword: DB error:", err.message);
return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false }); return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
} }
if (!user || !passwordHash.compare(currentPassword, user.passwordHash)) { if (!user) {
return res.render("admin/change-password", { error: "Mật khẩu hiện tại không đúng", success: false }); return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
}
if (!newPassword) {
return res.render("admin/change-password", { error: "Vui lòng nhập mật khẩu mới", success: false });
} }
user.passwordHash = passwordHash.hash(newPassword); passwordHash.compare(currentPassword, user.passwordHash, function (compareErr, isMatch) {
user.save(function (saveErr) { if (compareErr) {
if (saveErr) { console.error("changePassword: compare error:", compareErr.message);
console.error("changePassword: DB error saving:", saveErr.message); return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
return res.status(500).render("admin/change-password", { error: "Lỗi cơ sở dữ liệu", success: false });
} }
return res.render("admin/change-password", { error: null, success: true }); if (!isMatch) {
return res.render("admin/change-password", { error: "Mật khẩu hiện tại không đúng", success: false });
}
if (!newPassword) {
return res.render("admin/change-password", { error: "Vui lòng nhập mật khẩu mới", success: false });
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.render("admin/change-password", { error: "Mật khẩu mới phải có ít nhất 8 ký tự", success: false });
}
passwordHash.hash(newPassword, function (hashErr, hashed) {
if (hashErr) {
console.error("changePassword: hash error:", hashErr.message);
return res.status(500).render("admin/change-password", { error: "Lỗi cơ sở dữ liệu", success: false });
}
user.passwordHash = hashed;
user.save(function (saveErr) {
if (saveErr) {
console.error("changePassword: DB error saving:", saveErr.message);
return res.status(500).render("admin/change-password", { error: "Lỗi cơ sở dữ liệu", success: false });
}
return res.render("admin/change-password", { error: null, success: true });
});
});
}); });
}); });
}; };
...@@ -15,21 +15,26 @@ module.exports = function run(config, callback) { ...@@ -15,21 +15,26 @@ module.exports = function run(config, callback) {
console.error("adminBootstrap: no admin exists and ADMIN_USER/ADMIN_PASSWORD not set - cannot seed"); console.error("adminBootstrap: no admin exists and ADMIN_USER/ADMIN_PASSWORD not set - cannot seed");
return callback(null); return callback(null);
} }
AdminUser.create( passwordHash.hash(config.admin.password, function (hashErr, hashed) {
{ if (hashErr) {
username: String(config.admin.user).trim().toLowerCase(), return callback(hashErr);
passwordHash: passwordHash.hash(config.admin.password),
role: "admin",
active: true,
},
function (createErr, admin) {
if (createErr) {
return callback(createErr);
}
console.log("adminBootstrap: seeded first admin account:", admin.username);
return backfillTransactions(admin.username, callback);
} }
); AdminUser.create(
{
username: String(config.admin.user).trim().toLowerCase(),
passwordHash: hashed,
role: "admin",
active: true,
},
function (createErr, admin) {
if (createErr) {
return callback(createErr);
}
console.log("adminBootstrap: seeded first admin account:", admin.username);
return backfillTransactions(admin.username, callback);
}
);
});
}); });
}; };
......
"use strict"; "use strict";
var bcrypt = require("bcryptjs"); var bcrypt = require("bcryptjs");
function hash(plain) { function hash(plain, callback) {
return bcrypt.hashSync(plain, 10); bcrypt.hash(plain, 10, callback);
} }
function compare(plain, hashed) { function compare(plain, hashed, callback) {
return bcrypt.compareSync(plain, hashed); bcrypt.compare(plain, hashed, callback);
} }
module.exports = { module.exports = {
......
"use strict"; "use strict";
var AdminUser = require("../models/AdminUser");
module.exports = function requireLogin(req, res, next) { module.exports = function requireLogin(req, res, next) {
if (req.session && req.session.userId) { if (!req.session || !req.session.userId) {
return next(); if (req.is("json")) {
} return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" });
if (req.is("json")) { }
return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" }); return res.redirect("/admin/login");
} }
return res.redirect("/admin/login");
AdminUser.findById(req.session.userId, function (err, user) {
if (err) {
console.error("requireLogin: DB error:", err.message);
if (req.is("json")) {
return res.status(503).json({ code: "99", data: "SERVICE_UNAVAILABLE" });
}
return res.status(503).send("Hệ thống đang bận, vui lòng thử lại sau.");
}
if (!user || !user.active) {
return req.session.destroy(function () {
if (req.is("json")) {
return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" });
}
return res.redirect("/admin/login");
});
}
// Keep the session's cached role/username in sync with the DB on every request,
// in case an admin changed either after this session was created.
req.session.role = user.role;
req.session.username = user.username;
return next();
});
}; };
...@@ -100,6 +100,9 @@ ...@@ -100,6 +100,9 @@
{% if error == "MISSING_FIELDS" %}<div class="alert alert-error">Vui lòng nhập đủ thông tin.</div>{% endif %} {% if error == "MISSING_FIELDS" %}<div class="alert alert-error">Vui lòng nhập đủ thông tin.</div>{% endif %}
{% if error == "DUPLICATE_USERNAME" %}<div class="alert alert-error">Tên đăng nhập đã tồn tại.</div>{% endif %} {% if error == "DUPLICATE_USERNAME" %}<div class="alert alert-error">Tên đăng nhập đã tồn tại.</div>{% endif %}
{% if error == "DB_ERROR" %}<div class="alert alert-error">Lỗi cơ sở dữ liệu, vui lòng thử lại.</div>{% endif %} {% if error == "DB_ERROR" %}<div class="alert alert-error">Lỗi cơ sở dữ liệu, vui lòng thử lại.</div>{% endif %}
{% if error == "PASSWORD_TOO_SHORT" %}<div class="alert alert-error">Mật khẩu phải có ít nhất 8 ký tự.</div>{% endif %}
{% if error == "CANNOT_LOCK_SELF" %}<div class="alert alert-error">Bạn không thể khoá tài khoản của chính mình.</div>{% endif %}
{% if error == "LAST_ADMIN" %}<div class="alert alert-error">Không thể khoá — đây là tài khoản admin đang hoạt động cuối cùng.</div>{% endif %}
<div class="card"> <div class="card">
<h2>Tạo tài khoản nhân viên mới</h2> <h2>Tạo tài khoản nhân viên mới</h2>
...@@ -111,7 +114,7 @@ ...@@ -111,7 +114,7 @@
</div> </div>
<div class="form-field"> <div class="form-field">
<label for="password">Mật khẩu</label> <label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required minlength="8">
</div> </div>
<div class="form-field" style="max-width:140px;"> <div class="form-field" style="max-width:140px;">
<label for="role">Vai trò</label> <label for="role">Vai trò</label>
...@@ -144,11 +147,15 @@ ...@@ -144,11 +147,15 @@
<td><span class="pill pill-{% if u.active %}active{% else %}locked{% endif %}">{% if u.active %}Hoạt động{% else %}Đã khoá{% endif %}</span></td> <td><span class="pill pill-{% if u.active %}active{% else %}locked{% endif %}">{% if u.active %}Hoạt động{% else %}Đã khoá{% endif %}</span></td>
<td> <td>
<div class="row-actions"> <div class="row-actions">
{% if u.username == currentUsername %}
<span style="font-size:12.5px;color:var(--color-text-muted);">Tài khoản của bạn</span>
{% else %}
<form method="POST" action="/admin/accounts/{{u._id}}/toggle"> <form method="POST" action="/admin/accounts/{{u._id}}/toggle">
<button type="submit" class="secondary">{% if u.active %}Khoá{% else %}Mở khoá{% endif %}</button> <button type="submit" class="secondary">{% if u.active %}Khoá{% else %}Mở khoá{% endif %}</button>
</form> </form>
{% endif %}
<form method="POST" action="/admin/accounts/{{u._id}}/reset-password"> <form method="POST" action="/admin/accounts/{{u._id}}/reset-password">
<input type="password" name="newPassword" placeholder="Mật khẩu mới" required> <input type="password" name="newPassword" placeholder="Mật khẩu mới" required minlength="8">
<button type="submit" class="secondary">Đặt lại</button> <button type="submit" class="secondary">Đặt lại</button>
</form> </form>
</div> </div>
......
...@@ -71,9 +71,9 @@ ...@@ -71,9 +71,9 @@
</tbody> </tbody>
</table> </table>
<p class="pager"> <p class="pager">
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}">&laquo; Trang trước</a>{% endif %} {% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">&laquo; Trang trước</a>{% endif %}
Trang {{page}} Trang {{page}}
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}">Trang sau &raquo;</a>{% endif %} {% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">Trang sau &raquo;</a>{% endif %}
</p> </p>
</body> </body>
</html> </html>
...@@ -100,6 +100,7 @@ module.exports = function() { ...@@ -100,6 +100,7 @@ module.exports = function() {
cookie: { cookie: {
secure: cookieSecure, secure: cookieSecure,
httpOnly: true, httpOnly: true,
sameSite: 'lax',
maxAge: config.session_max_age maxAge: config.session_max_age
}, },
// store: new mongoStore({ // store: new mongoStore({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment