diff options
Diffstat (limited to 'app/auth.js')
-rw-r--r-- | app/auth.js | 448 |
1 files changed, 448 insertions, 0 deletions
diff --git a/app/auth.js b/app/auth.js new file mode 100644 index 0000000..eb71a2f --- /dev/null +++ b/app/auth.js @@ -0,0 +1,448 @@ +"use strict"; + +const fs = require("fs"); +const util = require("util"); +const crypto = require("crypto"); +const argon2 = require("argon2"); +const {sj, parseCookies, res204, err400, err401, err500} = require("./utils"); + +const rf = util.promisify(fs.readFile); +const wf = util.promisify(fs.writeFile); +const randHex = len => crypto.randomBytes(len).toString("hex"); + +module.exports = config => { +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} = config.auth; +const {DOMAIN, SECURE} = config; +const exports = {}; + +// 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(fs.readFileSync(path))); + } catch(e) { + console.log(Date.now()+` Error loading ${path}, creating fallback empty set`); + fs.rename(path, path+".bad."+Date.now(), err => {}); + } + 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))); +const getUID = exports.getUID = username => + userIDs.hasOwnProperty(username.toLowerCase()) && userIDs[username.toLowerCase()]; +const getUsername = exports.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, 2, 9); 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`)); + } 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?3:2, 9); 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`)); + } 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, fingerprint, 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, 3, 9); sj(res, error); }; + + let user; + try { user = JSON.parse(await rf(`private/${req.uid}.json`)); + } 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)); +} + +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); +}); } exports.authed = authed; + +exports.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:<sessionID>, ...}, ...], recent:[...]} + app.jpost("/deauth-session", authed(deauthSession)); // {id:<sessionID>} + app.post("/deauth-all", authed(deauthAll)); +}; + +// TODO +// - create user +// - delete user +// - OTP (MITM/XSS/shoulder surfing; 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? + +return exports; }; |