summaryrefslogtreecommitdiff
path: root/src/app/services/authentication
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/services/authentication')
-rw-r--r--src/app/services/authentication/helpers.js83
-rw-r--r--src/app/services/authentication/index.js140
2 files changed, 223 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;
+}