From 6fb45ca7133cba24b034d15fb1368783a4d32e54 Mon Sep 17 00:00:00 2001 From: pilcrowOnPaper <80624252+pilcrowOnPaper@users.noreply.github.com> Date: Thu, 10 Aug 2023 22:29:00 +0900 Subject: [PATCH] Add login throttling examples and guide (#956) --- .../guidebook/login-throttling/index.md | 144 ++++++++++++++++++ .../login-throttling-device-cookie/README.md | 10 ++ .../package.json | 14 ++ .../src/index.html | 24 +++ .../src/index.ts | 112 ++++++++++++++ examples/other/login-throttling/README.md | 10 ++ examples/other/login-throttling/package.json | 13 ++ .../other/login-throttling/src/index.html | 20 +++ examples/other/login-throttling/src/index.ts | 55 +++++++ 9 files changed, 402 insertions(+) create mode 100644 documentation/content/guidebook/login-throttling/index.md create mode 100644 examples/other/login-throttling-device-cookie/README.md create mode 100644 examples/other/login-throttling-device-cookie/package.json create mode 100644 examples/other/login-throttling-device-cookie/src/index.html create mode 100644 examples/other/login-throttling-device-cookie/src/index.ts create mode 100644 examples/other/login-throttling/README.md create mode 100644 examples/other/login-throttling/package.json create mode 100644 examples/other/login-throttling/src/index.html create mode 100644 examples/other/login-throttling/src/index.ts diff --git a/documentation/content/guidebook/login-throttling/index.md b/documentation/content/guidebook/login-throttling/index.md new file mode 100644 index 000000000..60c92b01c --- /dev/null +++ b/documentation/content/guidebook/login-throttling/index.md @@ -0,0 +1,144 @@ +--- +title: "Login throttling" +description: "Prevent password brute force attacks with login throttling" +--- + +When implementing password based authentication, a common attack is a brute force attack. While the complexity of the password is likely going to be the most important factor, you can implement login throttling to limit the number of login attempts an attacker can make. + +One simple approach is to use exponential backoff to increase the timeout on every unsuccessful login attempt. Since determining the exact origin of an attack is hard, throttling should be done a per-username/account basis. However, an attacker may try to use a common password across multiple accounts. As such, throttling based on IP addresses should be also be considered. + +## Basic example + +The following example stores the attempts in memory. The timeout doubles on every failed login attempt until the user is successfully authenticated. A [demo](https://github.com/pilcrowOnPaper/lucia/tree/main/examples/other/login-throttling) is available in the repository. + +```ts +const usernameThrottling = new Map< + string, + { + timeoutUntil: number; + timeoutSeconds: number; + } +>(); +``` + +```ts +const storedThrottling = usernameThrottling.get(username); +const timeoutUntil = storedThrottling?.timeoutUntil ?? 0; +if (Date.now() < timeoutUntil) { + // 429 too many requests + throw new Error(); +} +const validPassword = validatePassword(username, password); +if (!validPassword) { + // increase timeout + const timeoutSeconds = storedThrottling + ? storedThrottling.timeoutSeconds * 2 + : 1; + usernameThrottling.set(username, { + timeoutUntil: Date.now() + timeoutSeconds * 1000, + timeoutSeconds + }); + // invalid username or password + throw new Error(); +} +usernameThrottling.delete(username); +// success! +``` + +## Prevent DOS with device cookies + +One issue with the basic example above is that a valid user may be locked out if an attacker attempts to sign in. This is of course much better than being susceptible to brute force attacks, but one way to avoid it is to remember users/devices that signed in once and skipping the timeout for the first few attempts. + +The following example stores the attempts and valid device cookies in memory. When a user is authenticated, a new device cookie is created. This cookie allows the user to bypass the throttling for the first 5 login attempts if they sign out. A [demo](https://github.com/pilcrowOnPaper/lucia/tree/main/examples/other/login-throtting-device-cookie) is available in the repository. + +```ts +const usernameThrottling = new Map< + string, + { + timeoutUntil: number; + timeoutSeconds: number; + } +>(); + +const deviceCookie = new Map< + string, + { + username: string; + attempts: number; + } +>(); +``` + +```ts +const storedDeviceCookieId = getCookie("device_cookie") ?? null; +const validDeviceCookie = isValidateDeviceCookie( + storedDeviceCookieId, + username +); +if (!validDeviceCookie) { + setCookie("device_cookie", "", { + path: "/", + secure: false, // true for production + maxAge: 0, + httpOnly: true + }); + const storedThrottling = usernameThrottling.get(username) ?? null; + const timeoutUntil = storedThrottling?.timeoutUntil ?? 0; + if (Date.now() < timeoutUntil) { + // 429 too many requests + throw new Error(); + } + const validPassword = validatePassword(username, password); + if (!validPassword) { + const timeoutSeconds = storedThrottling + ? storedThrottling.timeoutSeconds * 2 + : 1; + usernameThrottling.set(username, { + timeoutUntil: Date.now() + timeoutSeconds * 1000, + timeoutSeconds + }); + // invalid username or password + throw new Error(); + } + usernameThrottling.delete(username); +} else { + const validPassword = validatePassword(username, password); + if (!validPassword) { + // invalid username or password + throw new Error(); + } +} +const newDeviceCookieId = generateRandomString(40); +deviceCookie.set(newDeviceCookieId, { + username, + attempts: 0 +}); +setCookie("device_cookie", newDeviceCookieId, { + path: "/", + secure: false, // true for production + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true +}); +// success! +``` + +```ts +const isValidateDeviceCookie = ( + deviceCookieId: string | null, + username: string +) => { + if (!deviceCookieId) return false; + const deviceCookieAttributes = deviceCookie.get(deviceCookieId) ?? null; + if (!deviceCookieAttributes) return false; + const currentAttempts = deviceCookieAttributes.attempts + 1; + if (currentAttempts > 5 || deviceCookieAttributes.username !== username) { + deviceCookie.delete(deviceCookieId); + return false; + } + deviceCookie.set(deviceCookieId, { + username, + attempts: currentAttempts + }); + return true; +}; +``` diff --git a/examples/other/login-throttling-device-cookie/README.md b/examples/other/login-throttling-device-cookie/README.md new file mode 100644 index 000000000..3e120b07f --- /dev/null +++ b/examples/other/login-throttling-device-cookie/README.md @@ -0,0 +1,10 @@ +# Login throttling with device cookies + +``` +npm install +npm run start +``` + +``` +open http://localhost:3000 +``` diff --git a/examples/other/login-throttling-device-cookie/package.json b/examples/other/login-throttling-device-cookie/package.json new file mode 100644 index 000000000..7d980d513 --- /dev/null +++ b/examples/other/login-throttling-device-cookie/package.json @@ -0,0 +1,14 @@ +{ + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.1.0", + "hono": "^3.4.1", + "lucia": "latest" + }, + "devDependencies": { + "@types/node": "^20.4.9", + "tsx": "^3.12.2" + } +} diff --git a/examples/other/login-throttling-device-cookie/src/index.html b/examples/other/login-throttling-device-cookie/src/index.html new file mode 100644 index 000000000..5ab34f162 --- /dev/null +++ b/examples/other/login-throttling-device-cookie/src/index.html @@ -0,0 +1,24 @@ + +
++ Successfully sign in to get a device cookie, valid for 5 unsuccessful + attempts. +
+ + + diff --git a/examples/other/login-throttling-device-cookie/src/index.ts b/examples/other/login-throttling-device-cookie/src/index.ts new file mode 100644 index 000000000..a78071d82 --- /dev/null +++ b/examples/other/login-throttling-device-cookie/src/index.ts @@ -0,0 +1,112 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { setCookie, getCookie } from "hono/cookie"; +import { generateRandomString } from "lucia/utils"; +import fs from "fs/promises"; + +const app = new Hono(); + +const usernameThrottling = new Map< + string, + { + timeoutUntil: number; + timeoutSeconds: number; + } +>(); + +const deviceCookie = new Map< + string, + { + username: string; + attempts: number; + } +>(); + +app.get("/", async (c) => { + const html = await fs.readFile("src/index.html"); + return c.html(html.toString()); +}); + +app.post("/", async (c) => { + const { username, password } = await c.req.parseBody(); + if (typeof username !== "string" || username.length < 1) { + return c.text("Invalid username", 400); + } + if (password !== "invalid" && password !== "valid") { + return c.text("Invalid request body", 400); + } + const storedDeviceCookieId = getCookie(c, "device_cookie") ?? null; + const validDeviceCookie = isValidateDeviceCookie( + storedDeviceCookieId, + username + ); + if (!validDeviceCookie) { + setCookie(c, "device_cookie", "", { + path: "/", + secure: false, // true for production + maxAge: 0, + httpOnly: true + }); + const storedThrottling = usernameThrottling.get(username) ?? null; + const timeoutUntil = storedThrottling?.timeoutUntil ?? 0; + if (Date.now() < timeoutUntil) { + return c.text( + `Too many requests - wait ${Math.floor( + (timeoutUntil - Date.now()) / 1000 + )} seconds`, + 400 + ); + } + if (password === "invalid") { + const timeoutSeconds = storedThrottling + ? storedThrottling.timeoutSeconds * 2 + : 1; + usernameThrottling.set(username, { + timeoutUntil: Date.now() + timeoutSeconds * 1000, + timeoutSeconds + }); + return c.json( + `Invalid credentials - timed out for ${timeoutSeconds} seconds`, + 400 + ); + } + usernameThrottling.delete(username); + } else { + if (password === "invalid") { + return c.json(`Invalid credentials`, 400); + } + } + const newDeviceCookieId = generateRandomString(40); + deviceCookie.set(newDeviceCookieId, { + username, + attempts: 0 + }); + setCookie(c, "device_cookie", newDeviceCookieId, { + path: "/", + secure: false, // true for production + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: true + }); + return c.text("Success - throttling reset"); +}); + +const isValidateDeviceCookie = ( + deviceCookieId: string | null, + username: string +) => { + if (!deviceCookieId) return false; + const deviceCookieAttributes = deviceCookie.get(deviceCookieId) ?? null; + if (!deviceCookieAttributes) return false; + const currentAttempts = deviceCookieAttributes.attempts + 1; + if (currentAttempts > 5 || deviceCookieAttributes.username !== username) { + deviceCookie.delete(deviceCookieId); + return false; + } + deviceCookie.set(deviceCookieId, { + username, + attempts: currentAttempts + }); + return true; +}; + +serve(app); diff --git a/examples/other/login-throttling/README.md b/examples/other/login-throttling/README.md new file mode 100644 index 000000000..cd6fca34a --- /dev/null +++ b/examples/other/login-throttling/README.md @@ -0,0 +1,10 @@ +# Login throttling + +``` +npm install +npm run start +``` + +``` +open http://localhost:3000 +``` diff --git a/examples/other/login-throttling/package.json b/examples/other/login-throttling/package.json new file mode 100644 index 000000000..8544a539e --- /dev/null +++ b/examples/other/login-throttling/package.json @@ -0,0 +1,13 @@ +{ + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.1.0", + "hono": "^3.4.1" + }, + "devDependencies": { + "@types/node": "^20.4.9", + "tsx": "^3.12.2" + } +} diff --git a/examples/other/login-throttling/src/index.html b/examples/other/login-throttling/src/index.html new file mode 100644 index 000000000..e298ef011 --- /dev/null +++ b/examples/other/login-throttling/src/index.html @@ -0,0 +1,20 @@ + + +