forked from cory/tildefriends
		
	
		
			
				
	
	
		
			356 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			356 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import * as core from './core.js';
 | 
						|
import * as form from './form.js';
 | 
						|
 | 
						|
let gDatabase = new Database("auth");
 | 
						|
 | 
						|
const kRefreshInterval = 1 * 7 * 24 * 60 * 60 * 1000;
 | 
						|
 | 
						|
/**
 | 
						|
 * Makes a Base64 value URL safe
 | 
						|
 * @param {string} value 
 | 
						|
 * @returns TODOC
 | 
						|
 */
 | 
						|
function b64url(value) {
 | 
						|
	value = value.replaceAll('+', '-').replaceAll('/', '_');
 | 
						|
	let equals = value.indexOf('=');
 | 
						|
 | 
						|
	if (equals !== -1) {
 | 
						|
		return value.substring(0, equals);
 | 
						|
	} else {
 | 
						|
		return value;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * TODOC
 | 
						|
 * @param {string} value 
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function unb64url(value) {
 | 
						|
	value = value.replaceAll('-', '+').replaceAll('_', '/');
 | 
						|
	let remainder = value.length % 4;
 | 
						|
 | 
						|
	if (remainder == 3) {
 | 
						|
		return value + '=';
 | 
						|
	} else if (remainder == 2) {
 | 
						|
		return value + '==';
 | 
						|
	} else {
 | 
						|
		return value;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Creates a JSON Web Token
 | 
						|
 * @param {object} payload Object: {"name": "username"}
 | 
						|
 * @returns the JWT
 | 
						|
 */
 | 
						|
function makeJwt(payload) {
 | 
						|
	const ids = ssb.getIdentities(':auth');
 | 
						|
	let id;
 | 
						|
	
 | 
						|
	if (ids?.length) {
 | 
						|
		id = ids[0];
 | 
						|
	} else {
 | 
						|
		id = ssb.createIdentity(':auth');
 | 
						|
	}
 | 
						|
 | 
						|
	let final_payload = b64url(base64Encode(JSON.stringify(Object.assign({}, payload, {exp: (new Date().valueOf()) + kRefreshInterval}))));
 | 
						|
	let jwt = [b64url(base64Encode(JSON.stringify({alg: 'HS256', typ: 'JWT'}))), final_payload, b64url(ssb.hmacsha256sign(final_payload, ':auth', id))].join('.');
 | 
						|
	return jwt;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Validates a JWT ?
 | 
						|
 * @param {*} session TODOC
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function readSession(session) {
 | 
						|
	let jwt_parts = session?.split('.');
 | 
						|
 | 
						|
	if (jwt_parts?.length === 3) {
 | 
						|
		let [header, payload, signature] = jwt_parts;
 | 
						|
		header = JSON.parse(utf8Decode(base64Decode(unb64url(header))));
 | 
						|
 | 
						|
		if (header.typ === 'JWT' && header.alg === 'HS256') {
 | 
						|
			signature = unb64url(signature);
 | 
						|
			let id = ssb.getIdentities(':auth');
 | 
						|
 | 
						|
			if (id?.length && ssb.hmacsha256verify(id[0], payload, signature)) {
 | 
						|
				const result = JSON.parse(utf8Decode(base64Decode(unb64url(payload))));
 | 
						|
				const now = new Date().valueOf()
 | 
						|
 | 
						|
				if (now < result.exp) {
 | 
						|
					print(`JWT valid for another ${(result.exp - now) / 1000} seconds.`);
 | 
						|
					return result;
 | 
						|
				} else {
 | 
						|
					print(`JWT expired by ${(now - result.exp) / 1000} seconds.`);
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				print('JWT verification failed.');
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			print('Invalid JWT header.');
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Check the provided password matches the hash
 | 
						|
 * @param {string} password 
 | 
						|
 * @param {string} hash bcrypt hash
 | 
						|
 * @returns true if the password matches the hash
 | 
						|
 */
 | 
						|
function verifyPassword(password, hash) {
 | 
						|
	return bCrypt.hashpw(password, hash) === hash;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Hashes a password
 | 
						|
 * @param {string} password 
 | 
						|
 * @returns {string} TODOC
 | 
						|
 */
 | 
						|
function hashPassword(password) {
 | 
						|
	let salt = bCrypt.gensalt(12);
 | 
						|
	return bCrypt.hashpw(password, salt);
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Check if there is an administrator on the instance
 | 
						|
 * @returns TODOC
 | 
						|
 */
 | 
						|
function noAdministrator() {
 | 
						|
	return !core.globalSettings || !core.globalSettings.permissions || !Object.keys(core.globalSettings.permissions).some(function(name) {
 | 
						|
		return core.globalSettings.permissions[name].indexOf("administration") != -1;
 | 
						|
	});
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Makes a user an administrator
 | 
						|
 * @param {string} name the user's name
 | 
						|
 */
 | 
						|
function makeAdministrator(name) {
 | 
						|
	if (!core.globalSettings.permissions) {
 | 
						|
		core.globalSettings.permissions = {};
 | 
						|
	}
 | 
						|
	if (!core.globalSettings.permissions[name]) {
 | 
						|
		core.globalSettings.permissions[name] = [];
 | 
						|
	}
 | 
						|
	if (core.globalSettings.permissions[name].indexOf("administration") == -1) {
 | 
						|
		core.globalSettings.permissions[name].push("administration");
 | 
						|
	}
 | 
						|
	
 | 
						|
	core.setGlobalSettings(core.globalSettings);
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * TODOC
 | 
						|
 * @param {*} headers most likely an object
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function getCookies(headers) {
 | 
						|
	let cookies = {};
 | 
						|
 | 
						|
	if (headers.cookie) {
 | 
						|
		let parts = headers.cookie.split(/,|;/);
 | 
						|
		for (let i in parts) {
 | 
						|
			let equals = parts[i].indexOf("=");
 | 
						|
			let name = parts[i].substring(0, equals).trim();
 | 
						|
			let value = parts[i].substring(equals + 1).trim();
 | 
						|
			cookies[name] = value;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return cookies;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Validates a username
 | 
						|
 * @param {string} name 
 | 
						|
 * @returns false | boolean[] ?
 | 
						|
 */
 | 
						|
function isNameValid(name) {
 | 
						|
	// TODO(tasiaiso): convert this into a regex
 | 
						|
	let c = name.charAt(0);
 | 
						|
	return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) && name.split().map(x => x >= ('a' && x <= 'z') || x >= ('A' && x <= 'Z') || x >= ('0' && x <= '9'));
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Request handler ?
 | 
						|
 * @param {*} request TODOC
 | 
						|
 * @param {*} response 
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function handler(request, response) {
 | 
						|
	// TODO(tasiaiso): split this function
 | 
						|
	let session = getCookies(request.headers).session;
 | 
						|
	if (request.uri == "/login") {
 | 
						|
		let formData = form.decodeForm(request.query);
 | 
						|
		if (query(request.headers)?.permissions?.authenticated) {
 | 
						|
			if (formData.return) {
 | 
						|
				response.writeHead(303, {"Location": formData.return});
 | 
						|
			} else {
 | 
						|
				response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + '/', "Content-Length": "0"});
 | 
						|
			}
 | 
						|
			response.end();
 | 
						|
			return;
 | 
						|
		}
 | 
						|
 | 
						|
		let sessionIsNew = false;
 | 
						|
		let loginError;
 | 
						|
 | 
						|
		if (request.method == "POST" || formData.submit) {
 | 
						|
			sessionIsNew = true;
 | 
						|
			formData = form.decodeForm(utf8Decode(request.body), formData);
 | 
						|
			if (formData.submit == "Login") {
 | 
						|
				let account = gDatabase.get("user:" + formData.name);
 | 
						|
				account = account ? JSON.parse(account) : account;
 | 
						|
				if (formData.register == '1') {
 | 
						|
					if (!account &&
 | 
						|
						isNameValid(formData.name) &&
 | 
						|
						formData.password == formData.confirm) {
 | 
						|
						let users = new Set();
 | 
						|
						let users_original = gDatabase.get('users');
 | 
						|
						try {
 | 
						|
							users = new Set(JSON.parse(users_original));
 | 
						|
						} catch {
 | 
						|
						}
 | 
						|
						if (!users.has(formData.name)) {
 | 
						|
							users.add(formData.name);
 | 
						|
						}
 | 
						|
						users = JSON.stringify([...users].sort());
 | 
						|
						if (users !== users_original) {
 | 
						|
							gDatabase.set('users', users);
 | 
						|
						}
 | 
						|
						session = makeJwt({name: formData.name});
 | 
						|
						account = {password: hashPassword(formData.password)};
 | 
						|
						gDatabase.set('user:' + formData.name, JSON.stringify(account));
 | 
						|
						if (noAdministrator()) {
 | 
						|
							makeAdministrator(formData.name);
 | 
						|
						}
 | 
						|
					} else {
 | 
						|
						loginError = 'Error registering account.';
 | 
						|
					}
 | 
						|
				} else if (formData.change == '1') {
 | 
						|
					if (account &&
 | 
						|
						isNameValid(formData.name) &&
 | 
						|
						formData.new_password == formData.confirm &&
 | 
						|
						verifyPassword(formData.password, account.password)) {
 | 
						|
						session = makeJwt({name: formData.name});
 | 
						|
						account = {password: hashPassword(formData.new_password)};
 | 
						|
						gDatabase.set('user:' + formData.name, JSON.stringify(account));
 | 
						|
					} else {
 | 
						|
						loginError = 'Error changing password.';
 | 
						|
					}
 | 
						|
				} else {
 | 
						|
					if (account &&
 | 
						|
						account.password &&
 | 
						|
						verifyPassword(formData.password, account.password)) {
 | 
						|
						session = makeJwt({name: formData.name});
 | 
						|
						if (noAdministrator()) {
 | 
						|
							makeAdministrator(formData.name);
 | 
						|
						}
 | 
						|
					} else {
 | 
						|
						loginError = 'Invalid username or password.';
 | 
						|
					}
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				// Proceed as Guest
 | 
						|
				session = makeJwt({name: 'guest'});
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		let cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; HttpOnly`;
 | 
						|
		let entry = readSession(session);
 | 
						|
		if (entry && formData.return) {
 | 
						|
			response.writeHead(303, {"Location": formData.return, "Set-Cookie": cookie});
 | 
						|
			response.end();
 | 
						|
		} else {
 | 
						|
			File.readFile("core/auth.html").then(function(data) {
 | 
						|
				let html = utf8Decode(data);
 | 
						|
				let auth_data = {
 | 
						|
					session_is_new: sessionIsNew,
 | 
						|
					name: entry?.name,
 | 
						|
					error: loginError,
 | 
						|
					code_of_conduct: core.globalSettings.code_of_conduct,
 | 
						|
					have_administrator: !noAdministrator(),
 | 
						|
				};
 | 
						|
				html = utf8Encode(html.replace('$AUTH_DATA', JSON.stringify(auth_data)));
 | 
						|
				response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": html.length});
 | 
						|
				response.end(html);
 | 
						|
			}).catch(function(error) {
 | 
						|
				response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"});
 | 
						|
				response.end("404 File not found");
 | 
						|
			});
 | 
						|
		}
 | 
						|
	} else if (request.uri == "/login/logout") {
 | 
						|
		response.writeHead(303, {"Set-Cookie": `session=; path=/; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly`, "Location": "/login" + (request.query ? "?" + request.query : "")});
 | 
						|
		response.end();
 | 
						|
	} else {
 | 
						|
		response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"});
 | 
						|
		response.end("Hello, " + request.client.peerName + ".");
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Gets a user's permissions based on it's session ?
 | 
						|
 * @param {*} session TODOC
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function getPermissions(session) {
 | 
						|
	let permissions;
 | 
						|
	let entry = readSession(session);
 | 
						|
	if (entry) {
 | 
						|
		permissions = getPermissionsForUser(entry.name);
 | 
						|
		permissions.authenticated = entry.name !== "guest";
 | 
						|
	}
 | 
						|
	return permissions || {};
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Get a user's permissions ?
 | 
						|
 * @param {string} userName TODOC
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function getPermissionsForUser(userName) {
 | 
						|
	let permissions = {};
 | 
						|
	if (core.globalSettings && core.globalSettings.permissions && core.globalSettings.permissions[userName]) {
 | 
						|
		for (let i in core.globalSettings.permissions[userName]) {
 | 
						|
			permissions[core.globalSettings.permissions[userName][i]] = true;
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return permissions;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * TODOC
 | 
						|
 * @param {*} headers 
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function query(headers) {
 | 
						|
	let session = getCookies(headers).session;
 | 
						|
	let entry;
 | 
						|
	let autologin = tildefriends.args.autologin;
 | 
						|
	if (entry = autologin ? {name: autologin} : readSession(session)) {
 | 
						|
		return {
 | 
						|
			session: entry,
 | 
						|
			permissions: autologin ? getPermissionsForUser(autologin) : getPermissions(session),
 | 
						|
		};
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Refreshes a JWT ?
 | 
						|
 * @param {*} credentials TODOC
 | 
						|
 * @returns 
 | 
						|
 */
 | 
						|
function makeRefresh(credentials) {
 | 
						|
	if (credentials?.session?.name) {
 | 
						|
		return {
 | 
						|
			token: makeJwt({name: credentials.session.name}),
 | 
						|
			interval: kRefreshInterval,
 | 
						|
		};
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
export { handler, query, makeRefresh };
 |