diff --git a/CHANGELOG.md b/CHANGELOG.md index 705bbd331..507ec26c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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). diff --git a/docs/api/admin/admin.md b/docs/api/admin/admin.md index a1c444a25..5e56599c3 100644 --- a/docs/api/admin/admin.md +++ b/docs/api/admin/admin.md @@ -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). | @@ -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 @@ -361,7 +366,11 @@ session.login(): Promise 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 diff --git a/docs/api/admin/context.md b/docs/api/admin/context.md index 614533f2b..0f392baef 100644 --- a/docs/api/admin/context.md +++ b/docs/api/admin/context.md @@ -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. diff --git a/docs/api/server/application.md b/docs/api/server/application.md index d94b183b3..a3fcd82be 100644 --- a/docs/api/server/application.md +++ b/docs/api/server/application.md @@ -114,12 +114,16 @@ 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' } } } ``` @@ -127,12 +131,16 @@ app: { | Key | Type | Description | | ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ | | `credentials.modelClass` | `string \| Class` | 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 diff --git a/docs/api/server/controllers.md b/docs/api/server/controllers.md index 7776a1acc..a7227817c 100644 --- a/docs/api/server/controllers.md +++ b/docs/api/server/controllers.md @@ -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 @@ -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 diff --git a/docs/api/server/model.md b/docs/api/server/model.md index 0aa36ced1..03bba3097 100644 --- a/docs/api/server/model.md +++ b/docs/api/server/model.md @@ -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". @@ -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. | diff --git a/docs/guide/server/controllers.md b/docs/guide/server/controllers.md index 5655e39f6..73a0ab588 100644 --- a/docs/guide/server/controllers.md +++ b/docs/guide/server/controllers.md @@ -664,6 +664,59 @@ const { user } = await post( `DELETE /users/passkeys/:id` removes one. The admin offers both with its `login.passkeys` option, see [`DitoAdmin`](../../api/admin/admin.md#parameters). +### Two-factor authentication + +Users can protect their password logins with a second factor, the codes of an +authenticator app (TOTP). Install the optional peer dependency `otpauth`, add +the model for the credentials as for passkeys, and turn on TOTP with a secret +of its own, a long random string that encrypts the users' TOTP secrets: + +```js +// config/index.js +export default { + app: { + session: { modelClass: 'Session' }, + auth: { + credentials: { + modelClass: 'Credential', + secret: process.env.CREDENTIALS_SECRET + }, + totp: { issuer: 'Example' } + } + } +} +``` + +A logged-in user sets up TOTP by scanning the QR code of `uri` in their +authenticator app, e.g. rendered with a QR code library, and confirming it with +a code. The response holds the backup codes, which are only shown once: + +```js +const { uri, secret } = await post('totp') +// Show `uri` as QR code, and `secret` to enter by hand: +const { backupCodes } = await post('totp-confirm', { code: '123456' }) +``` + +From then on, their password login answers `codeRequired` instead of logging +in, and the code of the app, or one of the backup codes, completes it: + +```js +let response = await post('login', { username, password }) +if (response.codeRequired) { + response = await post('login-code', { code: '123456' }) +} +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 +`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 +TOTP in the account menu, see +[`DitoAdmin`](../../api/admin/admin.md#parameters). + ## Admin The `AdminController` serves the admin interface at its path. In development diff --git a/packages/admin/package.json b/packages/admin/package.json index 94dba81ce..5fe8ee260 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -79,6 +79,7 @@ "codeflask": "^1.4.1", "fflate": "^0.8.3", "focus-trap": "^8.2.3", + "lean-qr": "^2.7.4", "nanoid": "^6.0.2", "sortablejs": "^1.15.7", "tippy.js": "^6.3.7", @@ -123,6 +124,7 @@ "codeflask": "^1.4.1", "fflate": "^0.8.3", "focus-trap": "^8.2.3", + "lean-qr": "^2.7.4", "nanoid": "^6.0.2", "sortablejs": "^1.15.7", "tippy.js": "^6.3.7", diff --git a/packages/admin/src/DitoAdmin.js b/packages/admin/src/DitoAdmin.js index 280bd9cf4..6e0ee3c76 100644 --- a/packages/admin/src/DitoAdmin.js +++ b/packages/admin/src/DitoAdmin.js @@ -62,7 +62,9 @@ export default class DitoAdmin { password: 'post', passkeyLoginOptions: 'post', passkeyLogin: 'post', - passkeyOptions: 'post' + passkeyOptions: 'post', + loginCode: 'post', + totpConfirm: 'post' } for (const [name, method] of Object.entries(methods)) { users[name] = getResource(users[name] || hyphenate(name), { @@ -77,6 +79,9 @@ export default class DitoAdmin { type: 'collection', parent: users }) + // The TOTP of the own user, loaded, set up and turned off by the security + // dialog through its methods: + users.totp = getResource(users.totp || 'totp', { parent: users }) api.users = users // Apps may override the handlers of resource paths: diff --git a/packages/admin/src/components/DitoAccount.test.js b/packages/admin/src/components/DitoAccount.test.js index 18d603baf..48df8986e 100644 --- a/packages/admin/src/components/DitoAccount.test.js +++ b/packages/admin/src/components/DitoAccount.test.js @@ -351,5 +351,163 @@ describe('DitoAccount', () => { ) }) }) + + describe('with TOTP', () => { + const backupCodes = Array.from( + { length: 10 }, + (_, index) => `code${index}-00000` + ) + + // A server with TOTP, without passkeys, which accepts the code + // '123456': + const mountWithTotp = async ({ enabled = false } = {}) => { + const createError = (status, message) => + Object.assign(new Error(message), { + response: { status, data: { message } } + }) + const checkCode = code => { + if (code === 'expired') { + throw createError(400, 'Missing or expired TOTP setup') + } else if (code !== '123456') { + throw createError(401, 'Code is incorrect') + } + } + const request = vi.fn(({ method = 'get', url, data }) => { + if (url === '/users/session') { + return { data: { user: { id: 1, username: 'tester' } } } + } else if (url === '/users/totp' && method === 'get') { + return { + data: { enabled, backupCodes: enabled ? backupCodes.length : 0 } + } + } else if (url === '/users/totp' && method === 'post') { + return { + data: { + uri: 'otpauth://totp/Example:tester?secret=JBSWY3DPEHPK3PXP', + secret: 'JBSWY3DPEHPK3PXP' + } + } + } else if (url === '/users/totp-confirm') { + checkCode(data.code) + enabled = true + return { data: { backupCodes } } + } else if (url === '/users/totp' && method === 'delete') { + checkCode(data.code) + enabled = false + return { data: { success: true } } + } + throw new Error(`Unexpected request: ${method} ${url}`) + }) + const admin = await mountAdmin({ + views, + api: { users: { path: 'users' } }, + request, + options: { login: { totp: true } } + }) + const { items } = await openMenu(admin) + expect(items.map(item => item.text())).toEqual(['Security', 'Sign Out']) + await items[0].trigger('click') + await flushPromises() + const getState = () => + admin.wrapper.find('.dito-dialog .dito-object[id$="totp"]').text() + return { admin, request, getState } + } + + const getLastDialog = admin => + admin.wrapper.findAll('.dito-dialog').at(-1) + + const submitCode = async (admin, code) => { + const dialog = getLastDialog(admin) + await enterValue(dialog.find('input[name="code"]'), code) + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + } + + it('sets up TOTP and shows the backup codes', async () => { + const { admin, request, getState } = await mountWithTotp() + expect(getState()).toContain('Not set up') + await findButton(admin, 'Set Up').trigger('click') + await flushPromises() + const dialog = getLastDialog(admin) + const image = dialog.find('img.dito-qr-code__image') + expect(image.attributes('src')).toMatch(/^data:image\/svg\+xml/) + expect(dialog.find('input[name="secret"]').element.value).toBe( + 'JBSW Y3DP EHPK 3PXP' + ) + await submitCode(admin, '123456') + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/users/totp-confirm', + data: { code: '123456' } + }) + ) + const codes = getLastDialog(admin).find('textarea').element.value + expect(codes.split(/\s+/)).toEqual(backupCodes) + await findButton(admin, 'Done').trigger('click') + await flushPromises() + expect(getState()).toContain('Enabled · 10 backup codes left') + }) + + it('asks for the code again after a wrong one', async () => { + const { admin, getState } = await mountWithTotp() + const notify = vi + .spyOn(admin.context, 'notify') + .mockImplementation(() => {}) + await findButton(admin, 'Set Up').trigger('click') + await flushPromises() + await submitCode(admin, '000000') + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', text: 'Code is incorrect' }) + ) + await submitCode(admin, '123456') + await findButton(admin, 'Done').trigger('click') + await flushPromises() + expect(getState()).toContain('Enabled') + }) + + it('stops asking for codes when the setup expired', async () => { + const { admin, request, getState } = await mountWithTotp() + const notify = vi + .spyOn(admin.context, 'notify') + .mockImplementation(() => {}) + await findButton(admin, 'Set Up').trigger('click') + await flushPromises() + await submitCode(admin, 'expired') + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: 'Missing or expired TOTP setup' + }) + ) + // Only the Security dialog stays, as the code dialog is leaving: + expect( + admin.wrapper.findAll('.dito-dialog:not(.dito-dialog-leave-active)') + ).toHaveLength(1) + expect( + request.mock.calls.filter( + ([{ url }]) => url === '/users/totp-confirm' + ) + ).toHaveLength(1) + expect(getState()).toContain('Not set up') + }) + + it('turns off TOTP with a code', async () => { + const { admin, request, getState } = await mountWithTotp({ + enabled: true + }) + expect(getState()).toContain('Enabled · 10 backup codes left') + await findButton(admin, 'Turn Off').trigger('click') + await flushPromises() + await submitCode(admin, '123456') + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'delete', + url: '/users/totp', + data: { code: '123456' } + }) + ) + expect(getState()).toContain('Not set up') + }) + }) }) }) diff --git a/packages/admin/src/components/DitoAccount.vue b/packages/admin/src/components/DitoAccount.vue index c0b2ca571..f43fd0869 100644 --- a/packages/admin/src/components/DitoAccount.vue +++ b/packages/admin/src/components/DitoAccount.vue @@ -20,7 +20,8 @@ export default { components: { DitoMenuButton }, props: { - passkeys: { type: Boolean, default: false } + passkeys: { type: Boolean, default: false }, + totp: { type: Boolean, default: false } }, setup(props) { @@ -34,7 +35,10 @@ export default { items: computed(() => [ settingsRoute.value && { value: 'settings', label: 'Settings' }, - props.passkeys && { value: 'security', label: 'Security' }, + (props.passkeys || props.totp) && { + value: 'security', + label: 'Security' + }, { value: 'logout', label: 'Sign Out' } ].filter(Boolean) ), @@ -44,7 +48,7 @@ export default { // Navigate to the path, which also opens the view's object form: router.push(router.resolve(settingsRoute.value).path) } else if (value === 'security') { - showSecurityDialog(adminContext, { passkeys: props.passkeys }) + showSecurityDialog(adminContext, props) } else if (value === 'logout') { adminContext.session.logout() } diff --git a/packages/admin/src/components/DitoDialog.test.js b/packages/admin/src/components/DitoDialog.test.js index 533510aa2..db9aa4840 100644 --- a/packages/admin/src/components/DitoDialog.test.js +++ b/packages/admin/src/components/DitoDialog.test.js @@ -108,6 +108,26 @@ describe('DitoDialog', () => { expect(isShown()).toBe(false) }) + it('stays visible for `minDuration` when submitted right away', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] }) + try { + const result = await showDialog({ + components, + buttons: { apply: { type: 'submit' } }, + data: { title: 'Emma' }, + settings: { minDuration: 1000 } + }) + await result.dialog.find('form').trigger('submit') + await vi.advanceTimersByTimeAsync(900) + expect(result.isSettled).toBe(false) + expect(result.isShown()).toBe(true) + await vi.advanceTimersByTimeAsync(100) + expect(await result.promise).toEqual({ title: 'Emma' }) + } finally { + vi.useRealTimers() + } + }) + it(`doesn't cancel with Escape keys that its components handled`, async () => { const result = await showDialog({ components, diff --git a/packages/admin/src/components/DitoDialog.vue b/packages/admin/src/components/DitoDialog.vue index 6144c30de..fafa93ff0 100644 --- a/packages/admin/src/components/DitoDialog.vue +++ b/packages/admin/src/components/DitoDialog.vue @@ -72,6 +72,7 @@ export default { })) const dialog = useTemplateRef('dialog') const dialogButtons = useTemplateRef('dialogButtons') + const openedAt = Date.now() const resolve = value => { props.promise.resolve(value) @@ -94,6 +95,15 @@ export default { // Wait for computed values that depend on pending loads, e.g. options. await waitUntilDataModelSettled(services) if (dataContext.validate()) { + // Keep dialogs that are submitted right away, e.g. by password + // managers, visible for `minDuration`: + const remaining = ( + (props.settings.minDuration ?? 0) - + (Date.now() - openedAt) + ) + if (remaining > 0) { + await new Promise(resolve => setTimeout(resolve, remaining)) + } resolve(dialogData.value) } } diff --git a/packages/admin/src/components/DitoQrCode.vue b/packages/admin/src/components/DitoQrCode.vue new file mode 100644 index 000000000..45e878807 --- /dev/null +++ b/packages/admin/src/components/DitoQrCode.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/admin/src/components/DitoRoot.test.js b/packages/admin/src/components/DitoRoot.test.js index 097a89cb4..3589d1200 100644 --- a/packages/admin/src/components/DitoRoot.test.js +++ b/packages/admin/src/components/DitoRoot.test.js @@ -172,17 +172,22 @@ describe('DitoRoot', () => { describe('login dialog', () => { const mountLoggedOut = async options => { - const request = vi.fn(({ method, url, data }) => - method === 'post' && url === '/logout' - ? { data: { success: true } } - : method === 'post' && url === '/login' - ? { data: { user: { id: 1, username: data.username } } } - : method === 'post' && url === '/passkey-login-options' - ? { data: { challenge: 'challenge' } } - : method === 'post' && url === '/passkey-login' - ? { data: { user: { id: 2, username: 'passkey-user' } } } - : null - ) + const request = vi.fn(({ method, url, data }) => { + // Users named 'totp-user' have TOTP: + const responses = { + '/logout': { success: true }, + '/login': + data?.username === 'totp-user' + ? { success: false, codeRequired: true, user: null } + : { user: { id: 1, username: data?.username } }, + '/login-code': { user: { id: 3, username: 'totp-user' } }, + '/passkey-login-options': { challenge: 'challenge' }, + '/passkey-login': { user: { id: 2, username: 'passkey-user' } } + } + return method === 'post' && url in responses + ? { data: responses[url] } + : null + }) const admin = await mountAdmin({ views: {}, request, options }) await admin.context.session.logout() await flushPromises() @@ -235,6 +240,67 @@ describe('DitoRoot', () => { expect(admin.context.user.username).toBe('reader') }) + describe('with TOTP', () => { + const loginWithPassword = async () => { + const mounted = await mountLoggedOut({}) + const { dialog } = mounted + await enterValue(dialog.find('input[name="username"]'), 'totp-user') + await enterValue(dialog.find('input[name="password"]'), 'secret') + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + return mounted + } + + it('asks for the code after the password', async () => { + const { admin, request } = await loginWithPassword() + // The password dialog may still be leaving: + const dialog = admin.wrapper.findAll('.dito-dialog').at(-1) + expect(dialog.find('input[name="password"]').exists()).toBe(false) + const input = dialog.find('input[name="code"]') + expect(input.attributes('placeholder')).toBe('123456') + expect(dialog.find('.dito-info').attributes('data-info')).toBe( + 'Enter the code from your authenticator app, or a backup code.' + ) + await enterValue(input, '123456') + // The code dialog stays visible for a second, e.g. when password + // managers fill in and submit the code: + vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] }) + try { + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + const isCodeSent = () => + request.mock.calls.some(([config]) => config.url === '/login-code') + expect(isCodeSent()).toBe(false) + await vi.advanceTimersByTimeAsync(1000) + } finally { + vi.useRealTimers() + } + await flushPromises() + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/login-code', + data: { code: '123456' } + }) + ) + expect(admin.context.user.username).toBe('totp-user') + }) + + it('goes back to the password when the code is cancelled', async () => { + const { admin } = await loginWithPassword() + const cancel = admin.wrapper + .findAll('.dito-dialog') + .at(-1) + .findAll('button') + .find(button => button.text() === 'Cancel') + await cancel.trigger('click') + await flushPromises() + const dialog = admin.wrapper.findAll('.dito-dialog').at(-1) + expect(dialog.find('input[name="password"]').exists()).toBe(true) + expect(admin.context.user).toBe(null) + }) + }) + it('offers passkeys only with the option', async () => { const { dialog } = await mountLoggedOut({}) expect(dialog.text()).not.toContain('Sign In with Passkey') diff --git a/packages/admin/src/components/DitoRoot.vue b/packages/admin/src/components/DitoRoot.vue index 9caa0ff44..5bb16def4 100644 --- a/packages/admin/src/components/DitoRoot.vue +++ b/packages/admin/src/components/DitoRoot.vue @@ -54,6 +54,7 @@ DitoAccount( v-if="adminContext.user" :passkeys="!!options.login?.passkeys" + :totp="!!options.login?.totp" ) button.dito-login( v-else-if="hasSessionStarted" @@ -96,6 +97,7 @@ import { import { RouteTree } from '../utils/RouteTree.ts' import { FileDropTargets } from '../utils/FileDropTargets.js' import { adminContextKey, fileDropTargetsKey } from '../utils/keys.ts' +import { createCodeComponent } from '../utils/security.js' export default { components: { DitoDialog }, @@ -205,6 +207,28 @@ export default { } } }) + }, + + // Return the code of users with TOTP, or `null` if the user cancels: + requestLoginCode() { + return showDialog({ + components: { + code: createCodeComponent() + }, + buttons: { + verify: { + type: 'submit', + text: 'Verify' + }, + + cancel: { + type: 'button', + text: 'Cancel' + } + }, + // Show password managers filling in the code: + settings: { minDuration: 1000 } + }) } } ) diff --git a/packages/admin/src/utils/Session.test.js b/packages/admin/src/utils/Session.test.js index 36bc097d4..66309488d 100644 --- a/packages/admin/src/utils/Session.test.js +++ b/packages/admin/src/utils/Session.test.js @@ -18,6 +18,7 @@ const user = { id: 1, username: 'tester' } function createSession({ responses = {}, loginData = [{ username: 'tester', password: 'secret' }], + loginCodes = [], resolvedViews = [true], redirectAfterLogin = null } = {}) { @@ -27,7 +28,8 @@ function createSession({ logout: { method: 'post', path: 'logout' }, session: { method: 'get', path: 'session' }, passkeyLoginOptions: { method: 'post', path: 'passkey-login-options' }, - passkeyLogin: { method: 'post', path: 'passkey-login' } + passkeyLogin: { method: 'post', path: 'passkey-login' }, + loginCode: { method: 'post', path: 'login-code' } }, resources: { any: resource => `/${resource.path}` }, getApiUrl: ({ url }) => url, @@ -50,6 +52,7 @@ function createSession({ }) const userInterface = { requestLoginData: vi.fn(async () => loginData.shift() ?? null), + requestLoginCode: vi.fn(async () => loginCodes.shift() ?? null), notify: vi.fn() } const detach = session.attachUserInterface(userInterface) @@ -169,6 +172,89 @@ describe('Session', () => { expect(userInterface.notify).not.toHaveBeenCalled() }) + describe('with TOTP', () => { + const codeRequired = { success: false, codeRequired: true, user: null } + + const createError = (status, error) => + Object.assign(new Error(error), { + response: { status, data: { error } } + }) + + it('asks for the code after the password', async () => { + const { session, sessionUser, api } = createSession({ + responses: { + 'post /login': codeRequired, + 'post /login-code': { user: { ...user } } + }, + loginCodes: [{ code: '123456' }] + }) + expect(await session.login()).toEqual(user) + expect(api.request).toHaveBeenLastCalledWith({ + method: 'post', + url: '/login-code', + data: { code: '123456' } + }) + expect(sessionUser.value).toEqual(user) + }) + + it('asks for the code again after a wrong one', async () => { + let attempt = 0 + const { session, userInterface } = createSession({ + responses: { + 'post /login': codeRequired, + 'post /login-code': () => { + if (++attempt === 1) { + throw createError(401, 'Code is incorrect') + } + return { user: { ...user } } + } + }, + loginCodes: [{ code: '000000' }, { code: '123456' }] + }) + expect(await session.login()).toEqual(user) + expect(userInterface.requestLoginData).toHaveBeenCalledOnce() + expect(userInterface.requestLoginCode).toHaveBeenCalledTimes(2) + expect(userInterface.notify).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Code is incorrect' }) + ) + }) + + it('asks for the password when the code is cancelled', async () => { + const { session, userInterface } = createSession({ + responses: { + 'post /login': codeRequired, + 'post /login-code': { user: { ...user } } + }, + loginData: [{ username: 'tester' }, { username: 'tester' }], + loginCodes: [null, { code: '123456' }] + }) + expect(await session.login()).toEqual(user) + expect(userInterface.requestLoginData).toHaveBeenCalledTimes(2) + expect(userInterface.notify).not.toHaveBeenCalled() + }) + + it('asks for the password after too many codes or once expired', async () => { + for (const message of [ + 'Missing or expired login', 'Too many attempts' + ]) { + const { session, userInterface } = createSession({ + responses: { + 'post /login': codeRequired, + 'post /login-code': createError(400, message) + }, + loginData: [{ username: 'tester' }, null], + loginCodes: [{ code: '123456' }] + }) + expect(await session.login()).toBe(null) + expect(userInterface.requestLoginData).toHaveBeenCalledTimes(2) + expect(userInterface.requestLoginCode).toHaveBeenCalledOnce() + expect(userInterface.notify).toHaveBeenCalledWith( + expect.objectContaining({ text: message }) + ) + } + }) + }) + it('returns `null` when the user cancels', async () => { const { session, api } = createSession({ loginData: [] }) expect(await session.login()).toBe(null) diff --git a/packages/admin/src/utils/Session.ts b/packages/admin/src/utils/Session.ts index 205c0820e..3db31b2e8 100644 --- a/packages/admin/src/utils/Session.ts +++ b/packages/admin/src/utils/Session.ts @@ -9,6 +9,7 @@ export type User = Record interface UserInterface { requestLoginData(): Promise | 'passkey' | null> + requestLoginCode(): Promise<{ code: string } | null> notify(options: NotifyOptions): void } @@ -43,9 +44,14 @@ export class Session { } // `requestLoginData()` returns the credentials, `'passkey'` to log in with a - // passkey, or `null` when cancelled. - attachUserInterface({ requestLoginData, notify }: UserInterface) { - const userInterface = { requestLoginData, notify } + // passkey, or `null` when cancelled. `requestLoginCode()` returns the TOTP or + // backup code of users with TOTP, or `null` when cancelled. + attachUserInterface({ + requestLoginData, + requestLoginCode, + notify + }: UserInterface) { + const userInterface = { requestLoginData, requestLoginCode, notify } this.#userInterface = userInterface return () => { if (this.#userInterface === userInterface) { @@ -79,7 +85,9 @@ export class Session { } else { response = await this.#request(users.login, loginData) } - user = response.data.user + user = response.data.codeRequired + ? await this.#loginWithCode() + : response.data.user } catch (error) { // Ask again quietly when the passkey prompt is cancelled: if (!isPasskeyPromptCancelled(error)) { @@ -87,13 +95,40 @@ export class Session { } return this.login() } - if (this.#redirectAfterLogin) { - location.replace(this.#redirectAfterLogin) + if (user) { + if (this.#redirectAfterLogin) { + location.replace(this.#redirectAfterLogin) + } else { + this.#setUser(user) + await this.#resolveViews() + } + return user } else { - this.#setUser(user) - await this.#resolveViews() + // The code step was cancelled or has expired, so ask for the password: + return this.login() + } + } + + // Ask for the code of users with TOTP until it's accepted, or resolve to + // `null` to go back to the password: + async #loginWithCode(): Promise { + const codeData = await this.#userInterface?.requestLoginCode() + if (codeData) { + try { + const response = await this.#request( + this.#api.users.loginCode, + codeData + ) + return response.data.user + } catch (error: any) { + this.#notifyError(error) + // Wrong codes answer 401, missing or expired logins and too many + // attempts 400: + return error.response?.status === 401 ? this.#loginWithCode() : null + } + } else { + return null } - return user } // Resolve to whether the user was logged out: diff --git a/packages/admin/src/utils/security.js b/packages/admin/src/utils/security.js index c9be00e4e..8232bfada 100644 --- a/packages/admin/src/utils/security.js +++ b/packages/admin/src/utils/security.js @@ -1,16 +1,39 @@ import { startRegistration } from '@simplewebauthn/browser' +import DitoQrCode from '../components/DitoQrCode.vue' import { isPasskeyPromptCancelled } from './Session.ts' // Dialogs are as wide as their content, up to their `width` setting, so // components as wide as the setting fill them: const securityWidth = 600 +const codeWidth = '360px' // The Security dialog of the account menu, which changes the password and -// manages the passkeys of the logged-in user: -export function showSecurityDialog(adminContext, { passkeys }) { +// manages the passkeys and the TOTP of the logged-in user: +export function showSecurityDialog(adminContext, { passkeys, totp }) { const { users } = adminContext.api const formatDate = ({ value, format }) => value ? format(value, { date: true }) : 'Never' + + // Ask for a code with `dialog` until `submit(code)` accepts it, or resolve + // to `null` when the dialog is cancelled or the submit fails otherwise, e.g. + // after too many attempts or when the setup expired: + const submitCode = async (context, dialog, submit) => { + const data = await context.showDialog(dialog) + if (data) { + try { + return await submit(data.code) + } catch (error) { + context.notify({ type: 'error', error, text: error.message }) + // Wrong codes answer 401: + return error.status === 401 + ? submitCode(context, dialog, submit) + : null + } + } else { + return null + } + } + return adminContext.showDialog({ components: { // The password itself is never shown, so render a placeholder: @@ -109,6 +132,121 @@ export function showSecurityDialog(adminContext, { passkeys }) { } } } + }), + + ...(totp && { + totp: { + type: 'object', + label: 'Two-Factor Authentication', + width: `${securityWidth}px`, + resource: users.totp, + render: ({ item }) => + item.enabled + ? `Enabled · ${item.backupCodes} backup codes left` + : 'Not set up', + buttons: { + setUp: { + text: 'Set Up', + if: ({ item }) => !item.enabled, + async onClick(context) { + const { request, showDialog, reload } = context + const { uri, secret } = await request({ + resource: users.totp, + method: 'post' + }) + const result = await submitCode( + context, + { + components: { + uri: { + type: 'component', + component: DitoQrCode, + label: 'Scan the QR code with your authenticator app' + }, + secret: { + type: 'text', + label: 'Or enter the key', + readonly: true + }, + code: { + type: 'text', + label: 'Authentication Code', + placeholder: '123456', + autocomplete: 'one-time-code', + autofocus: true, + width: codeWidth + } + }, + buttons: { + cancel: {}, + turnOn: { type: 'submit', text: 'Turn On' } + }, + // Group the key for reading: + data: { uri, secret: secret.match(/.{1,4}/g).join(' ') }, + settings: { label: 'Set Up Two-Factor Authentication' } + }, + code => + request({ resource: users.totpConfirm, data: { code } }) + ) + if (result) { + // The codes are stored hashed, so they're only shown now: + await showDialog({ + components: { + backupCodes: { + type: 'textarea', + label: 'Store these backup codes in a safe place', + info: ( + 'Each code logs you in once, instead of a code ' + + 'from your authenticator app.\nThey are only ' + + 'shown now.' + ), + width: codeWidth, + readonly: true, + lines: 5 + } + }, + buttons: { + done: { type: 'submit', text: 'Done' } + }, + data: { + backupCodes: formatBackupCodes(result.backupCodes) + }, + settings: { label: 'Backup Codes' } + }) + await reload() + } + } + }, + + turnOff: { + text: 'Turn Off', + if: ({ item }) => item.enabled, + async onClick(context) { + const { request, reload } = context + const result = await submitCode( + context, + { + components: { code: createCodeComponent() }, + buttons: { + cancel: {}, + turnOff: { type: 'submit', text: 'Turn Off' } + }, + settings: { label: 'Turn Off Two-Factor Authentication' } + }, + code => + request({ + resource: users.totp, + method: 'delete', + data: { code } + }) + ) + if (result) { + await reload() + } + } + } + } + } }) }, buttons: { @@ -119,3 +257,24 @@ export function showSecurityDialog(adminContext, { passkeys }) { settings: { label: 'Security', width: securityWidth } }) } + +// The code field of the login and the security dialogs: +export function createCodeComponent() { + return { + type: 'text', + label: 'Authentication Code', + info: 'Enter the code from your authenticator app, or a backup code.', + placeholder: '123456', + autocomplete: 'one-time-code', + autofocus: true, + width: codeWidth + } +} + +// Two codes per line: +function formatBackupCodes(codes) { + return codes + .map((code, index) => (index % 2 ? ` ${code}\n` : code)) + .join('') + .trim() +} diff --git a/packages/admin/src/utils/services.test.js b/packages/admin/src/utils/services.test.js index a69b7b047..ea6831bb4 100644 --- a/packages/admin/src/utils/services.test.js +++ b/packages/admin/src/utils/services.test.js @@ -111,14 +111,21 @@ describe('services', () => { it('converts errors of responses with data', async () => { const error = new Error('Request failed') - error.response = { data: { message: 'Not found', type: 'NotFound' } } + error.response = { + status: 404, + data: { message: 'Not found', type: 'NotFound' } + } const { schemaComponent } = await mountSchema({ schema: { components: { title: { type: 'text' } } }, request: () => Promise.reject(error) }) await expect( schemaComponent.context.request({ url: 'books' }) - ).rejects.toMatchObject({ message: 'Not found', type: 'NotFound' }) + ).rejects.toMatchObject({ + message: 'Not found', + type: 'NotFound', + status: 404 + }) }) }) diff --git a/packages/admin/src/utils/services.ts b/packages/admin/src/utils/services.ts index 7d8372319..11888039b 100644 --- a/packages/admin/src/utils/services.ts +++ b/packages/admin/src/utils/services.ts @@ -148,9 +148,14 @@ export function request( if (loadCache?.[cacheKey] === res) { delete loadCache[cacheKey] } - // Convert the data of error responses, e.g. Dito.js errors, to errors: + // Convert the data of error responses, e.g. Dito.js errors, to errors + // with the status of the response: const data = error.response?.data - throw data ? Object.assign(new Error(data.message), data) : error + throw data + ? Object.assign(new Error(data.message), data, { + status: error.response.status + }) + : error }) if (loadCache) { loadCache[cacheKey] = res diff --git a/packages/admin/types/index.d.ts b/packages/admin/types/index.d.ts index 0af7c9ca7..e7010b5b3 100644 --- a/packages/admin/types/index.d.ts +++ b/packages/admin/types/index.d.ts @@ -315,6 +315,18 @@ export interface ApiConfig { */ path?: string } + /** + * Posted to with the code of users with TOTP, when the login answers + * `codeRequired`, with the default path `'login-code'`. + */ + loginCode?: { path?: string; method?: HttpMethod } + /** + * The TOTP of the logged-in user, loaded, set up and turned off by the + * security dialog, and confirmed through `totpConfirm`, with the default + * paths `'totp'` and `'totp-confirm'`. + */ + totp?: { path?: string } + totpConfirm?: { path?: string; method?: HttpMethod } } /** * Custom resource path handlers, keyed by resource type. Merged @@ -3205,6 +3217,11 @@ interface AdminOptions { * account menu that manages the user's passkeys. */ passkeys?: boolean + /** + * Adds a security dialog to the account menu that sets up and turns off + * the user's two-factor authentication with TOTP. + */ + totp?: boolean } /** * Controls the loading spinner displayed in the diff --git a/packages/server/package.json b/packages/server/package.json index 84f5cd27e..f6fe52671 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -80,6 +80,7 @@ "@vitejs/plugin-vue": "^6.0.0", "knex": "^3.3.0", "objection": "^3.3.0", + "otpauth": "^9.0.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -95,6 +96,9 @@ "@vitejs/plugin-vue": { "optional": true }, + "otpauth": { + "optional": true + }, "vite": { "optional": true } @@ -113,6 +117,7 @@ "@types/node": "^26.6.4", "knex": "^3.3.0", "objection": "^3.3.0", + "otpauth": "^9.5.2", "typescript": "^6.0.3" } } diff --git a/packages/server/src/controllers/UsersController.js b/packages/server/src/controllers/UsersController.js index c7d75d8f3..b2e6d27b6 100644 --- a/packages/server/src/controllers/UsersController.js +++ b/packages/server/src/controllers/UsersController.js @@ -1,10 +1,28 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + hkdfSync, + randomBytes +} from 'node:crypto' import aaguids from 'aaguids' import { ModelController } from './ModelController.js' +import { + getUserIdentifier, + parseUserIdentifier +} from '../middleware/handleUser.js' +import { + authTimeout, + backupCodeCount, + codeLockTime, + maxCodeFailures +} from '../utils/auth.js' import { AuthenticationError, AuthorizationError, ControllerError, - NotFoundError + NotFoundError, + ResponseError } from '../errors/index.js' export class UsersController extends ModelController { @@ -13,6 +31,7 @@ export class UsersController extends ModelController { // setting doesn't apply to them: 'authorize': { 'post login': true, + 'post login-code': true, 'post logout': true, 'get session': true, 'post passkey-login-options': true, @@ -22,13 +41,49 @@ export class UsersController extends ModelController { 'get passkeys': ctx => this.isAuthenticated(ctx), 'post passkey-options': ctx => this.isAuthenticated(ctx), 'post passkeys': ctx => this.isAuthenticated(ctx), - 'delete passkeys/:id': ctx => this.isAuthenticated(ctx) + 'delete passkeys/:id': ctx => this.isAuthenticated(ctx), + // ...and their TOTP: + 'get totp': ctx => this.isAuthenticated(ctx), + 'post totp': ctx => this.isAuthenticated(ctx), + 'post totp-confirm': ctx => this.isAuthenticated(ctx), + 'delete totp': ctx => this.isAuthenticated(ctx) }, 'post login'(ctx) { return this.handleLogin(ctx, () => this.modelClass.login(ctx)) }, + // The second step of password logins of users with TOTP, with a TOTP code + // or a backup code: + 'post login-code'(ctx) { + return this.handleLogin(ctx, async () => { + const { pendingLogin } = ctx.session + const { modelName, id } = pendingLogin + ? parseUserIdentifier(pendingLogin.user) + : {} + if ( + modelName === this.modelClass.name && + pendingLogin.expires > Date.now() + ) { + const user = await this.modelClass + .sessionQuery(ctx.transaction) + .findById(id) + const { code } = ctx.request.body + if (user && (await this.verifyCode(ctx, user, code))) { + delete ctx.session.pendingLogin + await ctx.login(user) + return user + } + throw new AuthenticationError('Code is incorrect') + } else { + throw new AuthenticationError({ + message: 'Missing or expired login', + status: 400 + }) + } + }) + }, + async 'post logout'(ctx) { let success = false if (this.isAuthenticated(ctx)) { @@ -228,6 +283,109 @@ export class UsersController extends ModelController { } else { throw new NotFoundError() } + }, + + async 'get totp'(ctx) { + const { user } = ctx.state + const credentials = await getTotpQuery( + this.modelClass, + user, + ctx.transaction + ) + const types = credentials.map(({ type }) => type) + return { + enabled: types.includes('totp'), + backupCodes: types.filter(type => type === 'backup-code').length + } + }, + + // Keep the new secret in the session until the user confirms it with a + // code: + async 'post totp'(ctx) { + if (await hasTotp(this.modelClass, ctx)) { + throw new ResponseError({ + message: 'TOTP is already set up', + status: 409 + }) + } + const { base32: secret } = new this.otpAuth.Secret() + ctx.session.totpSetup = { + secret, + expires: Date.now() + authTimeout, + failures: 0 + } + return { + uri: createTotp(this, ctx.state.user, secret).toString(), + secret + } + }, + + async 'post totp-confirm'(ctx) { + const { user } = ctx.state + const { totpSetup } = ctx.session + if (!(totpSetup?.expires > Date.now())) { + throw new AuthenticationError({ + message: 'Missing or expired TOTP setup', + status: 400 + }) + } else if (await hasTotp(this.modelClass, ctx)) { + throw new ResponseError({ + message: 'TOTP is already set up', + status: 409 + }) + } + const { secret } = totpSetup + const step = validateTotp( + createTotp(this, user, secret), + ctx.request.body.code + ) + if (step !== null) { + delete ctx.session.totpSetup + const key = this.app.config.app.auth.credentials.secret + const backupCodes = Array.from({ length: backupCodeCount }, () => { + // 40 random bits, as two groups of five hex digits: + const code = randomBytes(5).toString('hex') + return `${code.slice(0, 5)}-${code.slice(5)}` + }) + // Batch inserts only work on PostgreSQL: + for (const [type, data] of [ + // The confirmed code can't be used again: + ['totp', { secret: encrypt(key, secret, user), step }], + ...backupCodes.map(code => [ + 'backup-code', + { hash: hashBackupCode(key, code) } + ]) + ]) { + await this.modelClass.credentialClass.query(ctx.transaction).insert({ + type, + userModel: this.modelClass.name, + userId: `${user.$id()}`, + data + }) + } + // The backup codes are stored hashed, so users only see them now: + return { backupCodes } + } else if (++totpSetup.failures >= maxCodeFailures) { + // The setup starts over after too many wrong codes: + delete ctx.session.totpSetup + throw createTooManyAttemptsError() + } else { + throw new AuthenticationError('Code is incorrect') + } + }, + + async 'delete totp'(ctx) { + const { code } = ctx.request.body + if (await this.verifyCode(ctx, ctx.state.user, code)) { + await getTotpQuery( + this.modelClass, + ctx.state.user, + ctx.transaction + ).delete() + return { success: true } + } else { + throw new AuthenticationError('Code is incorrect') + } } } @@ -239,61 +397,90 @@ export class UsersController extends ModelController { setup() { const { session, auth } = this.app.config.app this.passkeys = auth?.passkeys ?? null - if (this.passkeys) { - const missingKey = ['name', 'id', 'origins'].find( - key => !this.passkeys[key] - ) - // Passkey challenges are kept in the session, stored on the server so - // that old cookies can't replay them, and passkeys are credentials: + this.totp = auth?.totp ?? null + + // Passkey challenges and pending logins are kept in the session, stored on + // the server so that old cookies can't replay them, and both need + // credentials: + const checkAuth = name => { if (!(session?.modelClass || session?.store || session?.ContextStore)) { throw new ControllerError( this, - `Unable to use passkeys without sessions stored on the server, ` + + `Unable to use ${name} without sessions stored on the server, ` + `see 'app.session'.` ) } else if (!this.modelClass.credentialClass) { throw new ControllerError( this, - `Unable to use passkeys without credentials, see ` + + `Unable to use ${name} without credentials, see ` + `'app.auth.credentials'.` ) - } else if (missingKey) { + } + } + + if (this.passkeys) { + checkAuth('passkeys') + const missingKey = ['name', 'id', 'origins'].find( + key => !this.passkeys[key] + ) + if (missingKey) { throw new ControllerError( this, `Invalid passkeys, missing 'app.auth.passkeys.${missingKey}'.` ) } } + if (this.totp) { + if (!this.totp.issuer) { + throw new ControllerError( + this, + `Invalid TOTP, missing 'app.auth.totp.issuer'.` + ) + } + checkAuth('TOTP') + // TOTP secrets are encrypted with a secret of their own, not `app.keys`, + // since rotating those would make them unreadable: + if (!auth.credentials.secret) { + throw new ControllerError( + this, + `Unable to use TOTP without 'app.auth.credentials.secret'.` + ) + } + } // Set up the routes before importing, so that sub-classes that don't await // `super.setup()` still get them before `initialize()`, and configuration // errors are thrown right away: super.setup() - if (this.passkeys) { - // `@simplewebauthn/server` is an optional peer dependency, so we only - // import it when passkeys are used: - return import('@simplewebauthn/server').then( - webAuthn => { - this.webAuthn = webAuthn - }, - err => { - throw err.code === 'ERR_MODULE_NOT_FOUND' - ? new ControllerError( - this, - `Unable to use passkeys without '@simplewebauthn/server'.` - ) - : err - } - ) - } + // The libraries are optional peer dependencies, so we only import them + // when they are used: + const importModule = (name, module) => + import(module).catch(err => { + throw err.code === 'ERR_MODULE_NOT_FOUND' + ? new ControllerError( + this, + `Unable to use ${name} without '${module}'.` + ) + : err + }) + return Promise.all([ + this.passkeys && importModule('passkeys', '@simplewebauthn/server'), + this.totp && importModule('TOTP', 'otpauth') + ]).then(([webAuthn, otpAuth]) => { + this.webAuthn = webAuthn + this.otpAuth = otpAuth + }) } // @override processValues(values) { const processed = super.processValues(values) - // Without passkeys, we don't route their actions, which are all in + // Without passkeys or TOTP, we don't route their actions, which are all in // `collection`: - if (!this.passkeys && values === this.collection) { - for (const name of passkeyActions) { + if (values === this.collection) { + for (const name of [ + ...(this.passkeys ? [] : passkeyActions), + ...(this.totp ? [] : totpActions) + ]) { delete processed.values[name] } } @@ -325,9 +512,15 @@ export class UsersController extends ModelController { async handleLogin(ctx, login) { let user let error + let codeRequired = false try { user = await login() - await user.$patch({ lastLogin: new Date() }, ctx.transaction) + if (user) { + await user.$patch({ lastLogin: new Date() }, ctx.transaction) + } else { + // `UserMixin.login()` asks users with TOTP for a code first: + codeRequired = true + } } catch (err) { this.app.emit('error', err, ctx) user = null @@ -337,11 +530,69 @@ export class UsersController extends ModelController { const success = !!user return { success, + ...(codeRequired && { codeRequired }), authenticated: success && this.isAuthenticated(ctx), user, error } } + + // Verify a TOTP code, or consume a backup code of the user. Too many wrong + // codes in a row lock the account for a while. The credentials stay locked + // meanwhile, so that concurrent requests can't use the same code or miss a + // failure: + async verifyCode(ctx, user, code) { + const key = this.app.config.app.auth.credentials.secret + const { isValid, isLocked } = await this.modelClass.credentialClass + .transaction(ctx.transaction, async trx => { + const credentials = await getTotpQuery( + this.modelClass, + user, + trx + ).forUpdate() + const totp = credentials.find(({ type }) => type === 'totp') + if (totp) { + const { data } = totp + if (data.lockedUntil > Date.now()) { + return { isValid: false, isLocked: true } + } + const step = validateTotp( + createTotp(this, user, decrypt(key, data.secret, user)), + code + ) + const hash = hashBackupCode(key, code) + const backupCode = credentials.find( + ({ type, data }) => type === 'backup-code' && data.hash === hash + ) + // Each code is only accepted once, and backup codes are deleted: + const isTotpCode = step !== null && step > data.step + const isValid = isTotpCode || !!backupCode + if (backupCode && !isTotpCode) { + await backupCode.$query(trx).delete() + } + const failures = isValid ? 0 : (data.failures ?? 0) + 1 + const isLocked = failures >= maxCodeFailures + await totp.$query(trx).patch({ + data: { + ...data, + step: isTotpCode ? step : data.step, + // The count starts anew once the lock ends: + failures: isLocked ? 0 : failures, + lockedUntil: isLocked ? Date.now() + codeLockTime : null + }, + ...(isTotpCode && { lastUsedAt: new Date() }) + }) + return { isValid, isLocked } + } else { + return { isValid: false, isLocked: false } + } + }) + // Throw after the transaction, which keeps the failures: + if (isLocked) { + throw createTooManyAttemptsError() + } + return isValid + } } // Only routed with `app.auth.passkeys`, as they import the optional peer @@ -355,7 +606,19 @@ const passkeyActions = [ 'delete passkeys/:id' ] -const challengeTimeout = 5 * 60 * 1000 // 5 minutes +// Only routed with `app.auth.totp`, as they import the optional peer +// dependency `otpauth`: +const totpActions = [ + 'post login-code', + 'get totp', + 'post totp', + 'post totp-confirm', + 'delete totp' +] + +// The lengths of the IV and the authentication tag of AES-256-GCM: +const ivLength = 12 +const tagLength = 16 function getPasskeyQuery(modelClass, ctx) { return modelClass.credentialQuery(ctx.transaction).where({ @@ -367,7 +630,7 @@ function getPasskeyQuery(modelClass, ctx) { function storeChallenge(ctx, { challenge }) { ctx.session.passkeyChallenge = { challenge, - expires: Date.now() + challengeTimeout + expires: Date.now() + authTimeout } } @@ -384,3 +647,84 @@ function consumeChallenge(ctx) { }) } } + +// The attempt that uses up the limit answers it too, so that clients don't ask +// for another code: +function createTooManyAttemptsError() { + return new AuthenticationError({ message: 'Too many attempts', status: 400 }) +} + +function getTotpQuery(modelClass, user, trx) { + return modelClass + .credentialQuery(trx) + .where('userId', `${user.$id()}`) + .whereIn('type', ['totp', 'backup-code']) +} + +async function hasTotp(modelClass, ctx) { + return !!(await getTotpQuery( + modelClass, + ctx.state.user, + ctx.transaction + ).findOne({ type: 'totp' })) +} + +function createTotp(controller, user, secret) { + const { usernameProperty } = controller.modelClass.definition.options + return new controller.otpAuth.TOTP({ + issuer: controller.totp.issuer, + label: user[usernameProperty], + secret + }) +} + +// Return the time step of a valid code, or `null`. Accept the previous and the +// next code too, for clocks that drift: +function validateTotp(totp, code) { + const timestamp = Date.now() + const delta = totp.validate({ token: `${code}`, timestamp, window: 1 }) + return delta !== null ? totp.counter({ timestamp }) + delta : null +} + +// `app.auth.credentials.secret` is a long random string, so HKDF suffices to +// derive a key for each use: +function deriveKey(secret, use) { + return Buffer.from(hkdfSync('sha256', secret, 'dito-credentials', use, 32)) +} + +// Encrypt with AES-256-GCM, with a random IV and the authentication tag stored +// before the encrypted value. The user is authenticated data, so the value +// can't be moved to other users: +function encrypt(secret, value, user) { + const iv = randomBytes(ivLength) + const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, 'totp'), iv) + cipher.setAAD(Buffer.from(getUserIdentifier(user))) + const encrypted = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final() + ]) + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64') +} + +function decrypt(secret, value, user) { + const buffer = Buffer.from(value, 'base64') + const decipher = createDecipheriv( + 'aes-256-gcm', + deriveKey(secret, 'totp'), + buffer.subarray(0, ivLength) + ) + decipher.setAAD(Buffer.from(getUserIdentifier(user))) + decipher.setAuthTag(buffer.subarray(ivLength, ivLength + tagLength)) + return Buffer.concat([ + decipher.update(buffer.subarray(ivLength + tagLength)), + decipher.final() + ]).toString('utf8') +} + +// Backup codes are random, so a fast hash suffices. Keyed with the secret, a +// leaked database alone doesn't allow guessing them: +function hashBackupCode(secret, code) { + return createHmac('sha256', deriveKey(secret, 'backup-code')) + .update(`${code}`.toLowerCase().replace(/[^0-9a-f]/g, '')) + .digest('hex') +} diff --git a/packages/server/src/mixins/UserMixin.js b/packages/server/src/mixins/UserMixin.js index c7bf6681b..11a62c30c 100644 --- a/packages/server/src/mixins/UserMixin.js +++ b/packages/server/src/mixins/UserMixin.js @@ -1,6 +1,8 @@ import bcrypt from 'bcryptjs' import { mixin, asArray, isString } from '@ditojs/utils' import { AuthenticationError } from '../errors/index.js' +import { getUserIdentifier } from '../middleware/handleUser.js' +import { authTimeout } from '../utils/auth.js' export const UserMixin = mixin( Model => @@ -122,8 +124,27 @@ export const UserMixin = mixin( username ) if (user && (await user.$verifyPassword(password))) { - await ctx.login(user, options) - return user + const totp = this.app.config.app.auth?.totp + ? await this.credentialQuery(ctx.transaction).findOne({ + type: 'totp', + userId: `${user.$id()}` + }) + : null + if (totp) { + // Users with TOTP are only logged in once they enter a code, + // through `'post login-code'` of `UsersController`. Until then, + // there is no user to return. Like `ctx.login()`, regenerate + // the session id against session fixation: + ctx.session.pendingLogin = { + user: getUserIdentifier(user), + expires: Date.now() + authTimeout + } + await ctx.session.regenerate() + return null + } else { + await ctx.login(user, options) + return user + } } else { throw new AuthenticationError('Password or username is incorrect') } diff --git a/packages/server/src/utils/auth.js b/packages/server/src/utils/auth.js new file mode 100644 index 000000000..76ffe6ab1 --- /dev/null +++ b/packages/server/src/utils/auth.js @@ -0,0 +1,9 @@ +// The time to answer passkey challenges, and to complete TOTP setups and the +// logins of users with TOTP: +export const authTimeout = 5 * 60 * 1000 // 5 minutes + +// Wrong TOTP and backup codes in a row lock the account for a while: +export const maxCodeFailures = 10 +export const codeLockTime = 15 * 60 * 1000 // 15 minutes + +export const backupCodeCount = 10 diff --git a/packages/server/types/index.d.ts b/packages/server/types/index.d.ts index 19904af04..47ae2e840 100644 --- a/packages/server/types/index.d.ts +++ b/packages/server/types/index.d.ts @@ -208,6 +208,11 @@ export interface ApplicationConfig { * `Credential`. Its model uses `CredentialMixin`. */ modelClass: string | Class + /** + * A long random string that encrypts the TOTP secrets and hashes the + * backup codes, required by `totp`. Changing it invalidates them. + */ + secret?: string } /** * Enables passkeys, which need `session` and `credentials`. @@ -220,6 +225,14 @@ export interface ApplicationConfig { /** The origins of the frontends that use the passkeys. */ origins: string[] } + /** + * Enables two-factor authentication with TOTP, which needs `session`, + * `credentials` and `credentials.secret`. + */ + totp?: { + /** The name of the app shown by authenticator apps. */ + issuer: string + } } /** * Set signed cookie keys. @@ -4361,7 +4374,7 @@ export const CredentialMixin: >( target: T ) => T & Constructor<{ - /** `'passkey'` */ + /** `'passkey'`, `'totp'` or `'backup-code'` */ type: string /** The name of the user's model class. */ userModel: string @@ -4414,12 +4427,13 @@ export const UserMixin: >( /** * Authenticates a user with the credentials of the request body, and - * logs them in. + * logs them in. Resolves to `null` for users with TOTP, who are logged + * in once they enter a code through `'post login-code'`. */ login( ctx: KoaContext, options?: Record - ): Promise> + ): Promise | null> /** @internal */ sessionQuery( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e492b046e..073a152d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,6 +431,9 @@ importers: focus-trap: specifier: ^8.2.3 version: 8.2.3 + lean-qr: + specifier: ^2.7.4 + version: 2.7.4 nanoid: specifier: ^6.0.2 version: 6.0.2 @@ -684,6 +687,9 @@ importers: objection: specifier: ^3.3.0 version: 3.9.1(knex@3.3.0(better-sqlite3@13.0.3)(pg@8.23.1)(supports-color@7.2.0)) + otpauth: + specifier: ^9.5.2 + version: 9.5.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -746,6 +752,9 @@ importers: objection: specifier: ^3.3.0 version: 3.9.1(knex@3.3.0(better-sqlite3@13.0.3)(pg@8.23.1)(supports-color@10.2.2)) + otpauth: + specifier: ^9.5.2 + version: 9.5.2 vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.5)(vite@8.3.4(@types/node@26.6.4)(sass@1.105.1)(yaml@2.9.1)) @@ -1260,6 +1269,10 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4217,6 +4230,10 @@ packages: resolution: {integrity: sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==} engines: {node: '>= 18'} + lean-qr@2.7.4: + resolution: {integrity: sha512-tlMPFfqhAAZN7+j/djRSvl/2+AYT2+7RRuyq8bvcbV8gMiENiscquQ9WTfCSyeEdxsVaEB+tU7e9nFZC63BFnQ==} + hasBin: true + leather@3.0.3: resolution: {integrity: sha512-z8RafbVo9OohVmkNCpJ/OyncYQbPUcW+80NPrsJPZ7Jugu6LTw/GtqYGUrGKu0hdcRtBUe67GGiz+aiyTI2WIg==} @@ -4743,6 +4760,9 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + otpauth@9.5.2: + resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==} + own-keys@1.0.2: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} @@ -6817,6 +6837,8 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9904,6 +9926,8 @@ snapshots: type-is: 2.1.0 vary: 1.1.2 + lean-qr@2.7.4: {} + leather@3.0.3: {} lerna@10.0.1(@types/node@26.6.4)(supports-color@7.2.0)(typescript@6.0.3): @@ -10622,6 +10646,10 @@ snapshots: orderedmap@2.1.1: {} + otpauth@9.5.2: + dependencies: + '@noble/hashes': 2.4.0 + own-keys@1.0.2: dependencies: call-bound: 1.0.4 diff --git a/tests/e2e/passkeys/admin/__screenshots__/change-password.png b/tests/e2e/passkeys/admin/__screenshots__/change-password.png index daa490bb1..a3ada73c6 100644 Binary files a/tests/e2e/passkeys/admin/__screenshots__/change-password.png and b/tests/e2e/passkeys/admin/__screenshots__/change-password.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png b/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png index 65300550c..2e815b12c 100644 Binary files a/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png and b/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/login-code.png b/tests/e2e/passkeys/admin/__screenshots__/login-code.png new file mode 100644 index 000000000..45199e2ea Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/login-code.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/security-empty.png b/tests/e2e/passkeys/admin/__screenshots__/security-empty.png index bf28c643c..cd848c563 100644 Binary files a/tests/e2e/passkeys/admin/__screenshots__/security-empty.png and b/tests/e2e/passkeys/admin/__screenshots__/security-empty.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png b/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png index 56e6d7ed3..0a00d459d 100644 Binary files a/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png and b/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/security-totp.png b/tests/e2e/passkeys/admin/__screenshots__/security-totp.png new file mode 100644 index 000000000..d0d6fadf0 Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/security-totp.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/totp-backup-codes.png b/tests/e2e/passkeys/admin/__screenshots__/totp-backup-codes.png new file mode 100644 index 000000000..f6de05dca Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/totp-backup-codes.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/totp-setup.png b/tests/e2e/passkeys/admin/__screenshots__/totp-setup.png new file mode 100644 index 000000000..508b560c6 Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/totp-setup.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/totp-turn-off.png b/tests/e2e/passkeys/admin/__screenshots__/totp-turn-off.png new file mode 100644 index 000000000..9d3689bae Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/totp-turn-off.png differ diff --git a/tests/e2e/passkeys/admin/security.screenshot.ts b/tests/e2e/passkeys/admin/security.screenshot.ts index c5c8d088a..9a57ab3fd 100644 --- a/tests/e2e/passkeys/admin/security.screenshot.ts +++ b/tests/e2e/passkeys/admin/security.screenshot.ts @@ -49,6 +49,58 @@ async function addPasskeys() { } } +// TOTP with all backup codes left. The login and the security dialog only check +// that the credentials exist: +async function addTotp() { + const user = await User.query().findOne({ username: 'ada' }) + for (const type of ['totp', ...Array(10).fill('backup-code')]) { + await Credential.query().insert({ + type, + userModel: 'User', + userId: `${user!.id}`, + data: {} + } as any) + } +} + +// The server creates a random secret for each setup, and only confirms codes +// of it, so we answer both with fixed data for screenshots that stay the same: +async function mockTotpSetup(page: Page) { + const secret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' + await page.route('**/api/users/totp', route => + route.request().method() === 'POST' + ? route.fulfill({ + json: { + uri: `otpauth://totp/Example:ada?issuer=Example&secret=${secret}`, + secret + } + }) + : route.continue() + ) + await page.route('**/api/users/totp-confirm', route => + route.fulfill({ + json: { + backupCodes: Array.from( + { length: 10 }, + (_, index) => `${index}a2b4-c6d8e` + ) + } + }) + ) +} + +async function openTotpSetup(page: Page, url: string) { + await mockTotpSetup(page) + await login(page, url) + const security = await openSecurity(page) + await security.getByRole('button', { name: 'Set Up' }).click() + const dialog = page.getByRole('dialog', { + name: 'Set Up Two-Factor Authentication' + }) + await expect(dialog.getByRole('img', { name: 'QR code' })).toBeVisible() + return dialog +} + test.describe('screenshots', () => { test('login dialog with passkeys', async ({ page, url }) => { await page.goto(`${url}/admin/`) @@ -94,4 +146,50 @@ test.describe('screenshots', () => { await expect(dialog.getByLabel('Current Password')).toBeFocused() await expectScreenshot(page, 'change-password.png') }) + + test('login code dialog', async ({ page, url }) => { + await addTotp() + await page.goto(`${url}/admin/`) + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Username').fill('ada') + await dialog.getByLabel('Password').fill('secret') + await dialog.getByRole('button', { name: 'Sign In', exact: true }).click() + await expect(dialog.getByLabel('Authentication Code')).toBeFocused() + await expectScreenshot(page, 'login-code.png') + }) + + test('security dialog with TOTP', async ({ page, url }) => { + await login(page, url) + await addTotp() + const dialog = await openSecurity(page) + await expect(dialog).toContainText('Enabled · 10 backup codes left') + await expectScreenshot(page, 'security-totp.png') + }) + + test('TOTP setup dialog', async ({ page, url }) => { + const dialog = await openTotpSetup(page, url) + await expect(dialog.getByLabel('Authentication Code')).toBeFocused() + await expectScreenshot(page, 'totp-setup.png') + }) + + test('backup codes dialog', async ({ page, url }) => { + const setup = await openTotpSetup(page, url) + await setup.getByLabel('Authentication Code').fill('123456') + await setup.getByRole('button', { name: 'Turn On' }).click() + const dialog = page.getByRole('dialog', { name: 'Backup Codes' }) + await expect(dialog.getByRole('textbox')).toHaveValue(/0a2b4-c6d8e/) + await expectScreenshot(page, 'totp-backup-codes.png') + }) + + test('turn off TOTP dialog', async ({ page, url }) => { + await login(page, url) + await addTotp() + const security = await openSecurity(page) + await security.getByRole('button', { name: 'Turn Off' }).click() + const dialog = page.getByRole('dialog', { + name: 'Turn Off Two-Factor Authentication' + }) + await expect(dialog.getByLabel('Authentication Code')).toBeFocused() + await expectScreenshot(page, 'totp-turn-off.png') + }) }) diff --git a/tests/e2e/passkeys/admin/totp.spec.ts b/tests/e2e/passkeys/admin/totp.spec.ts new file mode 100644 index 000000000..220084645 --- /dev/null +++ b/tests/e2e/passkeys/admin/totp.spec.ts @@ -0,0 +1,85 @@ +import type { Page } from '@playwright/test' +import { TOTP } from 'otpauth' +import { test, expect } from '../fixtures.js' +import { Credential } from '../models/Credential.js' + +function getAccountButton(page: Page) { + return page.locator('.dito-account').getByRole('button') +} + +async function loginWithPassword(page: Page) { + const login = page.getByRole('dialog') + await login.getByLabel('Username').fill('ada') + await login.getByLabel('Password').fill('secret') + await login.getByRole('button', { name: 'Sign In', exact: true }).click() +} + +async function openSecurity(page: Page) { + await getAccountButton(page).click() + await page.getByRole('menuitem', { name: 'Security' }).click() + return page.getByRole('dialog', { name: 'Security' }) +} + +test.describe('TOTP', () => { + test('set up, log in with a code and turn off', async ({ page, url }) => { + await page.goto(`${url}/admin/`) + await loginWithPassword(page) + await expect(getAccountButton(page)).toHaveText('ada') + + // Set up TOTP in the security dialog, with the key for manual entry: + const security = await openSecurity(page) + await expect(security).toContainText('Not set up') + await security.getByRole('button', { name: 'Set Up' }).click() + const setup = page.getByRole('dialog', { + name: 'Set Up Two-Factor Authentication' + }) + await expect(setup.getByRole('img', { name: 'QR code' })).toBeVisible() + // `autofocus` focuses the code, not the key before it: + await expect(setup.getByLabel('Authentication Code')).toBeFocused() + const secret = await setup.getByLabel('Or enter the key').inputValue() + const totp = new TOTP({ secret: secret.replaceAll(' ', '') }) + await setup.getByLabel('Authentication Code').fill(totp.generate()) + await setup.getByRole('button', { name: 'Turn On' }).click() + const codes = page.getByRole('dialog', { name: 'Backup Codes' }) + const backupCodes = (await codes.getByRole('textbox').inputValue()).split( + /\s+/ + ) + expect(backupCodes).toHaveLength(10) + await codes.getByRole('button', { name: 'Done' }).click() + await expect(security).toContainText('Enabled · 10 backup codes left') + await security.getByRole('button', { name: 'Close' }).click() + + // Log out, and in again with the password and the next code, as the + // current one confirmed the setup: + await getAccountButton(page).click() + await page.getByRole('menuitem', { name: 'Sign Out' }).click() + await page.locator('button.dito-login').click() + await loginWithPassword(page) + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Authentication Code').fill('wrong') + await dialog.getByRole('button', { name: 'Verify' }).click() + await expect(page.locator('.dito-notification')).toContainText( + 'Code is incorrect' + ) + // Wait for the dialog of the wrong code to leave, as the next one asks: + await expect(dialog).toHaveCount(1) + await dialog + .getByLabel('Authentication Code') + .fill(totp.generate({ timestamp: Date.now() + totp.period * 1000 })) + await dialog.getByRole('button', { name: 'Verify' }).click() + await expect(getAccountButton(page)).toHaveText('ada') + const [credential] = await Credential.query().where('type', 'totp') + expect(credential.lastUsedAt).toBeInstanceOf(Date) + + // Turn off TOTP with a backup code: + const turnOff = await openSecurity(page) + await turnOff.getByRole('button', { name: 'Turn Off' }).click() + const confirm = page.getByRole('dialog', { + name: 'Turn Off Two-Factor Authentication' + }) + await confirm.getByLabel('Authentication Code').fill(backupCodes[0]) + await confirm.getByRole('button', { name: 'Turn Off' }).click() + await expect(turnOff).toContainText('Not set up') + expect(await Credential.query()).toEqual([]) + }) +}) diff --git a/tests/e2e/passkeys/fixtures.ts b/tests/e2e/passkeys/fixtures.ts index 7eeeb9542..c47abf1b1 100644 --- a/tests/e2e/passkeys/fixtures.ts +++ b/tests/e2e/passkeys/fixtures.ts @@ -32,11 +32,12 @@ export const test = defineScenario({ normalizePaths: true, helmet: false, keys: ['passkeys-secret'], - // Passkeys need sessions stored on the server: + // Passkeys and TOTP need sessions stored on the server: session: { modelClass: 'Session' }, auth: { - credentials: { modelClass: 'Credential' }, - passkeys + credentials: { modelClass: 'Credential', secret: 'credentials-secret' }, + passkeys, + totp: { issuer: 'Example' } } } }, diff --git a/tests/e2e/passkeys/views.ts b/tests/e2e/passkeys/views.ts index 469c6d28d..36fd1bb35 100644 --- a/tests/e2e/passkeys/views.ts +++ b/tests/e2e/passkeys/views.ts @@ -5,5 +5,6 @@ export const dashboard = { } as const export const login = { - passkeys: true + passkeys: true, + totp: true } as const diff --git a/tests/package.json b/tests/package.json index b4f1280ad..0266c1144 100644 --- a/tests/package.json +++ b/tests/package.json @@ -25,6 +25,7 @@ "better-sqlite3": "^13.0.3", "knex": "^3.3.0", "objection": "^3.3.0", + "otpauth": "^9.5.2", "vitest": "^5.0.3" } } diff --git a/tests/server/totp.test.ts b/tests/server/totp.test.ts new file mode 100644 index 000000000..451b72c5e --- /dev/null +++ b/tests/server/totp.test.ts @@ -0,0 +1,702 @@ +import { + CredentialModel, + SessionModel, + UserModel, + UsersController +} from '@ditojs/server' +import { TOTP } from 'otpauth' +import { createTestApp, getAppUrl } from '../utils/app.js' +import { createAuthenticator } from '../utils/authenticator.js' +import { createClient } from '../utils/client.js' +import { createTestDatabase } from '../utils/database.js' + +const loginEvents: string[] = [] + +class Session extends SessionModel { + declare value: Record +} + +class Credential extends CredentialModel { + declare id: number + declare type: string + declare userModel: string + declare userId: string + declare data: Record + declare lastUsedAt: Date | null +} + +class Member extends UserModel { + declare id: number + declare username: string + + static override hooks: any = { + 'after:login'(member: Member) { + loginEvents.push(`after login ${member.username}`) + } + } +} + +// Another user model, whose ids overlap with those of members: +class Guest extends UserModel { + declare id: number +} + +class Members extends UsersController { + override modelClass = Member +} + +class Guests extends UsersController { + override modelClass = Guest +} + +const passkeys = { + name: 'Example', + id: 'example.com', + origins: ['https://example.com'] +} + +const auth = { + credentials: { modelClass: 'Credential', secret: 'credentials-secret' }, + passkeys, + totp: { issuer: 'Example' } +} + +type Client = ReturnType + +describe('TOTP', () => { + const app = createTestApp({ + config: { + app: { + normalizePaths: true, + helmet: false, + keys: ['test-secret'], + session: { modelClass: 'Session' }, + auth + } + }, + models: { Session, Credential, Member, Guest }, + controllers: { Members, Guests } + }) + + let url: string + let member: Member + + const loginWithPassword = async (path = '/members') => { + const request = createClient(url) + const login = await request(`${path}/login`, { + method: 'POST', + body: { username: 'ada', password: 'secret' } + }) + return { request, login } + } + + const loginWithCode = async (code: string, path = '/members') => { + const { request, login } = await loginWithPassword(path) + expect(login.data.codeRequired).toBe(true) + const response = await request(`${path}/login-code`, { + method: 'POST', + body: { code } + }) + return { request, login: response } + } + + // Set up TOTP for ada, and return the TOTP of her authenticator app: + const setupTotp = async () => { + const { request } = await loginWithPassword() + const setup = await request('/members/totp', { method: 'POST' }) + expect(setup.status).toBe(200) + const totp = new TOTP({ + issuer: 'Example', + label: 'ada', + secret: setup.data.secret + }) + const confirm = await request('/members/totp-confirm', { + method: 'POST', + body: { code: totp.generate() } + }) + expect(confirm.status).toBe(200) + return { request, totp, setup, backupCodes: confirm.data.backupCodes } + } + + // The setup used the current code, so logins use the next one, which is + // accepted for clocks that drift: + const getNextCode = (totp: TOTP) => + totp.generate({ timestamp: Date.now() + totp.period * 1000 }) + + beforeAll(async () => { + await createTestDatabase(app, Session, Member, Guest, Credential) + for (const table of ['Member', 'Guest']) { + await app.knex.schema.alterTable(table, table => { + table.dropColumn('password') + }) + } + await app.start() + url = getAppUrl(app) + member = await Member.query().insert({ + username: 'ada', + password: 'secret' + } as any) + }) + + afterAll(async () => { + await app.stop() + await app.knex.destroy() + }) + + afterEach(async () => { + loginEvents.length = 0 + await app.knex('Session').del() + await app.knex('Credential').del() + }) + + it('sets up TOTP once the user confirms a code', async () => { + const { request } = await loginWithPassword() + const setup = await request('/members/totp', { method: 'POST' }) + expect(setup.data).toEqual({ + uri: expect.stringMatching(/^otpauth:\/\/totp\/Example:ada\?/), + secret: expect.stringMatching(/^[A-Z2-7]+$/) + }) + expect(setup.data.uri).toContain(`secret=${setup.data.secret}`) + // Nothing is stored before the confirmation: + expect(await Credential.query()).toEqual([]) + expect((await request('/members/totp')).data).toEqual({ + enabled: false, + backupCodes: 0 + }) + + const totp = new TOTP({ secret: setup.data.secret }) + const wrong = await request('/members/totp-confirm', { + method: 'POST', + body: { code: 'wrong' } + }) + expect(wrong.status).toBe(401) + expect(wrong.data.message).toBe('Code is incorrect') + + // The setup stays for another try: + const confirm = await request('/members/totp-confirm', { + method: 'POST', + body: { code: totp.generate() } + }) + expect(confirm.status).toBe(200) + const { backupCodes } = confirm.data + expect(backupCodes).toHaveLength(10) + for (const code of backupCodes) { + expect(code).toMatch(/^[0-9a-f]{5}-[0-9a-f]{5}$/) + } + expect((await request('/members/totp')).data).toEqual({ + enabled: true, + backupCodes: 10 + }) + + // The secret is stored encrypted, and the backup codes hashed: + const credentials = await Credential.query() + const [credential] = credentials.filter(({ type }) => type === 'totp') + expect(credential).toMatchObject({ + userModel: 'Member', + userId: `${member.id}` + }) + const stored = JSON.stringify(credentials.map(({ data }) => data)) + expect(stored).not.toContain(setup.data.secret) + for (const code of backupCodes) { + expect(stored).not.toContain(code.replace('-', '')) + } + + // Each setup is confirmed once: + const again = await request('/members/totp-confirm', { + method: 'POST', + body: { code: totp.generate() } + }) + expect(again.status).toBe(400) + expect(again.data.message).toBe('Missing or expired TOTP setup') + }) + + it('ends setups after too many wrong codes', async () => { + const { request } = await loginWithPassword() + const setup = await request('/members/totp', { method: 'POST' }) + const confirm = (code: string) => + request('/members/totp-confirm', { method: 'POST', body: { code } }) + for (let i = 1; i < 10; i++) { + expect((await confirm('wrong')).status).toBe(401) + } + const last = await confirm('wrong') + expect(last.status).toBe(400) + expect(last.data.message).toBe('Too many attempts') + const totp = new TOTP({ secret: setup.data.secret }) + const again = await confirm(totp.generate()) + expect(again.data.message).toBe('Missing or expired TOTP setup') + expect(await Credential.query()).toEqual([]) + }) + + it(`doesn't confirm setups once another one is confirmed`, async () => { + const first = await loginWithPassword() + const second = await loginWithPassword() + const secrets = [] + for (const { request } of [first, second]) { + const setup = await request('/members/totp', { method: 'POST' }) + secrets.push(setup.data.secret) + } + const confirm = ({ request }: { request: Client }, secret: string) => + request('/members/totp-confirm', { + method: 'POST', + body: { code: new TOTP({ secret }).generate() } + }) + expect((await confirm(first, secrets[0])).status).toBe(200) + const response = await confirm(second, secrets[1]) + expect(response.status).toBe(409) + expect(response.data.message).toBe('TOTP is already set up') + expect(await Credential.query().where('type', 'totp')).toHaveLength(1) + }) + + it(`doesn't set up TOTP twice`, async () => { + const { request } = await setupTotp() + const setup = await request('/members/totp', { method: 'POST' }) + expect(setup.status).toBe(409) + expect(await Credential.query().where('type', 'totp')).toHaveLength(1) + }) + + it('asks users with TOTP for a code after their password', async () => { + const { totp } = await setupTotp() + loginEvents.length = 0 + const { request, login } = await loginWithPassword() + expect(login.status).toBe(200) + expect(login.data).toEqual({ + success: false, + codeRequired: true, + authenticated: false, + user: null + }) + expect(loginEvents).toEqual([]) + expect((await request('/members/session')).data.authenticated).toBe(false) + + const code = await request('/members/login-code', { + method: 'POST', + body: { code: getNextCode(totp) } + }) + expect(code.status).toBe(200) + expect(code.data).toMatchObject({ + success: true, + authenticated: true, + user: { id: member.id, username: 'ada' } + }) + expect(code.data).not.toHaveProperty('codeRequired') + expect(loginEvents).toEqual(['after login ada']) + expect((await request('/members/session')).data.authenticated).toBe(true) + const [credential] = await Credential.query().where('type', 'totp') + expect(credential.lastUsedAt).toBeInstanceOf(Date) + }) + + it('logs in users without TOTP right away', async () => { + const { login } = await loginWithPassword() + expect(login.data).toMatchObject({ success: true, authenticated: true }) + expect(login.data).not.toHaveProperty('codeRequired') + }) + + it('accepts each code only once', async () => { + const { totp } = await setupTotp() + const code = getNextCode(totp) + expect((await loginWithCode(code)).login.data.success).toBe(true) + const { request, login } = await loginWithCode(code) + expect(login.status).toBe(401) + expect(login.data).toMatchObject({ + success: false, + authenticated: false, + error: 'Code is incorrect' + }) + expect((await request('/members/session')).data.authenticated).toBe(false) + }) + + it(`doesn't accept the code of the setup`, async () => { + const { totp } = await setupTotp() + const { login } = await loginWithCode(totp.generate()) + expect(login.status).toBe(401) + }) + + it('accepts each backup code once', async () => { + const { request, backupCodes } = await setupTotp() + const [code] = backupCodes + // Backup codes ignore case and separators: + const first = await loginWithCode(code.toUpperCase().replace('-', ' ')) + expect(first.login.data).toMatchObject({ + success: true, + authenticated: true + }) + expect((await request('/members/totp')).data).toEqual({ + enabled: true, + backupCodes: 9 + }) + const second = await loginWithCode(code) + expect(second.login.status).toBe(401) + }) + + it('regenerates the session id for the code', async () => { + await setupTotp() + const request = createClient(url) + // Store something to create a session before logging in: + await request('/members/passkey-login-options', { method: 'POST' }) + const ids = (await Session.query()).map(session => session.id) + await request('/members/login', { + method: 'POST', + body: { username: 'ada', password: 'secret' } + }) + const sessions = await Session.query() + const session = sessions.find(session => session.value.pendingLogin)! + expect(ids).not.toContain(session.id) + // The session with the old id is gone: + expect(sessions).toHaveLength(ids.length) + }) + + describe('wrong codes', () => { + const postCode = (request: Client, code: string) => + request('/members/login-code', { method: 'POST', body: { code } }) + + const getTotpData = async () => + (await Credential.query().findOne({ type: 'totp' }))!.data + + const enterWrongCodes = async (request: Client, count: number) => { + for (let i = 0; i < count; i++) { + const login = await postCode(request, 'wrong') + expect(login.status).toBe(401) + expect(login.data.error).toBe('Code is incorrect') + } + } + + it('lock the account after ten in a row', async () => { + const { totp } = await setupTotp() + const { request } = await loginWithPassword() + await enterWrongCodes(request, 9) + const last = await postCode(request, 'wrong') + expect(last.status).toBe(400) + expect(last.data.error).toBe('Too many attempts') + // Also right codes are refused while the account is locked: + const right = await postCode(request, getNextCode(totp)) + expect(right.status).toBe(400) + expect(right.data.error).toBe('Too many attempts') + expect((await getTotpData()).lockedUntil).toBeGreaterThan(Date.now()) + }) + + it('count across logins with the password', async () => { + const { totp } = await setupTotp() + await enterWrongCodes((await loginWithPassword()).request, 5) + // Logging in with the password again doesn't reset the count: + const { request } = await loginWithPassword() + await enterWrongCodes(request, 4) + expect((await postCode(request, 'wrong')).status).toBe(400) + const right = await postCode(request, getNextCode(totp)) + expect(right.data.error).toBe('Too many attempts') + }) + + it('count when turning off TOTP too', async () => { + const { request: self, totp } = await setupTotp() + const remove = (code: string) => + self('/members/totp', { method: 'DELETE', body: { code } }) + for (let i = 0; i < 9; i++) { + expect((await remove('wrong')).status).toBe(401) + } + const { request } = await loginWithPassword() + const last = await postCode(request, 'wrong') + expect(last.data.error).toBe('Too many attempts') + const right = await remove(getNextCode(totp)) + expect(right.status).toBe(400) + expect(right.data.message).toBe('Too many attempts') + }) + + it('start anew after a right code', async () => { + const { totp } = await setupTotp() + const { request } = await loginWithPassword() + await enterWrongCodes(request, 9) + expect((await postCode(request, getNextCode(totp))).status).toBe(200) + await enterWrongCodes((await loginWithPassword()).request, 9) + expect((await getTotpData()).failures).toBe(9) + }) + + it('unlock the account after the lock time', async () => { + const { totp } = await setupTotp() + const { request } = await loginWithPassword() + await enterWrongCodes(request, 9) + await postCode(request, 'wrong') + const credential = await Credential.query().findOne({ type: 'totp' }) + await credential!.$query().patch({ + data: { ...credential!.data, lockedUntil: Date.now() - 1 } + } as any) + const login = await postCode(request, getNextCode(totp)) + expect(login.status).toBe(200) + expect(await getTotpData()).toMatchObject({ + failures: 0, + lockedUntil: null + }) + }) + + it('count concurrent requests', async () => { + await setupTotp() + const clients = await Promise.all( + Array.from( + { length: 10 }, + async () => (await loginWithPassword()).request + ) + ) + const logins = await Promise.all( + clients.map(request => postCode(request, 'wrong')) + ) + expect(logins.map(({ status }) => status).sort()).toEqual([ + 400, + ...Array(9).fill(401) + ]) + expect((await getTotpData()).lockedUntil).toBeGreaterThan(Date.now()) + }) + }) + + it('accepts concurrent requests with the same code only once', async () => { + const { totp } = await setupTotp() + const clients = await Promise.all([ + loginWithPassword(), + loginWithPassword() + ]) + const code = getNextCode(totp) + const logins = await Promise.all( + clients.map(({ request }) => + request('/members/login-code', { method: 'POST', body: { code } }) + ) + ) + expect(logins.map(({ status }) => status).sort()).toEqual([200, 401]) + }) + + it('binds the encrypted secret to its user', async () => { + const { totp } = await setupTotp() + const other = await Member.query().insert({ + username: 'bo', + password: 'secret' + } as any) + try { + // Give bo a copy of ada's credential, with her encrypted secret: + const { data } = (await Credential.query().findOne({ type: 'totp' }))! + await Credential.query().insert({ + type: 'totp', + userModel: 'Member', + userId: `${other.id}`, + data + } as any) + const request = createClient(url) + const login = await request('/members/login', { + method: 'POST', + body: { username: 'bo', password: 'secret' } + }) + expect(login.data.codeRequired).toBe(true) + const code = await request('/members/login-code', { + method: 'POST', + body: { code: getNextCode(totp) } + }) + expect(code.data.success).toBe(false) + expect((await request('/members/session')).data.authenticated).toBe( + false + ) + } finally { + await Member.query().deleteById(other.id) + } + }) + + it('rejects expired pending logins', async () => { + const { totp } = await setupTotp() + const { request } = await loginWithPassword() + const session = (await Session.query()).find( + session => session.value.pendingLogin + )! + await session.$query().patch({ + value: { + ...session.value, + pendingLogin: { ...session.value.pendingLogin, expires: Date.now() - 1 } + } + } as any) + const login = await request('/members/login-code', { + method: 'POST', + body: { code: getNextCode(totp) } + }) + expect(login.status).toBe(400) + expect(login.data.error).toBe('Missing or expired login') + }) + + it('rejects codes without a pending login', async () => { + const { totp } = await setupTotp() + const request = createClient(url) + const login = await request('/members/login-code', { + method: 'POST', + body: { code: getNextCode(totp) } + }) + expect(login.status).toBe(400) + }) + + it('completes pending logins only for their user model', async () => { + const { totp } = await setupTotp() + const guest = await Guest.query().insert({ username: 'ada' } as any) + try { + expect(guest.id).toBe(member.id) + const { request } = await loginWithPassword() + const login = await request('/guests/login-code', { + method: 'POST', + body: { code: getNextCode(totp) } + }) + expect(login.status).toBe(400) + expect((await request('/guests/session')).data.authenticated).toBe(false) + } finally { + await Guest.query().deleteById(guest.id) + } + }) + + it('lets passkey logins skip TOTP', async () => { + const { request } = await setupTotp() + const authenticator = createAuthenticator({ + origin: 'https://example.com', + rpId: 'example.com' + }) + const options = await request('/members/passkey-options', { + method: 'POST' + }) + await request('/members/passkeys', { + method: 'POST', + body: authenticator.register(options.data) + }) + const client = createClient(url) + const loginOptions = await client('/members/passkey-login-options', { + method: 'POST' + }) + const login = await client('/members/passkey-login', { + method: 'POST', + body: authenticator.authenticate(loginOptions.data) + }) + expect(login.data).toMatchObject({ success: true, authenticated: true }) + }) + + it('turns off TOTP with a code', async () => { + const { request, totp } = await setupTotp() + const wrong = await request('/members/totp', { + method: 'DELETE', + body: { code: 'wrong' } + }) + expect(wrong.status).toBe(401) + const remove = await request('/members/totp', { + method: 'DELETE', + body: { code: getNextCode(totp) } + }) + expect(remove.data).toEqual({ success: true }) + expect(await Credential.query()).toEqual([]) + const { login } = await loginWithPassword() + expect(login.data).toMatchObject({ success: true, authenticated: true }) + }) + + it('turns off TOTP with a backup code', async () => { + const { request, backupCodes } = await setupTotp() + const remove = await request('/members/totp', { + method: 'DELETE', + body: { code: backupCodes[3] } + }) + expect(remove.data).toEqual({ success: true }) + expect(await Credential.query()).toEqual([]) + }) + + it('only lets logged-in users manage their TOTP', async () => { + const request: Client = createClient(url) + for (const [method, path] of [ + ['GET', 'totp'], + ['POST', 'totp'], + ['POST', 'totp-confirm'], + ['DELETE', 'totp'] + ]) { + const response = await request(`/members/${path}`, { method }) + expect(response.status).toBe(401) + } + }) +}) + +describe('TOTP turned off', () => { + // Models are only set up once, so the app needs its own user model: + class User extends UserModel {} + class Users extends UsersController { + override modelClass = User + } + + const app = createTestApp({ + config: { + app: { normalizePaths: true, keys: ['test-secret'], session: true } + }, + models: { User }, + controllers: { Users } + }) + + afterAll(() => app.knex.destroy()) + + it(`doesn't route the TOTP actions`, async () => { + await app.setup() + const getHandler = (method: string, path: string) => + app.router.find(method, `/users/${path}`).handler + expect(getHandler('POST', 'login')).toBeDefined() + for (const [method, path] of [ + ['POST', 'login-code'], + ['GET', 'totp'], + ['POST', 'totp'], + ['POST', 'totp-confirm'], + ['DELETE', 'totp'] + ]) { + expect(getHandler(method, path)).toBeUndefined() + } + }) +}) + +describe('TOTP setup', () => { + const setup = async (app: Record) => { + // Models are only set up once, so each app needs its own user model: + class User extends UserModel {} + class Users extends UsersController { + override modelClass = User + } + const testApp = createTestApp({ + config: { app: { keys: ['test-secret'], ...app } }, + models: { Session, Credential, User }, + controllers: { Users } + }) + try { + await testApp.setup() + } finally { + await testApp.knex.destroy() + } + } + + const totp = { issuer: 'Example' } + + it('requires sessions stored on the server', async () => { + for (const session of [undefined, true]) { + await expect( + setup({ session, auth: { credentials: auth.credentials, totp } }) + ).rejects.toThrow( + `Unable to use TOTP without sessions stored on the server, ` + + `see 'app.session'.` + ) + } + }) + + it('requires credentials', async () => { + await expect( + setup({ session: { modelClass: 'Session' }, auth: { totp } }) + ).rejects.toThrow( + `Unable to use TOTP without credentials, see 'app.auth.credentials'.` + ) + }) + + it('requires the secret of the credentials', async () => { + await expect( + setup({ + session: { modelClass: 'Session' }, + auth: { credentials: { modelClass: 'Credential' }, totp } + }) + ).rejects.toThrow( + `Unable to use TOTP without 'app.auth.credentials.secret'.` + ) + }) + + it('requires the issuer', async () => { + await expect( + setup({ + session: { modelClass: 'Session' }, + auth: { credentials: auth.credentials, totp: {} } + }) + ).rejects.toThrow(`Invalid TOTP, missing 'app.auth.totp.issuer'.`) + }) +}) diff --git a/tests/types/admin/admin.test-d.ts b/tests/types/admin/admin.test-d.ts index a0f5a813e..fd56d8b3b 100644 --- a/tests/types/admin/admin.test-d.ts +++ b/tests/types/admin/admin.test-d.ts @@ -66,7 +66,7 @@ describe('DitoAdmin', () => { assertType>([ '#app', { - login: { redirectAfterLogin: '/books', passkeys: true }, + login: { redirectAfterLogin: '/books', passkeys: true, totp: true }, spinner: null } ])