Compare commits
7 Commits
cc7b07dcec
...
d5fac1c9ba
Author | SHA1 | Date | |
---|---|---|---|
d5fac1c9ba | |||
be12b8a5cd | |||
4ace7a3ea0 | |||
3d79aafad9 | |||
820e05ba2a | |||
e4ee428a14 | |||
f5604e4a46 |
12
options.json
12
options.json
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -34,5 +34,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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.');
|
||||
|
@ -10,7 +10,7 @@ class Api404 extends ApiEndpoint {
|
||||
super(server, {
|
||||
name: 'api404',
|
||||
path: '/*',
|
||||
loadOrder: 10
|
||||
loadOrder: 9
|
||||
});
|
||||
|
||||
this.methods = [
|
||||
|
@ -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
|
||||
];
|
||||
|
||||
}
|
||||
|
@ -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 = [ ];
|
||||
|
||||
|
25
src/server/endpoints/api/Settings.js
Normal file
25
src/server/endpoints/api/Settings.js
Normal 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;
|
@ -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;
|
@ -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) ];
|
||||
|
||||
|
@ -8,7 +8,8 @@ class Home extends Endpoint {
|
||||
|
||||
super(server, {
|
||||
name: 'home',
|
||||
path: '/*'
|
||||
path: '/*',
|
||||
loadOrder: 10
|
||||
});
|
||||
|
||||
this.methods = [
|
||||
|
@ -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 ];
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user