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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ version, see the upgrade guides in `docs/`.

- **server:** Dito.js handles logins and sessions itself instead of through koa-passport, which `@ditojs/server` no longer depends on. `ctx.req.user` and custom Passport strategies are gone, `app.passport` has no effect, and `UserMixin.login()` reads the credentials only from the request body. Existing sessions stay valid.
- **server:** Changing a user's password ends their other sessions.
- **server:** The `patch` and `put` actions of `UsersController` no longer accept passwords, which only change through `'post password'`: apps that change passwords in a form through `PATCH /users/self` use the admin's Security dialog or `'post password'` instead. Admins can't set the passwords of other users through them any more, only password resets can.
- **server:** The actions of `UsersController` no longer accept the `hash` of the password, and changing the username through `patch` and `put` needs confirmed access.
- **server:** `UsersController` only treats users of its own model class as logged in, not users of its sub-classes.
- **server:** `Controller.setup()` and `UserMixin.sessionQuery()` are internal: apps override `initialize()` and set `sessionScope` instead.
- **admin:** Dialogs validate their data on submit, like forms, and only resolve once it's valid.
Expand All @@ -20,6 +22,7 @@ version, see the upgrade guides in `docs/`.
- **server, admin:** Two-factor authentication with TOTP, with `app.auth.totp` and `login: { totp: true }`, see [Two-factor authentication](docs/guide/server/controllers.md#two-factor-authentication).
- **server, admin:** Password resets by email, with `app.auth.passwordReset` and `login: { passwordReset: true }`, see [Password reset](docs/guide/server/controllers.md#password-reset).
- **server, admin:** Users change their password through `'post password'` of `UsersController`, and the Security dialog of the account menu.
- **server, admin:** Security changes need confirmed access, from a recent login or `'post confirm-access'` within `app.auth.reconfirmAccessAfter`. Turning off TOTP takes it instead of a code. See [Confirmed access](docs/guide/server/controllers.md#confirmed-access).
- **server:** Authorize functions are called with their controller as `this`, in relations the `RelationController`.
- **server:** `CredentialMixin` and `CredentialModel` store the credentials of all user models in one table, set in `app.auth.credentials`.
- **admin:** `settings.minDuration` keeps dialogs visible for a minimum time.
Expand Down
19 changes: 11 additions & 8 deletions docs/api/admin/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ new DitoAdmin(element, options)
| `options.dito` | [`GlobalConfig`](#globalconfig) | The global configuration. Defaults to `globalThis.dito`, which `AdminController` provides. |
| `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.login.passkeys` | `boolean` | Adds a Sign In with Passkey button to the login dialog, and lists, adds and removes the user's passkeys, named after their providers, in the Security dialog of the account menu, which always changes the user's password through [`api.users.password`](#users). Needs passkeys on the server, see [Passkeys](../../guide/server/controllers.md#passkeys), and the peer dependency `@simplewebauthn/browser`. |
| `options.login.totp` | `boolean` | Sets up two-factor authentication with TOTP in the Security dialog of the account menu, 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 @@ -160,23 +160,26 @@ api: {
passkeys: { path: 'passkeys' },
loginCode: { path: 'login-code', method: 'post' },
totp: { path: 'totp' },
totpConfirm: { path: 'totp-confirm', method: 'post' }
totpConfirm: { path: 'totp-confirm', method: 'post' },
confirmAccess: { path: 'confirm-access', method: 'post' }
}
}
```

`users.path` is the collection of the user model; `users: 'users'` is short
for `users: { path: 'users' }`. `login`, `logout`, `session`, `self`,
`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
`password`, `loginCode`, `confirmAccess`, 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
`success` from the response of `logout`. The
[account settings](#parameters) load the logged-in user from `self` and patch
it there, after which the admin fetches the session's user again. See [AdminContext](#session) for
the session that uses them.
it there, after which the admin fetches the session's user again. Requests
that answer `confirmationRequired` ask for the password, the TOTP code or a
passkey, post them to `confirmAccess` and are sent again. See
[AdminContext](#session) for the session that uses them.

### defaults

Expand Down
1 change: 1 addition & 0 deletions docs/api/server/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ app: {
| `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'`. |
| `reconfirmAccessAfter` | `number \| string` | How long logins and `'post confirm-access'` confirm the access for the security changes of [`UsersController`](controllers.md#userscontroller), in milliseconds or as a duration string. Default `'10m'`. |

Passkeys need the optional peer dependency `@simplewebauthn/server`, TOTP
needs `otpauth`.
Expand Down
42 changes: 27 additions & 15 deletions docs/api/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,21 @@ inherited actions, see [`allow`](#allow): a subclass that sets
| `'post logout'` | `{ success, authenticated }` |
| `'get session'` | `{ authenticated, user }` |
| `'get self'` | The authenticated user through `member.get`, or `null`. |
| `'post password'` | Changes the password of the authenticated user to `password` in the body, after verifying `currentPassword`: `{ success }`, or 400 if `currentPassword` is wrong. The new password is validated by the user model, e.g. its `minLength`. |
| `'post password'` | Changes the password of the authenticated user to `password` in the body, after verifying `currentPassword`: `{ success }`, or 400 if `currentPassword` is wrong. The new password is validated by the user model, e.g. its `minLength`. Needs confirmed access. |
| `'post confirm-access'` | Confirms the access of the authenticated user with `{ password }`, plus `code` for users with TOTP, or with `{ passkey }`, the response of `startAuthentication()` for the options of `'post passkey-login-options'`: `{ success }`, `{ success: false, codeRequired: true }` without a code, or 401 with `'Password is incorrect'`, `'Code is incorrect'` or `'Passkey is incorrect'`. |

Logins and `'post confirm-access'` confirm the access of the session for
[`app.auth.reconfirmAccessAfter`](./application.md#configappauth). Without
it, `'post password'`, `'post passkey-options'`, `'post passkeys'`,
`'delete passkeys/:id'`, `'post totp'`, `'post totp-confirm'` and
`'delete totp'` answer 403 with
`{ message: 'Confirmation required', confirmationRequired: true }`, see
[Confirmed access](../../guide/server/controllers.md#confirmed-access), and so
do `patch` and `put` actions that change the username.
`hasConfirmedAccess(ctx)` does the same for the `authorize` of other actions.
The `patch`, `put` and `post` actions answer 400 for bodies with the `hash`,
and `patch` and `put` also for bodies with the password. With `relate`, `post`
treats users with ids like updates.

With [`app.auth.passkeys`](./application.md#configappauth), users log in with
passkeys and manage their own ones through these collection actions, which
Expand Down Expand Up @@ -838,15 +852,15 @@ collection actions, which aren't routed without it:
| `'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 }`. |
| `'delete totp'` | Turns TOTP off and deletes the backup codes: `{ success }`. Needs confirmed access. |

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.
`'post confirm-access'`, 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'`
Expand All @@ -870,20 +884,18 @@ 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`.
through `'post password'`, 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.
request that changes the password stays logged in after `'post password'` and
`'post password-reset-confirm'`. Elsewhere, e.g. in other controllers or custom
actions, 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.
Expand Down
10 changes: 5 additions & 5 deletions docs/guide/admin/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,16 @@ export default {
type: 'email',
label: 'Email',
required: true
},
// Only sent when a new password is entered:
password: {
type: 'password',
label: 'New Password'
}
}
}
```

Passwords don't change through `PATCH /api/users/self`, but through
`'post password'` of the `UsersController`, which the Security dialog of the
account menu uses, see
[Confirmed access](../server/controllers.md#confirmed-access).

On the server, `PATCH /users/self` goes through the `patch` member action of
the `UsersController`, so the users need to be allowed to patch themselves:
`member = { allow: ['get', 'patch'] }`, which only allows users to patch
Expand Down
88 changes: 85 additions & 3 deletions docs/guide/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,9 @@ export class Users extends UsersController {
- `GET /users/self` returns the logged-in user.
- In member routes, `self` stands for the logged-in user, e.g.
`PATCH /users/self` runs the member's `patch` action on them, if it is
allowed. The admin's [account settings](../admin/views.md#account-settings)
use it.
allowed, but doesn't change passwords, see
[Confirmed access](#confirmed-access). The admin's
[account settings](../admin/views.md#account-settings) use it.

`authorize` on the controller doesn't apply to login, logout and session,
which authenticate, so users can always log in; `self` follows it. Member
Expand Down Expand Up @@ -710,7 +711,8 @@ const { user } = response

Ten wrong codes in a row lock the account for 15 minutes. `GET /users/totp`
tells whether TOTP is set up and how many backup codes are left, and
`DELETE /users/totp` with `{ code }` turns it off. Only password logins through
`DELETE /users/totp` turns it off, with
[confirmed access](#confirmed-access). Only password logins through
`login()` ask for the code, while `ctx.login(user)` logs in without it, e.g.
to impersonate users. The admin's login
dialog asks for the code, and its `login.totp` option sets up and turns off
Expand Down Expand Up @@ -778,6 +780,86 @@ the request to its login dialog, with `confirmUrl` pointing to the admin, e.g.
`'https://example.com/admin/#password-reset={token}'`, see
[`DitoAdmin`](../../api/admin/admin.md#parameters).

### Confirmed access

Security changes need confirmed access, so that a session alone, e.g. a stolen
one, can't change how the account logs in: changing the password through
`'post password'` or the username, adding and removing passkeys, and turning
TOTP on and off. Logging in confirms the access, also with a passkey or with
the TOTP code, for the time of `reconfirmAccessAfter`:

```js
// config/index.js
export default {
app: {
auth: {
reconfirmAccessAfter: '10m' // The default
}
}
}
```

Later, these actions answer status 403 with `confirmationRequired`, and the
user confirms the access again with their password, plus the code of their
authenticator app or a backup code if they use TOTP, or with a passkey:

```js
let response = await post('confirm-access', { password })
if (response.codeRequired) {
response = await post('confirm-access', { password, code: '123456' })
}
// Or with a passkey, through the options of passkey logins:
const optionsJSON = await post('passkey-login-options')
await post('confirm-access', {
passkey: await startAuthentication({ optionsJSON })
})
```

Wrong codes count towards the lock of the account, as in logins. Passwords
only change through `'post password'`: the `patch` and `put` actions of users
controllers answer 400 for bodies with a password or its `hash`, while `post`
still creates users with a password. Changing the username through them needs
confirmed access, as password resets go to it. The admin asks to confirm the
access whenever an action answers `confirmationRequired`, and then repeats the
action.

Protect other changes the same way, e.g. the `email` that the app sends the
password resets to, or custom actions with
`authorize: { 'post export': ctx => this.hasConfirmedAccess(ctx) }`:

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

member = {
allow: ['get', 'patch'],

async patch(ctx, modify) {
const { email } = ctx.request.body
if (email !== undefined) {
// Compare with the user that is patched, not the logged-in one:
const user = await this.modelClass.query().findById(ctx.memberId)
// `hasConfirmedAccess()` throws 403 with `confirmationRequired` without
// confirmed access, and returns `false` for users of other models:
if (email !== user?.email && !this.hasConfirmedAccess(ctx)) {
throw new AuthorizationError()
}
}
return super.patch(ctx, modify)
}
}
}
```

Passwords set through other routes of the app aren't covered: relations and
graphs that include user models, other controllers, and new users created
through an authorized collection `post`. Keep such routes to admins. Deleting
users through the member `delete`, e.g. `DELETE /users/self`, doesn't need
confirmed access either: apps that allow it can protect it with
`authorize: { delete: ctx => this.hasConfirmedAccess(ctx) && '$self' }` in
`member`, keeping `'$self'`, which the setting replaces, so that users only
delete themselves.

## Admin

The `AdminController` serves the admin interface at its path. In development
Expand Down
Loading
Loading