Source: controllers/AuthenticationController.mjs

import express from "express";
import session from "express-session";
import { USER_ROLE_MEMBER, UsersModel } from "../models/UsersModel.mjs";
import bcrypt from "bcrypt";
import { body } from "express-validator";
import { rejectInvalidForm } from "../utilities/formValidation.mjs";

/**
 * AuthenticationController handles user authentication, registration, and session management.
 */
export class AuthenticationController {
  /**
   * Session and authenticated-user middleware.
   * @type {express.Router}
   */
  static middleware = express.Router();
  /**
   * Login, registration and logout routes.
   * @type {express.Router}
   */
  static routes = express.Router();

  /**
   * Validation for the login form. Only the email is refilled after an error.
   * @type {express.RequestHandler[]}
   */
  static loginValidation = [
    body("username")
      .isString()
      .withMessage("Email must be text.")
      .bail()
      .trim()
      .isLength({ min: 1, max: 100 })
      .withMessage("Email must contain 1-100 characters.")
      .bail()
      .isEmail()
      .withMessage("Email must be valid."),
    body("password")
      .isString()
      .withMessage("Password is required.")
      .bail()
      .notEmpty()
      .withMessage("Password is required."),
    rejectInvalidForm("/authenticate", ["username"]),
  ];

  /**
   * Validation for the registration form. Limits match the users table columns;
   * the password is limited to 72 UTF-8 bytes because bcrypt ignores the rest.
   * @type {express.RequestHandler[]}
   */
  static registerValidation = [
    body("firstName")
      .isString()
      .withMessage("First name must be text.")
      .bail()
      .trim()
      .isLength({ min: 1, max: 45 })
      .withMessage("First name must contain 1-45 characters."),
    body("lastName")
      .isString()
      .withMessage("Last name must be text.")
      .bail()
      .trim()
      .isLength({ min: 1, max: 45 })
      .withMessage("Last name must contain 1-45 characters."),
    body("email")
      .isString()
      .withMessage("Email must be text.")
      .bail()
      .trim()
      .isLength({ min: 1, max: 100 })
      .withMessage("Email must contain 1-100 characters.")
      .bail()
      .isEmail()
      .withMessage("Email must be valid."),
    body("password")
      .isString()
      .withMessage("Password is required.")
      .bail()
      .custom(
        (value) => value.length >= 8 && Buffer.byteLength(value, "utf8") <= 72,
      )
      .withMessage(
        "Password must be at least 8 characters and at most 36 characters.",
      ),
    body("phone")
      .isString()
      .withMessage("Phone must be text.")
      .bail()
      .trim()
      .isLength({ min: 1, max: 20 })
      .withMessage("Phone must contain 1-20 characters.")
      .bail()
      .matches(/^\+?[\d ().-]+$/)
      .withMessage("Phone must contain digits and standard phone separators.")
      .bail()
      .custom((value) => /\d/.test(value))
      .withMessage("Phone must contain digits."),
    body("dob")
      .customSanitizer((value) => (value === "" ? null : value))
      .optional({ values: "null" })
      .isString()
      .withMessage("Date of birth must be a date.")
      .bail()
      .matches(/^\d{4}-\d{2}-\d{2}$/)
      .withMessage("Date of birth must use YYYY-MM-DD.")
      .bail()
      .isISO8601({ strict: true, strictSeparator: true })
      .withMessage("Date of birth must be a real calendar date."),
    rejectInvalidForm("/authenticate/register", [
      "firstName",
      "lastName",
      "email",
      "phone",
      "dob",
    ]),
  ];

  static {
    this.middleware.use(
      session({
        secret: "9c55abf5-111d-4235-b8d8-07c3463999e7",
        resave: false,
        saveUninitialized: false,
        cookie: { secure: "auto" },
      }),
    );
    this.middleware.use(this.#sessionAuthenticationProvider);

    this.routes.get("/", this.viewLogin);
    this.routes.post("/", this.loginValidation, this.handleLogin);
    this.routes.get("/register", this.viewRegister);
    this.routes.post("/register", this.registerValidation, this.handleRegister);

    this.routes.delete("/", this.handleLogout);
    this.routes.get("/logout", this.handleLogout);
  }

  /**
   * Automatically stores the respective EmployeeModel into req.authenticatedUsed
   * if there is an active session containing an userId
   * @type {express.RequestHandler}
   */
  static async #sessionAuthenticationProvider(req, res, next) {
    if (req.session.userId && !req.authenticatedUser) {
      try {
        req.authenticatedUser = await UsersModel.getById(req.session.userId);
      } catch (error) {
        console.error("Failed to authenticate user session - " + error);
      }
    }
    next();
  }

  /**
   * @type {express.RequestHandler}
   */
  static viewLogin(req, res) {
    res.render("login.ejs");
  }

  static viewRegister(req, res) {
    res.render("register.ejs");
  }

  /**
   * @type {express.RequestHandler}
   */
  static async handleLogin(req, res) {
    const username = req.body["username"];
    const password = req.body["password"];

    try {
      const user = await UsersModel.getByUsername(username);
      const isCorrectPassword = await bcrypt.compare(password, user.password);

      if (isCorrectPassword) {
        // Store the authenticated user's ID into the session
        req.session.userId = user.id;

        res.redirect("/");
      } else {
        res.status(400).render("status.ejs", {
          status: "Authentication Failed.",
          message: "Invalid credentials.",
        });
      }
    } catch (error) {
      if (error === "not found") {
        res.status(400).render("status.ejs", {
          status: "Authentication Failed.",
          message: "Invalid credentials.",
        });
      } else {
        console.error(error);
        res.status(500).render("status.ejs", {
          status: "Authentication Failed.",
          message: "Server error.",
        });
      }
    }
  }

  static async handleRegister(req, res) {
    const { firstName, lastName, email, password, phone, dob } = req.body;

    try {
      const passwordHash = await bcrypt.hash(password, 10);
      await UsersModel.create(
        new UsersModel(
          null,
          firstName,
          lastName,
          USER_ROLE_MEMBER,
          email,
          passwordHash,
          phone,
          dob || null,
          0,
          null,
        ),
      );
      res.redirect("/authenticate");
    } catch (error) {
      console.error(error);
      res.status(400).render("status.ejs", {
        status: "Registration Failed.",
        message: "The account could not be created.",
      });
    }
  }

  /**
   * @type {express.RequestHandler}
   */
  static handleLogout(req, res) {
    if (req.authenticatedUser) {
      if (req.session.userId) {
        req.session.destroy();
        res.status(200).render("status.ejs", {
          status: "Logged out successfully.",
          message: "You have been logged out.",
        });
      }
    } else {
      res.status(401).render("status.ejs", {
        status: "Unauthenticated.",
        message: "Please login to access the requested resource.",
      });
    }
  }

  /**
   * Restrict access to users with one of the specified roles.
   * @param {Array<"admin" | "trainer" | "member">} allowedRoles Roles allowed to access the resource.
   * @returns {express.RequestHandler}
   */
  static restrict(allowedRoles) {
    return function (req, res, next) {
      if (req.authenticatedUser) {
        if (allowedRoles.includes(req.authenticatedUser.role)) {
          next();
        } else {
          res.status(403).render("status.ejs", {
            status: "Access Forbidden.",
            message: "Role does not have access to the requested resource.",
          });
        }
      } else {
        res.status(401).render("status.ejs", {
          status: "Unauthenticated.",
          message: "Please login to access the requested resource.",
        });
      }
    };
  }
}