102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { Router, Request } from "express"
|
|
import { promises as fs } from "fs"
|
|
import { RateLimiter as Limiter } from "limiter"
|
|
import * as path from "path"
|
|
import safeCompare from "safe-compare"
|
|
import { rootPath } from "../constants"
|
|
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
|
|
import { hash, humanPath } from "../util"
|
|
|
|
export enum Cookie {
|
|
Key = "key",
|
|
}
|
|
|
|
// RateLimiter wraps around the limiter library for logins.
|
|
// It allows 2 logins every minute and 12 logins every hour.
|
|
class RateLimiter {
|
|
private readonly minuteLimiter = new Limiter(2, "minute")
|
|
private readonly hourLimiter = new Limiter(12, "hour")
|
|
|
|
public try(): boolean {
|
|
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
|
return true
|
|
}
|
|
return this.hourLimiter.tryRemoveTokens(1)
|
|
}
|
|
}
|
|
|
|
const getRoot = async (req: Request, error?: Error): Promise<string> => {
|
|
const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8")
|
|
let passwordMsg = `Check the config file at ${humanPath(req.args.config)} for the password.`
|
|
if (req.args.usingEnvPassword) {
|
|
passwordMsg = "Password was set from $PASSWORD."
|
|
} else if (req.args.usingEnvHashedPassword) {
|
|
passwordMsg = "Password was set from $HASHED_PASSWORD."
|
|
}
|
|
return replaceTemplates(
|
|
req,
|
|
content
|
|
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
|
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : ""),
|
|
)
|
|
}
|
|
|
|
const limiter = new RateLimiter()
|
|
|
|
export const router = Router()
|
|
|
|
router.use((req, res, next) => {
|
|
const to = (typeof req.query.to === "string" && req.query.to) || "/"
|
|
if (authenticated(req)) {
|
|
return redirect(req, res, to, { to: undefined })
|
|
}
|
|
next()
|
|
})
|
|
|
|
router.get("/", async (req, res) => {
|
|
res.send(await getRoot(req))
|
|
})
|
|
|
|
router.post("/", async (req, res) => {
|
|
try {
|
|
if (!limiter.try()) {
|
|
throw new Error("Login rate limited!")
|
|
}
|
|
|
|
if (!req.body.password) {
|
|
throw new Error("Missing password")
|
|
}
|
|
|
|
if (
|
|
req.args["hashed-password"]
|
|
? safeCompare(hash(req.body.password), req.args["hashed-password"])
|
|
: req.args.password && safeCompare(req.body.password, req.args.password)
|
|
) {
|
|
// The hash does not add any actual security but we do it for
|
|
// obfuscation purposes (and as a side effect it handles escaping).
|
|
res.cookie(Cookie.Key, hash(req.body.password), {
|
|
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
|
|
path: req.body.base || "/",
|
|
sameSite: "lax",
|
|
})
|
|
|
|
const to = (typeof req.query.to === "string" && req.query.to) || "/"
|
|
return redirect(req, res, to, { to: undefined })
|
|
}
|
|
|
|
console.error(
|
|
"Failed login attempt",
|
|
JSON.stringify({
|
|
xForwardedFor: req.headers["x-forwarded-for"],
|
|
remoteAddress: req.connection.remoteAddress,
|
|
userAgent: req.headers["user-agent"],
|
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
}),
|
|
)
|
|
|
|
throw new Error("Incorrect password")
|
|
} catch (error) {
|
|
res.send(await getRoot(req, error))
|
|
}
|
|
})
|