summaryrefslogtreecommitdiff
path: root/app/auth.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/auth.js')
-rw-r--r--app/auth.js448
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; };