Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,19 @@ version, see the upgrade guides in `docs/`.
- **server:** `UsersController` only treats users of its own model class as logged in, not users of its sub-classes, which have their own tables and ids. This affects apps that serve sub-classed user models through the parent's `UsersController`: their `session`, `self` and `logout` actions no longer mistake such users for the parent's user with the same id.
- **server:** `Controller.setup()` is internal, like `Model.setup()`, and may be asynchronous: apps override `initialize()` instead.
- **server:** `UserMixin.sessionQuery()` is internal: `sessionScope` sets the scopes of the users of logins and sessions.
- **server:** With `app.auth.totp`, `UserMixin.login()` doesn't log in users with TOTP and resolves to `null`, and `'post login'` answers `{ success: false, codeRequired: true }` for them, with status 200: they log in with their code through `'post login-code'`.
- **admin:** Dialogs validate their data when submitted, like forms, and only resolve once it's valid, where they resolved with invalid data, e.g. with empty `required` fields.
- **admin:** `@simplewebauthn/browser` and `lean-qr` are required peer dependencies of `@ditojs/admin`: admin apps install them, also without `login.passkeys` or `login.totp`.

### New

- **server:** Users log in with passkeys, with `app.auth.passkeys`, sessions stored on the server and the optional peer dependency `@simplewebauthn/server`. `UsersController` adds `'post passkey-login-options'` and `'post passkey-login'`, and lets users manage their own passkeys through `'get passkeys'`, `'post passkey-options'`, `'post passkeys'` and `'delete passkeys/:id'`, see [Passkeys](docs/guide/server/controllers.md#passkeys). Passkeys are named after their providers, e.g. `'1Password'`, through the new dependency `aaguids`.
- **server:** `UsersController` lets users change their password through `'post password'`, with the current password. Subclasses that set `collection.allow` list it to route it.
- **server:** `CredentialMixin` and `CredentialModel` store the credentials of all user models in one table, set in `app.auth.credentials`. Deleting users deletes their credentials.
- **server:** Two-factor authentication with TOTP, with `app.auth.totp`, `app.auth.credentials.secret`, sessions stored on the server and the optional peer dependency `otpauth`. Users with TOTP enter a code from their authenticator app or one of their backup codes after the password, through `'post login-code'`, and set up and turn off TOTP through `'get totp'`, `'post totp'`, `'post totp-confirm'` and `'delete totp'`, see [Two-factor authentication](docs/guide/server/controllers.md#two-factor-authentication). Ten wrong codes in a row lock the account for 15 minutes. Passkey logins don't ask for codes.
- **admin:** `login: { passkeys: true }` adds a passkey login to the login dialog, and a Security dialog to the account menu that changes the user's password, and lists, adds and removes the user's passkeys. Requires the new peer dependency `@simplewebauthn/browser`.
- **admin:** The login dialog asks users with TOTP for the code of their authenticator app, or a backup code, after the password. `login: { totp: true }` adds a Security dialog to the account menu that changes the user's password, sets up TOTP with a QR code and shows the backup codes, and turns it off again. Requires the new peer dependency `lean-qr`.
- **admin:** Dialogs take `settings.minDuration`, the time in milliseconds that they stay visible when they're submitted right after they opened. The code dialog of the login stays for a second, so users see their password manager fill in the code.
- **admin:** The `empty` option of lists shows a row with its text while the list has no items, e.g. `empty: 'No books yet.'`.
- **admin:** `context.reload()` reloads the items of the list or object that the component is or is in, e.g. after a list button changed them on the server.

Expand All @@ -54,6 +59,7 @@ version, see the upgrade guides in `docs/`.

### Fixes

- **admin:** Errors of `context.request()` carry the `status` of their response, as documented.
- **admin:** Lists outside of routes, e.g. in dialogs, keep their scope, page, order and filters to themselves, instead of loading the query of the current route and navigating to their changes. Lists in routes still change them through the route.
- **router:** A trailing slash of the `prefix` option is removed unless in strict mode, so `new Router({ prefix: '/api/' })` routes `/users` as `/api/users` instead of `/api//users` (#336).
- **router:** `**` routes added after a `*` route at the same position match, e.g. `/a/**` after `/a/*`, instead of being nested under the `*` route and never matching (#330).
Expand Down
17 changes: 13 additions & 4 deletions docs/api/admin/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ new DitoAdmin(element, options)
| `options.login.additionalComponents` | `Components` | Components added to the login dialog after `username` and `password`. Their values are sent with the credentials. |
| `options.login.redirectAfterLogin` | `string` | A URL to load after a successful login, instead of resolving the views in place. |
| `options.login.passkeys` | `boolean` | Adds a Sign In with Passkey button to the login dialog, and a Security item to the account menu, whose dialog changes the user's password through [`api.users.password`](#users), and lists, adds and removes the user's passkeys, named after their providers. Needs passkeys on the server, see [Passkeys](../../guide/server/controllers.md#passkeys), and the peer dependency `@simplewebauthn/browser`. |
| `options.login.totp` | `boolean` | Adds a Security item to the account menu, whose dialog changes the user's password, sets up two-factor authentication with TOTP, with a QR code for the authenticator app and the backup codes, and turns it off again. Needs TOTP on the server, see [Two-factor authentication](../../guide/server/controllers.md#two-factor-authentication), and the peer dependency `lean-qr`. The login dialog asks for the code whenever the server requires one. |
| `options.spinner` | `{ size?: string, color?: string } \| null` | Size and color of the loading spinner in the header. `null` hides it. |
| `options.account.settings` | `Form \| Promise \| () => …` | The form of the account settings, which the account menu opens at `/account/settings`, to edit the logged-in user through [`api.users.self`](#users). Also a promise or a function returning it, e.g. a dynamic `import()`. Its `label` is the view's label, default `'Settings'`. See [Account settings](../../guide/admin/views.md#account-settings). |

Expand Down Expand Up @@ -153,15 +154,19 @@ api: {
passkeyLoginOptions: { path: 'passkey-login-options', method: 'post' },
passkeyLogin: { path: 'passkey-login', method: 'post' },
passkeyOptions: { path: 'passkey-options', method: 'post' },
passkeys: { path: 'passkeys' }
passkeys: { path: 'passkeys' },
loginCode: { path: 'login-code', method: 'post' },
totp: { path: 'totp' },
totpConfirm: { path: 'totp-confirm', method: 'post' }
}
}
```

`users.path` is the collection of the user model; `users: 'users'` is short
for `users: { path: 'users' }`. `login`, `logout`, `session`, `self`,
`password` and the passkey resources of `login.passkeys` are resources nested
under it, shown here with their defaults, so the admin logs in through
`password`, `loginCode`, the passkey resources of `login.passkeys` and the
TOTP resources of `login.totp` are resources nested under it, shown here with
their defaults, so the admin logs in through
`POST users/login`. The server's `UsersController` provides them.
The admin reads `user` from the responses of `login` and `session`, and
`success` from the response of `logout`. The
Expand Down Expand Up @@ -361,7 +366,11 @@ session.login(): Promise<AdminUser | null>

Shows the login dialog and posts the credentials to `api.users.login`, or
logs in with a passkey through `api.users.passkeyLogin`, asking again after
each failure, until it succeeds or the user cancels. With
each failure, until it succeeds or the user cancels. When the response has
`codeRequired`, for users with TOTP, it asks for the code of their
authenticator app or a backup code, and posts it to `api.users.loginCode`,
asking again after wrong codes, and for the password when the user cancels or
the login expires. With
`login.redirectAfterLogin`, a successful login loads that URL instead.

##### Return value
Expand Down
1 change: 1 addition & 0 deletions docs/api/admin/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ Also available outside of schemas as `admin.context.showDialog()`, see
| `options.settings.label` | `string` | The accessible label of the dialog. Defaults to `'Dialog'`. |
| `options.settings.width` | `number` | The CSS max-width of the dialog, in pixels. Defaults to `480`. |
| `options.settings.clickToClose` | `boolean` | Whether clicking outside of the dialog cancels it. Defaults to `false`. |
| `options.settings.minDuration` | `number` | The time in milliseconds that the dialog stays visible at least, when it's submitted right after it opened, e.g. by a password manager. Defaults to `0`. |

Submitting validates the data first, like forms do. Escape cancels dialogs
that have a `cancel` button or no buttons.
Expand Down
14 changes: 11 additions & 3 deletions docs/api/server/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,25 +114,33 @@ augmentation of `ApplicationConfig` for project-specific settings.
app: {
session: { modelClass: 'Session' },
auth: {
credentials: { modelClass: 'Credential' },
credentials: {
modelClass: 'Credential',
secret: process.env.CREDENTIALS_SECRET
},
passkeys: {
name: 'Example',
id: 'example.com',
origins: ['https://example.com', 'https://admin.example.com']
}
},
totp: { issuer: 'Example' }
}
}
```

| Key | Type | Description |
| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ |
| `credentials.modelClass` | `string \| Class<Model>` | The model that stores the credentials of all user models, using [`CredentialMixin`](model.md#credentialmixin). |
| `credentials.secret` | `string` | A long random string, required by `totp`, that encrypts the TOTP secrets and hashes the backup codes. It is separate from `keys`, which can rotate: changing it makes the stored TOTP secrets and backup codes unusable. |
| `passkeys` | `object` | Enables the passkeys of [`UsersController`](controllers.md#userscontroller). Needs `credentials` and a `session` stored on the server, through `modelClass` or `store`, else the setup fails. |
| `passkeys.name` | `string` | The name of the app that authenticators show. |
| `passkeys.id` | `string` | The domain that the passkeys belong to, the WebAuthn relying party id. Frontends on this domain or its subdomains can use them. |
| `passkeys.origins` | `string[]` | The origins of the frontends that log in with passkeys, which may differ from the API's. |
| `totp` | `object` | Enables two-factor authentication with TOTP in [`UsersController`](controllers.md#userscontroller). Needs `credentials`, `credentials.secret` and a `session` stored on the server, else the setup fails. |
| `totp.issuer` | `string` | The name of the app that authenticator apps show. |

Passkeys need the optional peer dependency `@simplewebauthn/server`.
Passkeys need the optional peer dependency `@simplewebauthn/server`, TOTP
needs `otpauth`.

### config.admin

Expand Down
47 changes: 41 additions & 6 deletions docs/api/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ exported by `@ditojs/server`:
| [`CollectionController`](#collectioncontroller) | Base class of model and relation controllers. Not extended directly. |
| [`ModelController`](#modelcontroller) | CRUD routes for a model's collection and members, plus its relations. |
| [`RelationController`](#relationcontroller) | Routes for a relation of a model controller. Created from `relations`, never extended. |
| [`UsersController`](#userscontroller) | A model controller with login, logout, session and passkey actions. |
| [`UsersController`](#userscontroller) | A model controller with login, logout, session, passkey and TOTP actions. |
| [`AdminController`](#admincontroller) | Serves Dito.js Admin. |

Controllers are registered through the `controllers` option of the
Expand Down Expand Up @@ -820,12 +820,47 @@ passkey login counts as two factors. It only logs in users of the controller's
`modelClass`, through their `sessionScope`, and fires the
[login events](./model.md#login-events).

With [`app.auth.totp`](./application.md#configappauth), users with TOTP enter a
code after their password, and set up and turn off their TOTP through these
collection actions, which aren't routed without it:

| Action | Response |
| ---------------------- | ------------------------------------------------------- |
| `'post login-code'` | Logs in the user of the pending login with `{ code }`, a TOTP code or a backup code, like `'post login'`: `{ success, authenticated, user, error }`. |
| `'get totp'` | `{ enabled, backupCodes }`, with the number of the remaining backup codes. |
| `'post totp'` | A new secret, kept in the session until it is confirmed: `{ uri, secret }`, the `otpauth://` URI for the QR code of authenticator apps, and the secret in base32 to enter by hand. 409 if TOTP is already set up. |
| `'post totp-confirm'` | Stores the secret with `{ code }`, a code of the new secret, and creates 10 backup codes: `{ backupCodes }`, only shown in this response. |
| `'delete totp'` | Turns TOTP off with `{ code }`, a TOTP code or a backup code, and deletes the backup codes: `{ success }`. |

For users with TOTP, `'post login'` with the right password doesn't log in, but
responds with status 200 and `{ success: false, codeRequired: true,
authenticated: false, user: null }`, and keeps a pending login in the session,
see [`login()`](./model.md#static-login), with a new session id.
`'post login-code'` completes it within 5 minutes. Wrong codes answer
`'Code is incorrect'` with status 401, also in `'post totp-confirm'` and
`'delete totp'`, and missing or expired pending logins and setups status 400.
Ten wrong codes in a row lock the account for 15 minutes, across pending
logins and sessions: the tenth and all codes during the lock answer
`'Too many attempts'` with status 400. Ten wrong codes in `'post totp-confirm'`
end the setup the same way. Each TOTP code is accepted once, also the one that
confirmed the setup and of concurrent requests, as well as the codes before
and after the current one for clocks that drift. Each backup code is deleted
once used. Passkey logins don't ask for codes. A subclass that sets
`collection.allow` has to list the TOTP actions too, else they aren't routed.

TOTP is only asked for by [`UserMixin.login()`](./model.md#static-login), and
thus by `'post login'`. `ctx.login(user)` logs in without it, by design, e.g.
for impersonation or custom login flows. The credentials of TOTP are stored
under the name of the user model class: renaming the class drops them, and two
user model classes on one table each have their own, so TOTP set up through
one isn't asked for by logins through the other.

The controller's [`authorize`](#authorize) doesn't apply to `login`, `logout`,
`session`, `password` and the passkey actions, which authenticate users, or
are only allowed for the authenticated user; `self` follows it. Its member
actions default to `authorize: ['$self']`, and are routed when listed in
`allow` like on other model controllers. In member routes, `self` stands for the
authenticated user, see [`getMemberId()`](#getmemberid-1), e.g.
`session`, `password`, the passkey and the TOTP actions, which authenticate
users, or are only allowed for the authenticated user; `self` follows it. Its
member actions default to `authorize: ['$self']`, and are routed when listed
in `allow` like on other model controllers. In member routes, `self` stands
for the authenticated user, see [`getMemberId()`](#getmemberid-1), e.g.
`PATCH /users/self` runs `member.patch` on them.

```js
Expand Down
14 changes: 9 additions & 5 deletions docs/api/server/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,11 @@ const user = await User.login(ctx, options?)

Authenticates the credentials in the request body, under `usernameProperty` and
`passwordProperty`, and logs the user in with `ctx.login(user, options)`.
Resolves to the user. Rejects with an
Resolves to the user. With `app.auth.totp`, users with TOTP aren't logged in
yet: it keeps a pending login in the session, with a new session id, which
[`'post login-code'`](controllers.md#userscontroller) completes with their
code, and resolves to `null`. Only `login()` asks for TOTP, not
`ctx.login()`. Rejects with an
[`AuthenticationError`](errors.md#authenticationerror): status 400 with "Missing
credentials", or 401 with "Password or username is incorrect".

Expand Down Expand Up @@ -1086,19 +1090,19 @@ import { CredentialModel } from '@ditojs/server'
export class Credential extends CredentialModel {}
```

A model to store the credentials of users beyond their password, for now
their [passkeys](controllers.md#userscontroller). One table holds the
A model to store the credentials of users beyond their password: their
[passkeys and TOTP](controllers.md#userscontroller). One table holds the
credentials of all user models. Use it with `app.auth.credentials.modelClass`,
the model's name or class, see [`config.app`](application.md#configapp).

| Property | Description |
| ------------ | --------------------------------------------------------------------------- |
| `type` | `string`, required: `'passkey'`. |
| `type` | `string`, required: `'passkey'`, `'totp'`, one per user, or `'backup-code'`, ten per user with TOTP. |
| `userModel` | `string`, required: the name of the user's model class. |
| `userId` | `string`, required: the id of the user, also of integer ids. |
| `key` | `string`, nullable, unique per `userModel`: the credential id of passkeys. |
| `name` | `string`, nullable: the name of the provider of passkeys, e.g. `'1Password'`. |
| `data` | `object`, hidden: the public key, signature counter and transports of passkeys. |
| `data` | `object`, hidden: the public key, signature counter and transports of passkeys, the encrypted secret and the time step of the last accepted code of TOTP, the hash of backup codes. |
| `createdAt` | `timestamp`, set on insert. |
| `lastUsedAt` | `timestamp`, nullable: the time of the last login with the credential. |

Expand Down
Loading
Loading