import _ from "lodash"; import auth, { BasicAuthCheckerCallback } from "http-auth"; import authConnect from "http-auth-connect"; import bodyParser from "body-parser"; import bcrypt from "bcrypt"; import compress from "compression"; import express, { Express, NextFunction, Request, Response } from "express"; import { promises as fs } from "graceful-fs"; import * as apiRouter from "./router"; import { config } from "./config"; import type { CleartextPassword, PasswordHash, Username } from "./types"; import { isString } from "./types"; import Logger from "./logger"; import { HttpHeader, HttpStatusCode, MimeType } from "./shared/utils/http"; import history from "connect-history-api-fallback"; export const app: Express = express(); /** * Used to have some password comparison in case the user does not exist to avoid timing attacks. */ const INVALID_PASSWORD_HASH: PasswordHash = "$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi" as PasswordHash; /** * Trying to implement a timing safe string compare. * * TODO: Write tests for timing. */ function timingSafeEqual(a: T, b: T): boolean { const lenA = a.length; const lenB = b.length; // Greater than 0 for differing strings. let different = Math.abs(lenA - lenB); // Make sure b is always the same length as a. Use slice to try avoiding optimizations. b = (different === 0 ? b.slice() : a.slice()) as T; for (let i = 0; i < lenA; i += 1) { different += Math.abs(a.charCodeAt(i) - b.charCodeAt(i)); } return different === 0; } async function isValidLogin( username: Username, password: CleartextPassword ): Promise { if (!config.server.internal.active) { return false; } let passwordHash: PasswordHash | undefined = undefined; // Iterate over all users every time to reduce risk of timing attacks. for (const userConfig of config.server.internal.users) { if (timingSafeEqual(username, userConfig.username)) { passwordHash = userConfig.passwordHash; } } // Always compare some password even if the user does not exist to reduce risk of timing attacks. const isValidPassword = await bcrypt.compare( password, passwordHash || INVALID_PASSWORD_HASH ); // Make sure password is only considered valid is user exists and therefor passwordHash is not undefined. return isString(passwordHash) && isValidPassword; } export function init(): void { const router = express.Router(); // urls beneath /internal are protected const internalAuth = auth.basic( { realm: "Knotenformular - Intern", }, function ( username: string, password: string, callback: BasicAuthCheckerCallback ): void { isValidLogin(username as Username, password as CleartextPassword) .then((result) => callback(result)) .catch((err) => { Logger.tag("login").error(err); }); } ); router.use("/internal", authConnect(internalAuth)); router.use(bodyParser.json()); router.use(bodyParser.urlencoded({ extended: true })); const clientDir = __dirname + "/../client"; // TODO: This is deprecated. Remove some time after re-launch. Used only for legacy clients that have not yet reloaded. const templateDir = __dirname + "/templates"; const jsTemplateFiles = ["/config.js"]; function usePromise( f: (req: Request, res: Response) => Promise ): void { router.use((req: Request, res: Response, next: NextFunction): void => { f(req, res).then(next).catch(next); }); } router.use(compress()); async function serveTemplate( mimeType: MimeType, req: Request, res: Response ): Promise { const body = await fs.readFile( templateDir + "/" + req.path + ".template", "utf8" ); res.writeHead(HttpStatusCode.OK, { [HttpHeader.CONTENT_TYPE]: mimeType, }); res.end(_.template(body)({ config: config.client })); } usePromise(async (req: Request, res: Response): Promise => { if (jsTemplateFiles.indexOf(req.path) >= 0) { await serveTemplate(MimeType.APPLICATION_JSON, req, res); } }); router.use("/", express.static(clientDir + "/")); app.use(config.server.rootPath, router); apiRouter.init(); // Handle URLs not found before to be compatible with history mode. const historyLogger = Logger.tag("history-api-fallback"); app.use( history({ index: "/index.html", logger: historyLogger.debug.bind(historyLogger), }) ); // Re-serve static content after rewrite by history mode fallback. app.use(config.server.rootPath, router); }