diff options
Diffstat (limited to 'src/app/services')
| -rw-r--r-- | src/app/services/authentication/helpers.js | 83 | ||||
| -rw-r--r-- | src/app/services/authentication/index.js | 140 | ||||
| -rw-r--r-- | src/app/services/index.js | 70 | ||||
| -rw-r--r-- | src/app/services/permission/helpers.js | 133 | ||||
| -rw-r--r-- | src/app/services/shoe/index.js | 34 |
5 files changed, 460 insertions, 0 deletions
diff --git a/src/app/services/authentication/helpers.js b/src/app/services/authentication/helpers.js new file mode 100644 index 0000000..49f1e2a --- /dev/null +++ b/src/app/services/authentication/helpers.js @@ -0,0 +1,83 @@ +/** + * Authentication helper functions. + * @module app/services/authentication/helpers + */ + +import jsonwebtoken from "jsonwebtoken"; +import expressJwt from "express-jwt"; +import { createHmac, createHash, randomBytes } from "crypto"; + +/** + * Generate a random secret + * @return {string} a random 64-byte secret token + */ +export const generateSecret = () => randomBytes(64).toString("hex"); + +/** + * Store a password as SHA256 using the secret token + * @param {string} plainPassword the plaintext password + * @return {string} the sha256 of the password + */ +export const encryptPassword = (plainPassword) => + createHmac("sha256", process.env.TOKEN_SECRET) + .update(plainPassword, "utf8") + .digest("hex"); + +/** + * Generate a JSON web token with the desired payload. + * @param {Object} payload the object to attach to the JWT + * @return {string} a JSON web token + */ +export const generateAccessToken = (payload) => + jsonwebtoken.sign(payload, process.env.TOKEN_SECRET); + +/** + * Middleware to encrypt any passwords before they hit the database. + */ +export const storeEncryptedPassword = (request, response, next) => { + if (request.body.password) { + request.body.password = encryptPassword(request.body.password); + } + next(); +}; + +/** + * Hash a password, used in testing and when seeding the databse. + * Typically the passwords are hashed on the client side before being transmitted, + * and then hashed again before being inserted into the database. + * @param {string} plaintext the plaintext password + * @return {string} the SHA256 of the password + */ +export function hashPassword(plaintext) { + return createHash("sha256").update(plaintext, "utf8").digest("base64"); +} + +/** + * Express JWT middleware. + * @param {object} options options to pass to express-jwt + * @return {Function} the express-jwt middleware + */ +export function checkAccessToken(options = {}) { + if (!process.env.TOKEN_SECRET) return null; + return expressJwt({ + secret: process.env.TOKEN_SECRET, + algorithms: ["HS256"], + credentialsRequired: true, + ...options, + }); +} + +/** + * Middleware to check if a user is active before performing an API call. + */ +export async function checkUserIsActive(request, response, next) { + const User = request.bookshelf.model("User"); + const user = await new User({ + user_id: request.user.user_id, + }).fetch(); + if (!user.get("is_active")) { + next(new Error("UserNotActive")); + } else { + next(); + } +} diff --git a/src/app/services/authentication/index.js b/src/app/services/authentication/index.js new file mode 100644 index 0000000..df8d7d0 --- /dev/null +++ b/src/app/services/authentication/index.js @@ -0,0 +1,140 @@ +/** + * Authentication API service. + * @module app/services/authentication/index + */ + +import { + encryptPassword, + generateAccessToken, + checkAccessToken, + checkUserIsActive, +} from "app/services/authentication/helpers"; +import { ROLES } from "app/constants"; +import { randomFruit } from "app/utils/random_utils"; + +import Service from "app/db/service/base"; + +/** + * Service API for authenticating users + */ +export default async function AuthenticationService(bookshelf) { + const service = await Service({ + bookshelf, + name: "authentication", + authenticate: false, + routes: [ + { + method: "post", + route: "/login", + handlers: [login, sendAccessToken], + summary: "Generate a JWT for a user", + requestBody: { + username: { + type: "string", + description: "The username", + }, + password: { + type: "string", + description: "The SHA256 of the user's password", + }, + }, + }, + { + route: "/check", + handlers: [checkAccessToken(), checkUserIsActive, sendIsActive], + summary: + "Check if the user is still active. Simply queries using the bearer token", + requestBody: { + type: "object", + properties: { + active: { + type: "boolean", + description: "Will be true if the user is active", + }, + }, + }, + }, + { + route: "/generate", + handlers: [checkAccessToken(), checkUserIsActive, generatePassword], + summary: + "Generate a temporary random password. Passwords are long and memorable, but are not guaranteed to be cryptographically secure.", + requestBody: { + type: "object", + properties: { + password: { + type: "string", + description: "A temporary password.", + }, + }, + }, + }, + ], + }); + + /** + * Login API handler + */ + async function login(request, response, next) { + const User = bookshelf.model("User"); + const { username, password: plainPassword } = request.body; + const password = encryptPassword(plainPassword); + let user, error; + try { + user = await new User({ + username, + password, + }).fetch(); + } catch (dbError) { + error = dbError; + } + if (user && user.get("is_active")) { + request.user = user; + } else { + error = new Error("UserNotActive"); + } + next(error); + } + + /** + * Generate a JWT if the login was successful, and set it on the response object. + */ + function sendAccessToken(request, response, next) { + const token = { + user_id: request.user.get("user_id"), + username: request.user.get("username"), + role: request.user.get("role"), + }; + if (token.role < ROLES.moderator) { + token.can_search = request.user.get("can_search"); + } + response.locals.token = generateAccessToken(token); + next(); + } + + /** + * Generate a random passphrase + */ + async function generatePassword(request, response, next) { + // this emits a weird error when using `import` syntax + response.locals.password = [ + randomFruit("en", { maxWords: 1 }), + randomFruit("en", { maxWords: 1 }), + randomFruit("en", { maxWords: 1 }), + randomFruit("en", { maxWords: 1 }), + ] + .join(" ") + .toLowerCase(); + next(); + } + + /** + * Indicate that a user is active. Should have already been interrupted if the user is inactive. + */ + function sendIsActive(request, response, next) { + response.locals.active = true; + next(); + } + + return service; +} diff --git a/src/app/services/index.js b/src/app/services/index.js new file mode 100644 index 0000000..ea0175d --- /dev/null +++ b/src/app/services/index.js @@ -0,0 +1,70 @@ +/** + * Configure services and mount them. + * @module app/services/index + */ + +import AuthenticationService from "app/services/authentication"; +import ShoeService from "app/services/shoe"; + +import configureDatabase from "app/db/configure"; + +/** + * A cache of registered services + * @type {Object} + */ +const services = {}; + +/** + * Function to get a service by resource name + * @param {String} resourceName name of the service to fetch + */ +function get(resourceName) { + return services[resourceName]; +} + +/** + * List services + * @return {object} the services object + */ +function list() { + return Object.entries(services); +} + +/** + * Configure the API endpoints + * @param {Express} app Express application + * @param {Knex} knex Knex instance (optional) + */ +async function configure(app, knex) { + const { bookshelf } = configureDatabase(knex); + + /** + * Connect a service + * @param {Service} RoutableService the service to connect + * @return {express.Router} the service's router + */ + async function connect(RoutableService) { + const service = await RoutableService(bookshelf); + services[service.resource] = service; + return service.router; + } + + app.use("/api/v1/auth", await connect(AuthenticationService)); + app.use("/api/v1/shoe", await connect(ShoeService)); + + app.get("/api/v1/", describeApplication); + // app.get("/", describeApplication); +} + +/** + * Canary middleware to indicate that the API is working + */ +function describeApplication(request, response) { + response.json({ application: "Shoebox" }); +} + +export default { + configure, + get, + list, +}; diff --git a/src/app/services/permission/helpers.js b/src/app/services/permission/helpers.js new file mode 100644 index 0000000..46a7871 --- /dev/null +++ b/src/app/services/permission/helpers.js @@ -0,0 +1,133 @@ +/** + * Permission helpers. + * @module app/services/permission/helpers + */ + +import { PERMISSIONS } from "app/constants"; + +/** + * When assigning a permission (any type), store the ID of the user granting the permission + */ +export function setGrantedByUser(request, response, next) { + request.body.granted_by_user_id = request.user.user_id; + next(); +} + +/** + * Return a middleware function that checks permissions for a specific API method + * @param {object} permissions Service API permission dictionary. See Service API documentation. + * @param {string} action which action to check, read/create/update/destroy + * @return {function} middleware function that checks permissions + */ +export function checkPermission(permissions, action) { + const routePermission = permissions + ? action + ? permissions[action] + : permissions + : null; + return async function checkPermissionsMiddleware(request, response, next) { + const { user } = request; + if (!routePermission) { + // API access must be explicitly enabled. + request.permission = PERMISSIONS.DENY; + next(new Error("PermissionsError")); + } else if (routePermission.roles?.includes(user.role)) { + request.permission = PERMISSIONS.ALLOW; + next(); + } else if (routePermission.owner) { + request.permission = PERMISSIONS.ALLOW_FOR_OWNER; + next(); + } else if (routePermission.check) { + request.permission = await routePermission.check({ + request, + user, + id: request.params?.id, + }); + if (request.permission === PERMISSIONS.DENY) { + next(new Error("PermissionsError")); + } else { + next(); + } + } else { + request.permission = PERMISSIONS.DENY; + next(new Error("PermissionsError")); + } + }; +} + +/** + * Return a middleware function that recursively checks permissions on the parent service of an API method. + * Also checks if the parent object exists to begin with. + * @param {Service} parentService the service to check. + * @return {function} the Express middleware + */ +export function checkParentPermission(parentService) { + return async function checkParentMiddleware(request, response, next) { + let permission; + request.parents = {}; + try { + permission = await checkServicePermission(request, parentService); + } catch (error) { + console.error(error); + permission = PERMISSIONS.DENY; + } + request.permission = permission; + if (permission === PERMISSIONS.DENY) { + next(new Error("PermissionsError")); + } else { + next(); + } + }; +} + +/** + * Helper function to check permissions on a service + * @param {Request} request the Express request we're checking + * @param {Service} service the Service we're checking + * @return {permission} the evaluated permissions + */ +async function checkServicePermission(request, service) { + const user = request.user; + const readPermissions = service.options.permissions.read; + if (!readPermissions) { + return PERMISSIONS.DENY; + } + + /** Check read permissions on any parent objects first. */ + if (service.parent) { + const parentPermission = await checkServicePermission( + request, + service.parent + ); + if (parentPermission === PERMISSIONS.DENY) { + return PERMISSIONS.DENY; + } + } + + /** Make sure this service's object exists before proceeding. */ + const idAttribute = service.Model.prototype.idAttribute; + const objectId = request.params[idAttribute]; + const object = await new service.Model({ [idAttribute]: objectId }).fetch(); + request.parents[service.resource] = object; + + if (!object) { + /** If there is no object, deny */ + return PERMISSIONS.DENY; + } else if (readPermissions.roles?.includes(user.role)) { + /** Pass role permission check */ + return PERMISSIONS.ALLOW; + } else if (readPermissions.owner && object.get("user_id") === user.user_id) { + /** Pass owner permission check */ + return PERMISSIONS.ALLOW; + } else if (readPermissions.check) { + /** Pass custom permission check */ + return await readPermissions.check({ + request, + user, + id: objectId, + }); + } else { + /** No checks passed - deny */ + return PERMISSIONS.DENY; + } +} diff --git a/src/app/services/shoe/index.js b/src/app/services/shoe/index.js new file mode 100644 index 0000000..fc8bbe4 --- /dev/null +++ b/src/app/services/shoe/index.js @@ -0,0 +1,34 @@ +/** + * Shoe API service. + * @module app/services/shoe/index + */ + +import Service from "app/db/service/base"; + +/** + * Service API for listing shoes + */ +export default async function ShoeService(bookshelf) { + const service = await Service({ + bookshelf, + Model: bookshelf.model("Shoe"), + authenticate: false, + paginate: { + pageSize: 25, + pageSizeLimit: 100, + }, + enabled: ["index", "create"], + hooks: { + before: { + index: [], + show: [], + }, + after: { + index: [], + show: [], + }, + }, + }); + + return service; +} |
