Bernd-L/exDateMan

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

Summary

Maintainability
D
2 days
Test Coverage
File `authentication.ts` has 271 lines of code (exceeds 250 allowed). Consider refactoring.
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
*/
Similar blocks of code found in 2 locations. Consider refactoring.
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
*/
Similar blocks of code found in 2 locations. Consider refactoring.
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
*/
Function `handleLogin` has 31 lines of code (exceeds 25 allowed). Consider refactoring.
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
*/
Function `handleRegister` has 29 lines of code (exceeds 25 allowed). Consider refactoring.
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
*/
Function `updateUsersProjection` has 26 lines of code (exceeds 25 allowed). Consider refactoring.
Function `updateUsersProjection` has a Cognitive Complexity of 9 (exceeds 5 allowed). Consider refactoring.
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;
}