first pass

This commit is contained in:
Cody McGinnis 2024-09-14 01:52:41 +00:00
parent 106937dae5
commit 8e4246129d
No known key found for this signature in database
11 changed files with 187 additions and 495 deletions

View file

@ -52,7 +52,8 @@
"./client/index.html.tpl",
"./dist/package.json",
"./dist/**/*.js",
"./public/**"
"./public/**",
"./packages/**"
],
"dependencies": {
"@fastify/busboy": "1.0.0",

View file

@ -0,0 +1,15 @@
const bcrypt = require("bcryptjs");
module.exports = {
password: {
hash(password) {
return bcrypt.hashSync(password, bcrypt.genSaltSync(11));
},
compare(password, expected) {
return bcrypt.compare(password, expected);
},
requiresUpdate(password) {
return bcrypt.getRounds(password) !== 11;
},
},
};

View file

@ -0,0 +1,48 @@
const Helper = require("./helper");
async function localAuth(api, client, providerData) {
const user = providerData && providerData.user;
const password = providerData && providerData.password;
// If no user is found, or if the client has not provided a password,
// fail the authentication straight away
if (!password) {
api.Logger.error(`No password specified!`);
return false;
}
// If this user has no password set, fail the authentication
if (!client.config.password) {
api.Logger.error(`User ${user} with no password tried to sign in`);
return false;
}
return await Helper.password
.compare(password, client.config.password)
.then((matching) => {
if (matching && Helper.password.requiresUpdate(client.config.password)) {
const hash = Helper.password.hash(password);
client.setPassword(hash, (success) => {
if (success) {
api.Logger.info(
`User ${user} logged in and their hashed password has been updated to match new security requirements`
);
}
});
}
return matching;
})
.catch((error) => {
api.Logger.error(`Error while checking users password. Error: ${error}`);
return false;
});
}
module.exports = {
onServerStart(api) {
api.Logger.info("LOADED!");
api.Auth.register((client, data) => localAuth(api, client, data));
},
};

View file

@ -0,0 +1,34 @@
{
"name": "thelounge-auth-plugin-local",
"description": "Local Auth for thelounge",
"version": "1.0.0",
"preferGlobal": true,
"repository": {
"type": "git",
"url": "https://github.com/thelounge/thelounge.git"
},
"homepage": "https://thelounge.chat/",
"scripts": {},
"keywords": [
"lounge",
"browser",
"web",
"chat",
"client",
"irc",
"server",
"thelounge",
"auth"
],
"thelounge": {
"supports": ">=4.4.3"
},
"license": "MIT",
"files": [
"./index.js",
"./package.json"
],
"dependencies": {
"bcryptjs": "2.4.3"
}
}

View file

@ -4,7 +4,6 @@ import crypto from "crypto";
import fs from "fs";
import path from "path";
import Auth from "./plugins/auth";
import Client, {UserConfig} from "./client";
import Config from "./config";
import WebPush from "./plugins/webpush";
@ -28,20 +27,9 @@ class ClientManager {
if (!Config.values.public) {
this.loadUsers();
// LDAP does not have user commands, and users are dynamically
// created upon logon, so we don't need to watch for new files
if (!Config.values.ldap.enable) {
this.autoloadUsers();
}
}
}
findClient(name: string) {
name = name.toLowerCase();
return this.clients.find((u) => u.name.toLowerCase() === name);
}
loadUsers() {
let users = this.getUsers();
@ -74,75 +62,22 @@ class ClientManager {
return true;
});
// This callback is used by Auth plugins to load users they deem acceptable
const callbackLoadUser = (user) => {
this.loadUser(user);
};
if (!Auth.loadUsers(users, callbackLoadUser)) {
// Fallback to loading all users
users.forEach((name) => this.loadUser(name));
}
}
autoloadUsers() {
fs.watch(
Config.getUsersPath(),
_.debounce(
() => {
const loaded = this.clients.map((c) => c.name);
const updatedUsers = this.getUsers();
if (updatedUsers.length === 0) {
log.info(
`There are currently no users. Create one with ${colors.bold(
"thelounge add <name>"
)}.`
);
}
// Reload all users. Existing users will only have their passwords reloaded.
updatedUsers.forEach((name) => this.loadUser(name));
// Existing users removed since last time users were loaded
_.difference(loaded, updatedUsers).forEach((name) => {
const client = _.find(this.clients, {name});
if (client) {
client.quit(true);
this.clients = _.without(this.clients, client);
log.info(`User ${colors.bold(name)} disconnected and removed.`);
}
});
},
1000,
{maxWait: 10000}
)
);
// Fallback to loading all users
users.forEach((name) => this.loadUser(name));
}
loadUser(name: string) {
const userConfig = this.readUserConfig(name);
let userConfig = this.readUserConfig(name) ?? {
clientSettings: {},
log: true,
password: "",
sessions: {},
};
if (!userConfig) {
return;
}
name = name.toLowerCase();
let client = this.clients.find((u) => u.name.toLowerCase() === name);
let client = this.findClient(name);
if (client) {
if (userConfig.password !== client.config.password) {
/**
* If we happen to reload an existing client, make super duper sure we
* have their latest password. We're not replacing the entire config
* object, because that could have undesired consequences.
*
* @see https://github.com/thelounge/thelounge/issues/598
*/
client.config.password = userConfig.password;
log.info(`Password for user ${colors.bold(name)} was reset.`);
}
} else {
if (!client) {
client = new Client(this, name, userConfig);
client.connect();
this.clients.push(client);
@ -188,6 +123,14 @@ class ClientManager {
throw e;
}
this.fixUserPerms(name);
return true;
}
private fixUserPerms(name: string) {
const userPath = Config.getUserConfigPath(name);
try {
const userFolderStat = fs.statSync(Config.getUsersPath());
const userFileStat = fs.statSync(userPath);
@ -216,8 +159,6 @@ class ClientManager {
// We're simply verifying file owner as a safe guard for users
// that run `thelounge add` as root, so we don't care if it fails
}
return true;
}
getDataToSave(client: Client) {
@ -249,6 +190,8 @@ class ClientManager {
});
fs.renameSync(pathTemp, pathReal);
this.fixUserPerms(client.name);
return callback ? callback() : true;
} catch (e: any) {
log.error(`Failed to update user ${colors.green(client.name)} (${e})`);
@ -277,7 +220,7 @@ class ClientManager {
if (!fs.existsSync(userPath)) {
log.error(`Tried to read non-existing user ${colors.green(name)}`);
return false;
return null;
}
try {
@ -287,7 +230,7 @@ class ClientManager {
log.error(`Failed to read user ${colors.bold(name)}: ${e}`);
}
return false;
return null;
}
}

View file

@ -43,6 +43,15 @@ program
// our yarn invocation sets $HOME to the cachedir, so we must expand ~ now
// else the path will be invalid when npm expands it.
packageName = expandTildeInLocalPath(packageName);
readFile = fspromises
.readFile(path.join(packageName.substring("file:".length), "package.json"), "utf-8")
.then((data) => JSON.parse(data) as typeof packageJson);
} else if (packageName.startsWith("internal:")) {
isLocalFile = true;
// make it easy to install the internal packages
packageName = packageName.substring("internal:".length);
packageName = "file:" + path.resolve(__dirname, "../../../packages", packageName);
readFile = fspromises
.readFile(path.join(packageName.substring("file:".length), "package.json"), "utf-8")
.then((data) => JSON.parse(data) as typeof packageJson);

View file

@ -1,67 +0,0 @@
import colors from "chalk";
import Client from "../client";
import ClientManager from "../clientManager";
import log from "../log";
export type AuthHandler = (
manager: ClientManager,
client: Client,
user: string,
password: string,
callback: (success: boolean) => void
) => void;
// The order defines priority: the first available plugin is used.
// Always keep 'local' auth plugin at the end of the list; it should always be enabled.
const plugins = [import("./auth/ldap"), import("./auth/local")];
const toExport = {
moduleName: "<module with no name>",
// Must override: implements authentication mechanism
auth: () => unimplemented("auth"),
// Optional to override: implements filter for loading users at start up
// This allows an auth plugin to check if a user is still acceptable, if the plugin
// can do so without access to the user's unhashed password.
// Returning 'false' triggers fallback to default behaviour of loading all users
loadUsers: () => false,
// local auth should always be enabled, but check here to verify
initialized: false,
// TODO: fix typing
async initialize() {
if (toExport.initialized) {
return;
}
// Override default API stubs with exports from first enabled plugin found
const resolvedPlugins = await Promise.all(plugins);
for (const {default: plugin} of resolvedPlugins) {
if (plugin.isEnabled()) {
toExport.initialized = true;
for (const name in plugin) {
toExport[name] = plugin[name];
}
break;
}
}
if (!toExport.initialized) {
log.error("None of the auth plugins is enabled");
}
},
} as any;
function unimplemented(funcName: string) {
log.debug(
`Auth module ${colors.bold(toExport.moduleName)} doesn't implement function ${colors.bold(
funcName
)}`
);
}
// Default API implementations
export default toExport;

View file

@ -1,244 +0,0 @@
import ldap, {SearchOptions} from "ldapjs";
import colors from "chalk";
import log from "../../log";
import Config from "../../config";
import type {AuthHandler} from "../auth";
function ldapAuthCommon(
user: string,
bindDN: string,
password: string,
callback: (success: boolean) => void
) {
const config = Config.values;
const ldapclient = ldap.createClient({
url: config.ldap.url,
tlsOptions: config.ldap.tlsOptions,
});
ldapclient.on("error", function (err: Error) {
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
callback(false);
});
ldapclient.bind(bindDN, password, function (err) {
ldapclient.unbind();
if (err) {
log.error(`LDAP bind failed: ${err.toString()}`);
callback(false);
} else {
callback(true);
}
});
}
function simpleLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
if (!user || !password) {
return callback(false);
}
const config = Config.values;
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
const bindDN = `${config.ldap.primaryKey}=${userDN},${config.ldap.baseDN || ""}`;
log.info(`Auth against LDAP ${config.ldap.url} with provided bindDN ${bindDN}`);
ldapAuthCommon(user, bindDN, password, callback);
}
/**
* LDAP auth using initial DN search (see config comment for ldap.searchDN)
*/
function advancedLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
if (!user || !password) {
return callback(false);
}
const config = Config.values;
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
const ldapclient = ldap.createClient({
url: config.ldap.url,
tlsOptions: config.ldap.tlsOptions,
});
const base = config.ldap.searchDN.base;
const searchOptions: SearchOptions = {
scope: config.ldap.searchDN.scope,
filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`,
attributes: ["dn"],
};
ldapclient.on("error", function (err: Error) {
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
callback(false);
});
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function (err) {
if (err) {
log.error("Invalid LDAP root credentials");
ldapclient.unbind();
callback(false);
return;
}
ldapclient.search(base, searchOptions, function (err2, res) {
if (err2) {
log.warn(`LDAP User not found: ${userDN}`);
ldapclient.unbind();
callback(false);
return;
}
let found = false;
res.on("searchEntry", function (entry) {
found = true;
const bindDN = entry.objectName;
log.info(`Auth against LDAP ${config.ldap.url} with found bindDN ${bindDN || ""}`);
ldapclient.unbind();
// TODO: Fix type !
ldapAuthCommon(user, bindDN!, password, callback);
});
res.on("error", function (err3: Error) {
log.error(`LDAP error: ${err3.toString()}`);
callback(false);
});
res.on("end", function (result) {
ldapclient.unbind();
if (!found) {
log.warn(
`LDAP Search did not find anything for: ${userDN} (${
result?.status.toString() || "unknown"
})`
);
callback(false);
}
});
});
});
}
const ldapAuth: AuthHandler = (manager, client, user, password, callback) => {
// TODO: Enable the use of starttls() as an alternative to ldaps
// TODO: move this out of here and get rid of `manager` and `client` in
// auth plugin API
function callbackWrapper(valid: boolean) {
if (valid && !client) {
manager.addUser(user, null, true);
}
callback(valid);
}
let auth: typeof simpleLdapAuth | typeof advancedLdapAuth;
if ("baseDN" in Config.values.ldap) {
auth = simpleLdapAuth;
} else {
auth = advancedLdapAuth;
}
return auth(user, password, callbackWrapper);
};
/**
* Use the LDAP filter from config to check that users still exist before loading them
* via the supplied callback function.
*/
function advancedLdapLoadUsers(users: string[], callbackLoadUser) {
const config = Config.values;
const ldapclient = ldap.createClient({
url: config.ldap.url,
tlsOptions: config.ldap.tlsOptions,
});
const base = config.ldap.searchDN.base;
ldapclient.on("error", function (err: Error) {
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
});
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function (err) {
if (err) {
log.error("Invalid LDAP root credentials");
return true;
}
const remainingUsers = new Set(users);
const searchOptions: SearchOptions = {
scope: config.ldap.searchDN.scope,
filter: `${config.ldap.searchDN.filter}`,
attributes: [config.ldap.primaryKey],
paged: true,
};
ldapclient.search(base, searchOptions, function (err2, res) {
if (err2) {
log.error(`LDAP search error: ${err2?.toString()}`);
return true;
}
res.on("searchEntry", function (entry) {
const user = entry.attributes[0].vals[0].toString();
if (remainingUsers.has(user)) {
remainingUsers.delete(user);
callbackLoadUser(user);
}
});
res.on("error", function (err3) {
log.error(`LDAP error: ${err3.toString()}`);
});
res.on("end", function () {
remainingUsers.forEach((user) => {
log.warn(
`No account info in LDAP for ${colors.bold(
user
)} but user config file exists`
);
});
});
});
ldapclient.unbind();
});
return true;
}
function ldapLoadUsers(users: string[], callbackLoadUser) {
if ("baseDN" in Config.values.ldap) {
// simple LDAP case can't test for user existence without access to the
// user's unhashed password, so indicate need to fallback to default
// loadUser behaviour by returning false
return false;
}
return advancedLdapLoadUsers(users, callbackLoadUser);
}
function isLdapEnabled() {
return !Config.values.public && Config.values.ldap.enable;
}
export default {
moduleName: "ldap",
auth: ldapAuth,
isEnabled: isLdapEnabled,
loadUsers: ldapLoadUsers,
};

View file

@ -1,51 +0,0 @@
import colors from "chalk";
import log from "../../log";
import Helper from "../../helper";
import type {AuthHandler} from "../auth";
const localAuth: AuthHandler = (_manager, client, user, password, callback) => {
// If no user is found, or if the client has not provided a password,
// fail the authentication straight away
if (!client || !password) {
return callback(false);
}
// If this user has no password set, fail the authentication
if (!client.config.password) {
log.error(
`User ${colors.bold(
user
)} with no local password set tried to sign in. (Probably a LDAP user)`
);
return callback(false);
}
Helper.password
.compare(password, client.config.password)
.then((matching) => {
if (matching && Helper.password.requiresUpdate(client.config.password)) {
const hash = Helper.password.hash(password);
client.setPassword(hash, (success) => {
if (success) {
log.info(
`User ${colors.bold(
client.name
)} logged in and their hashed password has been updated to match new security requirements`
);
}
});
}
callback(matching);
})
.catch((error) => {
log.error(`Error while checking users password. Error: ${error}`);
});
};
export default {
moduleName: "local",
auth: localAuth,
isEnabled: () => true,
};

View file

@ -11,8 +11,11 @@ import fs from "fs";
import Utils from "../../command-line/utils";
import Client from "../../client";
type Package = {
onServerStart: (packageApis: any) => void;
export type AuthHandler = (client: Client, providerData: any) => Promise<boolean>;
export type PackageApis = ReturnType<typeof packageApis>;
export type Package = {
onServerStart: (packageApis: PackageApis) => void;
};
const packageMap = new Map<string, Package>();
@ -29,6 +32,7 @@ export type PackageInfo = {
const stylesheets: string[] = [];
const files: string[] = [];
const authProviders: [string, AuthHandler][] = [];
const TIME_TO_LIVE = 15 * 60 * 1000; // 15 minutes, in milliseconds
@ -42,6 +46,7 @@ export default {
getFiles,
getStylesheets,
getPackage,
getAuthProviders,
loadPackages,
outdated,
};
@ -49,6 +54,9 @@ export default {
// TODO: verify binds worked. Used to be 'this' instead of 'packageApis'
const packageApis = function (packageInfo: PackageInfo) {
return {
Auth: {
register: addAuthProvider.bind(packageApis, packageInfo.packageName),
},
Stylesheets: {
addFile: addStylesheet.bind(packageApis, packageInfo.packageName),
},
@ -107,6 +115,14 @@ function getEnabledPackages(packageJson: string) {
return [];
}
function addAuthProvider(packageName: string, handler: AuthHandler) {
authProviders.push([packageName, handler]);
}
function getAuthProviders() {
return authProviders;
}
function getPersistentStorageDir(packageName: string) {
const dir = path.join(Config.getPackagesPath(), packageName);
fs.mkdirSync(dir, {recursive: true}); // we don't care if it already exists or not

View file

@ -17,7 +17,6 @@ import Config, {ConfigType} from "./config";
import Identification from "./identification";
import changelog from "./plugins/changelog";
import inputs from "./plugins/inputs";
import Auth from "./plugins/auth";
import themes from "./plugins/packages/themes";
themes.loadLocalThemes();
@ -232,12 +231,8 @@ export default async function (
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
socket.on("error", (err) => log.error(`io socket error: ${err}`));
if (Config.values.public) {
performAuthentication.call(socket, {});
} else {
socket.on("auth:perform", performAuthentication);
socket.emit("auth:start", serverHash);
}
socket.on("auth:perform", performAuthentication);
socket.emit("auth:start", serverHash);
});
manager = new ClientManager();
@ -917,14 +912,12 @@ function getServerConfiguration(): ServerConfiguration {
return {...Config.values, ...{stylesheets: packages.getStylesheets()}};
}
function performAuthentication(this: Socket, data: AuthPerformData) {
async function performAuthentication(this: Socket, data: any) {
if (!_.isPlainObject(data)) {
return;
}
const socket = this;
let client: Client | undefined;
let token: string;
const finalInit = () => {
let lastMessage = -1;
@ -986,26 +979,6 @@ function performAuthentication(this: Socket, data: AuthPerformData) {
});
};
if (Config.values.public) {
client = new Client(manager!);
client.connect();
manager!.clients.push(client);
const cb_client = client; // ensure TS can see we never have a nil client
socket.on("disconnect", function () {
manager!.clients = _.without(manager!.clients, cb_client);
cb_client.quit();
});
initClient();
return;
}
if (typeof data.user !== "string") {
return;
}
const authCallback = (success: boolean) => {
// Authorization failed
if (!success) {
@ -1027,23 +1000,32 @@ function performAuthentication(this: Socket, data: AuthPerformData) {
return;
}
// If authorization succeeded but there is no loaded user,
// load it and find the user again (this happens with LDAP)
if (!client) {
client = manager!.loadUser(data.user);
if (!client) {
throw new Error(`authCallback: ${data.user} not found after second lookup`);
}
}
initClient();
};
client = manager!.findClient(data.user);
let client: Client | undefined;
let token: string;
if (Config.values.public) {
client = new Client(manager!);
client.connect();
manager!.clients.push(client);
const cb_client = client; // ensure TS can see we never have a nil client
socket.on("disconnect", function () {
manager!.clients = _.without(manager!.clients, cb_client);
cb_client.quit();
});
initClient();
return;
}
client = manager!.loadUser(data.user);
// We have found an existing user and client has provided a token
if (client && "token" in data && data.token) {
if ("token" in data && data.token) {
const providedToken = client.calculateTokenHash(data.token);
if (Object.prototype.hasOwnProperty.call(client.config.sessions, providedToken)) {
@ -1054,16 +1036,22 @@ function performAuthentication(this: Socket, data: AuthPerformData) {
}
}
if (!("user" in data && "password" in data)) {
log.warn("performAuthentication: callback data has no user or no password");
authCallback(false);
return;
log.warn("auth provider count: " + packages.getAuthProviders().length);
for (const [providerName, handler] of packages.getAuthProviders()) {
log.warn(`calling handler for ${providerName}`);
if (
await handler(client, {user: data.user, password: data.password, ...data[providerName]})
) {
manager!.saveUser(client);
authCallback(true);
return;
}
}
Auth.initialize().then(() => {
// Perform password checking
Auth.auth(manager, client, data.user, data.password, authCallback);
});
log.warn("no auth providers succeeded!");
authCallback(false);
}
function reverseDnsLookup(ip: string, callback: (hostname: string) => void) {