Phase 5: the account-provisioning backend — link-only stays, plus hybrid self-signup, an admin email-invite tool, and site-side unlink. - uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the shard (hashed there) and never stored/logged; the end-user browser IP is passed for the shard's per-IP cap; actor is stamped server-side. - Hybrid signup: POST /player/shard/account provisions a game account (its own username + password) for the signed-in user and mirrors the link locally. Gated by the new game_account_signup setting AND the shard's own mode (mapped 403/409/ 429/400/503). Serves both self-serve signup and the invite-accept game step. - Email invites: user_invites table (sha256 token hash, single-use, expiring); invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) + mailer.sendInvite (falls back to returning the accept link if email is off); public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the user at the invite's preset role and logs them in, bypassing the registration gate. Accept is race-safe (atomic single-use; rolls back the user if it loses). - Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local mirror drop; account.unlinked ingest reconciles the mirror when a player runs [unlink in game. account.audit / account.unlinked are logged (admin channel only — never on the public SSE allowlist). Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest reconcile/visibility. Full suite 193/193; swagger regenerated. Refs .plans/protocol2-integration.md (Phase 5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.1 KiB
JavaScript
192 lines
8.1 KiB
JavaScript
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 }
|