From 80d78cbf2505bf48bfbd05ea8ed6cf8414d8cfa9 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Wed, 20 Jun 2018 08:47:26 +1000 Subject: [PATCH] Backend --- src/backend/app.js | 95 +++++ src/backend/db.js | 23 ++ src/backend/index.js | 45 +++ src/backend/internal/token.js | 166 ++++++++ src/backend/internal/user.js | 382 ++++++++++++++++++ src/backend/lib/access.js | 256 ++++++++++++ src/backend/lib/access/roles.json | 45 +++ src/backend/lib/access/users-get.json | 23 ++ src/backend/lib/access/users-list.json | 7 + src/backend/lib/error.js | 83 ++++ src/backend/lib/express/cors.js | 32 ++ src/backend/lib/express/jwt-decode.js | 17 + src/backend/lib/express/jwt.js | 15 + src/backend/lib/express/pagination.js | 57 +++ src/backend/lib/express/user-id-from-me.js | 11 + src/backend/lib/helpers.js | 35 ++ src/backend/lib/migrate_template.js | 57 +++ src/backend/lib/validator/api.js | 47 +++ src/backend/lib/validator/index.js | 53 +++ src/backend/logger.js | 7 + src/backend/migrate.js | 17 + .../migrations/20180618015850_initial.js | 60 +++ src/backend/models/auth.js | 82 ++++ src/backend/models/token.js | 133 ++++++ src/backend/models/user.js | 35 ++ src/backend/routes/api/main.js | 32 ++ src/backend/routes/api/tokens.js | 56 +++ src/backend/routes/api/users.js | 256 ++++++++++++ src/backend/routes/main.js | 44 ++ src/backend/schema/definitions.json | 139 +++++++ src/backend/schema/endpoints/tokens.json | 100 +++++ src/backend/schema/endpoints/users.json | 240 +++++++++++ src/backend/schema/examples.json | 23 ++ src/backend/schema/index.json | 21 + src/backend/setup.js | 87 ++++ src/backend/views/index.ejs | 9 + src/backend/views/login.ejs | 9 + src/backend/views/partials/footer.ejs | 2 + src/backend/views/partials/header.ejs | 36 ++ 39 files changed, 2837 insertions(+) create mode 100644 src/backend/app.js create mode 100644 src/backend/db.js create mode 100644 src/backend/index.js create mode 100644 src/backend/internal/token.js create mode 100644 src/backend/internal/user.js create mode 100644 src/backend/lib/access.js create mode 100644 src/backend/lib/access/roles.json create mode 100644 src/backend/lib/access/users-get.json create mode 100644 src/backend/lib/access/users-list.json create mode 100644 src/backend/lib/error.js create mode 100644 src/backend/lib/express/cors.js create mode 100644 src/backend/lib/express/jwt-decode.js create mode 100644 src/backend/lib/express/jwt.js create mode 100644 src/backend/lib/express/pagination.js create mode 100644 src/backend/lib/express/user-id-from-me.js create mode 100644 src/backend/lib/helpers.js create mode 100644 src/backend/lib/migrate_template.js create mode 100644 src/backend/lib/validator/api.js create mode 100644 src/backend/lib/validator/index.js create mode 100644 src/backend/logger.js create mode 100644 src/backend/migrate.js create mode 100644 src/backend/migrations/20180618015850_initial.js create mode 100644 src/backend/models/auth.js create mode 100644 src/backend/models/token.js create mode 100644 src/backend/models/user.js create mode 100644 src/backend/routes/api/main.js create mode 100644 src/backend/routes/api/tokens.js create mode 100644 src/backend/routes/api/users.js create mode 100644 src/backend/routes/main.js create mode 100644 src/backend/schema/definitions.json create mode 100644 src/backend/schema/endpoints/tokens.json create mode 100644 src/backend/schema/endpoints/users.json create mode 100644 src/backend/schema/examples.json create mode 100644 src/backend/schema/index.json create mode 100644 src/backend/setup.js create mode 100644 src/backend/views/index.ejs create mode 100644 src/backend/views/login.ejs create mode 100644 src/backend/views/partials/footer.ejs create mode 100644 src/backend/views/partials/header.ejs diff --git a/src/backend/app.js b/src/backend/app.js new file mode 100644 index 0000000..40ead9c --- /dev/null +++ b/src/backend/app.js @@ -0,0 +1,95 @@ +'use strict'; + +const path = require('path'); +const express = require('express'); +const bodyParser = require('body-parser'); +const compression = require('compression'); +const log = require('./logger').express; + +/** + * App + */ +const app = express(); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({extended: true})); + +// Gzip +app.use(compression()); + +/** + * General Logging, BEFORE routes + */ + +app.disable('x-powered-by'); +app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); +app.enable('strict routing'); + +// pretty print JSON when not live +if (process.env.NODE_ENV !== 'production') { + app.set('json spaces', 2); +} + +// set the view engine to ejs +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, '/views')); + +// CORS for everything +app.use(require('./lib/express/cors')); + +// General security/cache related headers + server header +app.use(function (req, res, next) { + res.set({ + 'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload', + 'X-XSS-Protection': '0', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', + Pragma: 'no-cache', + Expires: 0 + }); + next(); +}); + +// ATTACH JWT value - FOR ANY RATE LIMITERS and JWT DECODE +app.use(require('./lib/express/jwt')()); + +/** + * Routes + */ +app.use('/assets', express.static('dist/assets')); +app.use('/css', express.static('dist/css')); +app.use('/fonts', express.static('dist/fonts')); +app.use('/images', express.static('dist/images')); +app.use('/js', express.static('dist/js')); +app.use('/api', require('./routes/api/main')); +app.use('/', require('./routes/main')); + +// production error handler +// no stacktraces leaked to user +app.use(function (err, req, res, next) { + + let payload = { + error: { + code: err.status, + message: err.public ? err.message : 'Internal Error' + } + }; + + if (process.env.NODE_ENV === 'development') { + payload.debug = { + stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null, + previous: err.previous + }; + } + + // Not every error is worth logging - but this is good for now until it gets annoying. + if (typeof err.stack !== 'undefined' && err.stack) { + log.warn(err.stack); + } + + res + .status(err.status || 500) + .send(payload); +}); + +module.exports = app; diff --git a/src/backend/db.js b/src/backend/db.js new file mode 100644 index 0000000..124c64b --- /dev/null +++ b/src/backend/db.js @@ -0,0 +1,23 @@ +'use strict'; + +let config = require('config'); + +if (!config.has('database')) { + throw new Error('Database config does not exist! Read the README for instructions.'); +} + +let knex = require('knex')({ + client: config.database.engine, + connection: { + host: config.database.host, + user: config.database.user, + password: config.database.password, + database: config.database.name, + port: config.database.port + }, + migrations: { + tableName: 'migrations' + } +}); + +module.exports = knex; diff --git a/src/backend/index.js b/src/backend/index.js new file mode 100644 index 0000000..5ced24c --- /dev/null +++ b/src/backend/index.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +'use strict'; + +const config = require('config'); +const app = require('./app'); +const logger = require('./logger').global; +const migrate = require('./migrate'); +const setup = require('./setup'); +const apiValidator = require('./lib/validator/api'); + +let port = process.env.PORT || 81; + +if (config.has('port')) { + port = config.get('port'); +} + +function appStart () { + return migrate.latest() + .then(() => { + return setup(); + }) + .then(() => { + return apiValidator.loadSchemas; + }) + .then(() => { + const server = app.listen(port, () => { + logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...'); + + process.on('SIGTERM', () => { + logger.info('PID ' + process.pid + ' received SIGTERM'); + server.close(() => { + logger.info('Stopping.'); + process.exit(0); + }); + }); + }); + }) + .catch(err => { + logger.error(err.message); + setTimeout(appStart, 1000); + }); +} + +appStart(); diff --git a/src/backend/internal/token.js b/src/backend/internal/token.js new file mode 100644 index 0000000..6d1b8ca --- /dev/null +++ b/src/backend/internal/token.js @@ -0,0 +1,166 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const userModel = require('../models/user'); +const authModel = require('../models/auth'); +const helpers = require('../lib/helpers'); +const TokenModel = require('../models/token'); + +module.exports = { + + /** + * @param {Object} data + * @param {String} data.identity + * @param {String} data.secret + * @param {String} [data.scope] + * @param {String} [data.expiry] + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromEmail: (data, issuer) => { + let Token = new TokenModel(); + + data.scope = data.scope || 'user'; + data.expiry = data.expiry || '30d'; + + return userModel + .query() + .where('email', data.identity) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .first() + .then(user => { + if (user) { + // Get auth + return authModel + .query() + .where('user_id', '=', user.id) + .where('type', '=', 'password') + .first() + .then(auth => { + if (auth) { + return auth.verifyPassword(data.secret) + .then(valid => { + if (valid) { + + if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) { + // The scope requested doesn't exist as a role against the user, + // you shall not pass. + throw new error.AuthError('Invalid scope: ' + data.scope); + } + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + return Token.create({ + iss: issuer || 'api', + attrs: { + id: user.id + }, + scope: [data.scope] + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + } else { + throw new error.AuthError('Invalid password'); + } + }); + } else { + throw new error.AuthError('No password auth for user'); + } + }); + } else { + throw new error.AuthError('No relevant user found'); + } + }); + }, + + /** + * @param {Access} access + * @param {Object} [data] + * @param {String} [data.expiry] + * @param {String} [data.scope] Only considered if existing token scope is admin + * @returns {Promise} + */ + getFreshToken: (access, data) => { + let Token = new TokenModel(); + + data = data || {}; + data.expiry = data.expiry || '30d'; + + if (access && access.token.get('attrs').id) { + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + let token_attrs = { + id: access.token.get('attrs').id + }; + + // Only admins can request otherwise scoped tokens + let scope = access.token.get('scope'); + if (data.scope && access.token.hasScope('admin')) { + scope = [data.scope]; + + if (data.scope === 'job-board' || data.scope === 'worker') { + token_attrs.id = 0; + } + } + + return Token.create({ + iss: 'api', + scope: scope, + attrs: token_attrs + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + } else { + throw new error.AssertionFailedError('Existing token contained invalid user data'); + } + }, + + /** + * @param {Object} user + * @returns {Promise} + */ + getTokenFromUser: user => { + let Token = new TokenModel(); + let expiry = helpers.parseDatePeriod('1d'); + + return Token.create({ + iss: 'api', + attrs: { + id: user.id + }, + scope: ['user'] + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString(), + user: user + }; + }); + } +}; diff --git a/src/backend/internal/user.js b/src/backend/internal/user.js new file mode 100644 index 0000000..e46c848 --- /dev/null +++ b/src/backend/internal/user.js @@ -0,0 +1,382 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../lib/error'); +const userModel = require('../models/user'); +const authModel = require('../models/auth'); +const gravatar = require('gravatar'); +const internalToken = require('./token'); + +function omissions () { + return ['is_deleted']; +} + +const internalUser = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + let auth = data.auth; + delete data.auth; + + data.avatar = data.avatar || ''; + data.roles = data.roles || []; + + if (typeof data.is_disabled !== 'undefined') { + data.is_disabled = data.is_disabled ? 1 : 0; + } + + return access.can('users:create', data) + .then(() => { + data.avatar = gravatar.url(data.email, {default: 'mm'}); + + return userModel + .query() + .omit(omissions()) + .insertAndFetch(data); + }) + .then(user => { + return authModel + .query() + .insert({ + user_id: user.id, + type: auth.type, + secret: auth.secret, + meta: {} + }) + .then(() => { + return internalUser.get(access, {id: user.id, expand: ['services']}); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.name] + * @return {Promise} + */ + update: (access, data) => { + if (typeof data.is_disabled !== 'undefined') { + data.is_disabled = data.is_disabled ? 1 : 0; + } + + return access.can('users:update', data.id) + .then(() => { + + // Make sure that the user being updated doesn't change their email to another user that is already using it + // 1. get user we want to update + return internalUser.get(access, {id: data.id}) + .then(user => { + + // 2. if email is to be changed, find other users with that email + if (typeof data.email !== 'undefined') { + data.email = data.email.toLowerCase().trim(); + + if (user.email !== data.email) { + return internalUser.isEmailAvailable(data.email, data.id) + .then(available => { + if (!available) { + throw new error.ValidationError('Email address already in use - ' + data.email); + } + + return user; + }); + } + } + + // No change to email: + return user; + }); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + data.avatar = gravatar.url(data.email || user.email, {default: 'mm'}); + + return userModel + .query() + .omit(omissions()) + .patchAndFetchById(user.id, data) + .then(saved_user => { + return _.omit(saved_user, omissions()); + }); + }) + .then(() => { + return internalUser.get(access, {id: data.id, expand: ['services']}); + }); + }, + + /** + * @param {Access} access + * @param {Object} [data] + * @param {Integer} [data.id] Defaults to the token user + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + if (typeof data.id === 'undefined' || !data.id) { + data.id = access.token.get('attrs').id; + } + + return access.can('users:get', data.id) + .then(() => { + let query = userModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .first(); + + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + query.omit(data.omit); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.eager('[' + data.expand.join(', ') + ']'); + } + + return query; + }) + .then(row => { + if (row) { + return _.omit(row, omissions()); + } else { + throw new error.ItemNotFoundError(data.id); + } + }); + }, + + /** + * Checks if an email address is available, but if a user_id is supplied, it will ignore checking + * against that user. + * + * @param email + * @param user_id + */ + isEmailAvailable: (email, user_id) => { + let query = userModel + .query() + .where('email', '=', email.toLowerCase().trim()) + .where('is_deleted', 0) + .first(); + + if (typeof user_id !== 'undefined') { + query.where('id', '!=', user_id); + } + + return query + .then(user => { + return !user; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.reason] + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('users:delete', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (!user) { + throw new error.ItemNotFoundError(data.id); + } + + // Make sure user can't delete themselves + if (user.id === access.token.get('attrs').id) { + throw new error.PermissionError('You cannot delete yourself.'); + } + + return userModel + .query() + .where('id', user.id) + .patch({ + is_deleted: 1 + }); + }) + .then(() => { + return true; + }); + }, + + /** + * This will only count the users + * + * @param {Access} access + * @param {String} [search_query] + * @returns {*} + */ + getCount: (access, search_query) => { + return access.can('users:list') + .then(() => { + let query = userModel + .query() + .count('id as count') + .where('is_deleted', 0) + .first(); + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('user.name', 'like', '%' + search_query + '%') + .orWhere('user.email', 'like', '%' + search_query + '%'); + }); + } + + return query; + }) + .then(row => { + return parseInt(row.count, 10); + }); + }, + + /** + * All users + * + * @param {Access} access + * @param {Integer} [start] + * @param {Integer} [limit] + * @param {Array} [sort] + * @param {Array} [expand] + * @param {String} [search_query] + * @returns {Promise} + */ + getAll: (access, start, limit, sort, expand, search_query) => { + return access.can('users:list') + .then(() => { + let query = userModel + .query() + .where('is_deleted', 0) + .groupBy('id') + .limit(limit ? limit : 100) + .omit(['is_deleted']); + + if (typeof start !== 'undefined' && start !== null) { + query.offset(start); + } + + if (typeof sort !== 'undefined' && sort !== null) { + _.map(sort, (item) => { + query.orderBy(item.field, item.dir); + }); + } else { + query.orderBy('name', 'DESC'); + } + + // Query is used for searching + if (typeof search_query === 'string') { + query.where(function () { + this.where('name', 'like', '%' + search_query + '%') + .orWhere('email', 'like', '%' + search_query + '%'); + }); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.eager('[' + expand.join(', ') + ']'); + } + + return query; + }); + }, + + /** + * @param {Access} access + * @param {Integer} [id_requested] + * @returns {[String]} + */ + getUserOmisionsByAccess: (access, id_requested) => { + let response = []; // Admin response + + if (!access.token.hasScope('admin') && access.token.get('attrs').id !== id_requested) { + response = ['roles', 'is_deleted']; // Restricted response + } + + return response; + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} data.type + * @param {String} data.secret + * @return {Promise} + */ + setPassword: (access, data) => { + return access.can('users:password', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + if (user.id === access.token.get('attrs').id) { + // they're setting their own password. Make sure their current password is correct + if (typeof data.current === 'undefined' || !data.current) { + throw new error.ValidationError('Current password was not supplied'); + } + + return internalToken.getTokenFromEmail({ + identity: user.email, + secret: data.current + }) + .then(() => { + return user; + }); + } + + return user; + }) + .then(user => { + return authModel + .query() + .where('user_id', user.id) + .andWhere('type', data.type) + .patch({ + type: data.type, + secret: data.secret + }) + .then(() => { + return true; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + */ + loginAs: (access, data) => { + return access.can('users:loginas', data.id) + .then(() => { + return internalUser.get(access, data); + }) + .then(user => { + return internalToken.getTokenFromUser(user); + }); + } +}; + +module.exports = internalUser; diff --git a/src/backend/lib/access.js b/src/backend/lib/access.js new file mode 100644 index 0000000..3bcda77 --- /dev/null +++ b/src/backend/lib/access.js @@ -0,0 +1,256 @@ +'use strict'; + +const _ = require('lodash'); +const validator = require('ajv'); +const error = require('./error'); +const userModel = require('../models/user'); +const TokenModel = require('../models/token'); +const roleSchema = require('./access/roles.json'); + +module.exports = function (token_string) { + let Token = new TokenModel(); + let token_data = null; + let initialised = false; + let object_cache = {}; + let allow_internal_access = false; + let user_roles = []; + + /** + * Loads the Token object from the token string + * + * @returns {Promise} + */ + this.init = () => { + return new Promise((resolve, reject) => { + if (initialised) { + resolve(); + } else if (!token_string) { + reject(new error.PermissionError('Permission Denied')); + } else { + resolve(Token.load(token_string) + .then((data) => { + token_data = data; + + // At this point we need to load the user from the DB and make sure they: + // - exist (and not soft deleted) + // - still have the appropriate scopes for this token + // This is only required when the User ID is supplied or if the token scope has `user` + + if (token_data.attrs.id || (typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'user') !== -1)) { + // Has token user id or token user scope + return userModel + .query() + .where('id', token_data.attrs.id) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .first('id') + .then((user) => { + if (user) { + // make sure user has all scopes of the token + // The `user` role is not added against the user row, so we have to just add it here to get past this check. + user.roles.push('user'); + + let is_ok = true; + _.forEach(token_data.scope, (scope_item) => { + if (_.indexOf(user.roles, scope_item) === -1) { + is_ok = false; + } + }); + + if (!is_ok) { + throw new error.AuthError('Invalid token scope for User'); + } else { + initialised = true; + user_roles = user.roles; + } + } else { + throw new error.AuthError('User cannot be loaded for Token'); + } + }); + } else { + initialised = true; + } + })); + } + }); + }; + + /** + * Fetches the object ids from the database, only once per object type, for this token. + * This only applies to USER token scopes, as all other tokens are not really bound + * by object scopes + * + * @param {String} object_type + * @returns {Promise} + */ + this.loadObjects = object_type => { + return new Promise((resolve, reject) => { + if (Token.hasScope('user')) { + if (typeof token_data.attrs.id === 'undefined' || !token_data.attrs.id) { + reject(new error.AuthError('User Token supplied without a User ID')); + } else { + let token_user_id = token_data.attrs.id ? token_data.attrs.id : 0; + + if (typeof object_cache[object_type] === 'undefined') { + switch (object_type) { + + // USERS - should only return yourself + case 'users': + resolve(token_user_id ? [token_user_id] : []); + break; + + // DEFAULT: null + default: + resolve(null); + break; + } + } else { + resolve(object_cache[object_type]); + } + } + } else { + resolve(null); + } + }) + .then(objects => { + object_cache[object_type] = objects; + return objects; + }); + }; + + /** + * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema + * + * @param {String} permission_label + * @returns {Object} + */ + this.getObjectSchema = permission_label => { + let base_object_type = permission_label.split(':').shift(); + + let schema = { + $id: 'objects', + $schema: 'http://json-schema.org/draft-07/schema#', + description: 'Actor Properties', + type: 'object', + additionalProperties: false, + properties: { + user_id: { + anyOf: [ + { + type: 'number', + enum: [Token.get('attrs').id] + } + ] + }, + scope: { + type: 'string', + pattern: '^' + Token.get('scope') + '$' + } + } + }; + + return this.loadObjects(base_object_type) + .then(object_result => { + if (typeof object_result === 'object' && object_result !== null) { + schema.properties[base_object_type] = { + type: 'number', + enum: object_result, + minimum: 1 + }; + } else { + schema.properties[base_object_type] = { + type: 'number', + minimum: 1 + }; + } + + return schema; + }); + }; + + return { + + token: Token, + + /** + * + * @param {Boolean} [allow_internal] + * @returns {Promise} + */ + load: allow_internal => { + return new Promise(function (resolve/*, reject*/) { + if (token_string) { + resolve(Token.load(token_string)); + } else { + allow_internal_access = allow_internal; + resolve(allow_internal_access || null); + } + }); + }, + + /** + * + * @param {String} permission + * @param {*} [data] + * @returns {Promise} + */ + can: (permission, data) => { + if (allow_internal_access === true) { + return Promise.resolve(true); + //return true; + } else { + return this.init() + .then(() => { + // Initialised, token decoded ok + + return this.getObjectSchema(permission) + .then(objectSchema => { + let data_schema = { + [permission]: { + data: data, + scope: Token.get('scope'), + roles: user_roles + } + }; + + let permissionSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $async: true, + $id: 'permissions', + additionalProperties: false, + properties: {} + }; + + permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json'); + + //console.log('objectSchema:', JSON.stringify(objectSchema, null, 2)); + //console.log('permissionSchema:', JSON.stringify(permissionSchema, null, 2)); + //console.log('data_schema:', JSON.stringify(data_schema, null, 2)); + + let ajv = validator({ + verbose: true, + allErrors: true, + format: 'full', + missingRefs: 'fail', + breakOnError: true, + coerceTypes: true, + schemas: [ + roleSchema, + objectSchema, + permissionSchema + ] + }); + + return ajv.validate('permissions', data_schema); + }); + }) + .catch(err => { + //console.log(err.message); + //console.log(err.errors); + + throw new error.PermissionError('Permission Denied', err); + }); + } + } + }; +}; diff --git a/src/backend/lib/access/roles.json b/src/backend/lib/access/roles.json new file mode 100644 index 0000000..18922a1 --- /dev/null +++ b/src/backend/lib/access/roles.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "roles", + "definitions": { + "admin": { + "type": "object", + "required": [ + "scope", + "roles" + ], + "properties": { + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + }, + "roles": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^admin$" + } + } + } + }, + "user": { + "type": "object", + "required": [ + "scope" + ], + "properties": { + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + } +} + diff --git a/src/backend/lib/access/users-get.json b/src/backend/lib/access/users-get.json new file mode 100644 index 0000000..04b4e9e --- /dev/null +++ b/src/backend/lib/access/users-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["data", "scope"], + "properties": { + "data": { + "$ref": "objects#/properties/users" + }, + "scope": { + "type": "array", + "contains": { + "type": "string", + "pattern": "^user$" + } + } + } + } + ] +} diff --git a/src/backend/lib/access/users-list.json b/src/backend/lib/access/users-list.json new file mode 100644 index 0000000..d2709fd --- /dev/null +++ b/src/backend/lib/access/users-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/lib/error.js b/src/backend/lib/error.js new file mode 100644 index 0000000..1580c45 --- /dev/null +++ b/src/backend/lib/error.js @@ -0,0 +1,83 @@ +'use strict'; + +const _ = require('lodash'); +const util = require('util'); + +module.exports = { + + PermissionError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = 'Permission Denied'; + this.public = true; + this.status = 403; + }, + + ItemNotFoundError: function (id, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = 'Item Not Found - ' + id; + this.public = true; + this.status = 404; + }, + + AuthError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = true; + this.status = 401; + }, + + InternalError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.status = 500; + this.public = false; + }, + + InternalValidationError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.status = 400; + this.public = false; + }, + + CacheError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.message = message; + this.previous = previous; + this.status = 500; + this.public = false; + }, + + ValidationError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = true; + this.status = 400; + }, + + AssertionFailedError: function (message, previous) { + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.previous = previous; + this.message = message; + this.public = false; + this.status = 400; + } +}; + +_.forEach(module.exports, function (error) { + util.inherits(error, Error); +}); diff --git a/src/backend/lib/express/cors.js b/src/backend/lib/express/cors.js new file mode 100644 index 0000000..1a5778e --- /dev/null +++ b/src/backend/lib/express/cors.js @@ -0,0 +1,32 @@ +'use strict'; + +const validator = require('../validator'); + +module.exports = function (req, res, next) { + + if (req.headers.origin) { + + // very relaxed validation.... + validator({ + type: 'string', + pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$' + }, req.headers.origin) + .then(function () { + res.set({ + 'Access-Control-Allow-Origin': req.headers.origin, + 'Access-Control-Allow-Credentials': true, + 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', + 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit', + 'Access-Control-Max-Age': 5 * 60, + 'Access-Control-Expose-Headers': 'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit' + }); + next(); + }) + .catch(next); + + } else { + // No origin + next(); + } + +}; diff --git a/src/backend/lib/express/jwt-decode.js b/src/backend/lib/express/jwt-decode.js new file mode 100644 index 0000000..ce386e4 --- /dev/null +++ b/src/backend/lib/express/jwt-decode.js @@ -0,0 +1,17 @@ +'use strict'; + +const Access = require('../access'); + +module.exports = () => { + return function (req, res, next) { + res.locals.access = null; + let access = new Access(res.locals.token || null); + access.load() + .then(() => { + res.locals.access = access; + next(); + }) + .catch(next); + }; +}; + diff --git a/src/backend/lib/express/jwt.js b/src/backend/lib/express/jwt.js new file mode 100644 index 0000000..b4e7dfe --- /dev/null +++ b/src/backend/lib/express/jwt.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = function () { + return function (req, res, next) { + if (req.headers.authorization) { + let parts = req.headers.authorization.split(' '); + + if (parts && parts[0] === 'Bearer' && parts[1]) { + res.locals.token = parts[1]; + } + } + + next(); + }; +}; diff --git a/src/backend/lib/express/pagination.js b/src/backend/lib/express/pagination.js new file mode 100644 index 0000000..c948edf --- /dev/null +++ b/src/backend/lib/express/pagination.js @@ -0,0 +1,57 @@ +'use strict'; + +let _ = require('lodash'); + +module.exports = function (default_sort, default_offset, default_limit, max_limit) { + + /** + * This will setup the req query params with filtered data and defaults + * + * sort will be an array of fields and their direction + * offset will be an int, defaulting to zero if no other default supplied + * limit will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied + * + */ + + return function (req, res, next) { + + req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10); + req.query.limit = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10); + + if (max_limit && req.query.limit > max_limit) { + req.query.limit = max_limit; + } + + // Sorting + let sort = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort; + let myRegexp = /.*\.(asc|desc)$/ig; + let sort_array = []; + + sort = sort.split(','); + _.map(sort, function (val) { + let matches = myRegexp.exec(val); + + if (matches !== null) { + let dir = matches[1]; + sort_array.push({ + field: val.substr(0, val.length - (dir.length + 1)), + dir: dir.toLowerCase() + }); + } else { + sort_array.push({ + field: val, + dir: 'asc' + }); + } + }); + + // Sort will now be in this format: + // [ + // { field: 'field1', dir: 'asc' }, + // { field: 'field2', dir: 'desc' } + // ] + + req.query.sort = sort_array; + next(); + }; +}; diff --git a/src/backend/lib/express/user-id-from-me.js b/src/backend/lib/express/user-id-from-me.js new file mode 100644 index 0000000..259b186 --- /dev/null +++ b/src/backend/lib/express/user-id-from-me.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = (req, res, next) => { + if (req.params.user_id === 'me' && res.locals.access) { + req.params.user_id = res.locals.access.token.get('attrs').id; + } else { + req.params.user_id = parseInt(req.params.user_id, 10); + } + + next(); +}; diff --git a/src/backend/lib/helpers.js b/src/backend/lib/helpers.js new file mode 100644 index 0000000..389cfc5 --- /dev/null +++ b/src/backend/lib/helpers.js @@ -0,0 +1,35 @@ +'use strict'; + +const moment = require('moment'); +const _ = require('lodash'); + +module.exports = { + + /** + * Takes an expression such as 30d and returns a moment object of that date in future + * + * Key Shorthand + * ================== + * years y + * quarters Q + * months M + * weeks w + * days d + * hours h + * minutes m + * seconds s + * milliseconds ms + * + * @param {String} expression + * @returns {Object} + */ + parseDatePeriod: function (expression) { + let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m); + if (matches) { + return moment().add(matches[1], matches[2]); + } + + return null; + } + +}; diff --git a/src/backend/lib/migrate_template.js b/src/backend/lib/migrate_template.js new file mode 100644 index 0000000..68ba6d6 --- /dev/null +++ b/src/backend/lib/migrate_template.js @@ -0,0 +1,57 @@ +'use strict'; + +const migrate_name = 'identifier_for_migrate'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex, Promise) { + + logger.info('[' + migrate_name + '] Migrating Up...'); + + // Create Table example: + + /*return knex.schema.createTable('notification', (table) => { + table.increments().primary(); + table.string('name').notNull(); + table.string('type').notNull(); + table.integer('created_on').notNull(); + table.integer('modified_on').notNull(); + }) + .then(function () { + logger.info('[' + migrate_name + '] Notification Table created'); + });*/ + + logger.info('[' + migrate_name + '] Migrating Up Complete'); + + return Promise.resolve(true); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex, Promise) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + // Drop table example: + + /*return knex.schema.dropTable('notification') + .then(() => { + logger.info('[' + migrate_name + '] Notification Table dropped'); + });*/ + + logger.info('[' + migrate_name + '] Migrating Down Complete'); + + return Promise.resolve(true); +}; diff --git a/src/backend/lib/validator/api.js b/src/backend/lib/validator/api.js new file mode 100644 index 0000000..4f259ef --- /dev/null +++ b/src/backend/lib/validator/api.js @@ -0,0 +1,47 @@ +'use strict'; + +const error = require('../error'); +const path = require('path'); +const parser = require('json-schema-ref-parser'); + +const ajv = require('ajv')({ + verbose: true, + validateSchema: true, + allErrors: false, + format: 'full', + coerceTypes: true +}); + +/** + * @param {Object} schema + * @param {Object} payload + * @returns {Promise} + */ +function apiValidator (schema, payload/*, description*/) { + return new Promise(function Promise_apiValidator (resolve, reject) { + if (typeof payload === 'undefined') { + reject(new error.ValidationError('Payload is undefined')); + } + + let validate = ajv.compile(schema); + let valid = validate(payload); + + if (valid && !validate.errors) { + resolve(payload); + } else { + let message = ajv.errorsText(validate.errors); + let err = new error.ValidationError(message); + err.debug = [validate.errors, payload]; + reject(err); + } + }); +} + +apiValidator.loadSchemas = parser + .dereference(path.resolve('src/backend/schema/index.json')) + .then(schema => { + ajv.addSchema(schema); + return schema; + }); + +module.exports = apiValidator; diff --git a/src/backend/lib/validator/index.js b/src/backend/lib/validator/index.js new file mode 100644 index 0000000..b7d9a52 --- /dev/null +++ b/src/backend/lib/validator/index.js @@ -0,0 +1,53 @@ +'use strict'; + +const _ = require('lodash'); +const error = require('../error'); +const definitions = require('../../schema/definitions.json'); + +RegExp.prototype.toJSON = RegExp.prototype.toString; + +const ajv = require('ajv')({ + verbose: true, //process.env.NODE_ENV === 'development', + allErrors: true, + format: 'full', // strict regexes for format checks + coerceTypes: true, + schemas: [ + definitions + ] +}); + +/** + * + * @param {Object} schema + * @param {Object} payload + * @returns {Promise} + */ +function validator (schema, payload) { + return new Promise(function (resolve, reject) { + if (!payload) { + reject(new error.InternalValidationError('Payload is falsy')); + } else { + try { + let validate = ajv.compile(schema); + + let valid = validate(payload); + if (valid && !validate.errors) { + resolve(_.cloneDeep(payload)); + } else { + //console.log('Validation failed:', schema, payload); + + let message = ajv.errorsText(validate.errors); + reject(new error.InternalValidationError(message)); + } + + } catch (err) { + reject(err); + } + + } + + }); + +} + +module.exports = validator; diff --git a/src/backend/logger.js b/src/backend/logger.js new file mode 100644 index 0000000..0cbfb09 --- /dev/null +++ b/src/backend/logger.js @@ -0,0 +1,7 @@ +const {Signale} = require('signale'); + +module.exports = { + global: new Signale({scope: 'Global '}), + migrate: new Signale({scope: 'Migrate '}), + express: new Signale({scope: 'Express '}) +}; diff --git a/src/backend/migrate.js b/src/backend/migrate.js new file mode 100644 index 0000000..68267e3 --- /dev/null +++ b/src/backend/migrate.js @@ -0,0 +1,17 @@ +'use strict'; + +const db = require('./db'); +const logger = require('./logger').migrate; + +module.exports = { + latest: function () { + return db.migrate.currentVersion() + .then(version => { + logger.info('Current database version:', version); + return db.migrate.latest({ + tableName: 'migrations', + directory: 'src/backend/migrations' + }); + }); + } +}; diff --git a/src/backend/migrations/20180618015850_initial.js b/src/backend/migrations/20180618015850_initial.js new file mode 100644 index 0000000..9ecb22c --- /dev/null +++ b/src/backend/migrations/20180618015850_initial.js @@ -0,0 +1,60 @@ +'use strict'; + +const migrate_name = 'initial-schema'; +const logger = require('../logger').migrate; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex/*, Promise*/) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.createTable('auth', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('user_id').notNull().unsigned(); + table.string('type', 30).notNull(); + table.string('secret').notNull(); + table.json('meta').notNull(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + }) + .then(() => { + logger.info('[' + migrate_name + '] auth Table created'); + + return knex.schema.createTable('user', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.integer('is_disabled').notNull().unsigned().defaultTo(0); + table.string('email').notNull(); + table.string('name').notNull(); + table.string('nickname').notNull(); + table.string('avatar').notNull(); + table.json('roles').notNull(); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user Table created'); + }); + +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex, Promise) { + logger.warn('[' + migrate_name + '] You can\'t migrate down the initial data.'); + return Promise.resolve(true); +}; diff --git a/src/backend/models/auth.js b/src/backend/models/auth.js new file mode 100644 index 0000000..b4e7e25 --- /dev/null +++ b/src/backend/models/auth.js @@ -0,0 +1,82 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const bcrypt = require('bcrypt-then'); +const db = require('../db'); +const Model = require('objection').Model; +const User = require('./user'); + +Model.knex(db); + +function encryptPassword () { + /* jshint -W040 */ + let _this = this; + + if (_this.type === 'password' && _this.secret) { + return bcrypt.hash(_this.secret, 13) + .then(function (hash) { + _this.secret = hash; + }); + } + + return null; +} + +class Auth extends Model { + $beforeInsert (queryContext) { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + + return encryptPassword.apply(this, queryContext); + } + + $beforeUpdate (queryContext) { + this.modified_on = Model.raw('NOW()'); + return encryptPassword.apply(this, queryContext); + } + + /** + * Verify a plain password against the encrypted password + * + * @param {String} password + * @returns {Promise} + */ + verifyPassword (password) { + return bcrypt.compare(password, this.secret); + } + + static get name () { + return 'Auth'; + } + + static get tableName () { + return 'auth'; + } + + static get jsonAttributes () { + return ['meta']; + } + + static get relationMappings () { + return { + user: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'auth.user_id', + to: 'user.id' + }, + filter: { + is_deleted: 0 + }, + modify: function (qb) { + qb.omit(['is_deleted']); + } + } + }; + } +} + +module.exports = Auth; diff --git a/src/backend/models/token.js b/src/backend/models/token.js new file mode 100644 index 0000000..2440962 --- /dev/null +++ b/src/backend/models/token.js @@ -0,0 +1,133 @@ +/** + NOTE: This is not a database table, this is a model of a Token object that can be created/loaded + and then has abilities after that. + */ + +'use strict'; + +const _ = require('lodash'); +const config = require('config'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const error = require('../lib/error'); +const ALGO = 'RS256'; + +module.exports = function () { + const public_key = config.get('jwt.pub'); + const private_key = config.get('jwt.key'); + + let token_data = {}; + + return { + /** + * @param {Object} payload + * @param {Object} [user_options] + * @param {Integer} [user_options.expires] + * @returns {Promise} + */ + create: (payload, user_options) => { + + user_options = user_options || {}; + + // sign with RSA SHA256 + let options = { + algorithm: ALGO + }; + + if (typeof user_options.expires !== 'undefined' && user_options.expires) { + options.expiresIn = user_options.expires; + } + + payload.jti = crypto.randomBytes(12) + .toString('base64') + .substr(-8); + + return new Promise((resolve, reject) => { + jwt.sign(payload, private_key, options, (err, token) => { + if (err) { + reject(err); + } else { + token_data = payload; + resolve({ + token: token, + payload: payload + }); + } + + }); + }); + + }, + + /** + * @param {String} token + * @returns {Promise} + */ + load: function (token) { + return new Promise((resolve, reject) => { + try { + if (!token || token === null || token === 'null') { + reject('Empty token'); + } else { + jwt.verify(token, public_key, {ignoreExpiration: false, algorithms: [ALGO]}, (err, result) => { + if (err) { + + if (err.name === 'TokenExpiredError') { + reject(new error.AuthError('Token has expired', err)); + } else { + reject(err); + } + + } else { + token_data = result; + + // Hack: some tokens out in the wild have a scope of 'all' instead of 'user'. + // For 30 days at least, we need to replace 'all' with user. + if ((typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'all') !== -1)) { + //console.log('Warning! Replacing "all" scope with "user"'); + + token_data.scope = ['user']; + } + + resolve(token_data); + } + }); + } + } catch (err) { + reject(err); + } + }); + + }, + + /** + * Does the token have the specified scope? + * + * @param {String} scope + * @returns {Boolean} + */ + hasScope: function (scope) { + return typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, scope) !== -1; + }, + + /** + * @param {String} key + * @return {*} + */ + get: function (key) { + if (typeof token_data[key] !== 'undefined') { + return token_data[key]; + } + + return null; + }, + + /** + * @param {String} key + * @param {*} value + */ + set: function (key, value) { + token_data[key] = value; + } + }; +}; diff --git a/src/backend/models/user.js b/src/backend/models/user.js new file mode 100644 index 0000000..b9a9a20 --- /dev/null +++ b/src/backend/models/user.js @@ -0,0 +1,35 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; + +Model.knex(db); + +class User extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'User'; + } + + static get tableName () { + return 'user'; + } + + static get jsonAttributes () { + return ['roles']; + } + +} + +module.exports = User; diff --git a/src/backend/routes/api/main.js b/src/backend/routes/api/main.js new file mode 100644 index 0000000..eff3fe9 --- /dev/null +++ b/src/backend/routes/api/main.js @@ -0,0 +1,32 @@ +'use strict'; + +const express = require('express'); +const pjson = require('../../../../package.json'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * Health Check + * GET /api + */ +router.get('/', (req, res/*, next*/) => { + let version = pjson.version.split('-').shift().split('.'); + + res.status(200).send({ + status: 'OK', + version: { + major: parseInt(version.shift(), 10), + minor: parseInt(version.shift(), 10), + revision: parseInt(version.shift(), 10) + } + }); +}); + +router.use('/tokens', require('./tokens')); +router.use('/users', require('./users')); + +module.exports = router; diff --git a/src/backend/routes/api/tokens.js b/src/backend/routes/api/tokens.js new file mode 100644 index 0000000..5420790 --- /dev/null +++ b/src/backend/routes/api/tokens.js @@ -0,0 +1,56 @@ +'use strict'; + +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalToken = require('../../internal/token'); +const apiValidator = require('../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + + /** + * GET /tokens + * + * Get a new Token, given they already have a token they want to refresh + * We also piggy back on to this method, allowing admins to get tokens + * for services like Job board and Worker. + */ + .get(jwtdecode(), (req, res, next) => { + internalToken.getFreshToken(res.locals.access, { + expiry: (typeof req.query.expiry !== 'undefined' ? req.query.expiry : null), + scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null) + }) + .then(data => { + res.status(200) + .send(data); + }) + .catch(next); + }) + + /** + * POST /tokens + * + * Create a new Token + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/tokens#/links/0/schema'}, req.body) + .then(payload => { + return internalToken.getTokenFromEmail(payload); + }) + .then(data => { + res.status(200) + .send(data); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/api/users.js b/src/backend/routes/api/users.js new file mode 100644 index 0000000..5e630da --- /dev/null +++ b/src/backend/routes/api/users.js @@ -0,0 +1,256 @@ +'use strict'; + +const express = require('express'); +const validator = require('../../lib/validator'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const pagination = require('../../lib/express/pagination'); +const userIdFromMe = require('../../lib/express/user-id-from-me'); +const internalUser = require('../../internal/user'); +const apiValidator = require('../../lib/validator/api'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * /api/users + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * GET /api/users + * + * Retrieve all users + */ + .get(pagination('name', 0, 50, 300), (req, res, next) => { + validator({ + additionalProperties: false, + required: ['sort'], + properties: { + sort: { + $ref: 'definitions#/definitions/sort' + }, + expand: { + $ref: 'definitions#/definitions/expand' + }, + query: { + $ref: 'definitions#/definitions/query' + } + } + }, { + sort: req.query.sort, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then((data) => { + return Promise.all([ + internalUser.getCount(res.locals.access, data.query), + internalUser.getAll(res.locals.access, req.query.offset, req.query.limit, data.sort, data.expand, data.query) + ]); + }) + .then((data) => { + res.setHeader('X-Dataset-Total', data.shift()); + res.setHeader('X-Dataset-Offset', req.query.offset); + res.setHeader('X-Dataset-Limit', req.query.limit); + return data.shift(); + }) + .then((users) => { + res.status(200) + .send(users); + }) + .catch(next); + }) + + /** + * POST /api/users + * + * Create a new User + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/1/schema'}, req.body) + .then((payload) => { + return internalUser.create(res.locals.access, payload); + }) + .then((result) => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user + * + * /api/users/123 + */ +router + .route('/:user_id') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * GET /users/123 or /users/me + * + * Retrieve a specific user + */ + .get((req, res, next) => { + validator({ + required: ['user_id'], + additionalProperties: false, + properties: { + user_id: { + $ref: 'definitions#/definitions/id' + }, + expand: { + $ref: 'definitions#/definitions/expand' + } + } + }, { + user_id: req.params.user_id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then((data) => { + return internalUser.get(res.locals.access, { + id: data.user_id, + expand: data.expand, + omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id) + }); + }) + .then((user) => { + res.status(200) + .send(user); + }) + .catch(next); + }) + + /** + * PUT /api/users/123 + * + * Update and existing user + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/2/schema'}, req.body) + .then((payload) => { + payload.id = req.params.user_id; + return internalUser.update(res.locals.access, payload); + }) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }) + + /** + * DELETE /api/users/123 + * + * Update and existing user + */ + .delete((req, res, next) => { + internalUser.delete(res.locals.access, {id: req.params.user_id}) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user auth + * + * /api/users/123/auth + */ +router + .route('/:user_id/auth') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * PUT /api/users/123/auth + * + * Update password for a user + */ + .put((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/4/schema'}, req.body) + .then(payload => { + payload.id = req.params.user_id; + return internalUser.setPassword(res.locals.access, payload); + }) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user service settings + * + * /api/users/123/services + */ +router + .route('/:user_id/services') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(userIdFromMe) + + /** + * POST /api/users/123/services + * + * Sets Service Settings for a user + */ + .post((req, res, next) => { + apiValidator({$ref: 'endpoints/users#/links/5/schema'}, req.body) + .then((payload) => { + payload.id = req.params.user_id; + return internalUser.setServiceSettings(res.locals.access, payload); + }) + .then((result) => { + res.status(200) + .send(result); + }) + .catch(next); + }); + +/** + * Specific user login as + * + * /api/users/123/login + */ +router + .route('/:user_id/login') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/users/123/login + * + * Log in as a user + */ + .post((req, res, next) => { + internalUser.loginAs(res.locals.access, {id: parseInt(req.params.user_id, 10)}) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + +module.exports = router; diff --git a/src/backend/routes/main.js b/src/backend/routes/main.js new file mode 100644 index 0000000..4d3232b --- /dev/null +++ b/src/backend/routes/main.js @@ -0,0 +1,44 @@ +'use strict'; + +const express = require('express'); +const fs = require('fs'); +const PACKAGE = require('../../../package.json'); + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * GET /login + */ +router.get('/login', function (req, res, next) { + res.render('login', { + version: PACKAGE.version + }); +}); + +/** + * GET .* + */ +router.get(/(.*)/, function (req, res, next) { + req.params.page = req.params['0']; + if (req.params.page === '/') { + res.render('index', { + version: PACKAGE.version + }); + } else { + fs.readFile('dist' + req.params.page, 'utf8', function (err, data) { + if (err) { + res.render('index', { + version: PACKAGE.version + }); + } else { + res.contentType('text/html').end(data); + } + }); + } +}); + +module.exports = router; diff --git a/src/backend/schema/definitions.json b/src/backend/schema/definitions.json new file mode 100644 index 0000000..43ff556 --- /dev/null +++ b/src/backend/schema/definitions.json @@ -0,0 +1,139 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "definitions", + "definitions": { + "id": { + "description": "Unique identifier", + "example": 123456, + "readOnly": true, + "type": "integer", + "minimum": 1 + }, + "token": { + "type": "string", + "minLength": 10 + }, + "expand": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "sort": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "field", + "dir" + ], + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "dir": { + "type": "string", + "pattern": "^(asc|desc)$" + } + } + } + }, + "query": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + ] + }, + "criteria": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object" + } + ] + }, + "fields": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "omit": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "created_on": { + "description": "Date and time of creation", + "format": "date-time", + "readOnly": true, + "type": "string" + }, + "modified_on": { + "description": "Date and time of last update", + "format": "date-time", + "readOnly": true, + "type": "string" + }, + "user_id": { + "description": "User ID", + "example": 1234, + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "description": "Email Address", + "example": "john@example.com", + "format": "email", + "type": "string", + "minLength": 8, + "maxLength": 100 + }, + "password": { + "description": "Password", + "type": "string", + "minLength": 8, + "maxLength": 255 + } + } +} diff --git a/src/backend/schema/endpoints/tokens.json b/src/backend/schema/endpoints/tokens.json new file mode 100644 index 0000000..920af63 --- /dev/null +++ b/src/backend/schema/endpoints/tokens.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/tokens", + "title": "Token", + "description": "Tokens are required to authenticate against the API", + "stability": "stable", + "type": "object", + "definitions": { + "identity": { + "description": "Email Address or other 3rd party providers identifier", + "example": "john@example.com", + "type": "string" + }, + "secret": { + "description": "A password or key", + "example": "correct horse battery staple", + "type": "string" + }, + "token": { + "description": "JWT", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", + "type": "string" + }, + "expires": { + "description": "Token expiry time", + "format": "date-time", + "type": "string" + }, + "scope": { + "description": "Scope of the Token, defaults to 'user'", + "example": "user", + "type": "string" + } + }, + "links": [ + { + "title": "Create", + "description": "Creates a new token.", + "href": "/tokens", + "access": "public", + "method": "POST", + "rel": "create", + "schema": { + "type": "object", + "required": [ + "identity", + "secret" + ], + "properties": { + "identity": { + "$ref": "#/definitions/identity" + }, + "secret": { + "$ref": "#/definitions/secret" + }, + "scope": { + "$ref": "#/definitions/scope" + } + } + }, + "targetSchema": { + "type": "object", + "properties": { + "token": { + "$ref": "#/definitions/token" + }, + "expires": { + "$ref": "#/definitions/expires" + } + } + } + }, + { + "title": "Refresh", + "description": "Returns a new token.", + "href": "/tokens", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": {}, + "targetSchema": { + "type": "object", + "properties": { + "token": { + "$ref": "#/definitions/token" + }, + "expires": { + "$ref": "#/definitions/expires" + }, + "scope": { + "$ref": "#/definitions/scope" + } + } + } + } + ] +} diff --git a/src/backend/schema/endpoints/users.json b/src/backend/schema/endpoints/users.json new file mode 100644 index 0000000..3d82e63 --- /dev/null +++ b/src/backend/schema/endpoints/users.json @@ -0,0 +1,240 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "endpoints/users", + "title": "Users", + "description": "Endpoints relating to Users", + "stability": "stable", + "type": "object", + "definitions": { + "id": { + "$ref": "../definitions.json#/definitions/id" + }, + "created_on": { + "$ref": "../definitions.json#/definitions/created_on" + }, + "modified_on": { + "$ref": "../definitions.json#/definitions/modified_on" + }, + "name": { + "description": "Name", + "example": "Jamie Curnow", + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "nickname": { + "description": "Nickname", + "example": "Jamie", + "type": "string", + "minLength": 2, + "maxLength": 50 + }, + "email": { + "$ref": "../definitions.json#/definitions/email" + }, + "avatar": { + "description": "Avatar", + "example": "http://somewhere.jpg", + "type": "string", + "minLength": 2, + "maxLength": 150, + "readOnly": true + }, + "roles": { + "description": "Roles", + "example": [ + "admin" + ], + "type": "array" + }, + "is_disabled": { + "description": "Is Disabled", + "example": false, + "type": "boolean" + } + }, + "links": [ + { + "title": "List", + "description": "Returns a list of Users", + "href": "/users", + "access": "private", + "method": "GET", + "rel": "self", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "array", + "items": { + "$ref": "#/properties" + } + } + }, + { + "title": "Create", + "description": "Creates a new User", + "href": "/users", + "access": "private", + "method": "POST", + "rel": "create", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "required": [ + "name", + "nickname", + "email" + ], + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + }, + "auth": { + "type": "object", + "description": "Auth Credentials", + "example": { + "type": "password", + "secret": "bigredhorsebanana" + } + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Update", + "description": "Updates a existing User", + "href": "/users/{definitions.identity.example}", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + } + } + }, + "targetSchema": { + "properties": { + "$ref": "#/properties" + } + } + }, + { + "title": "Delete", + "description": "Deletes a existing User", + "href": "/users/{definitions.identity.example}", + "access": "private", + "method": "DELETE", + "rel": "delete", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "targetSchema": { + "type": "boolean" + } + }, + { + "title": "Set Password", + "description": "Sets a password for an existing User", + "href": "/users/{definitions.identity.example}/auth", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "required": [ + "type", + "secret" + ], + "properties": { + "type": { + "type": "string", + "pattern": "^password$" + }, + "current": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "secret": { + "type": "string", + "minLength": 8, + "maxLength": 64 + } + } + }, + "targetSchema": { + "type": "boolean" + } + } + ], + "properties": { + "id": { + "$ref": "#/definitions/id" + }, + "created_on": { + "$ref": "#/definitions/created_on" + }, + "modified_on": { + "$ref": "#/definitions/modified_on" + }, + "name": { + "$ref": "#/definitions/name" + }, + "nickname": { + "$ref": "#/definitions/nickname" + }, + "email": { + "$ref": "#/definitions/email" + }, + "avatar": { + "$ref": "#/definitions/avatar" + }, + "roles": { + "$ref": "#/definitions/roles" + }, + "is_disabled": { + "$ref": "#/definitions/is_disabled" + } + } +} diff --git a/src/backend/schema/examples.json b/src/backend/schema/examples.json new file mode 100644 index 0000000..37bc6c4 --- /dev/null +++ b/src/backend/schema/examples.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "examples", + "type": "object", + "definitions": { + "name": { + "description": "Name", + "example": "John Smith", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "auth_header": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", + "X-API-Version": "next" + }, + "token": { + "type": "string", + "description": "JWT", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk" + } + } +} diff --git a/src/backend/schema/index.json b/src/backend/schema/index.json new file mode 100644 index 0000000..102d055 --- /dev/null +++ b/src/backend/schema/index.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "root", + "title": "Nginx Proxy Manager REST API", + "description": "This is the Nginx Proxy Manager REST API", + "version": "2.0.0", + "links": [ + { + "href": "http://npm.example.com/api", + "rel": "self" + } + ], + "properties": { + "tokens": { + "$ref": "endpoints/tokens.json" + }, + "users": { + "$ref": "endpoints/users.json" + } + } +} diff --git a/src/backend/setup.js b/src/backend/setup.js new file mode 100644 index 0000000..b6c8f97 --- /dev/null +++ b/src/backend/setup.js @@ -0,0 +1,87 @@ +'use strict'; + +const fs = require('fs'); +const NodeRSA = require('node-rsa'); +const config = require('config'); +const logger = require('./logger').global; +const userModel = require('./models/user'); +const authModel = require('./models/auth'); + +module.exports = function () { + return new Promise((resolve, reject) => { + // Now go and check if the jwt gpg keys have been created and if not, create them + if (!config.has('jwt') || !config.has('jwt.key') || !config.has('jwt.pub')) { + logger.info('Creating a new JWT key pair...'); + + // jwt keys are not configured properly + const filename = config.util.getEnv('NODE_CONFIG_DIR') + '/' + (config.util.getEnv('NODE_ENV') || 'default') + '.json'; + let config_data = {}; + + try { + config_data = require(filename); + } catch (err) { + // do nothing + } + + // Now create the keys and save them in the config. + let key = new NodeRSA({b: 2048}); + key.generateKeyPair(); + + config_data.jwt = { + key: key.exportKey('private').toString(), + pub: key.exportKey('public').toString() + }; + + // Write config + fs.writeFile(filename, JSON.stringify(config_data, null, 2), (err) => { + if (err) { + logger.error('Could not write JWT key pair to config file: ' + filename); + reject(err); + } else { + logger.info('Wrote JWT key pair to config file: ' + filename); + config.util.loadFileConfigs(); + resolve(); + } + }); + } else { + // JWT key pair exists + resolve(); + } + }) + .then(() => { + return userModel + .query() + .select(userModel.raw('COUNT(`id`) as `count`')) + .where('is_deleted', 0) + .first('count') + .then((row) => { + if (!row.count) { + // Create a new user and set password + logger.info('Creating a new user: admin@example.com with password: changeme'); + + let data = { + is_deleted: 0, + email: 'admin@example.com', + name: 'Administrator', + nickname: 'Admin', + avatar: '', + roles: ['admin'] + }; + + return userModel + .query() + .insertAndFetch(data) + .then(user => { + return authModel + .query() + .insert({ + user_id: user.id, + type: 'password', + secret: 'changeme', + meta: {} + }); + }); + } + }); + }); +}; diff --git a/src/backend/views/index.ejs b/src/backend/views/index.ejs new file mode 100644 index 0000000..89bd894 --- /dev/null +++ b/src/backend/views/index.ejs @@ -0,0 +1,9 @@ +<% var title = 'Nginx Proxy Manager' %> +<%- include partials/header.ejs %> + +
+ +
+ + +<%- include partials/footer.ejs %> diff --git a/src/backend/views/login.ejs b/src/backend/views/login.ejs new file mode 100644 index 0000000..d3bec11 --- /dev/null +++ b/src/backend/views/login.ejs @@ -0,0 +1,9 @@ +<% var title = 'Login – Nginx Proxy Manager' %> +<%- include partials/header.ejs %> + +
+ +
+ + +<%- include partials/footer.ejs %> diff --git a/src/backend/views/partials/footer.ejs b/src/backend/views/partials/footer.ejs new file mode 100644 index 0000000..2ab5c0d --- /dev/null +++ b/src/backend/views/partials/footer.ejs @@ -0,0 +1,2 @@ + + diff --git a/src/backend/views/partials/header.ejs b/src/backend/views/partials/header.ejs new file mode 100644 index 0000000..cef92e1 --- /dev/null +++ b/src/backend/views/partials/header.ejs @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + <%- title %> + + + + + + + + + + + + + + +