diff --git a/api/controllers/adminController.js b/api/controllers/adminController.js index 89b29cbd..61378a84 100644 --- a/api/controllers/adminController.js +++ b/api/controllers/adminController.js @@ -1,4 +1,5 @@ -const jwt = require('jsonwebtoken'); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); function login(req, res) { const { username, password } = req.body || {}; @@ -6,31 +7,46 @@ function login(req, res) { const ADMIN_USERNAME = process.env.ADMIN_USERNAME; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET; - const ADMIN_JWT_EXPIRES_IN = process.env.ADMIN_JWT_EXPIRES_IN || '2h'; + const ADMIN_JWT_EXPIRES_IN = process.env.ADMIN_JWT_EXPIRES_IN || "2h"; if (!ADMIN_USERNAME || !ADMIN_PASSWORD || !ADMIN_JWT_SECRET) { - return res.status(500).json({ success: false, message: 'Admin auth not configured' }); + return res + .status(500) + .json({ success: false, message: "Admin auth not configured" }); + } + + // Fail fast (and loudly, in the server logs) if ADMIN_PASSWORD isn't a bcrypt + // hash. This removes the old plaintext-comparison fallback entirely, so a + // misconfigured .env can no longer silently downgrade auth to `===`. + const isBcryptHash = /^\$2[aby]\$\d{2}\$/.test(ADMIN_PASSWORD); + if (!isBcryptHash) { + console.error( + "[admin-auth] ADMIN_PASSWORD is not a bcrypt hash. Generate one with " + + "`node -e \"console.log(require('bcryptjs').hashSync(process.argv[1], 12))\" yourPassword` " + + "and set the result as ADMIN_PASSWORD.", + ); + return res + .status(500) + .json({ success: false, message: "Admin auth not configured" }); } if (!username || !password) { - return res.status(400).json({ success: false, message: 'Username and password are required' }); + return res + .status(400) + .json({ success: false, message: "Username and password are required" }); } - const bcrypt = require('bcryptjs'); - const isBcrypt = ADMIN_PASSWORD.startsWith('$2a$') || ADMIN_PASSWORD.startsWith('$2b$'); - const passwordMatch = isBcrypt - ? bcrypt.compareSync(password, ADMIN_PASSWORD) - : password === ADMIN_PASSWORD; + const passwordMatch = bcrypt.compareSync(password, ADMIN_PASSWORD); if (username !== ADMIN_USERNAME || !passwordMatch) { - return res.status(401).json({ success: false, message: 'Invalid credentials' }); + return res + .status(401) + .json({ success: false, message: "Invalid credentials" }); } - const token = jwt.sign( - { username: ADMIN_USERNAME }, - ADMIN_JWT_SECRET, - { expiresIn: ADMIN_JWT_EXPIRES_IN } - ); + const token = jwt.sign({ username: ADMIN_USERNAME }, ADMIN_JWT_SECRET, { + expiresIn: ADMIN_JWT_EXPIRES_IN, + }); return res.json({ success: true, token, expiresIn: ADMIN_JWT_EXPIRES_IN }); } diff --git a/api/middlewares/adminLoginLimiter.js b/api/middlewares/adminLoginLimiter.js new file mode 100644 index 00000000..d8f58f72 --- /dev/null +++ b/api/middlewares/adminLoginLimiter.js @@ -0,0 +1,16 @@ +const rateLimit = require("express-rate-limit"); + +// Throttles the admin login endpoint so a leaked/plaintext password (or just +// a weak one) can't be brute-forced. Keyed on IP; tune window/max to taste. +const adminLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 attempts per IP per window + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + message: "Too many login attempts. Please try again later.", + }, +}); + +module.exports = { adminLoginLimiter }; diff --git a/api/routes/adminRoutes.js b/api/routes/adminRoutes.js index d1c5c176..fef2c4ab 100644 --- a/api/routes/adminRoutes.js +++ b/api/routes/adminRoutes.js @@ -1,7 +1,8 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const { login } = require('../controllers/adminController'); +const { login } = require("../controllers/adminController"); +const { adminLoginLimiter } = require("../middlewares/adminLoginLimiter"); -router.post('/login', login); +router.post("/login", adminLoginLimiter, login); module.exports = router; diff --git a/middlewares/adminLoginLimiter.js b/middlewares/adminLoginLimiter.js new file mode 100644 index 00000000..d8f58f72 --- /dev/null +++ b/middlewares/adminLoginLimiter.js @@ -0,0 +1,16 @@ +const rateLimit = require("express-rate-limit"); + +// Throttles the admin login endpoint so a leaked/plaintext password (or just +// a weak one) can't be brute-forced. Keyed on IP; tune window/max to taste. +const adminLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 attempts per IP per window + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + message: "Too many login attempts. Please try again later.", + }, +}); + +module.exports = { adminLoginLimiter };