2022-07-07 13:10:57 +02:00
|
|
|
import _ from "lodash";
|
2022-08-23 20:08:53 +02:00
|
|
|
import auth, { BasicAuthCheckerCallback } from "http-auth";
|
2022-07-07 13:10:57 +02:00
|
|
|
import authConnect from "http-auth-connect";
|
|
|
|
import bodyParser from "body-parser";
|
|
|
|
import bcrypt from "bcrypt";
|
|
|
|
import compress from "compression";
|
2022-08-23 20:08:53 +02:00
|
|
|
import express, { Express, NextFunction, Request, Response } from "express";
|
|
|
|
import { promises as fs } from "graceful-fs";
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-10-04 15:33:55 +02:00
|
|
|
import * as apiRouter from "./router";
|
2022-08-23 20:08:53 +02:00
|
|
|
import { config } from "./config";
|
|
|
|
import type { CleartextPassword, PasswordHash, Username } from "./types";
|
|
|
|
import { isString } from "./types";
|
2022-07-07 13:10:57 +02:00
|
|
|
import Logger from "./logger";
|
2022-09-20 19:09:49 +02:00
|
|
|
import { HttpHeader, HttpStatusCode, MimeType } from "./shared/utils/http";
|
2022-10-04 15:33:55 +02:00
|
|
|
import history from "connect-history-api-fallback";
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
export const app: Express = express();
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-07-07 13:10:57 +02:00
|
|
|
/**
|
|
|
|
* Used to have some password comparison in case the user does not exist to avoid timing attacks.
|
|
|
|
*/
|
2022-08-23 20:08:53 +02:00
|
|
|
const INVALID_PASSWORD_HASH: PasswordHash =
|
|
|
|
"$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi" as PasswordHash;
|
2022-07-07 13:10:57 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Trying to implement a timing safe string compare.
|
|
|
|
*
|
|
|
|
* TODO: Write tests for timing.
|
|
|
|
*/
|
2022-07-18 17:49:42 +02:00
|
|
|
function timingSafeEqual<T extends string>(a: T, b: T): boolean {
|
2022-07-07 13:10:57 +02:00
|
|
|
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.
|
2022-07-18 17:49:42 +02:00
|
|
|
b = (different === 0 ? b.slice() : a.slice()) as T;
|
2022-07-07 13:10:57 +02:00
|
|
|
|
|
|
|
for (let i = 0; i < lenA; i += 1) {
|
|
|
|
different += Math.abs(a.charCodeAt(i) - b.charCodeAt(i));
|
|
|
|
}
|
|
|
|
|
|
|
|
return different === 0;
|
|
|
|
}
|
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
async function isValidLogin(
|
|
|
|
username: Username,
|
|
|
|
password: CleartextPassword
|
|
|
|
): Promise<boolean> {
|
2022-07-07 13:10:57 +02:00
|
|
|
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) {
|
2022-07-18 17:49:42 +02:00
|
|
|
if (timingSafeEqual(username, userConfig.username)) {
|
2022-07-07 13:10:57 +02:00
|
|
|
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(
|
2022-07-18 17:49:42 +02:00
|
|
|
password,
|
|
|
|
passwordHash || INVALID_PASSWORD_HASH
|
2022-07-07 13:10:57 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
// Make sure password is only considered valid is user exists and therefor passwordHash is not undefined.
|
|
|
|
return isString(passwordHash) && isValidPassword;
|
|
|
|
}
|
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
export function init(): void {
|
|
|
|
const router = express.Router();
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
// urls beneath /internal are protected
|
|
|
|
const internalAuth = auth.basic(
|
|
|
|
{
|
2022-08-23 20:08:53 +02:00
|
|
|
realm: "Knotenformular - Intern",
|
2020-06-30 01:10:18 +02:00
|
|
|
},
|
2022-08-23 20:08:53 +02:00
|
|
|
function (
|
|
|
|
username: string,
|
|
|
|
password: string,
|
|
|
|
callback: BasicAuthCheckerCallback
|
|
|
|
): void {
|
2022-07-18 17:49:42 +02:00
|
|
|
isValidLogin(username as Username, password as CleartextPassword)
|
2022-08-23 20:08:53 +02:00
|
|
|
.then((result) => callback(result))
|
|
|
|
.catch((err) => {
|
|
|
|
Logger.tag("login").error(err);
|
2022-07-07 13:10:57 +02:00
|
|
|
});
|
2020-06-30 01:10:18 +02:00
|
|
|
}
|
|
|
|
);
|
2022-08-23 20:08:53 +02:00
|
|
|
router.use("/internal", authConnect(internalAuth));
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
router.use(bodyParser.json());
|
2022-08-23 20:08:53 +02:00
|
|
|
router.use(bodyParser.urlencoded({ extended: true }));
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
const clientDir = __dirname + "/../client";
|
2022-10-04 15:33:55 +02:00
|
|
|
|
|
|
|
// TODO: This is deprecated. Remove some time after re-launch. Used only for legacy clients that have not yet reloaded.
|
2022-08-23 20:08:53 +02:00
|
|
|
const templateDir = __dirname + "/templates";
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
const jsTemplateFiles = ["/config.js"];
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
function usePromise(
|
|
|
|
f: (req: Request, res: Response) => Promise<void>
|
|
|
|
): void {
|
2020-06-30 01:10:18 +02:00
|
|
|
router.use((req: Request, res: Response, next: NextFunction): void => {
|
2022-08-23 20:08:53 +02:00
|
|
|
f(req, res).then(next).catch(next);
|
2020-06-30 01:10:18 +02:00
|
|
|
});
|
|
|
|
}
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
router.use(compress());
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
async function serveTemplate(
|
2022-09-20 19:09:49 +02:00
|
|
|
mimeType: MimeType,
|
2022-08-23 20:08:53 +02:00
|
|
|
req: Request,
|
|
|
|
res: Response
|
|
|
|
): Promise<void> {
|
|
|
|
const body = await fs.readFile(
|
|
|
|
templateDir + "/" + req.path + ".template",
|
|
|
|
"utf8"
|
|
|
|
);
|
|
|
|
|
2022-09-20 19:09:49 +02:00
|
|
|
res.writeHead(HttpStatusCode.OK, {
|
|
|
|
[HttpHeader.CONTENT_TYPE]: mimeType,
|
|
|
|
});
|
2022-08-23 20:08:53 +02:00
|
|
|
res.end(_.template(body)({ config: config.client }));
|
2020-04-08 03:19:55 +02:00
|
|
|
}
|
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
usePromise(async (req: Request, res: Response): Promise<void> => {
|
|
|
|
if (jsTemplateFiles.indexOf(req.path) >= 0) {
|
2022-09-20 19:09:49 +02:00
|
|
|
await serveTemplate(MimeType.APPLICATION_JSON, req, res);
|
2020-06-30 01:10:18 +02:00
|
|
|
}
|
|
|
|
});
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2022-08-23 20:08:53 +02:00
|
|
|
router.use("/", express.static(clientDir + "/"));
|
2020-04-08 03:19:55 +02:00
|
|
|
|
2020-06-30 01:10:18 +02:00
|
|
|
app.use(config.server.rootPath, router);
|
2022-10-04 15:33:55 +02:00
|
|
|
|
|
|
|
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);
|
2020-06-30 01:10:18 +02:00
|
|
|
}
|