const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const settings = require('../../../model/settings/settings.model') const { setAuthCookie, clearAuthCookie } = require('../../../auth/token') const sessionService = require('../../../auth/session.service') const totp = require('../../../utils/totp') const botScore = require('../../../middleware/botScore') const loginProtection = require('../../../middleware/loginProtection') const usernamePolicy = require('../../../auth/usernamePolicy') const log = require('../../../utils/logger')('auth') // Honeypot input name — must match the hidden field rendered on the login form. // Chosen to look like a real field so naive bots fill it; real users never see it. const HONEYPOT_FIELD = 'company' // One generic failure response for every "you don't get in" case (wrong user, // wrong password, tripped honeypot). Never reveals which was wrong. const GENERIC_FAIL = { message: 'Incorrect username or password.' } // True when this user must complete a second factor before getting a session. function needsTotp(user) { return Boolean(user && user.totp_enabled) } // Issue the real session: create the session token via the session service, set // the cookie, clear the IP's failure backoff, and record the login. authMethod // records how this session was authenticated ('local' password, or 'totp' after // the second factor) — carried in the session token for downstream visibility. async function issueSession(req, res, user, authMethod = 'local') { loginProtection.recordSuccess(req.ip) await users.recordLogin(user.id, req.ip) const { token } = sessionService.createSession(user, authMethod) setAuthCookie(req, res, token) await activity.log({ req, userId: user.id, action: 'auth.login' }) log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod }) return res.json({ user: { id: user.id, username: user.username, role: user.role } }) } async function login(req, res) { const { username, password } = req.body // Honeypot: a populated hidden field means a bot. Fail generically, but score // it hard — this is an unambiguous signal, unlike a mistyped password. if (req.body[HONEYPOT_FIELD]) { botScore.recordHoneypot(req.ip) loginProtection.recordFailure(req.ip) log.warn('honeypot login hit', { ip: req.ip, username }) return res.status(401).json(GENERIC_FAIL) } try { const user = await users.getRawByUsername(username) const ok = user && (await users.validatePassword(user, password)) if (!ok) { botScore.recordLoginFailure(req.ip) loginProtection.recordFailure(req.ip) log.warn('login failed', { username, ip: req.ip }) return res.status(401).json(GENERIC_FAIL) } // Correct credentials, but the account is disabled/banned (or pending): do // not issue a session or a TOTP challenge. A distinct, clear message here is // fine — the caller already proved the password, so this leaks nothing. if (user.status && user.status !== 'active') { log.warn('login refused: inactive account', { username, status: user.status, ip: req.ip }) return res.status(403).json({ message: 'This account is not active. Contact an administrator.' }) } // Password is correct. If this user has TOTP on, do NOT issue a session yet — // hand back a short-lived, signed "password verified" challenge and require // the code. If TOTP is off, log them straight in. if (needsTotp(user)) { const challenge = sessionService.createPartialSession(user) log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip }) return res.json({ totpRequired: true, challenge }) } return issueSession(req, res, user, 'local') } catch (err) { log.error('login error', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // Public self-registration for a `player` account. Gated by the // `player_registration` setting (must allow the password path) and hardened the // same way as login: honeypot + registerLimiter + the global botScore guard. // On success the new player is auto-logged-in (session cookie set). async function register(req, res) { // Honeypot: identical treatment to login — a filled hidden field is a bot. if (req.body[HONEYPOT_FIELD]) { botScore.recordHoneypot(req.ip) loginProtection.recordFailure(req.ip) log.warn('honeypot register hit', { ip: req.ip }) return res.status(400).json({ message: 'Registration failed.' }) } try { const mode = await settings.getRegistrationMode() // Password self-registration is only open when the mode includes it. if (mode !== 'password' && mode !== 'both') { return res.status(403).json({ message: 'Registration is not open.' }) } const check = usernamePolicy.validateUsername(req.body.username) if (!check.ok) return res.status(400).json({ message: check.message }) const email = req.body.email ? String(req.body.email).trim() : null let user try { user = await users.createUser({ username: check.name, password: req.body.password, email, role: 'player', }) } catch (err) { // The UNIQUE index is the source of truth for the uniqueness race — a // concurrent duplicate loses here and gets a clean 409. if (users.isDuplicateUsername(err)) { return res.status(409).json({ message: 'That username is already taken.' }) } throw err } await activity.log({ req, userId: user.id, action: 'auth.register', detail: { username: user.username } }) log.info('player registered', { username: user.username, id: user.id, ip: req.ip }) // New password accounts never have TOTP yet — log straight in. return issueSession(req, res, user, 'local') } catch (err) { log.error('register error', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // Second step for TOTP users: verify the challenge token + code, then issue the // session. A wrong code counts as a failed attempt (backoff + bot score). async function loginTotp(req, res) { const { challenge, code } = req.body const decoded = sessionService.upgradeSessionAfterTotp(challenge) if (!decoded) { return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' }) } try { const user = await users.getRawById(decoded.id) if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) { botScore.recordLoginFailure(req.ip) loginProtection.recordFailure(req.ip) log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip }) return res.status(401).json({ message: 'Invalid verification code.' }) } return issueSession(req, res, user, 'totp') } catch (err) { log.error('loginTotp error', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // Clear the caller's cookie AND revoke this session server-side, so a copy of the // token (proxy log, shared machine, XSS-exfiltrated cookie) can't keep being used // after logout. attachSession populated req.session (best-effort) with the jti + // expiry; if there was no valid session, there's simply nothing to revoke. async function logout(req, res) { clearAuthCookie(req, res) try { if (req.session?.sessionId) { await sessionService.revokeSession(req.session.sessionId, { userId: req.session.userId, expiresAt: req.session.expiresAt, }) await activity.log({ req, userId: req.session.userId, action: 'auth.logout' }) } } catch (err) { // Never fail the logout on a revocation/logging hiccup — the cookie is cleared. log.error('logout revoke error', err) } return res.json({ message: 'Logged out.' }) } async function me(req, res) { try { const user = await users.getById(req.user.id) if (!user) return res.status(401).json({ message: 'Unauthorized' }) return res.json({ user }) } catch (err) { return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }