Bernd-L/exDateMan

View on GitHub
backend/src/app/authentication.ts

Summary

Maintainability
D
2 days
Test Coverage
import { Router, Request, Response } from "express";
import { readFileSync } from "fs";
import * as jwt from "jsonwebtoken";
import { ServerEvents, UserEvent } from "./server-events";
import { compareSync, hashSync } from "bcrypt";
import { v4 } from "uuid";
import { log, error } from "console";
import { totp } from "speakeasy";
import { inspect } from "util";

export class Authentication {
  /**
   * The current state of users (projection from authenticationEventLog)
   */
  private static usersProjection: User[] = [];

  /**
   * The current state of users (projection from authenticationEventLog)
   */
  public get users(): User[] {
    return Authentication.usersProjection;
  }

  /**
   * The authentication routes
   */
  public routes: Router;

  /**
   * The private key either as a string or a buffer
   */
  private JWT_PRIVATE_KEY: string | Buffer;

  /**
   * The public key either as a string or a buffer
   */
  private JWT_PUBLIC_KEY: string | Buffer;

  constructor() {
    // Instantiate the router
    this.routes = Router();

    // Mount the routes
    this.routes.post("/login", (req: Request, res: Response) =>
      this.handleLogin(req, res),
    );

    this.routes.post("/register", (req: Request, res: Response) =>
      this.handleRegister(req, res),
    );

    this.routes.post("/logout", (req: Request, res: Response) =>
      Authentication.handleLogout(req, res),
    );

    this.routes.get("/status", (req: Request, res: Response) =>
      this.handleGetStatus(req, res),
    );

    this.routes.get("/resolve/:email", (req: Request, res: Response) =>
      this.handleResolveEmail(req, res),
    );

    this.routes.get("/user/:uuid", (req: Request, res: Response) =>
      this.handleGetUser(req, res),
    );

    // Setup JWT keys
    this.getJwtKeys();
  }

  /**
   * Handles API requests to resolve email addresses to users
   */
  private handleResolveEmail(req: Request, res: Response): void {
    /**
     * This constant holds the copy of the found value
     */
    const result: User | undefined =
      // Copy the object as to not mutate the stored state
      Object.assign(
        // An empty object is the target
        {},

        // The user with a matching email adress (if any) is the source
        this.users.find((user: User) => req.params.email === user.email),
      );

    // Send an error as response and return
    if (JSON.stringify(result) === "{}") {
      res.sendStatus(404);
      return;
    }

    // Remove confidential information from the copy
    delete result.saltedPwdHash;
    delete result.totpSecret;

    // Send back the modified copy
    res.json(result);
  }

  /**
   * Handles API requests to get users by UUID
   */
  private handleGetUser(req: Request, res: Response): void {
    /**
     * This constant holds the copy of the found value
     */
    const result: User | undefined =
      // Copy the object as to not mutate the stored state
      Object.assign(
        // An empty object is the target
        {},

        // The user with a matching email adress (if any) is the source
        this.users.find((user: User) => req.params.uuid === user.uuid),
      );

    // Send an error as response and return
    if (JSON.stringify(result) === "{}") {
      res.sendStatus(404);
      return;
    }

    // Remove confidential information from the copy
    delete result.saltedPwdHash;
    delete result.totpSecret;

    // Send back the modified copy
    res.json(result);
  }

  /**
   * Obtains the private & public key for JWT signing & verifying
   *
   * Throws an error when they are not defined
   */
  private getJwtKeys(): void {
    try {
      this.JWT_PRIVATE_KEY =
        process.env.EDM_JWT_PRIVATE_KEY_VAL ||
        readFileSync(process.env.EDM_JWT_PRIVATE_KEY);
      this.JWT_PUBLIC_KEY =
        process.env.EDM_JWT_PUBLIC_KEY_VAL ||
        readFileSync(process.env.EDM_JWT_PUBLIC_KEY);
    } catch (err) {
      throw new Error(
        "Couldn't obtain JWT keys from environment.\n" +
          "Two RS256 keys (public and private) are required.\n" +
          "Please set the environment variables correctly.",
      );
    }
  }

  /**
   * Log a user in
   *
   * This method handles the login process and
   * provides JWTs for the authenticated users
   */
  private async handleLogin(req: Request, res: Response) {
    try {
      /**
       * The saltedPwdHash from the users projection
       */
      let user = this.users.find((user: User) => {
        // Find the user in the projection with a matching email
        return user.email === req.body.email;
      });

      // Credential validation, return 401 on invalid credentials
      if (!compareSync(req.body.pwd, user.saltedPwdHash)) {
        res.status(401).json({
          error: "Invalid credentials",
        });
        return;
      }

      // Check for 2FA
      if (user.totpSecret != null) {
        if (
          !totp.verify({
            token: req.body.totpToken,
            encoding: "base32",
            secret: user.totpSecret,
          })
        ) {
          // On failed TOTP authentication
          res.status(401).json({
            error: "Invalid TOTP token",
          });
          return;
        }
      }

      // Issue a JWT
      this.issueJWT(user.uuid, res);
    } catch (err) {
      // error("Couldn't log in:");
      // error(err);

      res.status(400).json({
        error: "Couldn't find email address",
      });
      return;
    }
  }

  /**
   * Handles the API call to create a new user
   */
  private async handleRegister(req: Request, res: Response) {
    // Check for duplicate email
    if (
      Authentication.usersProjection.find((user: User) => {
        return user.email === req.body.email;
      })
    ) {
      // Return an error when the email was found
      res.status(400).json({ error: "Email already in use" });
      return;
    }

    try {
      const userUuid = v4();
      // Append the event
      await ServerEvents.appendAuthenticationEvent({
        date: new Date(),
        data: {
          createdOn: new Date(),
          crudType: crudType.CREATE,
          email: req.body.email,
          saltedPwdHash: Authentication.makePwdHash(req.body.pwd),
          totpSecret: null,
          userUuid,
          name: req.body.name,
        },
      });

      // Issue a JWT for the new user
      this.issueJWT(userUuid, res);
    } catch (err) {
      error("Couldn't append user creation event:");
      error(err);
      res.status(400).json({ oof: true });
      return;
    }
  }

  /**
   * Handles API calls for user modification
   */
  private async handleUpdateUser(req: Request, res: Response) {
    try {
      const event: UserEvent = req.body;

      // Check for authorization
      if (this.verifyJWT(req.cookies.JWT).sub !== event.data.userUuid) {
        // Unauthorized
        res.sendStatus(401);

        // Stop execution
        return;
      }

      await ServerEvents.appendAuthenticationEvent(event);

      res.json({ success: true });
    } catch (err) {
      res.status(400).json({ oof: true });
      return;
    }
  }

  /**
   * Issues (and sends) a JWT for a user.
   *
   * @param user The user uuid for which to generate a token for
   * @param res The Express response object
   */
  private issueJWT(userUuid: string, res: Response) {
    // Generate a JWT
    let jwtBearerToken: string;
    try {
      jwtBearerToken = jwt.sign({}, this.JWT_PRIVATE_KEY, {
        algorithm: "RS256",
        // expiresIn: "10h",
        subject: userUuid + "",
      });
    } catch (error) {
      error("Couldn't sign JWT; " + error);
    }

    // Send the token back to the user
    res
      .cookie("JWT", jwtBearerToken, {
        httpOnly: true,
        secure: process.env.EDM_JWT_SECURE === "true",
      })
      .send();
  }

  /**
   * Slats and hashes a password
   *
   * @param pwd The password to be hashed
   */
  private static makePwdHash(pwd: string): string {
    /**
     * The number of rounds the passwords hash will be salted for
     */
    const saltRounds = 10;

    // Calculate and set the hash
    return hashSync(pwd, saltRounds);
  }

  /**
   * Removes the JWT cookie form the user
   */
  static handleLogout(req: Request, res: Response): void {
    res
      .clearCookie("JWT")
      .status(200)
      .json({ message: "Logout successful" });
    return;
  }

  /**
   * Handles login-status-check requests
   */
  private async handleGetStatus(req: Request, res: Response) {
    // Check for missing cookie
    if (req.cookies.JWT == undefined) {
      res.status(401).json({ authorized: false, reason: "Missing JWT cookie" });
      return;
    }

    /**
     * The parsed (and verified) JWT
     */
    let verified: parsedJWT;
    try {
      verified = this.verifyJWT(req.cookies.JWT);
    } catch (err) {
      res.status(400).json({ authorized: false, reason: "JWT invalid" });
      return;
    }

    /**
     * The user from the projection
     */
    const user = Object.assign(
      {},
      Authentication.usersProjection.find((user: User) => {
        // Find the user in the projection with a matching email
        return user.uuid === verified.sub;
      }),
    );

    // Check for not logged in requests
    if (user == undefined) {
      res.status(401).json({ authorized: false, reason: "No such user" });
      return;
    }

    // Remove sensitive data
    delete user.saltedPwdHash;
    delete user.totpSecret;

    // Send logged in user data
    res.json({ authorized: true, user });
  }

  /**
   * Verifies a JWT against the public key and returns its contents
   *
   * @param jwtString The JWT to be verified
   */
  verifyJWT(jwtString: string): parsedJWT {
    if (jwtString == null) throw new Error("Missing JWT string");
    return jwt.verify(jwtString, this.JWT_PUBLIC_KEY) as parsedJWT;
  }

  /**
   * Updates the projection, one event at a time
   *
   * @param event The event to be used to update the projection with
   */
  public static updateUsersProjection(event: UserEvent) {
    const index = Authentication.usersProjection.findIndex(
      (user: User) => user.uuid === event.data.userUuid,
    );

    switch (event.data.crudType) {
      case crudType.CREATE:
        Authentication.usersProjection.push({
          uuid: event.data.userUuid,
          email: event.data.email,
          saltedPwdHash: event.data.saltedPwdHash,
          totpSecret: event.data.totpSecret,
          name: event.data.name,
        });
        break;

      case crudType.UPDATE:
        /**
         * The user to be updated
         */
        const user = Authentication.usersProjection[index];

        // Assign the changed values
        if (event.data.email != null) user.email = event.data.email;
        if (event.data.name != null) user.name = event.data.name;
        if (event.data.saltedPwdHash != null)
          user.saltedPwdHash = event.data.saltedPwdHash;
        if (event.data.totpSecret != null)
          user.totpSecret = event.data.totpSecret;
        break;

      case crudType.DELETE:
        Authentication.usersProjection.splice(index, 1);
        break;
    }
  }

  //
  //
  // * Old code below; try to reuse
  //
  //

  // /**
  //  * Generates and adds 2FA TOTP data to a user
  //  *
  //  * @param user The actingUser
  //  */
  // static async addNewSecretToUser(user: User): Promise<any> {
  //   const secret: GeneratedSecret = generateSecret({
  //     name: "ExDateMan (" + user.email + ")",
  //     length: 32,
  //   });

  //   user.tfaSecret = secret.base32;
  //   user.tfaUrl = secret.otpauth_url;

  //   // Save the generated result to the db
  //   await UserController.saveUser(user);
  // }

  /**
   * Responds with all non-sensitive user data.
   * If 2FA is disabled and no secret exists, one will be created.
   */
  // static async getAuthDetails(req: Request, res: Response): Promise<void> {
  // const actingUser: User = res.locals.actingUser;
  // // Check if the user has 2FA enabled
  // if (actingUser.tfaEnabled) {
  //   // If the user has 2FA enabled, hide the 2FA data
  //   delete actingUser.tfaSecret;
  //   delete actingUser.tfaUrl;
  // } else {
  //   // If the user lacks 2FA data, generate it
  //   if (actingUser.tfaSecret == null || actingUser.tfaUrl == null) {
  //     await AccountController.addNewSecretToUser(actingUser);
  //   }
  // }
  // // Hide the users inventories
  // delete actingUser.inventoryUsers;
  // // Hide salted & hashed password
  // delete actingUser.saltedPwdHash;
  // res.status(200).json({
  //   status: "Authenticated",
  //   user: actingUser,
  // });
  // }

  // /**
  //  * Checks if a user is allowed to do something in a given inventory
  //  *
  //  * @returns true if the acting user may access a inventory
  //  */
  // public static async isAuthorized(
  //   user: User,
  //   inventory: Inventory,
  //   desiredAccess: InventoryUserAccessRightsEnum,
  // ): Promise<boolean> {
  //   /**
  //    * The inventoryUser to check permissions for
  //    */
  //   let inventoryUser: InventoryUser;
  //   try {
  //     // Get the TypeORM entity manager
  //     const entityManager: EntityManager = getManager();
  //
  //     // Try to get an inventoryUser matching both the user and the inventory specified
  //     inventoryUser = await entityManager.findOneOrFail(InventoryUser, {
  //       where: {
  //         user: user,
  //         inventory: inventory,
  //       },
  //     });
  //   } catch (error) {
  //     return false;
  //   }
  //
  //   // Needs to have the same access level (0) or higher (1)
  //   return (
  //     -1 <
  //     compareInventoryUserAccessRights(
  //       inventoryUser.InventoryUserAccessRights,
  //       desiredAccess,
  //     )
  //   );
  // }
  //
  //   // Check for 2FA
  //   if (actingUser.tfaEnabled) {
  //     if (
  //       !totp.verify({
  //         token: tfaToken,
  //         encoding: "base32",
  //         secret: actingUser.tfaSecret,
  //       })
  //     ) {
  //       // On failed TOTP authentication
  //       res.status(401).json({
  //         error: "Invalid TOTP token",
  //       });
  //       return;
  //     }
  //     // On successful TOTP authentication
  //   }
  //
  //   // Generate a JWT
  //   let jwtBearerToken: string;
  //   try {
  //     jwtBearerToken = jwt.sign({}, PRIVATE_KEY, {
  //       algorithm: "RS256",
  //       expiresIn: "10h",
  //       subject: actingUser.id + "",
  //     });
  //   } catch (error) {
  //     console.error(error);
  //   }
  //
  //   // Send the token back to the user
  //   res
  //     .status(200)
  //     .cookie("JWT", jwtBearerToken, {
  //       httpOnly: true,
  //       secure: process.env.EDM_MODE !== "development",
  //     })
  //     .json({
  //       status: 200,
  //       user: actingUser.name,
  //     });
  // }

  // /**
  //  * Authenticates a user's JWT and extracts the userId into res.locals.actingUser
  //  */
  // public static async authenticate(
  //   req: Request,
  //   res: Response,
  //   next: NextFunction,
  // ): Promise<void> {
  //   // Decode and verify the JWT
  //   let decoded: any;
  //   try {
  //     decoded = jwt.verify(req.cookies["JWT"], this.PUBLIC_KEY);
  //   } catch (e) {
  //     // If the token is invalid
  //     res.status(401).json({
  //       status: 401,
  //       error: "Invalid credentials (need valid JWT as cookie)",
  //     });
  //     return;
  //   }
  //
  //   // Load the user form the db
  //   try {
  //     res.locals.actingUser = await UserController.getUserByIdOrFail(
  //       decoded.sub as number,
  //     );
  //   } catch (error) {
  //     // If the user couldn't be found
  //     res
  //       .status(401)
  //       .clearCookie("JWT")
  //       .json({
  //         status: 401,
  //         error: "Account doesn't exist; token invalid",
  //       });
  //     return;
  //   }
  //
  //   // If the token is valid and the user was loaded
  //   next();
  // }

  // /**
  //  * Checks for authorization in the current inventory for the acting user
  //  *
  //  * @static
  //  * @param {Response} res The Express response object
  //  * @param {InventoryUserAccessRightsEnum} accessRights
  //  * @returns {Promise<void>}
  //  * @memberof AuthController
  //  */
  // public static async authOrError(
  //   res: Response,
  //   accessRights: InventoryUserAccessRightsEnum,
  // ): Promise<void> {
  //   if (
  //     !(await AccountController.isAuthorized(
  //       res.locals.actingUser,
  //       res.locals.inventory as Inventory,
  //       accessRights,
  //     ))
  //   ) {
  //     res.status(403).json({
  //       status: 403,
  //       error:
  //         "Requestor doesn't have the " +
  //         accessRights +
  //         " role or higher for this inventory.",
  //     });
  //     return;
  //   }
  // }

  // /**
  //  * This method alters a users account
  //  * @param req
  //  * @param res
  //  * @param next
  //  */
  // public static async alterUser(
  //   req: Request,
  //   res: Response,
  //   next: NextFunction,
  // ): Promise<void> {
  // /**
  //  * The user to alter and subsequently save to the db
  //  */
  // const alteredUser: User = req.body;
  // // Check if the acting user is the one to be altered
  // if ((res.locals.actingUser as User).id !== alteredUser.id) {
  //   res.status(403).json({ error: "Cannot alter other user" });
  //   return;
  // }
  // // Return error on bad request
  // if (
  //   alteredUser.tfaEnabled == null ||
  //   alteredUser.name == null ||
  //   alteredUser.name === "" ||
  //   alteredUser.email == null ||
  //   alteredUser.email === ""
  // ) {
  //   res.status(400).json({ error: "Bad request" });
  // }
  // // Change the password if desired
  // if (alteredUser.pwd === "" || alteredUser.pwd == null) {
  //   // Do not change the password
  // } else {
  //   // Change password
  //   alteredUser.saltedPwdHash = AccountController.makePwdHash(
  //     alteredUser.pwd,
  //   );
  // }
  // // Check for existing 2FA
  // if ((res.locals.actingUser as User).tfaEnabled) {
  //   // Disable 2FA if desired
  //   if (!alteredUser.tfaEnabled) {
  //     // Regenerate the 2FA secret
  //     await AccountController.addNewSecretToUser(res.locals
  //       .actingUser as User);
  //   }
  // } else {
  //   // Check if enabling 2FA is desired
  //   if (alteredUser.tfaEnabled) {
  //     // Try enabling 2FA
  //     if (
  //       totp.verify({
  //         secret: (res.locals.actingUser as User).tfaSecret,
  //         encoding: "base32",
  //         token: alteredUser.tfaToken,
  //       })
  //     ) {
  //       // Enable 2FA
  //       alteredUser.tfaEnabled = true;
  //     } else {
  //       // Return error because 2FA was wrong
  //       res.status(400).json({ error: "Invalid 2FA token" });
  //       return;
  //     }
  //   }
  // }
  // // Clean the request
  // delete alteredUser.createdOn;
  // delete alteredUser.inventoryUsers;
  // delete alteredUser.pwd;
  // delete alteredUser.tfaToken;
  // delete alteredUser.tfaSecret;
  // delete alteredUser.tfaUrl;
  // try {
  //   await UserController.saveUser(alteredUser);
  // } catch (error) {
  //   // Report duplicate email
  //   res.status(400).json({ error: "Email already in use" });
  //   return;
  // }
  // // Return auth details on success
  // AccountController.getAuthDetails(req, res);
  // }
}

/**
 * The structure of the state of a user
 */
export interface User {
  /**
   * Identifies the user
   */
  uuid: string;

  /**
   * Used for login
   */
  email: string;

  /**
   * A salted hash of a users password
   */
  saltedPwdHash: string;

  /**
   * The TOTP secret used to generate TOTP codes (used for 2FA)
   */
  totpSecret: string | null;

  /**
   * A friendly name for the user
   */
  name: string;
}

/**
 * Used to describe on which type of item an operation was performed on
 */
enum itemType {
  INVENTORY = "inventory",
  CATEGORY = "category",
  THING = "thing",
  STOCK = "stock",
}

/**
 * Used to describe which type of operation was performed
 *
 * (read is excluded from this list since it doesn't affect the data)
 */
export enum crudType {
  CREATE = "create",
  UPDATE = "update",
  DELETE = "delete",
}

/**
 * The format of the data coming back from jwt.verify(jwt, pub_key)
 */
export interface parsedJWT {
  iat: number;
  exp: number;
  sub: string;
}