Skip to content

Commit f74b6f1

Browse files
committed
docs: add docs for the loginHistory in user sessions plugin
1 parent 5626fa6 commit f74b6f1

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

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

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

204+
## Login history
205+
206+
Pass `loginHistory` to store every sign-in and every failed password attempt of a user in a resource.
207+
208+
Create a table for it in `./schema.prisma`:
209+
210+
```ts title='./schema.prisma'
211+
model login_history {
212+
id String @id
213+
created_at DateTime
214+
user_id String
215+
success Boolean
216+
attempts Int
217+
ip String?
218+
country String?
219+
user_agent String?
220+
ips String? /// JSON with failed attempts by IP
221+
222+
@@index([user_id, success, created_at])
223+
}
224+
```
225+
226+
```bash
227+
pnpm makemigration --name add-login-history ; pnpm migrate:local
228+
```
229+
230+
Add a resource for it:
231+
232+
```ts title='./resources/loginHistory.ts'
233+
import { AdminForthDataTypes, AdminForthResourceInput, AdminForthSortDirections } from 'adminforth';
234+
import { randomUUID } from 'crypto';
235+
236+
export default {
237+
dataSource: 'maindb',
238+
table: 'login_history',
239+
resourceId: 'login_history',
240+
label: 'Login history',
241+
columns: [
242+
{ name: 'id', primaryKey: true, fillOnCreate: () => randomUUID(), showIn: { all: false } },
243+
{ name: 'created_at', type: AdminForthDataTypes.DATETIME },
244+
{ name: 'user_id', foreignResource: { resourceId: 'adminuser' } },
245+
{ name: 'success', type: AdminForthDataTypes.BOOLEAN },
246+
{ name: 'attempts', type: AdminForthDataTypes.INTEGER },
247+
{ name: 'ip' },
248+
{ name: 'country' },
249+
{ name: 'user_agent', showIn: { list: false } },
250+
// substring search finds failed attempts by an IP inside the JSON
251+
{ name: 'ips', type: AdminForthDataTypes.JSON, filterOptions: { substringSearch: true } },
252+
],
253+
options: {
254+
allowedActions: { create: false, edit: false, delete: false },
255+
defaultSort: { columnName: 'created_at', direction: AdminForthSortDirections.desc },
256+
},
257+
} as AdminForthResourceInput;
258+
```
259+
260+
And point the plugin to it:
261+
262+
```ts title="./index.ts"
263+
new UserSessionsPlugin({
264+
keyValueAdapter,
265+
loginHistory: {
266+
resourceId: 'login_history',
267+
resourceColumns: {
268+
userIdColumnName: 'user_id',
269+
successColumnName: 'success',
270+
attemptsColumnName: 'attempts',
271+
ipColumnName: 'ip',
272+
countryColumnName: 'country',
273+
userAgentColumnName: 'user_agent',
274+
ipsColumnName: 'ips',
275+
createdAtColumnName: 'created_at',
276+
},
277+
},
278+
})
279+
```
280+
281+
A sign-in is stored right away, with `attempts` set to 1 and its IP, country and user agent.
282+
283+
Failed attempts are stored in a way which keeps the database safe from a bruteforce:
284+
285+
- Only attempts with a wrong password of an existing user are stored. Attempts with an unknown username
286+
are skipped: anybody could create records for them at will, and a mistyped username is often the password.
287+
Attempts rejected by `auth.rateLimit` or a [captcha](/docs/tutorial/Plugins/login-captcha) are skipped too.
288+
- Attempts are counted in memory and written once per `failedLogsInterval` (`1m` by default) as one record
289+
per user: `attempts` is their total, `ips` is a JSON object like
290+
`{ "1.2.3.4": { "attempts": 3, "country": "DE" } }`. So 5000 attempts in a minute cost one write.
291+
- `ips` keeps up to `maxIpsPerLog` IPs (100 by default), attempts from other IPs are still counted in `attempts`.
292+
- Only `maxFailedLogsPerUser` newest failed attempts records of a user are kept (100 by default), older ones
293+
are deleted.
294+
- Attempts counted since the last write are lost when the app restarts.
295+
296+
Sign-ins are not limited, remove old records by age with the [Auto Remove](/docs/tutorial/Plugins/auto-remove) plugin:
297+
298+
```ts title='./resources/loginHistory.ts'
299+
import AutoRemovePlugin from '@adminforth/auto-remove';
300+
301+
export default {
302+
...
303+
plugins: [
304+
new AutoRemovePlugin({
305+
createdAtField: 'created_at',
306+
mode: 'time-based',
307+
deleteOlderThan: '3mo',
308+
interval: '1d',
309+
}),
310+
],
311+
} as AdminForthResourceInput;
312+
```
313+
204314
## Options
205315
206316
| Option | Default | Description |
@@ -212,3 +322,8 @@ callback is logged by the plugin and doesn't affect the sign-in.
212322
| `canManageOtherUsersSessions` | — | `(adminUser) => Promise<boolean>`, decides who may see and revoke sessions of other users |
213323
| `lastUsedThrottleSeconds` | `60` | How often `last_used_at` is refreshed. Every refresh is one write to the adapter |
214324
| `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) |
325+
| `loginHistory.resourceId` | — | Resource where sign-ins and failed attempts are stored, see [Login history](#login-history) |
326+
| `loginHistory.resourceColumns` | required with `loginHistory` | Names of the columns of that resource |
327+
| `loginHistory.failedLogsInterval` | `1m` | How often counted failed attempts are written, one record per user |
328+
| `loginHistory.maxIpsPerLog` | `100` | How many IPs are kept in `ips` of one failed attempts record |
329+
| `loginHistory.maxFailedLogsPerUser` | `100` | How many failed attempts records are kept for one user |

0 commit comments

Comments
 (0)