Skip to content

Commit

Permalink
Add login throttling examples and guide (#956)
Browse files Browse the repository at this point in the history
  • Loading branch information
pilcrowonpaper authored Aug 10, 2023
1 parent 5a936f5 commit 6fb45ca
Show file tree
Hide file tree
Showing 9 changed files with 402 additions and 0 deletions.
144 changes: 144 additions & 0 deletions documentation/content/guidebook/login-throttling/index.md
Original file line number Diff line number Diff line change
@@ -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;
};
```
10 changes: 10 additions & 0 deletions examples/other/login-throttling-device-cookie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Login throttling with device cookies

```
npm install
npm run start
```

```
open http://localhost:3000
```
14 changes: 14 additions & 0 deletions examples/other/login-throttling-device-cookie/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 24 additions & 0 deletions examples/other/login-throttling-device-cookie/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<html>
<head>
<title>Rate limiting demo with device cookies</title>
</head>
<body>
<h1>Login throttling demo with device cookies</h1>
<p>
Successfully sign in to get a device cookie, valid for 5 unsuccessful
attempts.
</p>
<form method="post">
<label for="username">Username</label>
<input id="username" name="username" />
<fieldset id="attempt">
<legend>Password</legend>
<input type="radio" value="invalid" checked name="password" />
<label for="invalid">Incorrect password</label><br />
<input type="radio" value="valid" name="password" />
<label for="valid">Correct password</label><br />
</fieldset>
<button>Sign in</button>
</form>
</body>
</html>
112 changes: 112 additions & 0 deletions examples/other/login-throttling-device-cookie/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
10 changes: 10 additions & 0 deletions examples/other/login-throttling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Login throttling

```
npm install
npm run start
```

```
open http://localhost:3000
```
13 changes: 13 additions & 0 deletions examples/other/login-throttling/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 20 additions & 0 deletions examples/other/login-throttling/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<html>
<head>
<title>Rate limiting demo</title>
</head>
<body>
<h1>Login throttling demo</h1>
<form method="post">
<label for="username">Username</label>
<input id="username" name="username" />
<fieldset id="attempt">
<legend>Password</legend>
<input type="radio" value="invalid" checked name="password" />
<label for="invalid">Incorrect password</label><br />
<input type="radio" value="valid" name="password" />
<label for="valid">Correct password</label><br />
</fieldset>
<button>Sign in</button>
</form>
</body>
</html>
Loading

0 comments on commit 6fb45ca

Please sign in to comment.