Compare commits

...

3 Commits

Author SHA1 Message Date
e1b60f3c3e
rewrites of users & applications
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-13 02:09:27 +02:00
63f7c763ef
controller commands 2023-02-13 02:08:28 +02:00
7b94be6915
user structures tests & improvement to perms tests 2023-02-13 02:06:29 +02:00
19 changed files with 617 additions and 253 deletions

View File

@ -53,7 +53,7 @@ Main component that runs on the forked processes. Expects a message with a `_sta
## "Lesser" components ## "Lesser" components
**Authenticator:** `/src/server/middleware/Authenticator.js` **Authenticator:** `/src/server/middleware/Authenticator.js`
Takes care of sessions, authentication and authorisation, relies on an implementation of `AbstractUserDatabase.js`. Takes care of sessions, authentication and authorisation, relies on an implementation of `UserDatabaseInterface.js`.
**UserDatabase:** `/src/server/components/UserDatabase.js` **UserDatabase:** `/src/server/components/UserDatabase.js`
Implementation of `AbstractUserDatabase.js`, takes care of user management. Implementation of `UserDatabaseInterface.js`, takes care of user management.

View File

@ -15,6 +15,7 @@
"@navy.gif/logger": "^1.0.0", "@navy.gif/logger": "^1.0.0",
"@navy.gif/passport-discord": "^0.2.2-b", "@navy.gif/passport-discord": "^0.2.2-b",
"argon2": "^0.30.2", "argon2": "^0.30.2",
"commandparser": "^1.0.24",
"connect-mongo": "^4.6.0", "connect-mongo": "^4.6.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",

View File

@ -0,0 +1,10 @@
const { Command } = require("commandparser");
class BaseCommand extends Command {
constructor (controller, opts) {
super(opts);
this.controller = controller;
}
}
module.exports = BaseCommand;

View File

@ -5,10 +5,12 @@ const path = require('node:path');
// External // External
const { MasterLogger } = require('@navy.gif/logger'); const { MasterLogger } = require('@navy.gif/logger');
const { Parser: CommandParser } = require('commandparser');
const { Collection } = require('@discordjs/collection'); const { Collection } = require('@discordjs/collection');
// Local // Local
const Shard = require('./Shard'); const Shard = require('./Shard');
const { Util } = require('../util');
class Controller extends EventEmitter { class Controller extends EventEmitter {
@ -20,6 +22,7 @@ class Controller extends EventEmitter {
this._debug = options.debug || false; this._debug = options.debug || false;
this.logger = new MasterLogger(options.logger); this.logger = new MasterLogger(options.logger);
this.parser = null;
// Path to the file that gets forked into shards -- relative to process.cwd() (e.g. the directory in which the process was started) // Path to the file that gets forked into shards -- relative to process.cwd() (e.g. the directory in which the process was started)
this.serverFilePath = path.resolve(options.serverFilePath); this.serverFilePath = path.resolve(options.serverFilePath);
@ -54,6 +57,25 @@ class Controller extends EventEmitter {
const { shardCount = 1, shardOptions = {}, serverOptions = {}, logger = {}, discord = {}, databases = {} } = this._options; const { shardCount = 1, shardOptions = {}, serverOptions = {}, logger = {}, discord = {}, databases = {} } = this._options;
this.logger.info(`Spawning ${shardCount} shards`); this.logger.info(`Spawning ${shardCount} shards`);
this.commands = Util.readdirRecursive(path.resolve(__dirname, './commands')).map(file => {
const cmd = require(file);
if (typeof cmd !== 'function') {
this.logger.warn(`Attempted to load an invalid command: ${file}`);
return null;
}
const command = new cmd(this);
return command;
}).filter(cmd => cmd);
this.parser = new CommandParser({ commands: this.commands, prefix: '' });
process.stdin.on('data', (data) => {
const raw = data.toString('utf-8');
const words = raw.split(' ').map(word => word.trim());
this._handleStdin(words);
});
const promises = []; const promises = [];
for (let i = 0; i < shardCount; i++) { for (let i = 0; i < shardCount; i++) {
const shard = new Shard(this, i, { serverOptions: { ...serverOptions, logger, discord, databases }, ...shardOptions, env: this._options.env, path: this.serverFilePath }); const shard = new Shard(this, i, { serverOptions: { ...serverOptions, logger, discord, databases }, ...shardOptions, env: this._options.env, path: this.serverFilePath });
@ -92,9 +114,47 @@ class Controller extends EventEmitter {
shard.on('message', (msg) => this._handleMessage(shard, msg)); shard.on('message', (msg) => this._handleMessage(shard, msg));
} }
async _handleStdin (words) {
words = words.filter(word => word.length);
if (!words.length)
return;
let result = null;
try {
result = await this.parser.parseMessage(words.join(' '));
if (!result)
return this.logger.error(`${words[0]} is not a valid command`);
} catch (err) {
return this.logger.error(err.message);
}
// this.logger.info(inspect(rest));
const { command, ...rest } = result;
if (rest.args.help)
return this.logger.info(`COMMAND HELP\n${command.help}`);
let response = null;
try {
response = await command.execute(rest);
} catch (err) {
if (err.constructor.name === 'CommandError')
return this.logger.error(err);
this.logger.error(err.stack);
}
if (response)
// eslint-disable-next-line no-console
console.log(response);
}
async #shutdown () { async #shutdown () {
this.logger.info('Received SIGINT, shutting down'); this.logger.info('Received SIGINT, shutting down');
const promises = this.shards.map(shard => shard.awaitShutdown()); setTimeout(process.exit, 90_000);
const promises = this.shards.filter(shard => shard.ready).map(shard => shard.awaitShutdown());
if (promises.length)
await Promise.all(promises); await Promise.all(promises);
this.logger.info(`Shutdown completed, goodbye`); this.logger.info(`Shutdown completed, goodbye`);

View File

@ -34,6 +34,8 @@ class Shard extends EventEmitter {
this._awaitingShutdown = null; this._awaitingShutdown = null;
this.awaitingResponse = new Map();
} }
async spawn (waitForReady = false) { async spawn (waitForReady = false) {
@ -106,18 +108,30 @@ class Shard extends EventEmitter {
this._handleExit(null, null, false); this._handleExit(null, null, false);
} }
send (message) { send (message, expectResponse = false) {
if (!this.ready || !process)
return Promise.reject(new Error(`[shard-${this.id}] Cannot send message to dead shard.`));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.ready && this.process) {
if (expectResponse) {
message._id = Util.randomUUID();
const to = setTimeout(reject, 10_000, [ new Error('Message timeout') ]);
this.awaitingResponse.set(message._id, (...args) => {
clearTimeout(to);
resolve(...args);
});
}
this.process.send(message, err => { this.process.send(message, err => {
if (err) if (err)
reject(err); return reject(err);
else
if (!expectResponse)
resolve(); resolve();
}); });
} else {
reject(new Error(`[shard-${this.id}] Cannot send message to dead shard.`));
}
}); });
} }
@ -147,7 +161,11 @@ class Shard extends EventEmitter {
this.ready = false; this.ready = false;
this.fatal = true; this.fatal = true;
this._handleExit(null, null, false); this._handleExit(null, null, false);
this.emit('fatal', message); return this.emit('fatal', message);
} else if (message._id) {
const promise = this.awaitingResponse.get(message._id);
if (promise)
return promise(message);
} }
} }

View File

@ -0,0 +1,40 @@
const { OptionType } = require("commandparser");
const BaseCommand = require("../BaseCommand");
class CreateCommand extends BaseCommand {
constructor (controller) {
super(controller, {
name: 'create',
options: [{
name: 'registration-code',
aliases: [ 'code' ],
type: OptionType.SUB_COMMAND,
options: [{
name: 'amount',
flag: true,
type: OptionType.INTEGER,
valueOptional: true,
defaultValue: 1
}]
}]
});
}
async execute ({ subcommand, args }) {
const amount = args.amount?.value || 1;
const shard = this.controller.shards.random();
const msg = { amount };
if (subcommand === 'registration-code')
msg.type = 'reqregcode';
const { code } = await shard.send(msg, true);
return `Code ${code.code}, valid until ${new Date(code.created + code.validFor).toUTCString()}`;
}
}
module.exports = CreateCommand;

View File

@ -19,6 +19,8 @@ const { Registry, UserDatabase } = require('./components');
const { MariaDB, MongoDB, MemoryCache } = require('./database'); const { MariaDB, MongoDB, MemoryCache } = require('./database');
const Authenticator = require('./middleware/Authenticator'); const Authenticator = require('./middleware/Authenticator');
const pkg = require('../../package.json'); const pkg = require('../../package.json');
const { RateLimiter } = require('./middleware');
const MongoStore = require('connect-mongo');
/** /**
* Meant to be deployed behind a proxy (e.g. nginx) instance that takes care of certs and load balancing, trusts the first proxy * Meant to be deployed behind a proxy (e.g. nginx) instance that takes care of certs and load balancing, trusts the first proxy
@ -81,6 +83,7 @@ class Server extends EventEmitter {
// Takes care of loading endpoints, all endpoints need to inherit the endpoint class in /interfaces // Takes care of loading endpoints, all endpoints need to inherit the endpoint class in /interfaces
this.registry = new Registry(this, { path: path.join(__dirname, 'endpoints') }); this.registry = new Registry(this, { path: path.join(__dirname, 'endpoints') });
// TODO: Database definitions should probably be elsewhere through injection
// Mariadb isn't strictly necessary here for anything, it's just here pre-emptively // Mariadb isn't strictly necessary here for anything, it's just here pre-emptively
this.mariadb = new MariaDB(this, { options: databases.mariadb, MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB }); this.mariadb = new MariaDB(this, { options: databases.mariadb, MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB });
// Mongo is used for session and user storage // Mongo is used for session and user storage
@ -95,6 +98,8 @@ class Server extends EventEmitter {
MONGO_AUTH_DB MONGO_AUTH_DB
}); });
this.userDatabase = new UserDatabase(this, this.mongodb, { validUserTypes }); this.userDatabase = new UserDatabase(this, this.mongodb, { validUserTypes });
// Alias
this.users = this.userDatabase;
// Distributed memory storage, using mongo in this case, but this could be redis or whatever // Distributed memory storage, using mongo in this case, but this could be redis or whatever
this.memoryStoreProvider = new MongoDB(this, { this.memoryStoreProvider = new MongoDB(this, {
@ -111,12 +116,12 @@ class Server extends EventEmitter {
this.memoryCache = new MemoryCache(this, { this.memoryCache = new MemoryCache(this, {
provider: this.memoryStoreProvider provider: this.memoryStoreProvider
}); });
// Alias
this.users = this.userDatabase; this.rateLimiter = new RateLimiter(this, this.memoryCache);
// Authenticator takes care of sessions, logins and authorisations // Authenticator takes care of sessions, logins and authorisations
this.authenticator = new Authenticator(this, this.userDatabase, { this.authenticator = new Authenticator(this, this.userDatabase, {
mongo: this.mongodb, sessionStorage: MongoStore.create({ client: this.mongodb.client, dbName: this.mongodb.database, touchAfter: 600 }),
secret: SECRET, // Secret for sessions secret: SECRET, // Secret for sessions
name: `${pkg.name}.s`, name: `${pkg.name}.s`,
cookie: { cookie: {
@ -228,7 +233,16 @@ class Server extends EventEmitter {
_handleMessage (msg) { _handleMessage (msg) {
if (msg._shutdown) if (msg._shutdown)
this.shutdown(); return this.shutdown();
if (msg._id)
return this._handleCommand(msg);
}
async _handleCommand (msg) {
if (msg.type === 'reqregcode') {
const code = await this.userDatabase.createRegistrationCode();
process.send({ _id: msg._id, code });
}
} }
// Helper function to pass options to the logger in a unified way // Helper function to pass options to the logger in a unified way

View File

@ -1,18 +1,16 @@
const { Collection } = require("@discordjs/collection"); const { Collection } = require("@discordjs/collection");
const { ObjectId } = require("mongodb"); const { ObjectId } = require("mongodb");
const { AbstractUserDatabase } = require("../interfaces/"); const { UserDatabaseInterface } = require("../interfaces/");
const { User } = require("../structures"); const { User, UserApplication } = require("../structures");
const UserApplicataion = require("../structures/UserApplication");
const { Util } = require("../../util"); const { Util } = require("../../util");
// MongoDB based user db // MongoDB based user db
class UserDatabase extends AbstractUserDatabase { class UserDatabase extends UserDatabaseInterface {
constructor (server, db, { constructor (server, db, {
userColllection = 'users', userColllection = 'users',
appCollection = 'applications', appCollection = 'applications',
validUserTypes,
disableCache disableCache
}) { }) {
@ -21,7 +19,6 @@ class UserDatabase extends AbstractUserDatabase {
this.logger = server.createLogger(this); this.logger = server.createLogger(this);
this.cache = new Collection(); this.cache = new Collection();
this.userCollection = null; this.userCollection = null;
User.setValidTypes(validUserTypes);
this._userColllection = userColllection; this._userColllection = userColllection;
this._appCollection = appCollection; this._appCollection = appCollection;
@ -38,7 +35,7 @@ class UserDatabase extends AbstractUserDatabase {
if (!page) if (!page)
throw new Error('Missing page number'); throw new Error('Missing page number');
const data = await this.db.find('users', query, { limit: amount, skip: (page - 1) * amount }); const data = await this.db.find(this._userColllection, query, { limit: amount, skip: (page - 1) * amount });
const users = []; const users = [];
for (const user of data) { for (const user of data) {
let u = this.cache.get(user._id); let u = this.cache.get(user._id);
@ -63,7 +60,7 @@ class UserDatabase extends AbstractUserDatabase {
if (id.includes('temp')) if (id.includes('temp'))
return null; return null;
const data = await this.db.findOne(this._userColllection, { _id: new ObjectId(id) }); const data = await this.db.findOne(this._userColllection, { _id: id });
if (!data) if (!data)
return null; return null;
@ -97,16 +94,16 @@ class UserDatabase extends AbstractUserDatabase {
} }
async findUser (username) { async findUser (name) {
if (!username) if (!name)
throw new Error('Missing username'); throw new Error('Missing username');
let user = this.cache.find(u => u.username?.toLowerCase() === username.toLowerCase()); let user = this.cache.find(u => u.username?.toLowerCase() === name.toLowerCase());
if (user) if (user)
return Promise.resolve(user); return Promise.resolve(user);
const data = await this.db.findOne(this._userColllection, { username }, { collation: { locale: 'en', strength: 2 } }); const data = await this.db.findOne(this._userColllection, { name }, { collation: { locale: 'en', strength: 2 } });
if (!data) if (!data)
return null; return null;
@ -173,7 +170,7 @@ class UserDatabase extends AbstractUserDatabase {
return null; return null;
// If a user with the same username already exists, force the holder of the discord account to create a new account or log in to the other account and link them // If a user with the same username already exists, force the holder of the discord account to create a new account or log in to the other account and link them
const existing = await this.db.findOne(this._userColllection, { username: profile.username }, { collation: { locale: 'en', strength: 2 } }); const existing = await this.db.findOne(this._userColllection, { name: profile.username }, { collation: { locale: 'en', strength: 2 } });
if (existing) { if (existing) {
const temp = this._createUser({ type: 'user', temporary: true, displayName: profile.username }); const temp = this._createUser({ type: 'user', temporary: true, displayName: profile.username });
temp.addExternalProfile('discord', profile); temp.addExternalProfile('discord', profile);
@ -201,11 +198,10 @@ class UserDatabase extends AbstractUserDatabase {
* @param {string} password * @param {string} password
* @memberof UserDatabase * @memberof UserDatabase
*/ */
async createUser (username, password) { async createUser (name, password) {
const user = this._createUser({ const user = this._createUser({
username, name
type: 'user'
}); });
await user.setPassword(password); await user.setPassword(password);
await this.updateUser(user); await this.updateUser(user);
@ -252,7 +248,7 @@ class UserDatabase extends AbstractUserDatabase {
* @memberof UserDatabase * @memberof UserDatabase
*/ */
async updateUser (user) { async updateUser (user) {
const { json } = user; const { jsonPrivate: json } = user;
if (user._id) if (user._id)
await this.db.updateOne(this._userColllection, { _id: new ObjectId(user._id) }, json); await this.db.updateOne(this._userColllection, { _id: new ObjectId(user._id) }, json);
else { else {
@ -263,7 +259,7 @@ class UserDatabase extends AbstractUserDatabase {
} }
async updateApplication (app) { async updateApplication (app) {
const { json } = app; const { jsonPrivate: json } = app;
await this.db.updateOne(this._appCollection, { _id: new ObjectId(json._id) }, json, true); await this.db.updateOne(this._appCollection, { _id: new ObjectId(json._id) }, json, true);
} }
@ -279,11 +275,20 @@ class UserDatabase extends AbstractUserDatabase {
_createUser (data) { _createUser (data) {
if (!data) if (!data)
throw new Error(`Missing data to create user`); throw new Error(`Missing data to create user`);
return new User(this, data); if (data._id)
data.id = data._id;
if (!data.id)
data.id = new ObjectId();
return new User(this.updateUser.bind(this), data);
} }
_createApp (user, data) { _createApp (data) {
return new UserApplicataion(user, data); if (data._id)
data.id = data._id;
if (!data.id)
data.id = new ObjectId();
return new UserApplication(this.updateApplication.bind(this), data);
} }
} }

View File

@ -59,7 +59,7 @@ class UserEndpoint extends ApiEndpoint {
} }
user (req, res) { user (req, res) {
return res.json(req.user.safeJson); return res.json(req.user.json);
} }
async uploadAvatar (req, res) { async uploadAvatar (req, res) {
@ -239,7 +239,7 @@ class UserEndpoint extends ApiEndpoint {
async applications (req, res) { async applications (req, res) {
const { user } = req; const { user } = req;
const applications = await user.fetchApplications(); const applications = await user.fetchApplications();
res.json(Object.values(applications).map(app => app.safeJson)); res.json(Object.values(applications).map(app => app.json));
} }
async createApplication (req, res) { async createApplication (req, res) {
@ -249,7 +249,7 @@ class UserEndpoint extends ApiEndpoint {
if (!name) if (!name)
return res.status(400).send('Missing name'); return res.status(400).send('Missing name');
const application = await user.createApplication(name, opts); const application = await user.createApplication(name, opts);
res.json(application.safeJson); res.json(application.json);
} }

View File

@ -29,7 +29,7 @@ class UsersEndpoint extends ApiEndpoint {
return res.status(400).send('Missing page number'); return res.status(400).send('Missing page number');
const users = await this.users.fetchUsers(page, amount); const users = await this.users.fetchUsers(page, amount);
res.json(users.map(user => user.safeJson)); res.json(users.map(user => user.json));
} }
async user (req, res) { async user (req, res) {
@ -39,7 +39,7 @@ class UsersEndpoint extends ApiEndpoint {
if (!user) if (!user)
return res.status(404).end(); return res.status(404).end();
res.json(user.safeJson); res.json(user.json);
} }
async avatar (req, res) { async avatar (req, res) {
@ -60,7 +60,7 @@ class UsersEndpoint extends ApiEndpoint {
return res.status(404).send('Could not find the user'); return res.status(404).send('Could not find the user');
const applications = await user.fetchApplications(); const applications = await user.fetchApplications();
res.json(Object.values(applications).map(app => app.safeJson)); res.json(Object.values(applications).map(app => app.json));
} }
} }

View File

@ -0,0 +1,120 @@
const { ObjectId } = require("mongodb");
const { PermissionManager } = require("../../util");
class AbstractUser {
static ProtectedFields = [ '_id' ];
#_id = null;
#_name = null;
#_disabled = null;
#_permissions = null;
#_cachedTimestamp = null;
#_createdTimestamp = null;
#_icon = null;
#saveFunc = null;
constructor (saveFunction, { name, disabled, id, permissions, createdTimestamp, icon } = {}) {
if (this.constructor === AbstractUser)
throw new Error('This class cannot be instantiated, only derived');
if (!saveFunction || typeof saveFunction !== 'function')
throw new Error('Missing save function');
this.#saveFunc = saveFunction;
if (typeof id !== 'string' && !(id instanceof ObjectId))
throw new Error(`Missing ID: expecting string or ObjectId, got ${typeof id}`);
if (id instanceof ObjectId)
id = id.toString();
if (typeof name !== 'string')
throw new Error(`Missing name: expecting string, got ${typeof name}`);
this.#_id = id;
this.#_name = name;
this.#_disabled = disabled || false;
this.#_permissions = PermissionManager.merge(permissions || {}, PermissionManager.DefaultPermissions);
this.#_cachedTimestamp = Date.now();
this.#_createdTimestamp = createdTimestamp || Date.now();
this.#_icon = icon || null;
}
save () {
return this.#saveFunc(this);
}
hasPermission (perm, level = 1) {
return PermissionManager.checkPermissions(this.permissions, perm, level);
}
/**
* JSON data to be stored in DB, not safe to return as a response
*
* @memberof AbstractUser
*/
get jsonPrivate () {
return {
_id: this.id,
name: this.name,
disabled: this.disabled,
permissions: this.permissions,
createdTimestamp: this.createdTimestamp,
icon: this.icon
};
}
/**
* Safe to respond to requests with
*
* @readonly
* @memberof AbstractUser
*/
get json () {
const json = this.jsonPrivate;
// `_id` is omitted, instead using `id`
for (const key of AbstractUser.ProtectedFields)
delete json[key];
return {
...json,
id: this.id
};
}
get id () {
return this.#_id;
}
get name () {
return this.#_name;
}
get disabled () {
return this.#_disabled;
}
get icon () {
return this.#_icon;
}
get permissions () {
return Object.freeze({ ...this.#_permissions });
}
get createdAt () {
return new Date(this.#_createdTimestamp);
}
get createdTimestamp () {
return this.#_cachedTimestamp;
}
get cachedTimestamp () {
return this.#_cachedTimestamp;
}
}
module.exports = AbstractUser;

View File

@ -1,9 +1,14 @@
/** /**
* * Interface class, anything that interacts with a user database within the framework is implemented against this
* @abstract * @abstract
* @class AbstractUserDatabase * @class UserDatabaseInterface
*/ */
class AbstractUserDatabase { class UserDatabaseInterface {
constructor () {
if (this.constructor === UserDatabaseInterface)
throw new Error('This class cannot be instantiated, only derived');
}
// Fetch by ID // Fetch by ID
fetchUser () { fetchUser () {
@ -31,4 +36,4 @@ class AbstractUserDatabase {
} }
module.exports = AbstractUserDatabase; module.exports = UserDatabaseInterface;

View File

@ -1,119 +1,114 @@
const Argon2 = require('argon2'); const Argon2 = require('argon2');
const { ObjectId } = require('mongodb'); const { ObjectId } = require('mongodb');
const { Util, PermissionManager } = require('../../util'); const { Util } = require('../../util');
const UserApplicataion = require('./UserApplication'); const { AbstractUser } = require('../interfaces');
const UserApplication = require('./UserApplication');
// Fields omitted in safeJson // Fields omitted in the json getter
// Should be keys used in the json, not the ones defined in the constructor // Should be keys used in jsonPrivate, not the ones defined in the constructor
const ProtectedFields = [ '_id', 'otpSecret', 'password' ]; // const ProtectedFields = [ 'otpSecret', 'password' ];
class User { class User extends AbstractUser {
static validTypes = []; #passwordHash = null;
#otpSecret = null;
#_mfa = null;
constructor (db, data) { #_displayName = null;
#_externalProfiles = null;
#_applications = null;
this._db = db; constructor (saveFunc, data) {
this.temporary = data.temporary || false; super(saveFunc, data);
this.disabled = data.disabled || false;
this._id = data._id || null; AbstractUser.ProtectedFields.push('otpSecret', 'password');
if (this.temporary)
this._tempId = `temp-${Date.now()}`;
if (!data.username && !data.temporary) this.#_applications = data.applications || [];
throw new Error('Missing name for user'); this.#_externalProfiles = data.externalProfiles || {};
this.username = data.username || 'temporary user';
this._displayName = data.displayName || null;
if (!data.type)
throw new Error('Missing type for user');
if (User.validTypes.length && !User.validTypes.includes(data.type))
throw new Error('Invalid user type');
this.type = data.type;
this.applications = {};
this._applications = data.applications || [];
this.externalProfiles = data.externalProfiles || {};
this.permissions = PermissionManager.merge(data.permissions || {}, PermissionManager.DefaultPermissions);
/** @private */ /** @private */
this._passwordHash = data.password || null; this.#passwordHash = data.password || null;
this._otpSecret = data.otpSecret || null; this.#otpSecret = data.otpSecret || null;
this._2fa = data.twoFactor || false; this.#_mfa = data.twoFactor || false;
this.#_displayName = data.displyName || null;
this.cachedTimestamp = Date.now();
this.createdTimestamp = data.createdTimestamp || Date.now();
this.avatar = data.avatar || null;
}
get id () {
return this._id || this._tempId;
} }
get displayName () { get displayName () {
return this._displayName || this.username; return this.#_displayName || this.username;
}
get username () {
return this.name;
} }
get passwordLoginEnabled () { get passwordLoginEnabled () {
return this._passwordHash !== null; return this._passwordHash !== null;
} }
async fetchApplications () { get avatar () {
const apps = await Promise.all(this._applications.map(id => this._db.fetchApplication(id))); return this.icon;
for (const app of apps)
this.applications[app.id] = app;
return this.applications;
} }
async createApplication (name, { description } = {}) { get mfa () {
const _id = new ObjectId(); return this.#_mfa;
// User id : app id : creation time : random string }
const rawToken = `${this.id}:${_id}:${Date.now()}:${Util.randomString(8)}`;
const token = Util.encrypt(rawToken, Buffer.from(process.env.ENCRYPTION_KEY, 'base64')); get externalProfiles () {
return Object.freeze({ ...this.#_externalProfiles });
}
// async fetchApplications () {
// const apps = await Promise.all(this._applications.map(id => this._db.fetchApplication(id)));
// for (const app of apps)
// this.applications[app.id] = app;
// return this.applications;
// }
async createApplication (name, { description } = {}, saveFn) {
if (typeof saveFn !== 'function')
return Promise.reject(new Error('Missing save function for application'));
const id = new ObjectId().toString();
const token = Util.randomUUID();
const opts = { const opts = {
name, name,
_id, id,
token, token,
description description,
ownerId: this.id
}; };
const application = new UserApplicataion(this, opts); const application = new UserApplication(saveFn, opts);
await application.save(); await application.save();
this.applications[_id] = application; this.#_applications.push(id);
this._applications.push(_id);
await this.save(); await this.save();
return application; return application;
} }
async deleteApplication (id) { // async deleteApplication (id) {
const app = this._db.fetchApplication(id); // const app = this._db.fetchApplication(id);
if (!app) // if (!app)
return null; // return null;
// }
} // attachApplication (app) {
// this.applications[app.id] = app;
attachApplication (app) { // }
this.applications[app.id] = app;
}
async setPassword (passwd, save = false) { async setPassword (passwd, save = false) {
const hash = await Argon2.hash(passwd); const hash = await Argon2.hash(passwd);
this._passwordHash = hash; this.#passwordHash = hash;
if (save) if (save)
await this.save(); await this.save();
} }
async authenticate (passwd) { async authenticate (passwd) {
const result = await Argon2.verify(this._passwordHash, passwd); const result = await Argon2.verify(this.#passwordHash, passwd);
if (result) if (!result) {
return true;
// TODO: potentially add something to track failed attempts // TODO: potentially add something to track failed attempts
}
return result;
} }
/** /**
@ -125,58 +120,29 @@ class User {
*/ */
addExternalProfile (platform, profile) { addExternalProfile (platform, profile) {
profile.provider = platform; profile.provider = platform;
this.externalProfiles[platform] = profile; this.#_externalProfiles[platform] = profile;
}
/**
*
*
* @param {string} perm Permission to check for
* @return {boolean}
* @memberof User
*/
hasPermission (perm, level = 1) {
return PermissionManager.checkPermissions(this.permissions, perm, level);
} }
hasExternalProfile (name) { hasExternalProfile (name) {
return Boolean(this.externalProfiles[name]); return Boolean(this.externalProfiles[name]);
} }
save () { get jsonPrivate () {
return this._db.updateUser(this);
}
saveApplication (app) {
return this._db.updateApplication(app);
}
get json () {
return { return {
_id: this._id, ...super.jsonPrivate,
username: this.username, displayName: this.displayName,
displayName: this._displayName,
type: this.type,
permissions: this.permissions,
externalProfiles: this.externalProfiles, externalProfiles: this.externalProfiles,
password: this._passwordHash, password: this.#passwordHash,
otpSecret: this._otpSecret, otpSecret: this.#otpSecret,
twoFactor: this._2fa, twoFactor: this.mfa,
applications: this._applications, applications: this.#_applications,
createdTimestamp: this.createdTimestamp,
disabled: this.disabled,
avatar: this.avatar,
}; };
} }
get safeJson () { get json () {
const { json } = this; const json = super.json;
for (const key of ProtectedFields)
delete json[key];
return { return {
...json, ...json,
id: this.id,
externalProfiles: Object.values(this.externalProfiles).map(prof => { externalProfiles: Object.values(this.externalProfiles).map(prof => {
return { id: prof.id, provider: prof.provider, username: prof.username }; return { id: prof.id, provider: prof.provider, username: prof.username };
}), }),
@ -184,10 +150,6 @@ class User {
}; };
} }
static setValidTypes (types) {
User.validTypes = types;
}
} }
module.exports = User; module.exports = User;

View File

@ -1,67 +1,50 @@
const { PermissionManager } = require("../../util"); const { AbstractUser } = require("../interfaces");
// Fields omitted in safeJson class UserApplicataion extends AbstractUser {
// Should be keys used in the json, not the ones defined in the constructor
const ProtectedFields = [ '_id' ];
class UserApplicataion {
constructor (user, options) { #_token = null;
#_user = null;
#_description = null;
if (!user) constructor (saveFunc, options = {}) {
throw new Error('Missing user for user application');
this.user = user;
if (!options._id) super(saveFunc, options);
throw new Error('Missing id for application');
this.id = options._id;
if (!options.name) if (typeof options.token !== 'string')
throw new Error('Missing name for application');
this.name = options.name;
if (!options.token)
throw new Error('Missing token for appliaction'); throw new Error('Missing token for appliaction');
this.token = options.token; this.#_token = options.token;
this.description = options.description || null; if (typeof options.ownerId !== 'string')
throw new Error('Missing ownerId (owner) for application');
this.#_user = options.ownerId;
this.permissions = options.permissions || {}; this.#_description = options.description || null;
this.createdAt = options.createdAt || Date.now();
this.type = 'application';
} }
save () { get ownerId () {
return this.user.saveApplication(this); return this.#_user;
} }
hasPermission (perm, level = 1) { get description () {
return PermissionManager.checkPermissions(this.permissions, perm, level); return this.#_description;
} }
get json () { get token () {
return this.#_token;
}
get jsonPrivate () {
return { return {
_id: this.id, ...super.jsonPrivate,
name: this.name,
permissions: this.permissions,
token: this.token, token: this.token,
createdAt: this.createdAt, ownerId: this.#_user,
user: this.user.id,
description: this.description, description: this.description,
}; };
} }
get safeJson () { get json () {
return super.json;
const { json } = this;
for (const key of ProtectedFields)
delete json[key];
return {
...json,
id: this.id,
token: this.token.encrypted,
};
} }
} }

View File

@ -1,3 +1,4 @@
module.exports = { module.exports = {
User: require('./User') User: require('./User'),
UserApplication: require('./UserApplication')
}; };

View File

@ -1,4 +1,6 @@
const { scryptSync, createCipheriv, randomFillSync, createDecipheriv } = require('node:crypto'); const fs = require('node:fs');
const path = require('node:path');
const { scryptSync, createCipheriv, randomFillSync, createDecipheriv, randomUUID } = require('node:crypto');
const CRYPTO_ALGO = 'id-aes256-GCM'; const CRYPTO_ALGO = 'id-aes256-GCM';
class Util { class Util {
@ -98,9 +100,11 @@ class Util {
} }
static cryptoRandomString (len = 32, base = 'utf8') { static cryptoRandomString (len = 32, base = 'utf8') {
return randomFillSync(Buffer.alloc(len)).toString(base); return randomFillSync(Buffer.alloc(len)).toString(base);
}
static randomUUID () {
return randomUUID();
} }
static createEncryptionKey (secret, salt, len = 32) { static createEncryptionKey (secret, salt, len = 32) {
@ -172,6 +176,37 @@ class Util {
}); });
} }
/**
* Read directory recursively and return all file paths
* @static
* @param {string} directory Full path to target directory
* @param {boolean} [ignoreDotfiles=true]
* @return {string[]} Array with the paths to the files within the directory
* @memberof Util
*/
static readdirRecursive (directory, ignoreDotfiles = true) {
const result = [];
(function read (dir) {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith('.') && ignoreDotfiles)
continue;
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
read(filePath);
} else {
result.push(filePath);
}
}
}(directory));
return result;
}
} }
module.exports = Util; module.exports = Util;

View File

@ -5,11 +5,12 @@ beforeEach(() => {
perms = { developer: { default: 5 }, administrator: 10 }; perms = { developer: { default: 5 }, administrator: 10 };
}); });
test('Constructor', () => { describe('PermissionManager', () => {
test('Constructor', () => {
expect(() => new PermissionManager()).toThrow(); expect(() => new PermissionManager()).toThrow();
}); });
test('Ensure permission', () => { test('Ensure permission', () => {
expect(() => PermissionManager.ensurePermission()).toThrow(); expect(() => PermissionManager.ensurePermission()).toThrow();
@ -23,9 +24,9 @@ test('Ensure permission', () => {
expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug'); expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug');
expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force'); expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force');
}); });
test('Test permission merge', () => { test('Test permission merge', () => {
expect(perms).toHaveProperty('administrator', 10); expect(perms).toHaveProperty('administrator', 10);
PermissionManager.ensurePermission('administrator:removeuser:force'); PermissionManager.ensurePermission('administrator:removeuser:force');
@ -33,9 +34,9 @@ test('Test permission merge', () => {
expect(perms).toHaveProperty('administrator.removeuser.force'); expect(perms).toHaveProperty('administrator.removeuser.force');
expect(perms).toHaveProperty('administrator.default', 10); expect(perms).toHaveProperty('administrator.default', 10);
}); });
test('Check for permission', () => { test('Check for permission', () => {
expect(() => PermissionManager.checkPermissions()).toThrow(); expect(() => PermissionManager.checkPermissions()).toThrow();
expect(() => PermissionManager.checkPermissions(perms)).toThrow(); expect(() => PermissionManager.checkPermissions(perms)).toThrow();
@ -43,4 +44,8 @@ test('Check for permission', () => {
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true); expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true);
expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true); expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true);
expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false); expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false);
expect(PermissionManager.checkPermissions(perms, 'bargus', 0)).toBe(false);
});
}); });

View File

@ -0,0 +1,100 @@
const AbstractUser = require('../src/server/interfaces/AbstractUser');
const { User, UserApplication } = require('../src/server/structures');
const { Util } = require('../src/util');
test('Abstract user', () => {
expect(() => new AbstractUser()).toThrow();
const Extended = class extends AbstractUser { };
expect(() => new Extended()).toThrow();
expect(() => new Extended(() => null)).toThrow();
expect(() => new Extended(() => null, { id: 123 })).toThrow();
expect(() => new Extended(() => null, { id: '123' })).toThrow();
expect(() => new Extended(() => null, { id: '123', name: 'dingus' })).not.toThrow();
const save = jest.fn();
const now = Date.now();
const permissions = {
administrator: 5
};
const instance = new Extended(save, { id: '1ph45', name: 'navy', permissions, createdTimestamp: now });
instance.save();
expect(save).toHaveBeenCalled();
expect(instance.hasPermission('administrator', 10)).toBe(false);
expect(instance.hasPermission('administrator')).toBe(true);
expect(instance.json).toHaveProperty('id');
expect(instance.jsonPrivate).toHaveProperty('_id');
expect(instance.createdAt.getTime()).toEqual(now);
expect(instance.cachedTimestamp).toBeGreaterThanOrEqual(now);
});
test('User structure', async () => {
expect(() => new User()).toThrow();
const save = jest.fn();
const permissions = { developer: 5 };
const id = Util.randomString();
const name = 'navy';
const password = Util.randomString();
const instance = new User(save, { id, name, permissions, password });
expect(instance.passwordLoginEnabled).toBe(true);
expect(instance.avatar).toBe(null);
const saveApp = jest.fn();
const appName = 'ship';
const description = 'A ship';
await expect(() => instance.createApplication(appName, { description })).rejects.toThrow();
const app = await instance.createApplication(appName, { description }, saveApp);
expect(app.name).toBe(appName);
expect(app.description).toBe(description);
expect(saveApp).toHaveBeenCalledWith(app);
const pw = 'Pw123';
await instance.setPassword(pw, true);
expect(instance.passwordLoginEnabled).toBe(true);
expect(await instance.authenticate(pw)).toBe(true);
await expect(() => instance.setPassword({ prop: 'val' })).rejects.toThrow();
const profile = { username: 'dingus', id: 'asf231t' };
instance.addExternalProfile('platform', profile);
expect(instance.json.externalProfiles[0]).toEqual(profile);
expect(instance.hasExternalProfile('platform')).toEqual(true);
expect(instance.jsonPrivate).toHaveProperty('_id', id);
expect(instance.json).toHaveProperty('id', id);
expect(instance.json).toHaveProperty('displayName', name);
expect(instance.json).not.toHaveProperty('password');
instance.save();
expect(save).toHaveBeenCalledWith(instance);
});
test('Application structure', () => {
expect(() => new UserApplication()).toThrow();
const save = jest.fn();
const permissions = { developer: 5 };
const id = Util.randomString();
const ownerId = Util.randomString();
const name = 'ship';
const token = Util.randomUUID();
const instance = new UserApplication(save, { id, name, permissions, token, ownerId });
expect(instance.ownerId).toEqual(ownerId);
expect(instance.jsonPrivate).toHaveProperty('_id', id);
expect(instance.jsonPrivate).toHaveProperty('token', token);
expect(instance.json).toHaveProperty('id', id);
expect(instance.json).toHaveProperty('name', name);
expect(instance.json).toHaveProperty('ownerId', ownerId);
expect(instance.json).not.toHaveProperty('password');
instance.save();
expect(save).toHaveBeenCalledWith(instance);
});

View File

@ -1950,6 +1950,11 @@ color-support@^1.1.2:
resolved "https://registry.corgi.wtf/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" resolved "https://registry.corgi.wtf/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
commandparser@^1.0.24:
version "1.1.2"
resolved "https://registry.corgi.wtf/commandparser/-/commandparser-1.1.2.tgz#9e1c0e9431b13cae1f612ac97de7aed7b8da9773"
integrity sha512-zOicKRPAapf4W7EffUQXFjSlpMkZzgOJ3/Xq/KiHWqZKEHVKVj4vUMKuisU2y3tlxAEmoon5hxGkjeViyy42iQ==
concat-map@0.0.1: concat-map@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.corgi.wtf/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" resolved "https://registry.corgi.wtf/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"