Skip to content

Commit 52d69c3

Browse files
committed
docs: add docs for user sessions nonce cookies
1 parent 5294ae9 commit 52d69c3

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

‎dev-demo/globalPlugins.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export const globalPlugins = [
4848
onNewLogin: async ({ username, reasons, revokePath }) => {
4949
console.log(`New sign-in of ${username} (${reasons.join(', ')}), not you? http://localhost:3123${revokePath}`);
5050
},
51+
onSessionCopied: async ({ username, session, request }) => {
52+
console.log(
53+
`Session of ${username} is used in two browsers and was ended. ` +
54+
`Signed in: ${session.device?.browser} on ${session.device?.os}, IP: ${session.ip}. ` +
55+
`Other request: ${request.device?.browser} on ${request.device?.os}, IP: ${request.ip}`
56+
);
57+
},
5158
loginHistory: {
5259
resourceId: 'login_history',
5360
// short to see failed attempts in the list without waiting for a minute

‎documentation/docs/tutorial/09-Plugins/31-user-sessions.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,50 @@ The sender address or its domain must be verified in SES, see the SES setup step
236236
add HTML, escape the values of `session`, the IP may come from a request header. An error thrown from the
237237
callback is logged by the plugin and doesn't affect the sign-in.
238238

239+
## Detecting copied sessions
240+
241+
Malware on the user's computer can copy the cookies of the browser, and the copied session then works on
242+
another machine without the password and 2FA. Pass `onSessionCopied` to be told when a session is used in two
243+
browsers:
244+
245+
```ts title="./index.ts"
246+
new UserSessionsPlugin({
247+
keyValueAdapter,
248+
onSessionCopied: async ({ username, session, request }) => {
249+
await fetch(process.env.SLACK_WEBHOOK_URL!, {
250+
method: 'POST',
251+
headers: { 'Content-Type': 'application/json' },
252+
body: JSON.stringify({
253+
text: [
254+
`Session of *${username}* is used in two browsers and was ended`,
255+
`Signed in: ${session.device?.browser} on ${session.device?.os}, IP: ${session.ip}, country: ${session.country}`,
256+
`Other request: ${request.device?.browser} on ${request.device?.os}, IP: ${request.ip}, country: ${request.country}`,
257+
].join('\n'),
258+
}),
259+
});
260+
},
261+
})
262+
```
263+
264+
How a copy is found:
265+
266+
- On sign-in the browser gets a `session_nonce` cookie next to the auth cookie, its value is stored with the session.
267+
- Every 5 minutes (`nonceRotationMinutes`) a response replaces the value. A copy in another browser keeps the old one, so the old value
268+
coming after the new one was used tells that two browsers hold the session. It works the same way when the copy
269+
gets the new value first.
270+
- The session is revoked before the callback and both browsers are signed out, as the plugin can't tell which one
271+
belongs to the user. `request` is the one with the old value, `session` is how the session was signed in.
272+
- The old value is still accepted until the browser comes with the new one and for a minute after that, so parallel
273+
requests and a lost response don't end the session.
274+
- It costs no extra reads: the value is checked against the session which is read on every request anyway, and a
275+
new value is written together with `last_used_at`.
276+
- A copy is found only when both browsers use the session. While the computer of the user is off, the copy works
277+
until the user comes back or the auth token expires, a shorter session lifetime makes that time shorter.
278+
- Only sessions signed in while the option is set are checked, so after installing it into a running app every
279+
session is checked from its next sign-in.
280+
281+
The callback is not awaited, and its errors are only logged.
282+
239283
## Login history
240284

241285
Pass `loginHistory` to store every sign-in and every failed password attempt of a user in a resource.
@@ -389,6 +433,8 @@ In the list the country name is shown in a tooltip of the flag, on the show page
389433
| `canSeeIps` | — | `(adminUser) => Promise<boolean>`, decides who sees IPs of sessions, see [Hiding IP addresses](#hiding-ip-addresses) |
390434
| `lastUsedThrottleSeconds` | `60` | How often `last_used_at` is refreshed. Every refresh is one write to the adapter |
391435
| `onNewLogin` | — | `({ pk, username, session, reasons, revokePath }) => Promise<void>`, called after a sign-in from a new device or country, see [Notifying about sign-ins from new devices](#notifying-about-sign-ins-from-new-devices) |
436+
| `onSessionCopied` | — | `({ pk, username, session, request }) => Promise<void>`, called after a session used in two browsers was revoked, see [Detecting copied sessions](#detecting-copied-sessions) |
437+
| `nonceRotationMinutes` | `5` | How often the nonce cookie of `onSessionCopied` is replaced. Every replacement is two writes to the adapter |
392438
| `loginHistory.resourceId` | — | Resource where sign-ins and failed attempts are stored, see [Login history](#login-history) |
393439
| `loginHistory.resourceColumns` | required with `loginHistory` | Names of the columns of that resource |
394440
| `loginHistory.failedLogsInterval` | `1m` | How often counted failed attempts are written, one record per user |

0 commit comments

Comments
 (0)