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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ version, see the upgrade guides in `docs/`.
- **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'`.
- **server:** Changing a user's password ends their sessions: sessions store a hash of the user's `hash` at login, and end once it changes. The session of the request stays logged in after `'post password'`, `'post password-reset-confirm'` and the member `patch` and `update` of `UsersController`, e.g. `PATCH /users/self`. Elsewhere, e.g. in other controllers, custom actions or collection patches, it ends too, unless the action calls `ctx.login(user)` afterwards. Sessions from before this release, and logins of users loaded without `hash`, stay valid until their next login.
- **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`.

Expand All @@ -37,6 +38,8 @@ version, see the upgrade guides in `docs/`.
- **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.
- **server:** Password resets by email, with `app.auth.passwordReset`, `app.auth.credentials.secret` and sessions stored on the server. `UsersController` adds `'post password-reset'`, which emails a link through the app's `sendPasswordReset()` and answers the same for unknown usernames, and `'post password-reset-confirm'`, which sets the new password with the link's token. Links work once, until they expire after `expires`, `'1h'` by default, and changing the password invalidates them. One `confirmUrl` serves the whole app, see [Password reset](docs/guide/server/controllers.md#password-reset).
- **admin:** `login: { passwordReset: true }` adds a Forgot password? button to the login dialog, which requests the email, and opening the admin with `#password-reset=` and the token of the link asks for the new password, again after validation errors.
- **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.
Expand All @@ -59,6 +62,8 @@ version, see the upgrade guides in `docs/`.

### Fixes

- **admin:** Dialogs keep buttons that are wider than their components off their edges.
- **admin:** Failed login requests notify the `message` of their response, e.g. of a passkey login without a challenge, instead of `'Request failed with status code: 400'`.
- **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).
Expand Down
13 changes: 9 additions & 4 deletions docs/api/admin/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ new DitoAdmin(element, options)
| `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.login.passwordReset` | `boolean` | Adds a Forgot password? button to the login dialog, which asks for the username and posts it to [`api.users.passwordReset`](#users), for the server to email a link. The link opens the admin with `#password-reset=` and the token, which asks for the new password, posts it to `api.users.passwordResetConfirm`, asking again with the server's validation errors, and removes the token from the URL, before asking to log in. Needs password resets on the server, see [Password reset](../../guide/server/controllers.md#password-reset). |
| `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 @@ -151,6 +152,8 @@ api: {
session: { path: 'session', method: 'get' },
self: { path: 'self' },
password: { path: 'password', method: 'post' },
passwordReset: { path: 'password-reset', method: 'post' },
passwordResetConfirm: { path: 'password-reset-confirm', method: 'post' },
passkeyLoginOptions: { path: 'passkey-login-options', method: 'post' },
passkeyLogin: { path: 'passkey-login', method: 'post' },
passkeyOptions: { path: 'passkey-options', method: 'post' },
Expand All @@ -164,8 +167,9 @@ api: {

`users.path` is the collection of the user model; `users: 'users'` is short
for `users: { path: 'users' }`. `login`, `logout`, `session`, `self`,
`password`, `loginCode`, the passkey resources of `login.passkeys` and the
TOTP resources of `login.totp` are resources nested under it, shown here with
`password`, `loginCode`, the password reset resources of `login.passwordReset`,
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
Expand Down Expand Up @@ -370,8 +374,9 @@ 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.
the login expires. With `login.passwordReset`, it requests a password reset
instead when the user chooses Forgot password?, and then asks to log in again.
With `login.redirectAfterLogin`, a successful login loads that URL instead.

##### Return value

Expand Down
10 changes: 8 additions & 2 deletions docs/api/server/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,27 @@ app: {
id: 'example.com',
origins: ['https://example.com', 'https://admin.example.com']
},
totp: { issuer: 'Example' }
totp: { issuer: 'Example' },
passwordReset: {
confirmUrl: 'https://example.com/admin/#password-reset={token}'
}
}
}
```

| 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. |
| `credentials.secret` | `string` | A long random string, required by `totp` and `passwordReset`, that encrypts the TOTP secrets and hashes the backup codes and password reset tokens. It is separate from `keys`, which can rotate: changing it makes the stored TOTP secrets, backup codes and password reset links 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. |
| `passwordReset` | `object` | Enables password resets by email in [`UsersController`](controllers.md#userscontroller), which sends the emails through [`sendPasswordReset()`](controllers.md#sendpasswordreset). Needs `credentials`, `credentials.secret` and a `session` stored on the server, else the setup fails. |
| `passwordReset.confirmUrl` | `string` | The URL of the page that the emails link to, with a `{token}` placeholder for the token, best in the fragment, e.g. `'https://example.com/admin/#password-reset={token}'` for the admin's [`login.passwordReset`](../admin/admin.md). One URL for the whole app: all users controllers send links to it. |
| `passwordReset.expires` | `number \| string` | How long the links are valid, in milliseconds or as a duration string, e.g. `'30m'`. Default `'1h'`. |

Passkeys need the optional peer dependency `@simplewebauthn/server`, TOTP
needs `otpauth`.
Expand Down
69 changes: 62 additions & 7 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, passkey and TOTP actions. |
| [`UsersController`](#userscontroller) | A model controller with login, logout, session, passkey, TOTP and password reset actions. |
| [`AdminController`](#admincontroller) | Serves Dito.js Admin. |

Controllers are registered through the `controllers` option of the
Expand Down Expand Up @@ -848,6 +848,35 @@ 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.

With [`app.auth.passwordReset`](./application.md#configappauth), users who
forgot their password set a new one through a link that the app emails them,
with these collection actions, which aren't routed without it:

| Action | Response |
| -------------------------------- | ------------------------------------------------------- |
| `'post password-reset'` | Emails a link to the user of the username in the body, under the model's `usernameProperty`, through [`sendPasswordReset()`](#sendpasswordreset): `{ success: true }`, also for unknown usernames, or 400 without a username or with one that isn't a string. |
| `'post password-reset-confirm'` | Sets the password of the link's user to `{ token, password }`, validated by the user model: `{ success }`, or 400 with `'Invalid or expired token'`. Doesn't log in. |

`'post password-reset'` answers with the same response and status whether the
user exists or not, and doesn't wait for the email, but the time it takes can
still differ, as it stores a token for existing users. Users get at most one
email a minute. Limit the rate of requests per client in the proxy in front of
the app. Each link works once, until it expires, and setting the password, also
through `'post password'` or a member `patch` or `update`, invalidates the
user's other links. Only users of the controller's `modelClass` are found,
through their `sessionScope`. A subclass that sets `collection.allow` has to
list the password reset actions too, else they aren't routed, and only
controllers that route `'post password-reset'` need to override
`sendPasswordReset()`. All users controllers of the app share the one
`confirmUrl` of `app.auth.passwordReset`.

Any change of a user's `hash` ends the sessions of the user. The session of the
request that changes the password stays logged in after `'post password'`,
`'post password-reset-confirm'` and the member `patch` and `update` of
`UsersController`, e.g. `PATCH /users/self`. Elsewhere, e.g. in other
controllers, custom actions or collection patches, it ends too, unless the
action logs the user in again with `ctx.login(user)` afterwards.

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
Expand All @@ -856,12 +885,13 @@ 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`, 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.
`session`, `password`, the passkey, TOTP and password reset 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
import { UsersController } from '@ditojs/server'
Expand All @@ -880,6 +910,31 @@ const isAuthenticated = controller.isAuthenticated(ctx)

Returns whether the request is authenticated with a user of `modelClass`.

### sendPasswordReset()

```js
export class Users extends UsersController {
modelClass = User

// @override
async sendPasswordReset(ctx, user, url) {
await this.app.services.mailer.send({
to: user.email,
subject: 'Reset your password',
text: `Set a new password within the next hour: ${url}`
})
}
}
```

Sends `user` the email with `url`, the `confirmUrl` of
[`app.auth.passwordReset`](./application.md#configappauth) with the token, for
`'post password-reset'`. Controllers that route it override it, else the setup
fails. It may be asynchronous. It's called once the transaction of the request
is committed, if the action is `transacted`, without `ctx.transaction`. The
answer doesn't wait for it, so its errors, also synchronous ones, are only
emitted as `'error'` on the app.

### getMemberId()

```js
Expand Down
Loading
Loading