-
Notifications
You must be signed in to change notification settings - Fork 5.9k
/
Copy pathlogin.ts
122 lines (104 loc) · 4.55 KB
/
login.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import { Router, Request } from "express"
import { promises as fs } from "fs"
import { RateLimiter as Limiter } from "limiter"
import * as os from "os"
import * as path from "path"
import { CookieKeys } from "../../common/http"
import { rootPath } from "../constants"
import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http"
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
import i18n from "../i18n"
// RateLimiter wraps around the limiter library for logins.
// It allows 2 logins every minute plus 12 logins every hour.
export class RateLimiter {
private readonly minuteLimiter = new Limiter({ tokensPerInterval: 2, interval: "minute" })
private readonly hourLimiter = new Limiter({ tokensPerInterval: 12, interval: "hour" })
public canTry(): boolean {
// Note: we must check using >= 1 because technically when there are no tokens left
// you get back a number like 0.00013333333333333334
// which would cause fail if the logic were > 0
return this.minuteLimiter.getTokensRemaining() >= 1 || this.hourLimiter.getTokensRemaining() >= 1
}
public removeToken(): boolean {
return this.minuteLimiter.tryRemoveTokens(1) || 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")
const locale = req.args["locale"] || "en"
i18n.changeLanguage(locale)
const appName = req.args["app-name"] || "code-server"
const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: humanPath(os.homedir(), req.args.config) })
if (req.args.usingEnvPassword) {
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
} else if (req.args.usingEnvHashedPassword) {
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
}
return replaceTemplates(
req,
content
.replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
.replace(/{{I18N_LOGIN_BELOW}}/g, i18n.t("LOGIN_BELOW"))
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, i18n.t("PASSWORD_PLACEHOLDER"))
.replace(/{{I18N_SUBMIT}}/g, i18n.t("SUBMIT"))
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
)
}
const limiter = new RateLimiter()
export const router = Router()
router.use(async (req, res, next) => {
const to = (typeof req.query.to === "string" && req.query.to) || "/"
if (await authenticated(req)) {
return redirect(req, res, to, { to: undefined })
}
next()
})
router.get("/", async (req, res) => {
res.send(await getRoot(req))
})
router.post<{}, string, { password: string; base?: string }, { to?: string }>("/", async (req, res) => {
const password = sanitizeString(req.body.password)
const hashedPasswordFromArgs = req.args["hashed-password"]
try {
// Check to see if they exceeded their login attempts
if (!limiter.canTry()) {
throw new Error(i18n.t("LOGIN_RATE_LIMIT") as string)
}
if (!password) {
throw new Error(i18n.t("MISS_PASSWORD") as string)
}
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
const { isPasswordValid, hashedPassword } = await handlePasswordValidation({
passwordMethod,
hashedPasswordFromArgs,
passwordFromRequestBody: password,
passwordFromArgs: req.args.password,
})
if (isPasswordValid) {
// 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(CookieKeys.Session, hashedPassword, getCookieOptions(req))
const to = (typeof req.query.to === "string" && req.query.to) || "/"
return redirect(req, res, to, { to: undefined })
}
// Note: successful logins should not count against the RateLimiter
// which is why this logic must come after the successful login logic
limiter.removeToken()
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(i18n.t("INCORRECT_PASSWORD") as string)
} catch (error: any) {
const renderedHtml = await getRoot(req, error)
res.send(renderedHtml)
}
})