import {readFile as rf, writeFile as wf, rename} from "node:fs/promises"; import {readFileSync} from "node:fs"; import {randomBytes} from "node:crypto"; import argon2 from "argon2"; import {sj, parseCookies, res204, err400, err401, err500} from "./utils.js"; import {DOMAIN, SECURE, auth as authConfig} from "./config.js"; const { TOKEN_RENEW_AGE, TOKEN_MAX_AGE, TOKEN_LENGTH, SESSION_ID_LENGTH, USERNAME_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, RATE_LIMIT_MAX_LEVEL, RATE_LIMIT_MAX_WAITING, fingerprintIP, } = authConfig; const randHex = len => randomBytes(len).toString("hex"); // Run once immediately, then every interval until not called function debounce(fn, interval=100) { let timer, doubled; const timeoutFn = () => { timer = undefined; if (doubled) { timer = setTimeout(timeoutFn, interval); doubled = false; return fn(); } }; return () => { if (doubled) return; if (timer) return doubled = true; timer = setTimeout(timeoutFn, interval); return fn(); }; }; function loadJSONSync(path) { const obj = {}; try { Object.assign(obj, JSON.parse(readFileSync(path, "utf8"))); } catch(e) { console.log(Date.now()+` Error loading ${path}, creating fallback empty set`); rename(path, path+".bad."+Date.now()).catch(() => {}); // TODO make synchronous? } return obj; } const checkTokenAge = tokenData => (Date.now()-tokenData.timestamp > TOKEN_MAX_AGE*1000) || (tokenData.renewed && Date.now()-tokenData.renewed > 1000); const setTokenCookie = (res, token, keepSession) => res.setHeader("Set-Cookie", `token=${token}; `+ (token? keepSession? "Max-Age="+TOKEN_MAX_AGE+"; " : "" : "Max-Age=0; ")+ `HttpOnly; SameSite=Strict; Domain=${DOMAIN}`+(SECURE?"; Secure":"")); //res.setHeader("Cache-Control", 'no-cache="Set-Cookie"'); // Done globally in app.js // TODO Centralize cache options? const userIDsFile = "private/userIDs.json"; const userNames = loadJSONSync(userIDsFile), userIDs = {}; for (const [uid, username] of Object.entries(userNames)) userIDs[username.toLowerCase()] = uid; const writeUIDs = debounce(() => wf(userIDsFile, JSON.stringify(userNames))); export const getUID = username => userIDs.hasOwnProperty(username.toLowerCase()) && userIDs[username.toLowerCase()]; export const getUsername = uid => userNames.hasOwnProperty(uid) && userNames[uid]; const createUID = username => { let uid; do { uid = randHex(5); } while (getUsername(uid)); userIDs[username.toLowerCase()] = uid; writeUIDs(); return uid; } const changeUsername = (uid, newName) => { if (userIDs[newName.toLowerCase()] !== uid) return false; const oldName = userNames[uid].toLowerCase(); userNames[uid] = newName; delete userIDs[oldName]; userIDs[newName.toLowerCase()] = uid; writeUIDs(); return true; }; const tokensFile = "private/tokens.json"; const tokens = loadJSONSync(tokensFile), userSessions = {}, sessionIDs = new Set(), renewedTokens = {}; const getUserSessions = uid => userSessions[uid] ||= new Set(); for (const [token, tokenData] of Object.entries(tokens)) { if (checkTokenAge(tokenData)) delete tokens[token]; else { getUserSessions(tokenData.uid).add(token); sessionIDs.add(tokenData.sessionID); } } const newSessionID = () => { let id; do { id = randHex(SESSION_ID_LENGTH); } while (sessionIDs.has(id)); return id; }; const writeTokens = debounce(() => wf(tokensFile, JSON.stringify(tokens))); const deleteToken = (token, sessionEnd) => { const tokenData = tokens[token]; if (sessionEnd) sessionIDs.delete(tokenData.sessionID); userSessions[tokenData?.uid]?.delete(token); delete tokens[token]; writeTokens(); }; const getToken = token => { if (token.length !== TOKEN_LENGTH*2) { console.log(Date.now()+" [WARN] Incorrect token format"); return false; } if (renewedTokens[token]) { // Possible hijacking, nip both ends deleteToken(renewedTokens[token], true); return false; } const tokenData = tokens.hasOwnProperty(token) && tokens[token]; if (tokenData) { if (checkTokenAge(tokenData)) { deleteToken(token, true); return false; } tokenData.lastUsed = Date.now(); writeTokens(); } return tokenData; }; const createToken = (uid, meta) => { let token; do { token = randHex(TOKEN_LENGTH); } while (getToken(token)); const now = Date.now(); tokens[token] = Object.assign(meta, {uid, timestamp:now, lastUsed:now}); getUserSessions(uid).add(token); writeTokens(); return token; }; const renewToken = (res, token, fingerprint, gracePeriod=500) => { const tokenData = tokens[token]; if (tokenData.renewed) return; tokenData.renewed = Date.now(); const newToken = createToken(tokenData.uid, Object.assign(fingerprint, { sessionID: tokenData.sessionID, keepSession: tokenData.keepSession, sessionStart: tokenData.sessionStart, })); setTokenCookie(res, newToken, tokenData.keepSession); setTimeout(() => { deleteToken(token); renewedTokens[token] = newToken; }, gracePeriod); // Let parallel requests finish setTimeout(() => delete renewedTokens[token], TOKEN_RENEW_AGE*1000); }; const collectFingerprint = req => ({ userAgent: req.headers["user-agent"], ip: fingerprintIP(req), // TODO GeoIP? Maintain set? // TODO Sec-CH-UA }); const checkReferer = req => { if (req.headers["referer"] && !req.headers["referer"].includes(DOMAIN)) console.log(Date.now()+" [WARN] Unexpected HTTP Referer: "+req.headers["referer"]); }; async function login(req, res, match, data) { const currentToken = parseCookies(req)?.token; if (currentToken || !data.username || !data.password) return err400(res); const error = {success:false, msg:"Bad username or password"}; const sendError = res => { rateLimitIP(req, 1, 8); sj(res, error); }; const uid = getUID(data.username); if (!uid) return sendError(res); // User doesn't exist let user; try { user = JSON.parse(await rf(`private/${uid}.json`, "utf8")); } catch (e) { return err500(res); } // Can't load user data checkReferer(req); const pass = user.password; if (pass.startsWith("$change")) { if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(res); console.log(Date.now()+" Logging in "+uid+", must change password"); return sj(res, {success:true, changePassword:true}); } else if (pass.startsWith("$argon2")) { if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(res); // Avoid DDOS if (!await argon2.verify(pass, data.password)) return sendError(res); if (user.mustChangePassword) { console.log(Date.now()+" Logging in "+uid+", must change password"); return sj(res, {success:true, changePassword:true}); } console.log(Date.now()+" Logging in "+uid); setTokenCookie(res, createToken(uid, Object.assign(collectFingerprint(req), { sessionID: newSessionID(), keepSession: data.keepSession, sessionStart: Date.now(), })), data.keepSession); return sj(res, {success:true}); } else return err500(res); } function logout(req, res) { const token = parseCookies(req)?.token; const tokenData = getToken(token); if (tokenData) { console.log(Date.now()+" Logging out "+tokenData.uid); deleteToken(token, true); } else { console.log(Date.now()+" [WARN] Logging out bad token"); rateLimitIP(req, -2, 4); } setTokenCookie(res, ""); res204(res); } function changePassword(req, res, match, data) { const token = parseCookies(req)?.token; const fn = async () => { if (!data.password || !data.newPassword || (!token && !data.username)) return err400(res); const error = token? {success:false, msg:"Bad password"}: {success:false, msg:"Bad username or password"}; const sendError = res => { rateLimitIP(req, token?2:1, 8); sj(res, error); }; const uid = token? req.uid : getUID(data.username); if (!uid) return token? err401(res) : sendError(req); let user; try { user = JSON.parse(await rf(`private/${uid}.json`, "utf8")); } catch (e) { return err500(res); } checkReferer(req); const pass = user.password; if (pass.startsWith("$change")) { if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(req); } else if (pass.startsWith("$argon2")) { if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(req); // Avoid DDOS if (!await argon2.verify(pass, data.password)) return sendError(req); if (!token && !user.mustChangePassword) return err401(res); // Must log in; OTP etc. } else return err500(res); if (data.newPassword.length < PASSWORD_MIN_LENGTH) return sj(res, {success:false, msg:`New password is too short (must be >=${PASSWORD_MIN_LENGTH} chars)`}) if (data.newPassword.length > PASSWORD_MAX_LENGTH) return sj(res, {success:false, msg:`New password is too long (must be <=${PASSWORD_MAX_LENGTH} chars)`}) console.log(Date.now()+" Changing password for "+uid); user.password = await argon2.hash(data.newPassword, {type: argon2.argon2id}); await wf(`private/${uid}.json`, JSON.stringify(user)); if (!res.getHeader("Set-Cookie")) { // Might have been renewed by authed() if (token) renewToken(res, token, collectFingerprint(req), 0); else setTokenCookie(res, createToken(uid, Object.assign(collectFingerprint(req), { sessionID: newSessionID(), sessionStart: Date.now(), keepSession: data.keepSession, })), data.keepSession); } sj(res, {success:true}); }; if (token) return authed(fn)(req, res); else return fn(); } async function changeUsernameReq(req, res, match, data) { if (!data.newUsername || !data.password) return err400(res); if (data.newUsername.length > USERNAME_MAX_LENGTH) return sj(res, {success:false, msg:`New username is too long (must be <=${USERNAME_MAX_LENGTH} chars)`}) const error = {success:false, msg:"Bad password"}; const sendError = res => { rateLimitIP(req, 2, 8); sj(res, error); }; let user; try { user = JSON.parse(await rf(`private/${req.uid}.json`, "utf8")); } catch (e) { return err500(res); } const pass = user.password; if (pass.startsWith("$change")) { if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(req); } else if (pass.startsWith("$argon2")) { if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(req); // Avoid DDOS if (!await argon2.verify(pass, data.password)) return sendError(req); } else return err500(res); console.log(Date.now()+` Changing username for ${req.uid} to ${data.newUsername}`); rateLimitIP(req, -2, 7); // To limit name probing if (changeUsername(req.uid, data.newUsername)) return sj(res, {success:true}); else return sj(res, {success:false, msg:"Username already taken"}) } function sessionList(req, res) { console.log(Date.now()+" Getting session list for "+req.uid); sj(res, { active: Array.from(getUserSessions(req.uid)) .map(t => tokens[t]).filter(s => !checkTokenAge(s) && !s.renewed) .map(s => ({ id: s.sessionID, currentSession: s.sessionID === req.sessionID, sessionStart: s.sessionStart, userAgent: s.userAgent, lastUsed: s.lastUsed, ip: s.ip, // TODO other fingerprint info users should see to validate sessions })), recent: [], // TODO }); } function deauthSession(req, res, match, data) { if (!data.id || data.id.length !== SESSION_ID_LENGTH) return err400(res); console.log(Date.now()+" Deauthing session "+req.uid+":"+data.id); const token = Array.from(getUserSessions(req.uid)).find(t => tokens[t].hash.startsWith(data.id)); if (!token) { console.log(Date.now()+" [WARN] Session doesn't exist"); return err400(res); } deleteToken(token, true); if (req.sessionID === data.id) setTokenCookie(res, ""); return res204(res); } function deauthAll(req, res) { const uid = req.uid; console.log(Date.now()+" Ending all sessions for "+uid); const sessions = Array.from(getUserSessions(req.uid)); for (const token of sessions) deleteToken(token, true); setTokenCookie(res, ""); return res204(res); } const rateLimitIPs = {}; const rateLimitTime = level => 100 * Math.pow(2, level); function rateLimitIP(req, level, stickiness) { // TODO verify const ip = fingerprintIP(req); const rl = rateLimitIPs[ip] ||= {level, startingLevel:level, waiting:0}; const delta = level > rl.level? level - rl.level : 1; rl.level += delta; if (rl.level >= 0) console.log(Date.now()+` [WARN] Rate limiting ${ip} (${level}:${rl.level})`); setTimeout(() => { if (rl.level >= 0) console.log(Date.now()+` Rate limiting decreased for ${ip}`); rl.level -= delta; if (rl.level === rl.startingLevel) delete rateLimitIPs[ip]; }, rateLimitTime(rl.level + stickiness)); } const rateLimit = fn => (req, res, ...rest) => { const rl = rateLimitIPs[fingerprintIP(req)]; if (!rl || rl.level < 0) return fn(req, res, ...rest); if (rl.level > RATE_LIMIT_MAX_LEVEL || rl.waiting > RATE_LIMIT_MAX_WAITING) { console.log(Date.now()+" [WARN] Aborting request (rate limiting maxed out)"); return req.destroy(); } console.log(Date.now()+` [WARN] Rate limiting request for ${rateLimitTime(rl.level)}ms`); rl.waiting++; setTimeout(() => { rl.waiting--; fn(req, res, ...rest); }, rateLimitTime(rl.level)); } export function authed(fn) { return rateLimit((req, res, ...rest) => { const token = parseCookies(req)?.token; if (!token) return err401(res); const tokenData = getToken(token); const uid = tokenData?.uid; if (!tokenData) { console.log(Date.now()+" [WARN] Bad token"); rateLimitIP(req, -2, 4); setTokenCookie(res, ""); return err401(res); } checkReferer(req); const fingerprint = collectFingerprint(req); if (Date.now()-tokenData.timestamp < TOKEN_RENEW_AGE*1000 && fingerprint.ip !== tokenData.ip && fingerprint.userAgent !== tokenData.userAgent) { console.log(Date.now()+" [WARN] Fingerprint check failed"); deleteToken(token, true); rateLimitIP(req, 0, 4); setTokenCookie(res, ""); return err401(res); } // TODO other verification from fingerprint // - if user-agent changes too much (could be browser update if small) // - if IP alternates between 2+ places too fast if (Date.now()-tokenData.timestamp > TOKEN_RENEW_AGE*1000 && !tokenData.renewed) { console.log(Date.now()+" Renewing token for "+uid); renewToken(res, token, fingerprint); } req.uid = uid; req.sessionID = tokenData.sessionID; return fn(req, res, ...rest); }); } export const attach = (app) => { // TODO make endpoints RESTier? app.jpost("/login", rateLimit(login)); // {username, password[, keepSession]} -> {success[, msg][, mustChangePassword]} app.post("/logout", rateLimit(logout)); app.jpost("/change-password", rateLimit(changePassword)); // {password, newPassword[, username[, keepSession]]} -> {success[, msg]} app.jpost("/change-username", authed(changeUsernameReq)); // {newUsername, password} -> {success[, msg]} app.get("/session-list", authed(sessionList)); // -> {active:[{id:, ...}, ...], recent:[...]} app.jpost("/deauth-session", authed(deauthSession)); // {id:} app.post("/deauth-all", authed(deauthAll)); }; // TODO // - create user // - delete user // - OTP (prevents login replay; ensure codes only work once) // - PIN (client-side, and easy session verification) // - normalize error response times? // - rate limit username/uid signin attempts? // - log failed attempts, then notify user // - https://github.com/zxcvbn-ts/zxcvbn // - non-login-event user notification system (warnings, must change password...) // - encrypt `private/` on disk using TPM?