-
-
Notifications
You must be signed in to change notification settings - Fork 502
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add login throttling examples and guide (#956)
- Loading branch information
1 parent
5a936f5
commit 6fb45ca
Showing
9 changed files
with
402 additions
and
0 deletions.
There are no files selected for viewing
144 changes: 144 additions & 0 deletions
144
documentation/content/guidebook/login-throttling/index.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
14
examples/other/login-throttling-device-cookie/package.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
24
examples/other/login-throttling-device-cookie/src/index.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
112
examples/other/login-throttling-device-cookie/src/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.