summaryrefslogtreecommitdiff
path: root/src/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/services')
-rw-r--r--src/app/services/authentication/helpers.js83
-rw-r--r--src/app/services/authentication/index.js140
-rw-r--r--src/app/services/index.js70
-rw-r--r--src/app/services/permission/helpers.js133
-rw-r--r--src/app/services/shoe/index.js34
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;
+}