Compare commits

...

7 Commits

Author SHA1 Message Date
d5fac1c9ba
discord provider opts 2022-11-27 23:20:18 +02:00
be12b8a5cd
upgrade packages 2022-11-27 23:20:07 +02:00
4ace7a3ea0
cleanup 2022-11-27 23:19:54 +02:00
3d79aafad9
get & post methods 2022-11-27 23:19:42 +02:00
820e05ba2a
OAuthProviders 2022-11-27 23:19:33 +02:00
e4ee428a14
debug 2022-11-27 23:17:04 +02:00
f5604e4a46
change order of endpoint & method for consistency 2022-11-27 23:16:03 +02:00
17 changed files with 765 additions and 510 deletions

View File

@ -11,7 +11,16 @@
},
"validUserTypes": ["user", "service", "system"],
"registrationEnabled": true,
"requireCodeForRegister": true
"requireCodeForRegister": true,
"OAuthProviders": [
{
"name": "Discord",
"authoriseURL": "https://discord.com/oauth2/authorize",
"tokenURL": "https://discord.com/api/v10/oauth2/token",
"profileURL": "https://discord.com/api/v10/users/@me",
"iconURL": "https://assets-global.website-files.com/6257adef93867e50d84d30e2/636e0a69f118df70ad7828d4_icon_clyde_blurple_RGB.svg"
}
]
},
"logger": {
"customTypes": ["access", "unauthorised"],
@ -38,7 +47,6 @@
"mongodb": {
"load": true,
"client": {
"useNewUrlParser": true
}
}
}

View File

@ -34,5 +34,8 @@
},
"scripts": {
"start": "node index.js"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -58,6 +58,7 @@ class Server extends EventEmitter {
this.registrationEnabled = options.registrationEnabled;
// Only lets people with valid registration urls to register
this.requireCodeForRegister = options.requireCodeForRegister;
this.OAuthProviders = options.OAuthProviders;
this.server = null;
this.app = express();
@ -194,10 +195,15 @@ class Server extends EventEmitter {
const { DISCORD_ID, DISCORD_SECRET } = process.env;
const { callbackURL, discord } = this._options;
const authParams = {
clientID: DISCORD_ID,
clientSecret: DISCORD_SECRET,
callbackURL: this.baseURL + callbackURL + '/discord',
scope: discord.scope,
version: discord.version
};
this.authenticator.addStrategy('discord', new DiscordStrategy({
clientID: DISCORD_ID, clientSecret: DISCORD_SECRET, callbackURL: this.baseURL + callbackURL + '/discord', scope: discord.scope, version: discord.version
}, async (accessToken, refreshToken, profile, callback) => {
this.authenticator.addStrategy('discord', new DiscordStrategy(authParams, async (accessToken, refreshToken, profile, callback) => {
this.logger.info(`${profile.username} (${profile.id}) is logging in.`);
// We don't need this here
delete profile.accessToken;
@ -205,7 +211,7 @@ class Server extends EventEmitter {
let err = null;
if (!user) err = new Error('No such user');
callback(err, user);
}), { successRedirect: '/home' });
}), { successRedirect: '/home', authParams });
this.authenticator.addStrategy('local', new LocalStrategy(async (username, password, callback) => {
const user = await this.userDatabase.findUser(username);
@ -222,6 +228,16 @@ class Server extends EventEmitter {
}
get clientSettings () {
return {
registrationEnabled: this.registrationEnabled,
requireCodeForRegister: this.requireCodeForRegister,
OAuthProviders: this.OAuthProviders.map(provider => {
return { name: provider.name, icon: provider.iconURL, url: `/api/user/connect/${provider.name.toLowerCase()}` };
})
};
}
}
process.once('message', (msg) => {

View File

@ -21,7 +21,7 @@ class Registry {
this.endpoints.forEach(ep => {
let str = `${spacer}[${ep.resolveable}]\n${spacer}[${ep.methods.map(([ method ]) => method.toUpperCase()).join(', ') || 'NONE'}] ${ep.path}`;
for (const [ sub, method ] of ep.subpaths) str += `\n${spacer.repeat(2)} - ${method.toUpperCase()}: ${sub}`;
for (const [ method, sub ] of ep.subpaths) str += `\n${spacer.repeat(2)} - ${method.toUpperCase()}: ${sub}`;
out.push(str);
});

View File

@ -42,6 +42,7 @@ class MongoDB {
this.logger.status('Initializing database connection.');
await this.client.connect();
this.logger.debug(`Connected, selecting DB`);
this.db = await this.client.db(this.database);
this.logger.status('Database connected.');

View File

@ -10,7 +10,7 @@ class Api404 extends ApiEndpoint {
super(server, {
name: 'api404',
path: '/*',
loadOrder: 10
loadOrder: 9
});
this.methods = [

View File

@ -14,9 +14,9 @@ class LoginEndpoint extends ApiEndpoint {
[ 'post', this.twoFactorRedirect.bind(this), [ server.authenticator.local ]]
];
this.subpaths = [
[ '/fail', 'get', this.fail.bind(this) ],
[ '/discord', 'get', server.authenticator.discord ],
[ '/verify', 'post', this.twoFactor.bind(this), server.authenticator.authenticate ], // 2FA verification
[ 'get', '/fail', this.fail.bind(this) ],
[ 'get', '/discord', server.authenticator.discord ],
[ 'post', '/verify', this.twoFactor.bind(this), server.authenticator.authenticate ], // 2FA verification
];
}

View File

@ -13,9 +13,9 @@ class RegisterEndpoint extends ApiEndpoint {
[ 'post', this.register.bind(this), [ this.notLoggedIn.bind(this) ]]
];
this.subpaths = [
[ '/finalise', 'post', this.finaliseRegistration.bind(this), [ server.auth.authenticate ]],
[ '/toggle', 'post', this.toggleRegistration.bind(this), [ server.auth.createAuthoriser('administrator', 5) ]],
[ '/code', 'get', this.registrationCode.bind(this), [ server.auth.createAuthoriser('administrator', 5) ]]
[ 'post', '/finalise', this.finaliseRegistration.bind(this), [ server.auth.authenticate ]],
[ 'post', '/toggle', this.toggleRegistration.bind(this), [ server.auth.createAuthoriser('administrator', 5) ]],
[ 'get', '/code', this.registrationCode.bind(this), [ server.auth.createAuthoriser('administrator', 5) ]]
];
this.middleware = [ ];

View File

@ -0,0 +1,25 @@
const { ApiEndpoint } = require("../../interfaces");
class SettingsEndpoint extends ApiEndpoint {
constructor (server) {
super(server, {
name: 'settings',
path: '/settings'
});
this.methods = [
[ 'get', this.getSettings.bind(this) ]
];
// this.middleware = [ server.auth.authenticate ];
}
getSettings (req, res) {
res.json(this.server.clientSettings);
}
}
module.exports = SettingsEndpoint;

View File

@ -1,10 +1,17 @@
/* eslint-disable camelcase */
const { ApiEndpoint } = require("../../interfaces");
const { UtilMiddleware } = require('../../middleware');
const pkg = require('../../../../package.json');
const { Util } = require("../../../util");
const qrcode = require('qrcode');
const { authenticator } = require('otplib');
const { inspect } = require('node:util');
// Populated by the constructor
const ServiceProfileLinks = {};
class UserEndpoint extends ApiEndpoint {
constructor (server) {
@ -19,11 +26,18 @@ class UserEndpoint extends ApiEndpoint {
];
this.subpaths = [
[ '/2fa', 'get', this.twoFactor.bind(this) ],
[ '/2fa/disable', 'get', this.disable2fa.bind(this) ],
[ '/2fa/verify', 'post', this.verify2fa.bind(this) ],
[ '/applications', 'get', this.applications.bind(this) ],
[ '/applications', 'post', this.createApplication.bind(this) ],
// 2 Factor Authentication
[ 'get', '/2fa', this.twoFactor.bind(this) ],
[ 'post', '/2fa/disable', this.disable2fa.bind(this) ],
[ 'post', '/2fa/verify', this.verify2fa.bind(this) ],
// 3rd Party Connections
[ 'get', '/connect/:service', this.connectOAuth.bind(this) ],
[ 'get', '/connect/:service/finalise', this.connectOAuthFinalise.bind(this) ],
// Applications
[ 'get', '/applications', this.applications.bind(this) ],
[ 'post', '/applications', this.createApplication.bind(this) ],
];
this.middleware = [
@ -31,27 +45,16 @@ class UserEndpoint extends ApiEndpoint {
UtilMiddleware.requireBody
];
server.OAuthProviders.forEach(provider => {
ServiceProfileLinks[provider.name.toLowerCase()] = provider.profileURL;
});
}
user (req, res) {
return res.json(req.user.safeJson);
}
async applications (req, res) {
const { user } = req;
const applications = await user.fetchApplications();
res.json(Object.values(applications).map(app => app.safeJson));
}
async createApplication (req, res) {
const { body, user } = req;
const { name } = body;
if (!name) return res.status(400).send('Missing name');
const application = await user.createApplication(name);
res.json(application.safeJson);
}
async twoFactor (req, res) {
const { user } = req;
@ -101,6 +104,84 @@ class UserEndpoint extends ApiEndpoint {
res.send('Disabled 2FA');
}
async connectOAuth (req, res) {
const { params } = req;
const service = params.service.toLowerCase();
if (!this.server.OAuthProviders.some(provider => provider.name.toLowerCase() === service)) return res.status(404).send('Provider not found');
const authoriseLink = this.server.auth.getOAuthLink(service, { redirect_uri: this.redirectURI(service) });
res.redirect(authoriseLink);
}
async connectOAuthFinalise (req, res) {
const { params, query, user } = req;
const service = params.service.toLowerCase();
if (!this.server.OAuthProviders.some(provider => provider.name.toLowerCase() === service)) return res.status(404).send('Provider not found');
const { code } = query;
const OAuthParams = this.server.auth.getOAuthTokenParams(service);
const tokenLink = OAuthParams.link;
const body = {
client_id: OAuthParams.clientID,
client_secret: OAuthParams.clientSecret,
grant_type: 'authorization_code',
redirect_uri: this.redirectURI(service),
code
};
const response = await Util.post(tokenLink, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body
});
if (response.status === 400) return res.status(400).send('Invalid code');
if (response.status === 401) return res.status(401).end();
if (!response.success) {
this.logger.error(`Error in OAuth: ${response.status} ${inspect(response.data)}`);
return res.status(500).end();
}
const serviceProfileLink = ServiceProfileLinks[service];
const profile = await Util.get(serviceProfileLink, {
headers: {
Authorization: `Bearer ${response.data.access_token}`
}
});
if (profile.status !== 200) {
this.logger.error(`Non 200 code while fetching profile: ${profile.status} ${inspect(profile.data)}`);
return res.status(500).end();
}
user.addExternalProfile(service, profile.data);
await user.save();
res.send(`Successfully linked ${user.username} with ${profile.data.username} (${profile.data.id})`);
}
async applications (req, res) {
const { user } = req;
const applications = await user.fetchApplications();
res.json(Object.values(applications).map(app => app.safeJson));
}
async createApplication (req, res) {
const { body, user } = req;
const { name } = body;
if (!name) return res.status(400).send('Missing name');
const application = await user.createApplication(name);
res.json(application.safeJson);
}
redirectURI (service) {
return encodeURI(`${this.server.baseURL}/api/user/connect/${service}/finalise`);
}
}
module.exports = UserEndpoint;

View File

@ -11,9 +11,9 @@ class UsersEndpoint extends ApiEndpoint {
this.methods.push([ 'get', this.getUsers.bind(this), [ server.auth.createAuthoriser('administrator', 10) ]]);
this.subpaths = [
[ '/:userid', 'get', this.user.bind(this), [ server.auth.createAuthoriser('administrator', 10) ]],
[ '/:userid/avatar', 'get', this.avatar.bind(this) ],
[ '/:userid/applications', 'get', this.userApplications.bind(this), [ server.auth.createAuthoriser('administrator', 10) ]]
[ 'get', '/:userid', this.user.bind(this), [ server.auth.createAuthoriser('administrator', 10) ]],
[ 'get', '/:userid/avatar', this.avatar.bind(this) ],
[ 'get', '/:userid/applications', this.userApplications.bind(this), [ server.auth.createAuthoriser('administrator', 10) ]]
];
// this.middleware = [ server.auth.createAuthoriser('administrator', 10) ];

View File

@ -8,7 +8,8 @@ class Home extends Endpoint {
super(server, {
name: 'home',
path: '/*'
path: '/*',
loadOrder: 10
});
this.methods = [

View File

@ -15,7 +15,7 @@ class Endpoint {
// Subpaths that should exist on *all* endpoints, the subpaths property can be overwritten, so storing these separately to ensure they exist
this._subpaths = [
[ '/debug', 'post', this.toggleDebug.bind(this), [ server.authenticator.createAuthoriser('developer') ]]
[ 'post', '/debug', this.toggleDebug.bind(this), [ server.authenticator.createAuthoriser('developer') ]]
];
this.middleware = [];
@ -44,7 +44,7 @@ class Endpoint {
this.subpaths = [ ...this._subpaths, ...this.subpaths ];
// eslint-disable-next-line prefer-const
for (let [ sub, method, cb, mw = [] ] of this.subpaths) {
for (let [ method, sub, cb, mw = [] ] of this.subpaths) {
if (typeof method !== 'string') throw new Error(`Invalid method parameter type in Endpoint ${this.name} subpath ${sub}`);
if (!this.middleware.length && !mw.length && !cb) throw new Error(`Cannot have endpoint with no handler and no middleware, expecting at least one to be defined`);
if (typeof mw === 'function') mw = [ mw ];

View File

@ -1,3 +1,4 @@
/* eslint-disable camelcase */
const { AbstractUserDatabase } = require('../interfaces');
const { Util } = require('../../util');
@ -5,6 +6,8 @@ const session = require('express-session');
const MongoStore = require('connect-mongo');
const Passport = require('passport');
const OAuthLinks = {};
/**
* Takes care of sessions and authentication
*
@ -61,6 +64,14 @@ class Authenticator {
callback(null, user);
});
// Stores parameters for OAuth requests, such as client id, secret, scope etc
this.OAuthParams = {};
server.OAuthProviders.forEach(provider => {
const providerName = provider.name.toLowerCase();
OAuthLinks[providerName] = provider.authoriseURL;
OAuthLinks[providerName +'Token'] = provider.tokenURL;
});
}
/**
@ -71,9 +82,10 @@ class Authenticator {
* @param {object} [{ failureRedirect = '/api/login/fail', successRedirect = '/home' }={}]
* @memberof Authenticator
*/
addStrategy (name, strategy, { failureRedirect = '/api/login/fail', successRedirect } = {}) {
addStrategy (name, strategy, { failureRedirect = '/api/login/fail', successRedirect, authParams } = {}) {
this.logger.info(`Adding ${name} authentication strategy`);
this.passport.use(name, strategy);
this.OAuthParams[name] = authParams || null;
// Quick access getter to get the middleware for authenticating
Object.defineProperty(this, name, {
get: () => {
@ -171,6 +183,29 @@ class Authenticator {
}
getOAuthLink (service, { ...rest } = {}) {
service = service.toLowerCase();
const link = OAuthLinks[service];
const OAuthParams = this.OAuthParams[service];
const params = {
response_type: 'code',
client_id: OAuthParams.clientID,
redirect_uri: OAuthParams.callbackURL,
scope: OAuthParams.scope.join(' '),
state: null,
...rest
};
return `${link}?${Object.entries(params).filter(([ , v ]) => Boolean(v)).map(([ k, v ]) => `${k}=${v}`).join('&')}`;
}
getOAuthTokenParams (service) {
service = service.toLowerCase();
return {
link: OAuthLinks[service + 'Token'],
...this.OAuthParams[service]
};
}
}
module.exports = Authenticator;

View File

@ -4,7 +4,8 @@ const { Util } = require('../../util');
const UserApplicataion = require('./UserApplication');
// Fields omitted in safeJson
const ProtectedFields = [ '_id', '_otpSecret', '_passwordHash' ];
// Should be keys used in the json, not the ones defined in the constructor
const ProtectedFields = [ '_id', 'otpSecret', 'password' ];
class User {
@ -75,7 +76,7 @@ class User {
this.cachedTimestamp = Date.now();
this.createdTimestamp = data.createdTimestamp || Date.now();
this.avatarURL = data.avatarURL || null;
this.avatar = data.avatar || null;
}
@ -180,7 +181,7 @@ class User {
applications: this._applications,
createdTimestamp: this.createdTimestamp,
disabled: this.disabled,
avatarURL: this.avatarURL,
avatar: this.avatar,
};
}

View File

@ -17,6 +17,67 @@ class Util {
});
}
static async #parseResponse (response) {
const { headers: rawHeaders, status } = response;
// Fetch returns heders as an interable for some reason
const headers = [ ...rawHeaders ].reduce((acc, [ key, val ]) => {
acc[key.toLowerCase()] = val;
return acc;
}, {});
const success = status >= 200 && status < 300;
const base = { success, status };
if (headers['content-type']?.includes('application/json')) {
const data = await response.json();
return { ...base, data };
}
return { ...base, message: await response.text() };
}
static #params (params) {
return `${Object.entries(params)
.filter(([ , v ]) => Boolean(v))
.map(([ k, v ]) => `${encodeURI(k)}=${encodeURI(v)}`)
.join('&')}`;
}
static async get (url, options = {}) {
const { params, ...rest } = options;
if (params) url = Util.#params(url, params);
const response = await fetch(url, { ...rest });
return Util.#parseResponse(response);
}
static async post (url, options = {}) {
// eslint-disable-next-line prefer-const
let { params, body, ...rest } = options;
if (params) url += '?' + Util.#params(params);
if (rest.headers) {
const headers = Object.keys(rest.headers);
const contentType = headers.find((k) => k.toLowerCase() === 'content-type');
if (contentType) {
const value = rest.headers[contentType].toLowerCase();
if (value === 'application/json' && typeof body !== 'string') {
body = JSON.stringify(body);
} else if (value === 'application/x-www-form-urlencoded') {
if (body) body = Util.#params(body);
}
}
}
const response = await fetch(url, { ...rest, body, method: 'post' });
return Util.#parseResponse(response);
}
static checkPermissions (perms, perm, level = 1) {
if (!perms || typeof perms !== 'object') throw new Error('Missing perms object');

951
yarn.lock

File diff suppressed because it is too large Load Diff