Compare commits

...

101 Commits

Author SHA1 Message Date
James Elliott 53d3cdb271
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-05-30 09:15:20 +10:00
James Elliott b4083df061
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-05-20 10:26:42 +10:00
James Elliott 1334e9e007
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-05-20 10:26:23 +10:00
James Elliott 91ebdd6bc6
docs: update webauthn docs
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-05-20 10:26:11 +10:00
James Elliott ecbd6511e1
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-05-19 22:53:24 +10:00
James Elliott 5e9fe907c8
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-05-05 22:49:23 +10:00
James Elliott bb563f4baa
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-05-05 14:34:49 +10:00
James Elliott 5faffbe46b
docs: add alert for configuration sections
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-05-04 21:21:03 +10:00
James Elliott 12443920e6
Merge remote-tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-05-04 20:58:34 +10:00
James Elliott 873749a28f
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-04-21 21:32:32 +10:00
James Elliott 79973414e4
fix: misc translations
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-16 11:31:19 +10:00
James Elliott 7c69152a86
fix: null transports
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-16 11:23:25 +10:00
James Elliott c3e785872d
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-04-16 07:59:08 +10:00
James Elliott a8a8089f33
fix: missing files
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 16:21:42 +10:00
James Elliott 29ddc73012
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	internal/suites/scenario_backend_protection_test.go
2023-04-15 15:05:09 +10:00
James Elliott e464295c8b
fix: more webauthn consistency fixes
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 11:00:52 +10:00
James Elliott 1341ef79d6
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-04-15 10:52:49 +10:00
James Elliott 53668e0c60
feat: backport changes
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 10:03:19 +10:00
James Elliott 6c89ee1f9c
Merge orgin/master into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 03:12:55 +10:00
James Elliott 86b525ce21
Merge remote tracking branch origin/master into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 02:14:23 +10:00
James Elliott d97c0eb0ea
fix: remove gen files
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-15 01:28:47 +10:00
James Elliott f549afd480
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	internal/authentication/ldap_client_mock.go
#	internal/authentication/types.go
2023-04-14 21:43:04 +10:00
James Elliott f35e49a1fd
fix: misc
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-14 21:11:03 +10:00
James Elliott 774f64a932
Merge remote tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-14 20:58:49 +10:00
James Elliott f3d447d76a
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-04-12 14:43:32 +10:00
James Elliott 51e1f41620
Merge remote-tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-11 22:21:00 +10:00
James Elliott 7fdcc351d4
Merge remote-tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>

# Conflicts:
#	internal/handlers/handler_register_webauthn.go
#	internal/handlers/webauthn.go
#	internal/handlers/webauthn_test.go
#	internal/mocks/storage.go
#	internal/model/webauthn.go
#	internal/storage/provider.go
#	internal/storage/sql_provider.go
#	web/package.json
#	web/pnpm-lock.yaml
#	web/src/layouts/LoginLayout.tsx
2023-04-11 21:34:45 +10:00
James Elliott 928df8a698
Merge remote-tracking branch 'origin/master' into feat-oidc-auth-mode
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>

# Conflicts:
#	internal/configuration/validator/const.go
2023-04-09 13:19:29 +10:00
James Elliott 904b659fcb
Merge remote-tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-02 16:12:08 +10:00
James Elliott 54509bcc60
build(deps): remove @types/qrcode.react
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-02 14:21:59 +10:00
James Elliott 1ba4f705f0
Merge remote-tracking branch 'origin/master' into feat-settings-ui
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
2023-04-02 14:14:29 +10:00
James Elliott 4f46514fdf
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-03-19 08:09:17 +11:00
James Elliott e584e0c4a3
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-03-12 11:41:31 +11:00
James Elliott 7ef1ba23df
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	docs/package.json
#	docs/pnpm-lock.yaml
#	internal/configuration/validator/identity_providers_test.go
#	web/package.json
#	web/pnpm-lock.yaml
2023-03-12 00:09:42 +11:00
James Elliott b6883a337f
Merge origin/master into feat-settings-ui 2023-03-07 10:12:49 +11:00
James Elliott e64661af3f
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-28 20:40:51 +11:00
James Elliott 8b8d6ce417
Merge remote-tracking branch origin/master into feat-settings-ui 2023-02-28 20:07:42 +11:00
James Elliott e6ef74fd8e
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	go.mod
#	web/package.json
#	web/pnpm-lock.yaml
2023-02-25 13:46:06 +11:00
James Elliott f2e40a72e7
build(deps): bump 2023-02-25 13:32:32 +11:00
James Elliott ea2350f0e4
refactor: down migrations 2023-02-19 14:59:45 +11:00
James Elliott a3d7212f23
test: fix test 2023-02-19 14:08:18 +11:00
James Elliott 257bd2a25a
test: fix test 2023-02-19 12:48:11 +11:00
James Elliott 3e53ae7b2e
test: fix test 2023-02-19 12:11:33 +11:00
James Elliott a6cc022e5c
Merge remote tracking branch origin/master into feat-settings-ui 2023-02-19 11:53:11 +11:00
James Elliott a13a3c45f2
fix: encoding 2023-02-19 11:48:35 +11:00
James Elliott e5cdb175b4
feat: cred props 2023-02-18 15:36:58 +11:00
James Elliott 5be5de02d8
feat: webauthn users 2023-02-17 06:40:40 +11:00
James Elliott e84ca4956a
refactor: sql updates 2023-02-14 23:35:15 +11:00
James Elliott 236fcb1e37
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-13 18:53:54 +11:00
James Elliott ee56740f46
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-13 06:33:46 +11:00
James Elliott 130a28a430
fix: misc 2023-02-12 23:57:43 +11:00
James Elliott 526dd8347d
fix: misc 2023-02-12 23:12:31 +11:00
James Elliott ba1ed1252c
fix: tests 2023-02-12 22:11:00 +11:00
James Elliott 515309c10e
feat: translate all the things 2023-02-12 21:57:45 +11:00
James Elliott 7e56cf2d15
test(suites): fix postgres 2023-02-12 12:48:39 +11:00
James Elliott d0160edc70
test(suites): fix standalone 2023-02-12 12:39:17 +11:00
James Elliott be21d73c72
fix: sql migration 2023-02-12 12:25:15 +11:00
James Elliott 40e247fcee
Merge branch 'master' into feat-settings-ui 2023-02-12 03:02:26 +11:00
James Elliott f920ef9dd9
build: update lockfiles 2023-02-12 03:01:51 +11:00
James Elliott 62fa7a6244
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/pnpm-lock.yaml
2023-02-12 02:51:34 +11:00
James Elliott 3b6f5482b8
fix: multi-cookie domain webauthn 2023-02-12 02:47:03 +11:00
James Elliott 8c057f65a5
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-11 21:53:34 +11:00
James Elliott 852dc808bd
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-11 14:13:18 +11:00
James Elliott 1f1210c6ac
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-02-08 13:52:07 +11:00
James Elliott dc334234a8
build: fix transform ignores 2023-02-06 13:13:47 +11:00
James Elliott 9e5aa1c1a9
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	web/package.json
#	web/pnpm-lock.yaml
2023-02-05 20:19:40 +11:00
James Elliott d7be1c1359
refactor: reduce complexity 2023-02-01 22:10:38 +11:00
James Elliott 3af20a7daf
build(deps): use @simplewebauthn/browser 2023-01-30 16:37:53 +11:00
James Elliott a36c45f1e1
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-01-30 09:52:33 +11:00
James Elliott 4bed5d2461
Merge branch 'master' into feat-settings-ui 2023-01-27 11:27:12 +11:00
James Elliott 7d17c39c52
Merge origin/master into feat-settings-ui 2023-01-25 22:11:41 +11:00
James Elliott 25244c42f1
fix: unused import 2023-01-21 14:48:33 +11:00
James Elliott bd279900ca
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2023-01-20 17:56:06 +11:00
James Elliott 49d421e910
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	api/openapi.yml
#	web/src/views/DeviceRegistration/RegisterWebauthn.tsx
#	web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx
2023-01-07 11:50:19 +11:00
James Elliott 917ac89e38
refactor: 2fa api 2023-01-01 22:16:28 +11:00
James Elliott dd781ffc51
refactor: adjust settings components 2022-12-31 18:27:43 +11:00
James Elliott 4239db6171
refactor: adjust settings components 2022-12-31 16:28:46 +11:00
James Elliott f2ee86472d
revert: 2fa skip 2022-12-30 23:51:52 +11:00
James Elliott 0e2770e72d
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2022-12-27 20:05:02 +11:00
James Elliott 4a2fd3dea7
Merge remote-tracking branch 'origin/master' into feat-settings-ui 2022-12-23 16:08:47 +11:00
James Elliott a186dca3bf
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	api/openapi.yml
2022-12-17 15:47:34 +11:00
James Elliott 67381b1318
fix: no webauthn devices doesn't display correctly (#4537)
* fix: no webauthn devices doesn't display correctly

* refactor: factorize
2022-12-12 12:21:27 +11:00
Stephen Kent 326ed60a65
refactor: retitle settings appbar to authelia settings (#4454) 2022-12-03 16:55:16 +11:00
James Elliott 133f1626ab
Merge remote tracking branch 'origin/master' into feat-settings-ui 2022-11-30 10:00:33 +11:00
Stephen Kent d6f1365d42
feat: rework webauthn devices list ui (#4435)
* feat: add loading skeleton to webauthn devices list in settings ui

* refactor: move webauthn device index knowledge out of webauthndeviceitem

* feat: implement webauthn device delete confirmation

* fix: don't unset deleting idx for dialog on webauthn device delete

* feat: implement webauthn device rename with dialog

* refactor: remove `@root` from import paths

* refactor: remove `@root` from import paths

* feat: rework webauthn devices list ui
2022-11-28 18:39:08 +11:00
Stephen Kent 33520daa10
feat: implement webauthn device rename with dialog (#4427)
* feat: add loading skeleton to webauthn devices list in settings ui

* refactor: move webauthn device index knowledge out of webauthndeviceitem

* feat: implement webauthn device delete confirmation

* fix: don't unset deleting idx for dialog on webauthn device delete

* feat: implement webauthn device rename with dialog

* refactor: remove `@root` from import paths

* refactor: remove `@root` from import paths
2022-11-27 11:08:13 +11:00
Stephen Kent 24d947624b
feat: implement webauthn device delete confirmation (#4426)
* refactor: move webauthn device index knowledge out of webauthndeviceitem

* feat: implement webauthn device delete confirmation

* fix: don't unset deleting idx for dialog on webauthn device delete

* refactor: remove `@root` from import paths
2022-11-27 10:46:24 +11:00
Stephen Kent b842a22236
feat: add loading skeleton to webauthn devices list in settings ui (#4406) 2022-11-26 17:22:40 +11:00
Stephen Kent 2967500401
feat: hide empty webauthn devices table when there are no devices (#4405) 2022-11-19 19:31:08 +11:00
James Elliott 6f8b6adfb5
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	internal/model/webauthn.go
2022-11-19 17:43:59 +11:00
James Elliott 5d1b840e2b
refactor: merge master and fix missing rebinds (#4404)
* build(deps): update module github.com/jackc/pgx/v5 to v5.1.0 (#4365)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* docs: add smkent as a contributor for code, design, and ideas (#4367)

* update README.md

* update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* build(deps): update module github.com/ory/fosite to v0.43.0 (#4269)

This updates fosite and refactors our usage out of compose.

* refactor(cmd): restrict bootstrap pnpm tasks to dev environment (#4370)

* build(deps): update alpine docker tag to v3.16.3 (#4362)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update module github.com/ory/x to v0.0.514 (#4368)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* refactor: sql formatting (#4371)

* refactor: sql spacing

* refactor editor config

* docs: clarify cloudflare docs (#4373)

* build(deps): update dependency @types/react-dom to v18.0.9 (#4379)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update typescript-eslint monorepo to v5.43.0 (#4380)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency @types/jest to v29.2.3 (#4381)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency esbuild to v0.15.14 (#4383)

* build(deps): update material-ui monorepo to v5.10.14 (#4385)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency vite to v3.2.4 (#4386)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update font awesome to v6.2.1 (#4389)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency typescript to v4.9.3 (#4390)

* docs: adjust issue templates (#4391)

* docs: adjust issue templates

* docs: adjust wording

* build(deps): update dependency jest-watch-typeahead to v2.2.1 (#4392)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency i18next to v22.0.6 (#4395)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update github.com/duosecurity/duo_api_golang digest to 091daa0 (#4396)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update traefik docker tag to v2.9.5 (#4398)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update module github.com/jackc/pgx/v5 to v5.1.1 (#4400)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update mariadb docker tag to v10.10.2 (#4399)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency eslint-plugin-react to v7.31.11 (#4401)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* build(deps): update dependency eslint to v8.28.0 (#4402)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(storage): schema inconsistency (#4262)

* fix: missing pg rebinds

* fix: refactoring issues

* fix: refactoring issues

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
2022-11-19 17:42:03 +11:00
Stephen Kent 2584e3d328
feat: move webauthn device enrollment flow to new settings ui (#4376)
The current 2-factor authentication method registration flow requires
email verification for both initial 2FA registration, and 2FA
re-registration even if the user is already logged in with 2FA.

This change removes email ID verification for users who are already
logged in with 2-factor authentication. Users who have only completed
first factor authentication (password) are still required to complete
email ID verification.
2022-11-19 16:48:47 +11:00
James Elliott ff26673659
feat: better menu matching and overview page (#4384) 2022-11-15 19:26:09 +11:00
James Elliott 0f8de33f2f
feat: settings router (#4377) 2022-11-14 22:13:10 +11:00
Stephen Kent dcd65515fc
fix: add toolbar below appbar in settings page to avoid content overlap (#4375) 2022-11-14 16:38:06 +11:00
James Elliott 164fc5e80d
feat: settings i18n [skip test] (#4372) 2022-11-14 14:49:34 +11:00
James Elliott 1a1b85489c
feat: settings ui device details (#4369)
This adds details to the settings ui.
2022-11-14 13:19:18 +11:00
Stephen Kent 92b3a5804b
feat: provide webauthn device description from frontend on registration (#4363) 2022-11-13 18:59:21 +11:00
James Elliott bbc9e6422e
fix: lint 2022-11-13 10:18:57 +11:00
James Elliott 9b66bb4fe2
Merge remote-tracking branch 'origin/master' into feat-settings-ui
# Conflicts:
#	internal/model/webauthn.go
2022-11-13 09:19:22 +11:00
Clément Michaud a69ba22f46 feat: implement a ui for supporting multiple u2f devices 2022-10-30 09:52:49 +01:00
112 changed files with 3149 additions and 1289 deletions

View File

@ -849,6 +849,45 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse' $ref: '#/components/schemas/middlewares.OkResponse'
security: security:
- authelia_auth: [] - authelia_auth: []
/api/secondfactor/webauthn/devices/{deviceID}:
delete:
tags:
- Second Factor
summary: WebAuthn Device Deletion
description: This endpoint deletes the specified WebAuthn credential.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/middlewares.OkResponse'
security:
- authelia_auth: []
parameters:
- $ref: '#/components/parameters/deviceID'
put:
tags:
- Second Factor
summary: WebAuthn Device Update
description: This endpoint updates the description of the specified WebAuthn credential.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/webauthn.DeviceUpdateRequest'
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/middlewares.OkResponse'
security:
- authelia_auth: []
parameters:
- $ref: '#/components/parameters/deviceID'
{{- end }} {{- end }}
{{- if .Duo }} {{- if .Duo }}
/api/secondfactor/duo: /api/secondfactor/duo:
@ -1433,6 +1472,13 @@ paths:
{{- end }} {{- end }}
components: components:
parameters: parameters:
deviceID:
in: path
name: deviceID
schema:
type: integer
required: true
description: Numeric WebAuthn Device ID
originalMethodParam: originalMethodParam:
name: X-Original-Method name: X-Original-Method
in: header in: header
@ -1917,23 +1963,28 @@ components:
type: string type: string
format: byte format: byte
webauthn.CredentialAttestationResponse: webauthn.CredentialAttestationResponse:
allOf: type: object
- $ref: '#/components/schemas/webauthn.PublicKeyCredential' properties:
- type: object credential:
properties: allOf:
clientExtensionResults: - $ref: '#/components/schemas/webauthn.PublicKeyCredential'
type: object - type: object
properties: properties:
appidExclude: clientExtensionResults:
type: boolean type: object
response:
allOf:
- $ref: '#/components/schemas/webauthn.AuthenticatorResponse'
- type: object
properties: properties:
attestationObject: appidExclude:
type: string type: boolean
format: byte response:
allOf:
- $ref: '#/components/schemas/webauthn.AuthenticatorResponse'
- type: object
properties:
attestationObject:
type: string
format: byte
description:
type: string
webauthn.CredentialAssertionResponse: webauthn.CredentialAssertionResponse:
allOf: allOf:
- $ref: '#/components/schemas/webauthn.PublicKeyCredential' - $ref: '#/components/schemas/webauthn.PublicKeyCredential'
@ -1971,6 +2022,11 @@ components:
format: uuid format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: '3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c' example: '3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c'
webauthn.DeviceUpdateRequest:
type: object
properties:
description:
type: string
webauthn.PublicKeyCredentialCreationOptions: webauthn.PublicKeyCredentialCreationOptions:
type: object type: object
properties: properties:

View File

@ -38,3 +38,4 @@ this instance if you wanted to downgrade to pre1 you would need to use an Authel
| 7 | 4.37.3 | Fixed some schema inconsistencies most notably the MySQL/MariaDB Engine and Collation | | 7 | 4.37.3 | Fixed some schema inconsistencies most notably the MySQL/MariaDB Engine and Collation |
| 8 | 4.38.0 | OpenID Connect 1.0 Pushed Authorization Requests | | 8 | 4.38.0 | OpenID Connect 1.0 Pushed Authorization Requests |
| 9 | 4.38.0 | Fix a PostgreSQL NOT NULL constraint issue on the `aaguid` column of the `webauthn_devices` table | | 9 | 4.38.0 | Fix a PostgreSQL NOT NULL constraint issue on the `aaguid` column of the `webauthn_devices` table |
| 10 | 4.38.0 | WebAuthn adjustments for multi-cookie domain changes |

View File

@ -2,7 +2,7 @@
title: "Database Schema" title: "Database Schema"
description: "Authelia Development Database Schema Guidelines" description: "Authelia Development Database Schema Guidelines"
lead: "This section covers the database schema guidelines we use for development." lead: "This section covers the database schema guidelines we use for development."
date: 2022-11-19T16:47:09+11:00 date: 2022-11-19T17:42:03+11:00
draft: false draft: false
images: [] images: []
menu: menu:

View File

@ -45,14 +45,14 @@ Easy, right?!
## Frequently Asked Questions ## Frequently Asked Questions
### Can I register multiple FIDO2 WebAuthn devices? ### Can I register multiple FIDO2 WebAuthn credentials?
At present this is not possible in the frontend. However the backend technically supports it. We plan to add this to the Yes, as of v4.38.0 and above Authelia supprots registering multiple WebAuthn credentials as per the
frontend in the near future. Subscribe to [this issue](https://github.com/authelia/authelia/issues/275) for updates. [roadmap](../../../roadmap/active/webauthn.md#multi-device-registration).
### Can I perform a passwordless login? ### Can I perform a passwordless login?
Not at this time. We will tackle this at a later date. Not at this time. We will tackle this at a later date as per the [roadmap](../../../roadmap/active/webauthn.md#passwordless-login).
### Why don't I have access to the *Security Key* option? ### Why don't I have access to the *Security Key* option?

View File

@ -2,7 +2,7 @@
title: "Database Integrations" title: "Database Integrations"
description: "A database integration reference guide" description: "A database integration reference guide"
lead: "This section contains a database integration reference guide for Authelia." lead: "This section contains a database integration reference guide for Authelia."
date: 2022-11-19T16:47:09+11:00 date: 2022-11-19T17:42:03+11:00
draft: false draft: false
images: [] images: []
menu: menu:

View File

@ -2,7 +2,7 @@
title: "Integrations" title: "Integrations"
description: "A collection of integration reference guides" description: "A collection of integration reference guides"
lead: "This section contains integration reference guides for Authelia." lead: "This section contains integration reference guides for Authelia."
date: 2022-11-19T16:47:09+11:00 date: 2022-11-19T17:42:03+11:00
draft: false draft: false
images: [] images: []
menu: menu:

View File

@ -1 +1 @@
{"defaults":{"language":{"display":"English","locale":"en"},"namespace":"portal"},"namespaces":["portal"],"languages":[{"display":"English","locale":"en","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic","locale":"ar","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic (Saudi Arabia)","locale":"ar-SA","namespaces":["portal"],"fallbacks":["ar","en"]},{"display":"Czech","locale":"cs","namespaces":["portal"],"fallbacks":["en"]},{"display":"Czech (Czechia)","locale":"cs-CZ","namespaces":["portal"],"fallbacks":["cs","en"]},{"display":"Danish","locale":"da","namespaces":["portal"],"fallbacks":["en"]},{"display":"Danish (Denmark)","locale":"da-DK","namespaces":["portal"],"fallbacks":["da","en"]},{"display":"German","locale":"de","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek","locale":"el","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek (Greece)","locale":"el-GR","namespaces":["portal"],"fallbacks":["el","en"]},{"display":"Spanish","locale":"es","namespaces":["portal"],"fallbacks":["en"]},{"display":"Finnish","locale":"fi","namespaces":["portal"],"fallbacks":["en"]},{"display":"French","locale":"fr","namespaces":["portal"],"fallbacks":["en"]},{"display":"Hungarian","locale":"hu","namespaces":["portal"],"fallbacks":["en"]},{"display":"Italian","locale":"it","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese","locale":"ja","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese (Japan)","locale":"ja-JP","namespaces":["portal"],"fallbacks":["ja","en"]},{"display":"Norwegian Bokmål","locale":"nb","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål (Norway)","locale":"nb-NO","namespaces":["portal"],"fallbacks":["nb","en"]},{"display":"Dutch","locale":"nl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål","locale":"no","namespaces":["portal"],"fallbacks":["en"]},{"display":"Polish","locale":"pl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Portuguese","locale":"pt","namespaces":["portal"],"fallbacks":["en"]},{"display":"Brazilian Portuguese","locale":"pt-BR","namespaces":["portal"],"fallbacks":["en"]},{"display":"Romanian","locale":"ro","namespaces":["portal"],"fallbacks":["en"]},{"display":"Russian","locale":"ru","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian","locale":"sl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian (Slovenia)","locale":"sl-SI","namespaces":["portal"],"fallbacks":["sl","en"]},{"display":"Swedish","locale":"sv","namespaces":["portal"],"fallbacks":["en"]},{"display":"Swedish (Sweden)","locale":"sv-SE","namespaces":["portal"],"fallbacks":["sv","en"]},{"display":"Ukrainian","locale":"uk","namespaces":["portal"],"fallbacks":["en"]},{"display":"Ukrainian (Ukraine)","locale":"uk-UA","namespaces":["portal"],"fallbacks":["uk","en"]},{"display":"Chinese","locale":"zh","namespaces":["portal"],"fallbacks":["en"]},{"display":"Chinese (China)","locale":"zh-CN","namespaces":["portal"],"fallbacks":["zh","en"]},{"display":"Chinese (Taiwan)","locale":"zh-TW","namespaces":["portal"],"fallbacks":["en"]}]} {"defaults":{"language":{"display":"English","locale":"en"},"namespace":"portal"},"namespaces":["portal","settings"],"languages":[{"display":"English","locale":"en","namespaces":["portal","settings"],"fallbacks":["en"]},{"display":"Arabic","locale":"ar","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic (Saudi Arabia)","locale":"ar-SA","namespaces":["portal"],"fallbacks":["ar","en"]},{"display":"Czech","locale":"cs","namespaces":["portal"],"fallbacks":["en"]},{"display":"Czech (Czechia)","locale":"cs-CZ","namespaces":["portal"],"fallbacks":["cs","en"]},{"display":"Danish","locale":"da","namespaces":["portal"],"fallbacks":["en"]},{"display":"Danish (Denmark)","locale":"da-DK","namespaces":["portal"],"fallbacks":["da","en"]},{"display":"German","locale":"de","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek","locale":"el","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek (Greece)","locale":"el-GR","namespaces":["portal"],"fallbacks":["el","en"]},{"display":"Spanish","locale":"es","namespaces":["portal"],"fallbacks":["en"]},{"display":"Finnish","locale":"fi","namespaces":["portal"],"fallbacks":["en"]},{"display":"French","locale":"fr","namespaces":["portal"],"fallbacks":["en"]},{"display":"Hungarian","locale":"hu","namespaces":["portal"],"fallbacks":["en"]},{"display":"Italian","locale":"it","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese","locale":"ja","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese (Japan)","locale":"ja-JP","namespaces":["portal"],"fallbacks":["ja","en"]},{"display":"Norwegian Bokmål","locale":"nb","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål (Norway)","locale":"nb-NO","namespaces":["portal"],"fallbacks":["nb","en"]},{"display":"Dutch","locale":"nl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål","locale":"no","namespaces":["portal"],"fallbacks":["en"]},{"display":"Polish","locale":"pl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Portuguese","locale":"pt","namespaces":["portal"],"fallbacks":["en"]},{"display":"Brazilian Portuguese","locale":"pt-BR","namespaces":["portal"],"fallbacks":["en"]},{"display":"Romanian","locale":"ro","namespaces":["portal"],"fallbacks":["en"]},{"display":"Russian","locale":"ru","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian","locale":"sl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian (Slovenia)","locale":"sl-SI","namespaces":["portal"],"fallbacks":["sl","en"]},{"display":"Swedish","locale":"sv","namespaces":["portal"],"fallbacks":["en"]},{"display":"Swedish (Sweden)","locale":"sv-SE","namespaces":["portal"],"fallbacks":["sv","en"]},{"display":"Ukrainian","locale":"uk","namespaces":["portal"],"fallbacks":["en"]},{"display":"Ukrainian (Ukraine)","locale":"uk-UA","namespaces":["portal"],"fallbacks":["uk","en"]},{"display":"Chinese","locale":"zh","namespaces":["portal"],"fallbacks":["en"]},{"display":"Chinese (China)","locale":"zh-CN","namespaces":["portal"],"fallbacks":["zh","en"]},{"display":"Chinese (Taiwan)","locale":"zh-TW","namespaces":["portal"],"fallbacks":["en"]}]}

2
go.mod
View File

@ -15,7 +15,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.5-0.20230521105649-cdb0754f6668 github.com/go-ldap/ldap/v3 v3.4.5-0.20230521105649-cdb0754f6668
github.com/go-rod/rod v0.113.1 github.com/go-rod/rod v0.113.1
github.com/go-sql-driver/mysql v1.7.1 github.com/go-sql-driver/mysql v1.7.1
github.com/go-webauthn/webauthn v0.5.0 github.com/go-webauthn/webauthn v0.8.2
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0

4
go.sum
View File

@ -152,8 +152,8 @@ github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-webauthn/revoke v0.1.9 h1:gSJ1ckA9VaKA2GN4Ukp+kiGTk1/EXtaDb1YE8RknbS0= github.com/go-webauthn/revoke v0.1.9 h1:gSJ1ckA9VaKA2GN4Ukp+kiGTk1/EXtaDb1YE8RknbS0=
github.com/go-webauthn/revoke v0.1.9/go.mod h1:j6WKPnv0HovtEs++paan9g3ar46gm1NarktkXBaPR+w= github.com/go-webauthn/revoke v0.1.9/go.mod h1:j6WKPnv0HovtEs++paan9g3ar46gm1NarktkXBaPR+w=
github.com/go-webauthn/webauthn v0.5.0 h1:Tbmp37AGIhYbQmcy2hEffo3U3cgPClqvxJ7cLUnF7Rc= github.com/go-webauthn/webauthn v0.8.2 h1:8KLIbpldjz9KVGHfqEgJNbkhd7bbRXhNw4QWFJE15oA=
github.com/go-webauthn/webauthn v0.5.0/go.mod h1:0CBq/jNfPS9l033j4AxMk8K8MluiMsde9uGNSPFLEVE= github.com/go-webauthn/webauthn v0.8.2/go.mod h1:d+ezx/jMCNDiqSMzOchuynKb9CVU1NM9BumOnokfcVQ=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=

View File

@ -550,7 +550,7 @@ func (ctx *CmdCtx) StorageUserWebAuthnListRunE(cmd *cobra.Command, args []string
user := args[0] user := args[0]
devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, user) devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, "", user)
switch { switch {
case len(devices) == 0 || (err != nil && errors.Is(err, storage.ErrNoWebAuthnDevice)): case len(devices) == 0 || (err != nil && errors.Is(err, storage.ErrNoWebAuthnDevice)):

View File

@ -68,6 +68,7 @@ const (
messageAuthenticationFailed = "Authentication failed. Check your credentials." messageAuthenticationFailed = "Authentication failed. Check your credentials."
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
messageUnableToRegisterSecurityKey = "Unable to register your security key." messageUnableToRegisterSecurityKey = "Unable to register your security key."
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
messageUnableToResetPassword = "Unable to reset your password." messageUnableToResetPassword = "Unable to reset your password."
messageMFAValidationFailed = "Authentication failed, please retry later." messageMFAValidationFailed = "Authentication failed, please retry later."
messagePasswordWeak = "Your supplied password does not meet the password policy requirements" messagePasswordWeak = "Your supplied password does not meet the password policy requirements"

View File

@ -2,6 +2,8 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"strings"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@ -11,35 +13,21 @@ import (
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/storage"
) )
// WebauthnIdentityStart the handler for initiating the identity validation. // WebAuthnRegistrationPUT returns the attestation challenge from the server.
var WebauthnIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{ func WebAuthnRegistrationPUT(ctx *middlewares.AutheliaCtx) {
MailTitle: "Register your key",
MailButtonContent: "Register",
TargetEndpoint: "/webauthn/register",
ActionClaim: ActionWebAuthnRegistration,
IdentityRetrieverFunc: identityRetrieverFromSession,
}, nil)
// WebauthnIdentityFinish the handler for finishing the identity validation.
var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish(
middlewares.IdentityVerificationFinishArgs{
ActionClaim: ActionWebAuthnRegistration,
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
}, SecondFactorWebAuthnAttestationGET)
// SecondFactorWebAuthnAttestationGET returns the attestation challenge from the server.
func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) {
var ( var (
w *webauthn.WebAuthn w *webauthn.WebAuthn
user *model.WebAuthnUser user *model.WebAuthnUser
userSession session.UserSession userSession session.UserSession
bodyJSON bodyRegisterWebAuthnPUTRequest
err error err error
) )
if userSession, err = ctx.GetSession(); err != nil { if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation challenge", regulation.AuthTypeWebAuthn) ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration challenge", regulation.AuthTypeWebAuthn)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -47,40 +35,90 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
} }
if w, err = newWebAuthn(ctx); err != nil { if w, err = newWebAuthn(ctx); err != nil {
ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to create provider to generate %s registration challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if user, err = getWebAuthnUser(ctx, userSession); err != nil { if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to parse %s registration request PUT data for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
var credentialCreation *protocol.CredentialCreation
if credentialCreation, userSession.WebAuthn, err = w.BeginRegistration(user); err != nil {
ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if length := len(bodyJSON.Description); length == 0 || length > 64 {
ctx.Logger.Errorf("Failed to validate the user chosen display name for during %s registration for user '%s': the value has a length of %d but must be between 1 and 64", regulation.AuthTypeWebAuthn, userSession.Username, length)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
devices, err := ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, w.Config.RPID, userSession.Username)
if err != nil && err != storage.ErrNoWebAuthnDevice {
ctx.Logger.Errorf("Unable to load existing %s devices for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
for _, device := range devices {
if strings.EqualFold(device.Description, bodyJSON.Description) {
ctx.Logger.Errorf("Unable to generate %s registration challenge: device for for user '%s' with display name '%s' already exists", regulation.AuthTypeWebAuthn, userSession.Username, bodyJSON.Description)
ctx.SetStatusCode(fasthttp.StatusConflict)
ctx.SetJSONError(messageSecurityKeyDuplicateName)
return
}
}
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for registration challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
var (
creation *protocol.CredentialCreation
)
opts := []webauthn.RegistrationOption{
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementDiscouraged),
}
data := session.WebAuthn{
Description: bodyJSON.Description,
}
if creation, data.SessionData, err = w.BeginRegistration(user, opts...); err != nil {
ctx.Logger.Errorf("Unable to create %s registration challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
userSession.WebAuthn = &data
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "attestation challenge", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "registration challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if err = ctx.SetJSONBody(credentialCreation); err != nil { if err = ctx.SetJSONBody(creation); err != nil {
ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -89,8 +127,8 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
} }
} }
// WebAuthnAttestationPOST processes the attestation challenge response from the client. // WebAuthnRegistrationPOST processes the attestation challenge response from the client.
func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) { func WebAuthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
var ( var (
err error err error
w *webauthn.WebAuthn w *webauthn.WebAuthn
@ -98,75 +136,89 @@ func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
userSession session.UserSession userSession session.UserSession
attestationResponse *protocol.ParsedCredentialCreationData response *protocol.ParsedCredentialCreationData
credential *webauthn.Credential
credential *webauthn.Credential
) )
if userSession, err = ctx.GetSession(); err != nil { if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation response", regulation.AuthTypeWebAuthn) ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration response", regulation.AuthTypeWebAuthn)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if userSession.WebAuthn == nil { if userSession.WebAuthn == nil || userSession.WebAuthn.SessionData == nil {
ctx.Logger.Errorf("WebAuthn session data is not present in order to handle attestation for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) ctx.Logger.Errorf("WebAuthn session data is not present in order to handle %s registration for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", regulation.AuthTypeWebAuthn, userSession.Username)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if w, err = newWebAuthn(ctx); err != nil { if w, err = newWebAuthn(ctx); err != nil {
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to configure %s during registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if attestationResponse, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil { if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) switch e := err.(type) {
case *protocol.Error:
ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v (%s)", regulation.AuthTypeWebAuthn, userSession.Username, err, e.DevInfo)
default:
ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
}
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if user, err = getWebAuthnUser(ctx, userSession); err != nil { if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s user details for registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if credential, err = w.CreateCredential(user, *userSession.WebAuthn, attestationResponse); err != nil { if credential, err = w.CreateCredential(user, *userSession.WebAuthn.SessionData, response); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) switch e := err.(type) {
case *protocol.Error:
ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v (%s)", regulation.AuthTypeWebAuthn, userSession.Username, err, e.DevInfo)
default:
ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
}
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
device := model.NewWebAuthnDeviceFromCredential(w.Config.RPID, userSession.Username, "Primary", credential) device := model.NewWebAuthnDeviceFromCredential(w.Config.RPID, userSession.Username, userSession.WebAuthn.Description, credential)
device.Discoverable = webauthnCredentialCreationIsDiscoverable(ctx, response)
if err = ctx.Providers.StorageProvider.SaveWebAuthnDevice(ctx, device); err != nil { if err = ctx.Providers.StorageProvider.SaveWebAuthnDevice(ctx, device); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to save %s device registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
userSession.WebAuthn = nil userSession.WebAuthn = nil
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the attestation challenge", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the registration challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
} }
ctx.ReplyOK() ctx.ReplyOK()
ctx.SetStatusCode(fasthttp.StatusCreated) ctx.SetStatusCode(fasthttp.StatusCreated)
ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "WebAuthn Credential", "Device Name": "Primary"}) ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "WebAuthn Credential", "Credential Description": device.Description})
} }

View File

@ -30,45 +30,50 @@ func WebAuthnAssertionGET(ctx *middlewares.AutheliaCtx) {
} }
if w, err = newWebAuthn(ctx); err != nil { if w, err = newWebAuthn(ctx); err != nil {
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to configure %s during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
if user, err = getWebAuthnUser(ctx, userSession); err != nil { if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s user details during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
extensions := map[string]any{}
if user.HasFIDOU2F() {
extensions["appid"] = w.Config.RPOrigins[0]
}
var opts = []webauthn.LoginOption{ var opts = []webauthn.LoginOption{
webauthn.WithAllowedCredentials(user.WebAuthnCredentialDescriptors()), webauthn.WithAllowedCredentials(user.WebAuthnCredentialDescriptors()),
} }
extensions := map[string]any{}
if user.HasFIDOU2F() {
extensions["appid"] = w.Config.RPOrigin
}
if len(extensions) != 0 { if len(extensions) != 0 {
opts = append(opts, webauthn.WithAssertionExtensions(extensions)) opts = append(opts, webauthn.WithAssertionExtensions(extensions))
} }
var assertion *protocol.CredentialAssertion var (
assertion *protocol.CredentialAssertion
data session.WebAuthn
)
if assertion, userSession.WebAuthn, err = w.BeginLogin(user, opts...); err != nil { if assertion, data.SessionData, err = w.BeginLogin(user, opts...); err != nil {
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to create %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
userSession.WebAuthn = &data
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
@ -115,8 +120,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if userSession.WebAuthn == nil { if userSession.WebAuthn == nil || userSession.WebAuthn.SessionData == nil {
ctx.Logger.Errorf("WebAuthn session data is not present in order to handle assertion for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) ctx.Logger.Errorf("WebAuthn session data is not present in order to handle authentication challenge for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -124,7 +129,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
} }
if w, err = newWebAuthn(ctx); err != nil { if w, err = newWebAuthn(ctx); err != nil {
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to configure %s during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -137,23 +142,23 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
user *model.WebAuthnUser user *model.WebAuthnUser
) )
if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(ctx.PostBody())); err != nil { if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil {
ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to parse %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
if user, err = getWebAuthnUser(ctx, userSession); err != nil { if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s credentials for authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
if credential, err = w.ValidateLogin(user, *userSession.WebAuthn, assertionResponse); err != nil { if credential, err = w.ValidateLogin(user, *userSession.WebAuthn.SessionData, assertionResponse); err != nil {
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebAuthn, err) _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebAuthn, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -169,8 +174,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
found = true found = true
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceSignIn(ctx, device.ID, device.RPID, device.LastUsedAt, device.SignCount, device.CloneWarning); err != nil { if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceSignIn(ctx, device); err != nil {
ctx.Logger.Errorf("Unable to save %s device signin count for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -182,7 +187,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
} }
if !found { if !found {
ctx.Logger.Errorf("Unable to save %s device signin count for assertion challenge for user '%s' device '%x' count '%d': unable to find device", regulation.AuthTypeWebAuthn, userSession.Username, credential.ID, credential.Authenticator.SignCount) ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s' device '%x' count '%d': unable to find device", regulation.AuthTypeWebAuthn, userSession.Username, credential.ID, credential.Authenticator.SignCount)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -204,11 +209,11 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
} }
userSession.SetTwoFactorWebAuthn(ctx.Clock.Now(), userSession.SetTwoFactorWebAuthn(ctx.Clock.Now(),
assertionResponse.Response.AuthenticatorData.Flags.UserPresent(), assertionResponse.Response.AuthenticatorData.Flags.HasUserPresent(),
assertionResponse.Response.AuthenticatorData.Flags.UserVerified()) assertionResponse.Response.AuthenticatorData.Flags.HasUserVerified())
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the assertion challenge and authentication time", regulation.AuthTypeWebAuthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the authentiation challenge and authentication time", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)

View File

@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/storage"
)
func getWebAuthnDeviceIDFromContext(ctx *middlewares.AutheliaCtx) (int, error) {
deviceIDStr, ok := ctx.UserValue("deviceID").(string)
if !ok {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return 0, errors.New("Invalid device ID type")
}
deviceID, err := strconv.Atoi(deviceIDStr)
if err != nil {
ctx.Error(err, messageOperationFailed)
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return 0, err
}
return deviceID, nil
}
// WebAuthnDevicesGET returns all devices registered for the current user.
func WebAuthnDevicesGET(ctx *middlewares.AutheliaCtx) {
var (
userSession session.UserSession
origin *url.URL
err error
)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
ctx.ReplyForbidden()
return
}
if origin, err = ctx.GetOrigin(); err != nil {
ctx.Logger.WithError(err).Error("Error occurred retrieving origin")
ctx.ReplyForbidden()
return
}
devices, err := ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, origin.Hostname(), userSession.Username)
if err != nil && err != storage.ErrNoWebAuthnDevice {
ctx.Error(err, messageOperationFailed)
return
}
if err = ctx.SetJSONBody(devices); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
}
// WebAuthnDevicePUT updates the description for a specific device for the current user.
func WebAuthnDevicePUT(ctx *middlewares.AutheliaCtx) {
var (
bodyJSON bodyEditWebAuthnDeviceRequest
id int
device *model.WebAuthnDevice
userSession session.UserSession
err error
)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
ctx.ReplyForbidden()
return
}
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.Errorf("Unable to parse %s update request data for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.Error(err, messageOperationFailed)
return
}
if id, err = getWebAuthnDeviceIDFromContext(ctx); err != nil {
return
}
if device, err = ctx.Providers.StorageProvider.LoadWebAuthnDeviceByID(ctx, id); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
if device.Username != userSession.Username {
ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed)
return
}
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceDescription(ctx, userSession.Username, id, bodyJSON.Description); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
}
// WebAuthnDeviceDELETE deletes a specific device for the current user.
func WebAuthnDeviceDELETE(ctx *middlewares.AutheliaCtx) {
var (
id int
device *model.WebAuthnDevice
userSession session.UserSession
err error
)
if id, err = getWebAuthnDeviceIDFromContext(ctx); err != nil {
return
}
if device, err = ctx.Providers.StorageProvider.LoadWebAuthnDeviceByID(ctx, id); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
ctx.ReplyForbidden()
return
}
if device.Username != userSession.Username {
ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed)
return
}
if err = ctx.Providers.StorageProvider.DeleteWebAuthnDevice(ctx, device.KID.String()); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
ctx.ReplyOK()
}

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"net/url" "net/url"
@ -35,6 +36,16 @@ type bodySignWebAuthnRequest struct {
TargetURL string `json:"targetURL"` TargetURL string `json:"targetURL"`
Workflow string `json:"workflow"` Workflow string `json:"workflow"`
WorkflowID string `json:"workflowID"` WorkflowID string `json:"workflowID"`
Response json.RawMessage `json:"response"`
}
type bodyRegisterWebAuthnPUTRequest struct {
Description string `json:"description"`
}
type bodyEditWebAuthnDeviceRequest struct {
Description string `json:"description"`
} }
// bodySignDuoRequest is the model of the request body of Duo 2FA authentication endpoint. // bodySignDuoRequest is the model of the request body of Duo 2FA authentication endpoint.

View File

@ -1,28 +1,42 @@
package handlers package handlers
import ( import (
"fmt"
"net/url" "net/url"
"strings"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/random"
) )
func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *model.WebAuthnUser, err error) { func getWebAuthnUserByRPID(ctx *middlewares.AutheliaCtx, username, displayname string, rpid string) (user *model.WebAuthnUser, err error) {
user = &model.WebAuthnUser{ if user, err = ctx.Providers.StorageProvider.LoadWebAuthnUser(ctx, rpid, username); err != nil {
Username: userSession.Username, return nil, err
DisplayName: userSession.DisplayName, }
if user == nil {
user = &model.WebAuthnUser{
RPID: rpid,
Username: username,
UserID: ctx.Providers.Random.StringCustom(64, random.CharSetASCII),
DisplayName: displayname,
}
if err = ctx.Providers.StorageProvider.SaveWebAuthnUser(ctx, *user); err != nil {
return nil, err
}
} else {
user.DisplayName = displayname
} }
if user.DisplayName == "" { if user.DisplayName == "" {
user.DisplayName = user.Username user.DisplayName = user.Username
} }
if user.Devices, err = ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, userSession.Username); err != nil { if user.Devices, err = ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, rpid, user.Username); err != nil {
return nil, err return nil, err
} }
@ -31,33 +45,72 @@ func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSessi
func newWebAuthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) { func newWebAuthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) {
var ( var (
u *url.URL origin *url.URL
) )
if u, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil { if origin, err = ctx.GetOrigin(); err != nil {
return nil, err return nil, err
} }
rpID := u.Hostname()
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
config := &webauthn.Config{ config := &webauthn.Config{
RPDisplayName: ctx.Configuration.WebAuthn.DisplayName, RPID: origin.Hostname(),
RPID: rpID, RPDisplayName: ctx.Configuration.WebAuthn.DisplayName,
RPOrigin: origin, RPOrigins: []string{origin.String()},
RPIcon: "",
AttestationPreference: ctx.Configuration.WebAuthn.ConveyancePreference, AttestationPreference: ctx.Configuration.WebAuthn.ConveyancePreference,
AuthenticatorSelection: protocol.AuthenticatorSelection{ AuthenticatorSelection: protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform, AuthenticatorAttachment: protocol.CrossPlatform,
UserVerification: ctx.Configuration.WebAuthn.UserVerification,
RequireResidentKey: protocol.ResidentKeyNotRequired(), RequireResidentKey: protocol.ResidentKeyNotRequired(),
ResidentKey: protocol.ResidentKeyRequirementDiscouraged,
UserVerification: ctx.Configuration.WebAuthn.UserVerification,
},
Debug: false,
EncodeUserIDAsString: true,
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: ctx.Configuration.WebAuthn.Timeout,
TimeoutUVD: ctx.Configuration.WebAuthn.Timeout,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: ctx.Configuration.WebAuthn.Timeout,
TimeoutUVD: ctx.Configuration.WebAuthn.Timeout,
},
}, },
Timeout: int(ctx.Configuration.WebAuthn.Timeout.Milliseconds()),
} }
ctx.Logger.Tracef("Creating new WebAuthn RP instance with ID %s and Origins %s", config.RPID, config.RPOrigin) ctx.Logger.Tracef("Creating new WebAuthn RP instance with ID %s and Origins %s", config.RPID, strings.Join(config.RPOrigins, ", "))
return webauthn.New(config) return webauthn.New(config)
} }
func webauthnCredentialCreationIsDiscoverable(ctx *middlewares.AutheliaCtx, response *protocol.ParsedCredentialCreationData) (discoverable bool) {
if value, ok := response.ClientExtensionResults["credProps"]; ok {
switch credentialProperties := value.(type) {
case map[string]any:
var v any
if v, ok = credentialProperties["rk"]; ok {
if discoverable, ok = v.(bool); ok {
ctx.Logger.WithFields(map[string]any{"discoverable": discoverable}).Trace("Determined Credential Discoverability via Client Extension Results")
return discoverable
} else {
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'rk' field for the 'credProps' extension in the Client Extension Results was not a boolean")
}
} else {
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'rk' field for the 'credProps' extension was missing from the Client Extension Results")
}
return false
default:
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'credProps' extension in the Client Extension Results does not appear to be a dictionary")
return false
}
}
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'credProps' extension is missing from the Client Extension Results")
return false
}

View File

@ -5,12 +5,14 @@ import (
"testing" "testing"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/random"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
) )
@ -22,10 +24,14 @@ func TestWebAuthnGetUser(t *testing.T) {
DisplayName: "John Smith", DisplayName: "John Smith",
} }
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{ ctx.StorageMock.EXPECT().
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
{ {
ID: 1, ID: 1,
RPID: "https://example.com", RPID: "example.com",
Username: "john", Username: "john",
Description: "Primary", Description: "Primary",
KID: model.NewBase64([]byte("abc123")), KID: model.NewBase64([]byte("abc123")),
@ -48,12 +54,12 @@ func TestWebAuthnGetUser(t *testing.T) {
}, },
}, nil) }, nil)
user, err := getWebAuthnUser(ctx.Ctx, userSession) user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, user) require.NotNil(t, user)
assert.Equal(t, []byte{}, user.WebAuthnID()) assert.Equal(t, []byte("john123"), user.WebAuthnID())
assert.Equal(t, "john", user.WebAuthnName()) assert.Equal(t, "john", user.WebAuthnName())
assert.Equal(t, "john", user.Username) assert.Equal(t, "john", user.Username)
@ -65,7 +71,107 @@ func TestWebAuthnGetUser(t *testing.T) {
require.Len(t, user.Devices, 2) require.Len(t, user.Devices, 2)
assert.Equal(t, 1, user.Devices[0].ID) assert.Equal(t, 1, user.Devices[0].ID)
assert.Equal(t, "https://example.com", user.Devices[0].RPID) assert.Equal(t, "example.com", user.Devices[0].RPID)
assert.Equal(t, "john", user.Devices[0].Username)
assert.Equal(t, "Primary", user.Devices[0].Description)
assert.Equal(t, "", user.Devices[0].Transport)
assert.Equal(t, "fido-u2f", user.Devices[0].AttestationType)
assert.Equal(t, []byte("data"), user.Devices[0].PublicKey)
assert.Equal(t, uint32(0), user.Devices[0].SignCount)
assert.False(t, user.Devices[0].CloneWarning)
descriptors := user.WebAuthnCredentialDescriptors()
assert.Equal(t, "fido-u2f", descriptors[0].AttestationType)
assert.Equal(t, "abc123", string(descriptors[0].CredentialID))
assert.Equal(t, protocol.PublicKeyCredentialType, descriptors[0].Type)
assert.Len(t, descriptors[0].Transport, 0)
assert.Equal(t, 2, user.Devices[1].ID)
assert.Equal(t, "example.com", user.Devices[1].RPID)
assert.Equal(t, "john", user.Devices[1].Username)
assert.Equal(t, "Secondary", user.Devices[1].Description)
assert.Equal(t, "usb,nfc", user.Devices[1].Transport)
assert.Equal(t, "packed", user.Devices[1].AttestationType)
assert.Equal(t, []byte("data"), user.Devices[1].PublicKey)
assert.Equal(t, uint32(100), user.Devices[1].SignCount)
assert.False(t, user.Devices[1].CloneWarning)
assert.Equal(t, "packed", descriptors[1].AttestationType)
assert.Equal(t, "123abc", string(descriptors[1].CredentialID))
assert.Equal(t, protocol.PublicKeyCredentialType, descriptors[1].Type)
assert.Len(t, descriptors[1].Transport, 2)
assert.Equal(t, protocol.AuthenticatorTransport("usb"), descriptors[1].Transport[0])
assert.Equal(t, protocol.AuthenticatorTransport("nfc"), descriptors[1].Transport[1])
}
func TestWebAuthnGetNewUser(t *testing.T) {
ctx := mocks.NewMockAutheliaCtx(t)
// Use the random mock.
ctx.Ctx.Providers.Random = ctx.RandomMock
userSession := session.UserSession{
Username: "john",
DisplayName: "John Smith",
}
gomock.InOrder(
ctx.StorageMock.EXPECT().
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
Return(nil, nil),
ctx.RandomMock.EXPECT().
StringCustom(64, random.CharSetASCII).
Return("=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"),
ctx.StorageMock.EXPECT().
SaveWebAuthnUser(ctx.Ctx, model.WebAuthnUser{RPID: "example.com", Username: "john", DisplayName: "John Smith", UserID: "=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"}).
Return(nil),
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
{
ID: 1,
RPID: "example.com",
Username: "john",
Description: "Primary",
KID: model.NewBase64([]byte("abc123")),
AttestationType: "fido-u2f",
PublicKey: []byte("data"),
SignCount: 0,
CloneWarning: false,
},
{
ID: 2,
RPID: "example.com",
Username: "john",
Description: "Secondary",
KID: model.NewBase64([]byte("123abc")),
AttestationType: "packed",
Transport: "usb,nfc",
PublicKey: []byte("data"),
SignCount: 100,
CloneWarning: false,
},
}, nil),
)
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, []byte("=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"), user.WebAuthnID())
assert.Equal(t, "john", user.WebAuthnName())
assert.Equal(t, "john", user.Username)
assert.Equal(t, "", user.WebAuthnIcon())
assert.Equal(t, "John Smith", user.WebAuthnDisplayName())
assert.Equal(t, "John Smith", user.DisplayName)
require.Len(t, user.Devices, 2)
assert.Equal(t, 1, user.Devices[0].ID)
assert.Equal(t, "example.com", user.Devices[0].RPID)
assert.Equal(t, "john", user.Devices[0].Username) assert.Equal(t, "john", user.Devices[0].Username)
assert.Equal(t, "Primary", user.Devices[0].Description) assert.Equal(t, "Primary", user.Devices[0].Description)
assert.Equal(t, "", user.Devices[0].Transport) assert.Equal(t, "", user.Devices[0].Transport)
@ -107,7 +213,11 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
Username: "john", Username: "john",
} }
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{ ctx.StorageMock.EXPECT().
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
{ {
ID: 1, ID: 1,
RPID: "example.com", RPID: "example.com",
@ -121,7 +231,7 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
}, },
}, nil) }, nil)
user, err := getWebAuthnUser(ctx.Ctx, userSession) user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, user) require.NotNil(t, user)
@ -137,9 +247,15 @@ func TestWebAuthnGetUserWithErr(t *testing.T) {
Username: "john", Username: "john",
} }
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return(nil, errors.New("not found")) ctx.StorageMock.EXPECT().
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
user, err := getWebAuthnUser(ctx.Ctx, userSession) ctx.StorageMock.EXPECT().
LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").
Return(nil, errors.New("not found"))
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
assert.EqualError(t, err, "not found") assert.EqualError(t, err, "not found")
assert.Nil(t, user) assert.Nil(t, user)
@ -165,5 +281,5 @@ func TestWebAuthnNewWebAuthnShouldReturnErrWhenWebAuthnNotConfigured(t *testing.
w, err := newWebAuthn(ctx.Ctx) w, err := newWebAuthn(ctx.Ctx)
assert.Nil(t, w) assert.Nil(t, w)
assert.EqualError(t, err, "Configuration error: Missing RPDisplayName") assert.EqualError(t, err, "error occurred validating the configuration: the field 'RPDisplayName' must be configured but it is empty")
} }

View File

@ -69,8 +69,19 @@ func (ctx *AutheliaCtx) Error(err error, message string) {
// SetJSONError sets the body of the response to an JSON error KO message. // SetJSONError sets the body of the response to an JSON error KO message.
func (ctx *AutheliaCtx) SetJSONError(message string) { func (ctx *AutheliaCtx) SetJSONError(message string) {
if replyErr := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); replyErr != nil { if err := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); err != nil {
ctx.Logger.Error(replyErr) ctx.Logger.Error(err)
}
}
// SetAuthenticationErrorJSON sets the body of the response to an JSON error KO message.
func (ctx *AutheliaCtx) SetAuthenticationErrorJSON(status int, message string, authentication, elevation bool) {
if status > fasthttp.StatusOK {
ctx.SetStatusCode(status)
}
if err := ctx.ReplyJSON(AuthenticationErrorResponse{Status: "KO", Message: message, Authentication: authentication, Elevation: elevation}, 0); err != nil {
ctx.Logger.Error(err)
} }
} }
@ -517,6 +528,18 @@ func (ctx *AutheliaCtx) GetXOriginalURLOrXForwardedURL() (requestURI *url.URL, e
} }
} }
// GetOrigin returns the expected origin for requests from this endpoint.
func (ctx *AutheliaCtx) GetOrigin() (origin *url.URL, err error) {
if origin, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil {
return nil, err
}
origin.Path = ""
origin.RawPath = ""
return origin, nil
}
// IssuerURL returns the expected Issuer. // IssuerURL returns the expected Issuer.
func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) { func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) {
issuerURL = &url.URL{ issuerURL = &url.URL{

View File

@ -95,48 +95,55 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
} }
} }
func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
var bodyJSON IdentityVerificationFinishBody
err := json.Unmarshal(ctx.PostBody(), &bodyJSON)
if err != nil {
ctx.Error(err, messageOperationFailed)
return nil, err
}
if bodyJSON.Token == "" {
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
return nil, err
}
token, err := jwt.ParseWithClaims(bodyJSON.Token, &model.IdentityVerificationClaim{},
func(token *jwt.Token) (any, error) {
return []byte(ctx.Configuration.JWTSecret), nil
})
if err != nil {
if ve, ok := err.(*jwt.ValidationError); ok {
switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0:
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
return nil, err
case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
// Token is either expired or not active yet.
ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
return nil, err
default:
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
return nil, err
}
}
ctx.Error(err, messageOperationFailed)
return nil, err
}
return token, nil
}
// IdentityVerificationFinish the middleware for finishing the identity validation process. // IdentityVerificationFinish the middleware for finishing the identity validation process.
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler { func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) { return func(ctx *AutheliaCtx) {
var finishBody IdentityVerificationFinishBody token, err := identityVerificationValidateToken(ctx)
if token == nil || err != nil {
b := ctx.PostBody()
err := json.Unmarshal(b, &finishBody)
if err != nil {
ctx.Error(err, messageOperationFailed)
return
}
if finishBody.Token == "" {
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
return
}
token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
func(token *jwt.Token) (any, error) {
return []byte(ctx.Configuration.JWTSecret), nil
})
if err != nil {
if ve, ok := err.(*jwt.ValidationError); ok {
switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0:
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
return
case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
// Token is either expired or not active yet.
ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
return
default:
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
return
}
}
ctx.Error(err, messageOperationFailed)
return return
} }
@ -176,8 +183,7 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
return return
} }
err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP())) if err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP())); err != nil {
if err != nil {
ctx.Error(err, messageOperationFailed) ctx.Error(err, messageOperationFailed)
return return
} }

View File

@ -0,0 +1,43 @@
package middlewares
import (
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/authentication"
)
// Require1FA requires the user to have authenticated with at least one-factor authentication (i.e. password).
func Require1FA(next RequestHandler) RequestHandler {
return func(ctx *AutheliaCtx) {
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.OneFactor {
ctx.ReplyForbidden()
return
}
next(ctx)
}
}
// Require2FA requires the user to have authenticated with two-factor authentication.
func Require2FA(next RequestHandler) RequestHandler {
return func(ctx *AutheliaCtx) {
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor {
ctx.ReplyForbidden()
return
}
next(ctx)
}
}
// Require2FAWithAPIResponse requires the user to have authenticated with two-factor authentication.
func Require2FAWithAPIResponse(next RequestHandler) RequestHandler {
return func(ctx *AutheliaCtx) {
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor {
ctx.SetAuthenticationErrorJSON(fasthttp.StatusForbidden, "Authentication Required.", true, false)
return
}
next(ctx)
}
}

View File

@ -1,17 +0,0 @@
package middlewares
import (
"github.com/authelia/authelia/v4/internal/authentication"
)
// Require1FA check if user has enough permissions to execute the next handler.
func Require1FA(next RequestHandler) RequestHandler {
return func(ctx *AutheliaCtx) {
if s, err := ctx.GetSession(); err != nil || s.AuthenticationLevel < authentication.OneFactor {
ctx.ReplyForbidden()
return
}
next(ctx)
}
}

View File

@ -121,3 +121,11 @@ type ErrorResponse struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
} }
// AuthenticationErrorResponse model of an error response.
type AuthenticationErrorResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Authentication bool `json:"authentication"`
Elevation bool `json:"elevation"`
}

View File

@ -419,6 +419,21 @@ func (mr *MockStorageMockRecorder) LoadUserOpaqueIdentifiers(arg0 interface{}) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadUserOpaqueIdentifiers", reflect.TypeOf((*MockStorage)(nil).LoadUserOpaqueIdentifiers), arg0) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadUserOpaqueIdentifiers", reflect.TypeOf((*MockStorage)(nil).LoadUserOpaqueIdentifiers), arg0)
} }
// LoadWebAuthnDeviceByID mocks base method.
func (m *MockStorage) LoadWebAuthnDeviceByID(arg0 context.Context, arg1 int) (*model.WebAuthnDevice, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadWebAuthnDeviceByID", arg0, arg1)
ret0, _ := ret[0].(*model.WebAuthnDevice)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadWebAuthnDeviceByID indicates an expected call of LoadWebAuthnDeviceByID.
func (mr *MockStorageMockRecorder) LoadWebAuthnDeviceByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDeviceByID", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDeviceByID), arg0, arg1)
}
// LoadWebAuthnDevices mocks base method. // LoadWebAuthnDevices mocks base method.
func (m *MockStorage) LoadWebAuthnDevices(arg0 context.Context, arg1, arg2 int) ([]model.WebAuthnDevice, error) { func (m *MockStorage) LoadWebAuthnDevices(arg0 context.Context, arg1, arg2 int) ([]model.WebAuthnDevice, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -435,18 +450,33 @@ func (mr *MockStorageMockRecorder) LoadWebAuthnDevices(arg0, arg1, arg2 interfac
} }
// LoadWebAuthnDevicesByUsername mocks base method. // LoadWebAuthnDevicesByUsername mocks base method.
func (m *MockStorage) LoadWebAuthnDevicesByUsername(arg0 context.Context, arg1 string) ([]model.WebAuthnDevice, error) { func (m *MockStorage) LoadWebAuthnDevicesByUsername(arg0 context.Context, arg1, arg2 string) ([]model.WebAuthnDevice, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1) ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1, arg2)
ret0, _ := ret[0].([]model.WebAuthnDevice) ret0, _ := ret[0].([]model.WebAuthnDevice)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// LoadWebAuthnDevicesByUsername indicates an expected call of LoadWebAuthnDevicesByUsername. // LoadWebAuthnDevicesByUsername indicates an expected call of LoadWebAuthnDevicesByUsername.
func (mr *MockStorageMockRecorder) LoadWebAuthnDevicesByUsername(arg0, arg1 interface{}) *gomock.Call { func (mr *MockStorageMockRecorder) LoadWebAuthnDevicesByUsername(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDevicesByUsername", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDevicesByUsername), arg0, arg1) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDevicesByUsername", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDevicesByUsername), arg0, arg1, arg2)
}
// LoadWebAuthnUser mocks base method.
func (m *MockStorage) LoadWebAuthnUser(arg0 context.Context, arg1, arg2 string) (*model.WebAuthnUser, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadWebAuthnUser", arg0, arg1, arg2)
ret0, _ := ret[0].(*model.WebAuthnUser)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadWebAuthnUser indicates an expected call of LoadWebAuthnUser.
func (mr *MockStorageMockRecorder) LoadWebAuthnUser(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnUser", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnUser), arg0, arg1, arg2)
} }
// RevokeOAuth2PARContext mocks base method. // RevokeOAuth2PARContext mocks base method.
@ -702,6 +732,20 @@ func (mr *MockStorageMockRecorder) SaveWebAuthnDevice(arg0, arg1 interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebAuthnDevice", reflect.TypeOf((*MockStorage)(nil).SaveWebAuthnDevice), arg0, arg1) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebAuthnDevice", reflect.TypeOf((*MockStorage)(nil).SaveWebAuthnDevice), arg0, arg1)
} }
// SaveWebAuthnUser mocks base method.
func (m *MockStorage) SaveWebAuthnUser(arg0 context.Context, arg1 model.WebAuthnUser) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveWebAuthnUser", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SaveWebAuthnUser indicates an expected call of SaveWebAuthnUser.
func (mr *MockStorageMockRecorder) SaveWebAuthnUser(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebAuthnUser", reflect.TypeOf((*MockStorage)(nil).SaveWebAuthnUser), arg0, arg1)
}
// SchemaEncryptionChangeKey mocks base method. // SchemaEncryptionChangeKey mocks base method.
func (m *MockStorage) SchemaEncryptionChangeKey(arg0 context.Context, arg1 string) error { func (m *MockStorage) SchemaEncryptionChangeKey(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -863,16 +907,30 @@ func (mr *MockStorageMockRecorder) UpdateTOTPConfigurationSignIn(arg0, arg1, arg
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTOTPConfigurationSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateTOTPConfigurationSignIn), arg0, arg1, arg2) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTOTPConfigurationSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateTOTPConfigurationSignIn), arg0, arg1, arg2)
} }
// UpdateWebAuthnDeviceSignIn mocks base method. // UpdateWebAuthnDeviceDescription mocks base method.
func (m *MockStorage) UpdateWebAuthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error { func (m *MockStorage) UpdateWebAuthnDeviceDescription(arg0 context.Context, arg1 string, arg2 int, arg3 string) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceSignIn", arg0, arg1, arg2, arg3, arg4, arg5) ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceDescription", arg0, arg1, arg2, arg3)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWebAuthnDeviceDescription indicates an expected call of UpdateWebAuthnDeviceDescription.
func (mr *MockStorageMockRecorder) UpdateWebAuthnDeviceDescription(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebAuthnDeviceDescription", reflect.TypeOf((*MockStorage)(nil).UpdateWebAuthnDeviceDescription), arg0, arg1, arg2, arg3)
}
// UpdateWebAuthnDeviceSignIn mocks base method.
func (m *MockStorage) UpdateWebAuthnDeviceSignIn(arg0 context.Context, arg1 model.WebAuthnDevice) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceSignIn", arg0, arg1)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)
return ret0 return ret0
} }
// UpdateWebAuthnDeviceSignIn indicates an expected call of UpdateWebAuthnDeviceSignIn. // UpdateWebAuthnDeviceSignIn indicates an expected call of UpdateWebAuthnDeviceSignIn.
func (mr *MockStorageMockRecorder) UpdateWebAuthnDeviceSignIn(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { func (mr *MockStorageMockRecorder) UpdateWebAuthnDeviceSignIn(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebAuthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebAuthnDeviceSignIn), arg0, arg1, arg2, arg3, arg4, arg5) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebAuthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebAuthnDeviceSignIn), arg0, arg1)
} }

View File

@ -76,10 +76,17 @@ func (w WebAuthnUser) WebAuthnCredentials() (credentials []webauthn.Credential)
ID: device.KID.Bytes(), ID: device.KID.Bytes(),
PublicKey: device.PublicKey, PublicKey: device.PublicKey,
AttestationType: device.AttestationType, AttestationType: device.AttestationType,
Flags: webauthn.CredentialFlags{
UserPresent: device.Present,
UserVerified: device.Verified,
BackupEligible: device.BackupEligible,
BackupState: device.BackupState,
},
Authenticator: webauthn.Authenticator{ Authenticator: webauthn.Authenticator{
AAGUID: aaguid, AAGUID: aaguid,
SignCount: device.SignCount, SignCount: device.SignCount,
CloneWarning: device.CloneWarning, CloneWarning: device.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(device.Attachment),
}, },
} }
@ -128,9 +135,15 @@ func NewWebAuthnDeviceFromCredential(rpid, username, description string, credent
Description: description, Description: description,
KID: NewBase64(credential.ID), KID: NewBase64(credential.ID),
AttestationType: credential.AttestationType, AttestationType: credential.AttestationType,
Attachment: string(credential.Authenticator.Attachment),
Transport: strings.Join(transport, ","), Transport: strings.Join(transport, ","),
SignCount: credential.Authenticator.SignCount, SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning, CloneWarning: credential.Authenticator.CloneWarning,
Discoverable: false,
Present: credential.Flags.UserPresent,
Verified: credential.Flags.UserVerified,
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
PublicKey: credential.PublicKey, PublicKey: credential.PublicKey,
} }
@ -153,9 +166,15 @@ type WebAuthnDevice struct {
KID Base64 `db:"kid"` KID Base64 `db:"kid"`
AAGUID uuid.NullUUID `db:"aaguid"` AAGUID uuid.NullUUID `db:"aaguid"`
AttestationType string `db:"attestation_type"` AttestationType string `db:"attestation_type"`
Attachment string `db:"attachment"`
Transport string `db:"transport"` Transport string `db:"transport"`
SignCount uint32 `db:"sign_count"` SignCount uint32 `db:"sign_count"`
CloneWarning bool `db:"clone_warning"` CloneWarning bool `db:"clone_warning"`
Discoverable bool `db:"discoverable"`
Present bool `db:"present"`
Verified bool `db:"verified"`
BackupEligible bool `db:"backup_eligible"`
BackupState bool `db:"backup_state"`
PublicKey []byte `db:"public_key"` PublicKey []byte `db:"public_key"`
} }
@ -171,7 +190,7 @@ func (d *WebAuthnDevice) UpdateSignInInfo(config *webauthn.Config, now time.Time
switch d.AttestationType { switch d.AttestationType {
case attestationTypeFIDOU2F: case attestationTypeFIDOU2F:
d.RPID = config.RPOrigin d.RPID = config.RPOrigins[0]
default: default:
d.RPID = config.RPID d.RPID = config.RPID
} }
@ -210,8 +229,13 @@ func (d *WebAuthnDevice) ToData() WebAuthnDeviceData {
KID: d.KID.String(), KID: d.KID.String(),
AAGUID: d.DataValueAAGUID(), AAGUID: d.DataValueAAGUID(),
AttestationType: d.AttestationType, AttestationType: d.AttestationType,
Attachment: d.Attachment,
SignCount: d.SignCount, SignCount: d.SignCount,
CloneWarning: d.CloneWarning, CloneWarning: d.CloneWarning,
Present: d.Present,
Verified: d.Verified,
BackupEligible: d.BackupEligible,
BackupState: d.BackupState,
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey),
} }
@ -269,9 +293,15 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
d.Username = o.Username d.Username = o.Username
d.Description = o.Description d.Description = o.Description
d.AttestationType = o.AttestationType d.AttestationType = o.AttestationType
d.Attachment = o.Attachment
d.Transport = strings.Join(o.Transports, ",") d.Transport = strings.Join(o.Transports, ",")
d.SignCount = o.SignCount d.SignCount = o.SignCount
d.CloneWarning = o.CloneWarning d.CloneWarning = o.CloneWarning
d.Discoverable = o.Discoverable
d.Present = o.Present
d.Verified = o.Verified
d.BackupEligible = o.BackupEligible
d.BackupState = o.BackupState
if o.LastUsedAt != nil { if o.LastUsedAt != nil {
d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt} d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt}
@ -291,9 +321,15 @@ type WebAuthnDeviceData struct {
KID string `json:"kid" yaml:"kid"` KID string `json:"kid" yaml:"kid"`
AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"` AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"`
AttestationType string `json:"attestation_type" yaml:"attestation_type"` AttestationType string `json:"attestation_type" yaml:"attestation_type"`
Attachment string `json:"attachment" yaml:"attachment"`
Transports []string `json:"transports" yaml:"transports"` Transports []string `json:"transports" yaml:"transports"`
SignCount uint32 `json:"sign_count" yaml:"sign_count"` SignCount uint32 `json:"sign_count" yaml:"sign_count"`
CloneWarning bool `json:"clone_warning" yaml:"clone_warning"` CloneWarning bool `json:"clone_warning" yaml:"clone_warning"`
Discoverable bool `json:"discoverable" yaml:"discoverable"`
Present bool `json:"present" yaml:"present"`
Verified bool `json:"verified" yaml:"verified"`
BackupEligible bool `json:"backup_eligible" yaml:"backup_eligible"`
BackupState bool `json:"backup_state" yaml:"backup_state"`
PublicKey string `json:"public_key" yaml:"public_key"` PublicKey string `json:"public_key" yaml:"public_key"`
} }
@ -304,9 +340,15 @@ func (d *WebAuthnDeviceData) ToDevice() (device *WebAuthnDevice, err error) {
Username: d.Username, Username: d.Username,
Description: d.Description, Description: d.Description,
AttestationType: d.AttestationType, AttestationType: d.AttestationType,
Attachment: d.Attachment,
Transport: strings.Join(d.Transports, ","), Transport: strings.Join(d.Transports, ","),
SignCount: d.SignCount, SignCount: d.SignCount,
CloneWarning: d.CloneWarning, CloneWarning: d.CloneWarning,
Discoverable: d.Discoverable,
Present: d.Present,
Verified: d.Verified,
BackupEligible: d.BackupEligible,
BackupState: d.BackupState,
} }
if device.PublicKey, err = base64.StdEncoding.DecodeString(d.PublicKey); err != nil { if device.PublicKey, err = base64.StdEncoding.DecodeString(d.PublicKey); err != nil {

View File

@ -174,6 +174,11 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
WithPostMiddlewares(middlewares.Require1FA). WithPostMiddlewares(middlewares.Require1FA).
Build() Build()
middleware2FA := middlewares.NewBridgeBuilder(*config, providers).
WithPreMiddlewares(middlewares.SecurityHeaders, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
WithPostMiddlewares(middlewares.Require2FAWithAPIResponse).
Build()
r.HEAD("/api/health", middlewareAPI(handlers.HealthGET)) r.HEAD("/api/health", middlewareAPI(handlers.HealthGET))
r.GET("/api/health", middlewareAPI(handlers.HealthGET)) r.GET("/api/health", middlewareAPI(handlers.HealthGET))
@ -256,13 +261,15 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
} }
if !config.WebAuthn.Disable { if !config.WebAuthn.Disable {
// WebAuthn Endpoints. r.GET("/api/secondfactor/webauthn", middleware1FA(handlers.WebAuthnAssertionGET))
r.POST("/api/secondfactor/webauthn/identity/start", middleware1FA(handlers.WebauthnIdentityStart)) r.POST("/api/secondfactor/webauthn", middleware1FA(handlers.WebAuthnAssertionPOST))
r.POST("/api/secondfactor/webauthn/identity/finish", middleware1FA(handlers.WebauthnIdentityFinish))
r.POST("/api/secondfactor/webauthn/attestation", middleware1FA(handlers.WebAuthnAttestationPOST))
r.GET("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionGET)) // Management of the webauthn devices.
r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionPOST)) r.GET("/api/secondfactor/webauthn/credentials", middleware1FA(handlers.WebAuthnDevicesGET))
r.PUT("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebAuthnRegistrationPUT))
r.POST("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebAuthnRegistrationPOST))
r.PUT("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebAuthnDevicePUT))
r.DELETE("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebAuthnDeviceDELETE))
} }
// Configure DUO api endpoint only if configuration exists. // Configure DUO api endpoint only if configuration exists.

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "سرية", "Secret": "سرية",
"Security Key - WebAuthN": "مفتاح الأمان - WebAuthN", "Security Key - WebAuthn": "مفتاح الأمان - WebAuthn",
"Select a Device": "حدد جهاز", "Select a Device": "حدد جهاز",
"Sign in": "تسجيل الدخول", "Sign in": "تسجيل الدخول",
"Sign out": "تسجيل الخروج", "Sign out": "تسجيل الخروج",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Tajný klíč", "Secret": "Tajný klíč",
"Security Key - WebAuthN": "Bezpečnostní klíč - WebAuthN", "Security Key - WebAuthn": "Bezpečnostní klíč - WebAuthn",
"Select a Device": "Vybrat zařízení", "Select a Device": "Vybrat zařízení",
"Sign in": "Přihlásit se", "Sign in": "Přihlásit se",
"Sign out": "Odhlásit se", "Sign out": "Odhlásit se",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Hemmelighed", "Secret": "Hemmelighed",
"Security Key - WebAuthN": "Sikkerhedsnøgle - WebAuthN", "Security Key - WebAuthn": "Sikkerhedsnøgle - WebAuthn",
"Select a Device": "Vælg en enhed", "Select a Device": "Vælg en enhed",
"Sign in": "Log ind", "Sign in": "Log ind",
"Sign out": "Log ud", "Sign out": "Log ud",

View File

@ -51,7 +51,7 @@
"Reset": "Zurücksetzen", "Reset": "Zurücksetzen",
"Scan QR Code": "QR-Code scannen", "Scan QR Code": "QR-Code scannen",
"Secret": "Geheimnis", "Secret": "Geheimnis",
"Security Key - WebAuthN": "Sicherheitsschlüssel - WebAuthN", "Security Key - WebAuthn": "Sicherheitsschlüssel - WebAuthn",
"Select a Device": "Gerät auswählen", "Select a Device": "Gerät auswählen",
"Sign in": "Anmelden", "Sign in": "Anmelden",
"Sign out": "Abmelden", "Sign out": "Abmelden",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Μυστικό", "Secret": "Μυστικό",
"Security Key - WebAuthN": "Κλειδί Ασφαλείας - WebAuthn", "Security Key - WebAuthn": "Κλειδί Ασφαλείας - WebAuthn",
"Select a Device": "Επιλέξτε μια συσκευή", "Select a Device": "Επιλέξτε μια συσκευή",
"Sign in": "Σύνδεση", "Sign in": "Σύνδεση",
"Sign out": "Αποσύνδεση", "Sign out": "Αποσύνδεση",

View File

@ -8,6 +8,7 @@
"Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction", "Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction",
"Cancel": "Cancel", "Cancel": "Cancel",
"Client ID": "Client ID: {{client_id}}", "Client ID": "Client ID: {{client_id}}",
"Close": "Close",
"Consent Request": "Consent Request", "Consent Request": "Consent Request",
"Contact your administrator to register a device": "Contact your administrator to register a device.", "Contact your administrator to register a device": "Contact your administrator to register a device.",
"Could not obtain user settings": "Could not obtain user settings", "Could not obtain user settings": "Could not obtain user settings",
@ -50,8 +51,9 @@
"Reset password?": "Reset password?", "Reset password?": "Reset password?",
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Scope": "Scope {{name}}",
"Secret": "Secret", "Secret": "Secret",
"Security Key - WebAuthN": "Security Key - WebAuthN", "Security Key - WebAuthn": "Security Key - WebAuthn",
"Select a Device": "Select a Device", "Select a Device": "Select a Device",
"Sign in": "Sign in", "Sign in": "Sign in",
"Sign out": "Sign out", "Sign out": "Sign out",

View File

@ -0,0 +1,52 @@
{
"Actions": "Actions",
"Add": "Add",
"Add Credential": "Add Credential",
"Added": "Added {{when, datetime}}",
"Are you sure you want to remove the WebAuthn credential from from your account": "Are you sure you want to remove the WebAuthn credential {{description}} from your account?",
"Attestation Type": "Attestation Type",
"Authenticator GUID": "Authenticator GUID",
"Cancel": "Cancel",
"Click to add a WebAuthn credential to your account": "Click to add a WebAuthn credential to your account",
"Click to copy the": "Click to copy the",
"Clone Warning": "Clone Warning",
"Created": "Created",
"Delete": "Delete",
"Details": "Details",
"Display extended information for this WebAuthn credential": "Display extended information for this WebAuthn credential",
"Edit": "Edit",
"Edit information for this WebAuthn credential": "Edit information for this WebAuthn credential",
"Edit WebAuthn Credential": "Edit WebAuthn Credential",
"Enabled": "Enabled",
"Enter a new name for this WebAuthn credential": "Enter a new name for this WebAuthn credential:",
"Enter a description for this credential": "Enter a description for this credential",
"Extended WebAuthn credential information for security key": "Extended WebAuthn credential information for security key {{description}}",
"Identifier": "Identifier",
"Last Used": "Last Used {{when, datetime}}",
"Manage your security keys": "Manage your security keys",
"Name": "Name",
"No": "No",
"No Registered WebAuthn Credentials": "No Registered WebAuthn Credentials",
"Overview": "Overview",
"Provide the details for the new security key": "Provide the details for the new security key",
"Register WebAuthn Credential": "Register WebAuthn Credential",
"Relying Party ID": "Relying Party ID",
"Remove": "Remove",
"Remove this WebAuthn credential": "Remove this WebAuthn credential",
"Remove WebAuthn Credential": "Remove WebAuthn Credential",
"Settings": "Settings",
"Successfully deleted the WebAuthn credential": "Successfully deleted the WebAuthn credential",
"Successfully updated the WebAuthn credential": "Successfully updated the WebAuthn credential",
"There was a problem deleting the WebAuthn credential": "There was a problem deleting the WebAuthn credential",
"There was a problem updating the WebAuthn credential": "There was a problem updating the WebAuthn credential",
"Transports": "Transports",
"Two-Factor Authentication": "Two-Factor Authentication",
"Usage Count": "Usage Count",
"WebAuthn Credential Details": "WebAuthn Credential Details",
"WebAuthn Credentials": "WebAuthn Credentials",
"Yes": "Yes",
"You must have a higher authentication level to delete WebAuthn credentials": "You must have a higher authentication level to delete WebAuthn credentials",
"You must be elevated to delete WebAuthn credentials": "You must be elevated to delete WebAuthn credentials",
"You must have a higher authentication level to update WebAuthn credentials": "You must have a higher authentication level to update WebAuthn credentials",
"You must be elevated to update WebAuthn credentials": "You must be elevated to update WebAuthn credentials"
}

View File

@ -50,7 +50,7 @@
"Reset": "Restablecer", "Reset": "Restablecer",
"Scan QR Code": "Escanear Código QR", "Scan QR Code": "Escanear Código QR",
"Secret": "Secreto", "Secret": "Secreto",
"Security Key - WebAuthN": "Clave de seguridad - WebAuthN", "Security Key - WebAuthn": "Clave de seguridad - WebAuthn",
"Select a Device": "Seleccionar Dispositivo", "Select a Device": "Seleccionar Dispositivo",
"Sign in": "Iniciar Sesión", "Sign in": "Iniciar Sesión",
"Sign out": "Cerrar Sesión", "Sign out": "Cerrar Sesión",

View File

@ -50,7 +50,7 @@
"Reset": "Nollaa", "Reset": "Nollaa",
"Scan QR Code": "Skannaa QR-koodi", "Scan QR Code": "Skannaa QR-koodi",
"Secret": "Salainen", "Secret": "Salainen",
"Security Key - WebAuthN": "Suojausavain - WebAuthN", "Security Key - WebAuthn": "Suojausavain - WebAuthn",
"Select a Device": "Valitse laite", "Select a Device": "Valitse laite",
"Sign in": "Kirjaudu sisään", "Sign in": "Kirjaudu sisään",
"Sign out": "Kirjaudu ulos", "Sign out": "Kirjaudu ulos",

View File

@ -50,7 +50,7 @@
"Reset": "Réinitialiser", "Reset": "Réinitialiser",
"Scan QR Code": "Scannez le QR Code", "Scan QR Code": "Scannez le QR Code",
"Secret": "Secret", "Secret": "Secret",
"Security Key - WebAuthN": "Clé de sécurité - WebAuthN", "Security Key - WebAuthn": "Clé de sécurité - WebAuthn",
"Select a Device": "Sélectionnez un appareil", "Select a Device": "Sélectionnez un appareil",
"Sign in": "Se connecter", "Sign in": "Se connecter",
"Sign out": "Se déconnecter", "Sign out": "Se déconnecter",

View File

@ -51,7 +51,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Segreto", "Secret": "Segreto",
"Security Key - WebAuthN": "Chiave Di Sicurezza - WebAuthN", "Security Key - WebAuthn": "Chiave Di Sicurezza - WebAuthn",
"Select a Device": "Seleziona un dispositivo", "Select a Device": "Seleziona un dispositivo",
"Sign in": "Accedi", "Sign in": "Accedi",
"Sign out": "Esci", "Sign out": "Esci",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "シークレット", "Secret": "シークレット",
"Security Key - WebAuthN": "セキュリティキー - WebAuthN", "Security Key - WebAuthn": "セキュリティキー - WebAuthn",
"Select a Device": "デバイスを選択", "Select a Device": "デバイスを選択",
"Sign in": "サインイン", "Sign in": "サインイン",
"Sign out": "サインアウト", "Sign out": "サインアウト",

View File

@ -51,7 +51,7 @@
"Reset": "Tilbakestill", "Reset": "Tilbakestill",
"Scan QR Code": "Skann QR Kode", "Scan QR Code": "Skann QR Kode",
"Secret": "Hemmelig", "Secret": "Hemmelig",
"Security Key - WebAuthN": "Sikkerhetsnøkkel - WebAuthN", "Security Key - WebAuthn": "Sikkerhetsnøkkel - WebAuthn",
"Select a Device": "Velg en enhet", "Select a Device": "Velg en enhet",
"Sign in": "Logg inn", "Sign in": "Logg inn",
"Sign out": "Logg ut", "Sign out": "Logg ut",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Geheim", "Secret": "Geheim",
"Security Key - WebAuthN": "Beveiligingssleutel - WebAuthN", "Security Key - WebAuthn": "Beveiligingssleutel - WebAuthn",
"Select a Device": "Selecteer een apparaat", "Select a Device": "Selecteer een apparaat",
"Sign in": "Log in", "Sign in": "Log in",
"Sign out": "Log uit", "Sign out": "Log uit",

View File

@ -51,7 +51,7 @@
"Reset": "Tilbakestill", "Reset": "Tilbakestill",
"Scan QR Code": "Skann QR Kode", "Scan QR Code": "Skann QR Kode",
"Secret": "Hemmelig", "Secret": "Hemmelig",
"Security Key - WebAuthN": "Sikkerhetsnøkkel - WebAuthN", "Security Key - WebAuthn": "Sikkerhetsnøkkel - WebAuthn",
"Select a Device": "Velg en enhet", "Select a Device": "Velg en enhet",
"Sign in": "Logg inn", "Sign in": "Logg inn",
"Sign out": "Logg ut", "Sign out": "Logg ut",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Sekretny", "Secret": "Sekretny",
"Security Key - WebAuthN": "Klucz bezpieczeństwa - WebAuthN", "Security Key - WebAuthn": "Klucz bezpieczeństwa - WebAuthn",
"Select a Device": "Wybierz urządzenie", "Select a Device": "Wybierz urządzenie",
"Sign in": "Zaloguj się", "Sign in": "Zaloguj się",
"Sign out": "Wyloguj się", "Sign out": "Wyloguj się",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Segredo", "Secret": "Segredo",
"Security Key - WebAuthN": "Chave de segurança - WebAuthN", "Security Key - WebAuthn": "Chave de segurança - WebAuthn",
"Select a Device": "Selecione um dispositivo", "Select a Device": "Selecione um dispositivo",
"Sign in": "Iniciar sessão", "Sign in": "Iniciar sessão",
"Sign out": "Encerrar sessão", "Sign out": "Encerrar sessão",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Ler Código QR", "Scan QR Code": "Ler Código QR",
"Secret": "Segredo", "Secret": "Segredo",
"Security Key - WebAuthN": "Chave de Segurança - WebAuthN", "Security Key - WebAuthn": "Chave de Segurança - WebAuthn",
"Select a Device": "Selecione um Dispositivo", "Select a Device": "Selecione um Dispositivo",
"Sign in": "Iniciar sessão", "Sign in": "Iniciar sessão",
"Sign out": "Terminar sessão", "Sign out": "Terminar sessão",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Secret", "Secret": "Secret",
"Security Key - WebAuthN": "Cheie de securitate - WebAuthN", "Security Key - WebAuthn": "Cheie de securitate - WebAuthn",
"Select a Device": "Selectați un dispozitiv", "Select a Device": "Selectați un dispozitiv",
"Sign in": "Autentificare", "Sign in": "Autentificare",
"Sign out": "Deconectare", "Sign out": "Deconectare",

View File

@ -50,7 +50,7 @@
"Reset": "Сбросить", "Reset": "Сбросить",
"Scan QR Code": "Отсканировать QR Code", "Scan QR Code": "Отсканировать QR Code",
"Secret": "Секрет", "Secret": "Секрет",
"Security Key - WebAuthN": "Сектретный ключ - WebAuthN", "Security Key - WebAuthn": "Сектретный ключ - WebAuthn",
"Select a Device": "Выберите устройство", "Select a Device": "Выберите устройство",
"Sign in": "Авторизация", "Sign in": "Авторизация",
"Sign out": "Выход", "Sign out": "Выход",

View File

@ -50,7 +50,7 @@
"Reset": "Återställ", "Reset": "Återställ",
"Scan QR Code": "Skanna QR koden", "Scan QR Code": "Skanna QR koden",
"Secret": "Kod", "Secret": "Kod",
"Security Key - WebAuthN": "Säkerhetsnyckel - WebAuthN", "Security Key - WebAuthn": "Säkerhetsnyckel - WebAuthn",
"Select a Device": "Välj en enhet", "Select a Device": "Välj en enhet",
"Sign in": "Logga in", "Sign in": "Logga in",
"Sign out": "Logga ut", "Sign out": "Logga ut",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Secret": "Секрет", "Secret": "Секрет",
"Security Key - WebAuthN": "Ключ безпеки - WebAuthN", "Security Key - WebAuthn": "Ключ безпеки - WebAuthn",
"Select a Device": "Оберіть пристрій", "Select a Device": "Оберіть пристрій",
"Sign in": "Увійти", "Sign in": "Увійти",
"Sign out": "Вийти", "Sign out": "Вийти",

View File

@ -50,7 +50,7 @@
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "扫描二维码", "Scan QR Code": "扫描二维码",
"Secret": "密钥", "Secret": "密钥",
"Security Key - WebAuthN": "安全密钥 - WebAuthN", "Security Key - WebAuthn": "安全密钥 - WebAuthn",
"Select a Device": "选择一个设备", "Select a Device": "选择一个设备",
"Sign in": "登录", "Sign in": "登录",
"Sign out": "登出", "Sign out": "登出",

View File

@ -51,7 +51,7 @@
"Reset": "重設", "Reset": "重設",
"Scan QR Code": "掃描 QR Code", "Scan QR Code": "掃描 QR Code",
"Secret": "密錀", "Secret": "密錀",
"Security Key - WebAuthN": "安全密鑰 - WebAuthN", "Security Key - WebAuthn": "安全密鑰 - WebAuthn",
"Select a Device": "選擇裝置", "Select a Device": "選擇裝置",
"Sign in": "登入", "Sign in": "登入",
"Sign out": "登出", "Sign out": "登出",

View File

@ -36,7 +36,7 @@ type UserSession struct {
AuthenticationMethodRefs oidc.AuthenticationMethodsReferences AuthenticationMethodRefs oidc.AuthenticationMethodsReferences
// WebAuthn holds the session registration data for this session. // WebAuthn holds the session registration data for this session.
WebAuthn *webauthn.SessionData WebAuthn *WebAuthn
// This boolean is set to true after identity verification and checked // This boolean is set to true after identity verification and checked
// while doing the query actually updating the password. // while doing the query actually updating the password.
@ -45,7 +45,13 @@ type UserSession struct {
RefreshTTL time.Time RefreshTTL time.Time
} }
// Identity identity of the user who is being verified. // WebAuthn holds the standard webauthn session data plus some extra.
type WebAuthn struct {
*webauthn.SessionData
Description string
}
// Identity of the user who is being verified.
type Identity struct { type Identity struct {
Username string Username string
Email string Email string

View File

@ -12,6 +12,7 @@ const (
tableUserOpaqueIdentifier = "user_opaque_identifier" tableUserOpaqueIdentifier = "user_opaque_identifier"
tableUserPreferences = "user_preferences" tableUserPreferences = "user_preferences"
tableWebAuthnDevices = "webauthn_devices" tableWebAuthnDevices = "webauthn_devices"
tableWebAuthnUsers = "webauthn_users"
tableOAuth2BlacklistedJTI = "oauth2_blacklisted_jti" tableOAuth2BlacklistedJTI = "oauth2_blacklisted_jti"
tableOAuth2ConsentSession = "oauth2_consent_session" tableOAuth2ConsentSession = "oauth2_consent_session"

View File

@ -0,0 +1,31 @@
ALTER TABLE webauthn_devices
RENAME _bkp_DOWN_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL DEFAULT NULL,
rpid TEXT,
username VARCHAR(100) NOT NULL,
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
kid VARCHAR(512) NOT NULL,
public_key BLOB NOT NULL,
attestation_type VARCHAR(32),
transport VARCHAR(20) DEFAULT '',
aaguid CHAR(36) NOT NULL,
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE KEY (username, description),
UNIQUE KEY (kid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
FROM _bkp_DOWN_V0008_webauthn_devices
WHERE legacy = TRUE;
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
DROP TABLE IF EXISTS webauthn_users;

View File

@ -0,0 +1,43 @@
ALTER TABLE webauthn_devices
RENAME _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
description VARCHAR(30) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NOT NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64) NOT NULL,
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
legacy BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BLOB NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT id, created_at, last_used_at, CAST(rpid AS CHAR) AS rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, TRUE, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
userid CHAR(64) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);

View File

@ -0,0 +1,36 @@
ALTER TABLE webauthn_devices
DROP CONSTRAINT IF EXISTS webauthn_devices_pkey;
DROP INDEX IF EXISTS webauthn_devices_pkey;
DROP INDEX IF EXISTS webauthn_devices_kid_key;
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
ALTER TABLE webauthn_devices
RENAME TO _bkp_DOWN_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id SERIAL CONSTRAINT webauthn_devices_pkey PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE NULL DEFAULT NULL,
rpid TEXT,
username VARCHAR(100) NOT NULL,
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
kid VARCHAR(512) NOT NULL,
public_key BYTEA NOT NULL,
attestation_type VARCHAR(32),
transport VARCHAR(20) DEFAULT '',
aaguid CHAR(36) NOT NULL,
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (username, description);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
FROM _bkp_DOWN_V0008_webauthn_devices
WHERE legacy = TRUE;
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
DROP TABLE IF EXISTS webauthn_users;

View File

@ -0,0 +1,50 @@
ALTER TABLE webauthn_devices
DROP CONSTRAINT IF EXISTS webauthn_devices_pkey;
DROP INDEX IF EXISTS webauthn_devices_pkey;
DROP INDEX IF EXISTS webauthn_devices_kid_key;
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
ALTER TABLE webauthn_devices
RENAME TO _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id SERIAL CONSTRAINT webauthn_devices_pkey PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
description VARCHAR(64) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NOT NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64) NOT NULL,
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
legacy BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BYTEA NOT NULL
);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_users (
id SERIAL CONSTRAINT webauthn_users_pkey PRIMARY KEY,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
userid CHAR(64) NOT NULL
);
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);

View File

@ -0,0 +1,32 @@
ALTER TABLE webauthn_devices
RENAME TO _bkp_DOWN_V0008_webauthn_devices;
DROP INDEX IF EXISTS webauthn_devices_kid_key;
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL DEFAULT NULL,
rpid TEXT,
username VARCHAR(100) NOT NULL,
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
kid VARCHAR(512) NOT NULL,
public_key BLOB NOT NULL,
attestation_type VARCHAR(32),
transport VARCHAR(20) DEFAULT '',
aaguid CHAR(36) NULL,
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (username, description);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
FROM _bkp_DOWN_V0008_webauthn_devices
WHERE legacy = TRUE;
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
DROP TABLE IF EXISTS webauthn_users;

View File

@ -0,0 +1,46 @@
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
DROP INDEX IF EXISTS webauthn_devices_kid_key;
ALTER TABLE webauthn_devices
RENAME TO _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
description VARCHAR(64) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64),
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
legacy BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BLOB NOT NULL
);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
userid CHAR(64) NOT NULL
);
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);

View File

@ -9,7 +9,7 @@ import (
const ( const (
// This is the latest schema version for the purpose of tests. // This is the latest schema version for the purpose of tests.
LatestVersion = 9 LatestVersion = 10
) )
func TestShouldObtainCorrectUpMigrations(t *testing.T) { func TestShouldObtainCorrectUpMigrations(t *testing.T) {

View File

@ -38,12 +38,17 @@ type Provider interface {
LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error) LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error)
LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []model.TOTPConfiguration, err error) LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []model.TOTPConfiguration, err error)
SaveWebAuthnUser(ctx context.Context, user model.WebAuthnUser) (err error)
LoadWebAuthnUser(ctx context.Context, rpid, username string) (user *model.WebAuthnUser, err error)
SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error) SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error)
UpdateWebAuthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) UpdateWebAuthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error)
UpdateWebAuthnDeviceSignIn(ctx context.Context, device model.WebAuthnDevice) (err error)
DeleteWebAuthnDevice(ctx context.Context, kid string) (err error) DeleteWebAuthnDevice(ctx context.Context, kid string) (err error)
DeleteWebAuthnDeviceByUsername(ctx context.Context, username, description string) (err error) DeleteWebAuthnDeviceByUsername(ctx context.Context, username, description string) (err error)
LoadWebAuthnDevices(ctx context.Context, limit, page int) (devices []model.WebAuthnDevice, err error) LoadWebAuthnDevices(ctx context.Context, limit, page int) (devices []model.WebAuthnDevice, err error)
LoadWebAuthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebAuthnDevice, err error) LoadWebAuthnDevicesByUsername(ctx context.Context, rpid, username string) (devices []model.WebAuthnDevice, err error)
LoadWebAuthnDeviceByID(ctx context.Context, id int) (device *model.WebAuthnDevice, err error)
SavePreferredDuoDevice(ctx context.Context, device model.DuoDevice) (err error) SavePreferredDuoDevice(ctx context.Context, device model.DuoDevice) (err error)
DeletePreferredDuoDevice(ctx context.Context, username string) (err error) DeletePreferredDuoDevice(ctx context.Context, username string) (err error)

View File

@ -46,16 +46,19 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations), sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations),
sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations), sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations),
sqlUpsertWebAuthnDevice: fmt.Sprintf(queryFmtUpsertWebAuthnDevice, tableWebAuthnDevices), sqlInsertWebAuthnUser: fmt.Sprintf(queryFmtInsertWebAuthnUser, tableWebAuthnUsers),
sqlSelectWebAuthnDevices: fmt.Sprintf(queryFmtSelectWebAuthnDevices, tableWebAuthnDevices), sqlSelectWebAuthnUser: fmt.Sprintf(queryFmtSelectWebAuthnUser, tableWebAuthnUsers),
sqlSelectWebAuthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByUsername, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignIn, tableWebAuthnDevices), sqlInsertWebAuthnDevice: fmt.Sprintf(queryFmtInsertWebAuthnDevice, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignInByUsername, tableWebAuthnDevices), sqlSelectWebAuthnDevices: fmt.Sprintf(queryFmtSelectWebAuthnDevices, tableWebAuthnDevices),
sqlSelectWebAuthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByUsername, tableWebAuthnDevices),
sqlDeleteWebAuthnDevice: fmt.Sprintf(queryFmtDeleteWebAuthnDevice, tableWebAuthnDevices), sqlSelectWebAuthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByRPIDByUsername, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsername, tableWebAuthnDevices), sqlSelectWebAuthnDeviceByID: fmt.Sprintf(queryFmtSelectWebAuthnDeviceByID, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices), sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignIn, tableWebAuthnDevices),
sqlDeleteWebAuthnDevice: fmt.Sprintf(queryFmtDeleteWebAuthnDevice, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsername, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsernameAndDisplayName: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices), sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices), sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
@ -164,17 +167,23 @@ type SQLProvider struct {
sqlUpdateTOTPConfigRecordSignIn string sqlUpdateTOTPConfigRecordSignIn string
sqlUpdateTOTPConfigRecordSignInByUsername string sqlUpdateTOTPConfigRecordSignInByUsername string
// Table: webauthn_devices. // Table: webauthn_users.
sqlUpsertWebAuthnDevice string sqlInsertWebAuthnUser string
sqlSelectWebAuthnDevices string sqlSelectWebAuthnUser string
sqlSelectWebAuthnDevicesByUsername string
sqlUpdateWebAuthnDeviceRecordSignIn string // Table: webauthn_devices.
sqlUpdateWebAuthnDeviceRecordSignInByUsername string sqlInsertWebAuthnDevice string
sqlSelectWebAuthnDevices string
sqlSelectWebAuthnDevicesByUsername string
sqlSelectWebAuthnDevicesByRPIDByUsername string
sqlSelectWebAuthnDeviceByID string
sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID string
sqlUpdateWebAuthnDeviceRecordSignIn string
sqlDeleteWebAuthnDevice string sqlDeleteWebAuthnDevice string
sqlDeleteWebAuthnDeviceByUsername string sqlDeleteWebAuthnDeviceByUsername string
sqlDeleteWebAuthnDeviceByUsernameAndDescription string sqlDeleteWebAuthnDeviceByUsernameAndDisplayName string
// Table: duo_devices. // Table: duo_devices.
sqlUpsertDuoDevice string sqlUpsertDuoDevice string
@ -365,7 +374,7 @@ func (p *SQLProvider) LoadUserOpaqueIdentifier(ctx context.Context, identifier u
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, nil return nil, nil
default: default:
return nil, err return nil, fmt.Errorf("error selecting user opaque id with value '%s': %w", identifier.String(), err)
} }
} }
@ -404,7 +413,7 @@ func (p *SQLProvider) LoadUserOpaqueIdentifierBySignature(ctx context.Context, s
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, nil return nil, nil
default: default:
return nil, err return nil, fmt.Errorf("error selecting user opaque with service '%s' and sector '%s' for username '%s': %w", service, sectorID, username, err)
} }
} }
@ -845,7 +854,7 @@ func (p *SQLProvider) DeleteTOTPConfiguration(ctx context.Context, username stri
func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error) { func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error) {
config = &model.TOTPConfiguration{} config = &model.TOTPConfiguration{}
if err = p.db.QueryRowxContext(ctx, p.sqlSelectTOTPConfig, username).StructScan(config); err != nil { if err = p.db.GetContext(ctx, config, p.sqlSelectTOTPConfig, username); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoTOTPConfiguration return nil, ErrNoTOTPConfiguration
} }
@ -881,28 +890,65 @@ func (p *SQLProvider) LoadTOTPConfigurations(ctx context.Context, limit, page in
return configs, nil return configs, nil
} }
// SaveWebAuthnUser saves a registered WebAuthn user.
func (p *SQLProvider) SaveWebAuthnUser(ctx context.Context, user model.WebAuthnUser) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebAuthnUser, user.RPID, user.Username, user.UserID); err != nil {
return fmt.Errorf("error inserting WebAuthn user '%s' with relying party id '%s': %w", user.Username, user.RPID, err)
}
return nil
}
// LoadWebAuthnUser loads a registered WebAuthn user.
func (p *SQLProvider) LoadWebAuthnUser(ctx context.Context, rpid, username string) (user *model.WebAuthnUser, err error) {
user = &model.WebAuthnUser{}
if err = p.db.GetContext(ctx, user, p.sqlSelectWebAuthnUser, rpid, username); err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("error selecting WebAuthn user '%s' with relying party id '%s': %w", user.Username, user.RPID, err)
}
}
return user, nil
}
// SaveWebAuthnDevice saves a registered WebAuthn device. // SaveWebAuthnDevice saves a registered WebAuthn device.
func (p *SQLProvider) SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error) { func (p *SQLProvider) SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error) {
if device.PublicKey, err = p.encrypt(device.PublicKey); err != nil { if device.PublicKey, err = p.encrypt(device.PublicKey); err != nil {
return fmt.Errorf("error encrypting WebAuthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err) return fmt.Errorf("error encrypting WebAuthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err)
} }
if _, err = p.db.ExecContext(ctx, p.sqlUpsertWebAuthnDevice, if _, err = p.db.ExecContext(ctx, p.sqlInsertWebAuthnDevice,
device.CreatedAt, device.LastUsedAt, device.CreatedAt, device.LastUsedAt, device.RPID, device.Username, device.Description,
device.RPID, device.Username, device.Description, device.KID, device.AAGUID, device.AttestationType, device.Attachment, device.Transport,
device.KID, device.PublicKey, device.SignCount, device.CloneWarning, device.Discoverable, device.Present, device.Verified,
device.AttestationType, device.Transport, device.AAGUID, device.SignCount, device.CloneWarning, device.BackupEligible, device.BackupState, device.PublicKey,
); err != nil { ); err != nil {
return fmt.Errorf("error upserting WebAuthn device for user '%s' kid '%x': %w", device.Username, device.KID, err) return fmt.Errorf("error inserting WebAuthn device for user '%s' kid '%x': %w", device.Username, device.KID, err)
}
return nil
}
// UpdateWebAuthnDeviceDescription updates a registered WebAuthn device's description.
func (p *SQLProvider) UpdateWebAuthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID, description, username, deviceID); err != nil {
return fmt.Errorf("error updating WebAuthn device description to '%s' for device id '%d': %w", description, deviceID, err)
} }
return nil return nil
} }
// UpdateWebAuthnDeviceSignIn updates a registered WebAuthn devices sign in information. // UpdateWebAuthnDeviceSignIn updates a registered WebAuthn devices sign in information.
func (p *SQLProvider) UpdateWebAuthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) { func (p *SQLProvider) UpdateWebAuthnDeviceSignIn(ctx context.Context, device model.WebAuthnDevice) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn, rpid, lastUsedAt, signCount, cloneWarning, id); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn,
return fmt.Errorf("error updating WebAuthn signin metadata for id '%x': %w", id, err) device.RPID, device.LastUsedAt, device.SignCount, device.Discoverable, device.Present, device.Verified,
device.BackupEligible, device.BackupState, device.CloneWarning, device.ID,
); err != nil {
return fmt.Errorf("error updating WebAuthn authentication metadata for id '%x': %w", device.ID, err)
} }
return nil return nil
@ -918,18 +964,18 @@ func (p *SQLProvider) DeleteWebAuthnDevice(ctx context.Context, kid string) (err
} }
// DeleteWebAuthnDeviceByUsername deletes registered WebAuthn devices by username or username and description. // DeleteWebAuthnDeviceByUsername deletes registered WebAuthn devices by username or username and description.
func (p *SQLProvider) DeleteWebAuthnDeviceByUsername(ctx context.Context, username, description string) (err error) { func (p *SQLProvider) DeleteWebAuthnDeviceByUsername(ctx context.Context, username, displayname string) (err error) {
if len(username) == 0 { if len(username) == 0 {
return fmt.Errorf("error deleting WebAuthn device with username '%s' and description '%s': username must not be empty", username, description) return fmt.Errorf("error deleting WebAuthn device with username '%s' and displayname '%s': username must not be empty", username, displayname)
} }
if len(description) == 0 { if len(displayname) == 0 {
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsername, username); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsername, username); err != nil {
return fmt.Errorf("error deleting WebAuthn devices for username '%s': %w", username, err) return fmt.Errorf("error deleting WebAuthn devices for username '%s': %w", username, err)
} }
} else { } else {
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDescription, username, description); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDisplayName, username, displayname); err != nil {
return fmt.Errorf("error deleting WebAuthn device with username '%s' and description '%s': %w", username, description, err) return fmt.Errorf("error deleting WebAuthn device with username '%s' and displayname '%s': %w", username, displayname, err)
} }
} }
@ -957,11 +1003,33 @@ func (p *SQLProvider) LoadWebAuthnDevices(ctx context.Context, limit, page int)
return devices, nil return devices, nil
} }
// LoadWebAuthnDevicesByUsername loads all WebAuthn devices registration for a given username. // LoadWebAuthnDeviceByID loads a WebAuthn device registration for a given id.
func (p *SQLProvider) LoadWebAuthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebAuthnDevice, err error) { func (p *SQLProvider) LoadWebAuthnDeviceByID(ctx context.Context, id int) (device *model.WebAuthnDevice, err error) {
if err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByUsername, username); err != nil { device = &model.WebAuthnDevice{}
if err = p.db.GetContext(ctx, device, p.sqlSelectWebAuthnDeviceByID, id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoWebAuthnDevice return nil, sql.ErrNoRows
}
return nil, fmt.Errorf("error selecting WebAuthn device with id '%d': %w", id, err)
}
return device, nil
}
// LoadWebAuthnDevicesByUsername loads all WebAuthn devices registration for a given username.
func (p *SQLProvider) LoadWebAuthnDevicesByUsername(ctx context.Context, rpid, username string) (devices []model.WebAuthnDevice, err error) {
switch len(rpid) {
case 0:
err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByUsername, username)
default:
err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByRPIDByUsername, rpid, username)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return devices, ErrNoWebAuthnDevice
} }
return nil, fmt.Errorf("error selecting WebAuthn devices for user '%s': %w", username, err) return nil, fmt.Errorf("error selecting WebAuthn devices for user '%s': %w", username, err)

View File

@ -30,7 +30,6 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
// Specific alterations to this provider. // Specific alterations to this provider.
// PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead. // PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead.
provider.sqlUpsertWebAuthnDevice = fmt.Sprintf(queryFmtUpsertWebAuthnDevicePostgreSQL, tableWebAuthnDevices)
provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtUpsertDuoDevicePostgreSQL, tableDuoDevices) provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtUpsertDuoDevicePostgreSQL, tableDuoDevices)
provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations) provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations)
provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtUpsertPreferred2FAMethodPostgreSQL, tableUserPreferences) provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtUpsertPreferred2FAMethodPostgreSQL, tableUserPreferences)
@ -58,13 +57,19 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig) provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig)
provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs) provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs)
provider.sqlInsertWebAuthnUser = provider.db.Rebind(provider.sqlInsertWebAuthnUser)
provider.sqlSelectWebAuthnUser = provider.db.Rebind(provider.sqlSelectWebAuthnUser)
provider.sqlInsertWebAuthnDevice = provider.db.Rebind(provider.sqlInsertWebAuthnDevice)
provider.sqlSelectWebAuthnDevices = provider.db.Rebind(provider.sqlSelectWebAuthnDevices) provider.sqlSelectWebAuthnDevices = provider.db.Rebind(provider.sqlSelectWebAuthnDevices)
provider.sqlSelectWebAuthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebAuthnDevicesByUsername) provider.sqlSelectWebAuthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebAuthnDevicesByUsername)
provider.sqlSelectWebAuthnDevicesByRPIDByUsername = provider.db.Rebind(provider.sqlSelectWebAuthnDevicesByRPIDByUsername)
provider.sqlSelectWebAuthnDeviceByID = provider.db.Rebind(provider.sqlSelectWebAuthnDeviceByID)
provider.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID)
provider.sqlUpdateWebAuthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignIn) provider.sqlUpdateWebAuthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignIn)
provider.sqlUpdateWebAuthnDeviceRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignInByUsername)
provider.sqlDeleteWebAuthnDevice = provider.db.Rebind(provider.sqlDeleteWebAuthnDevice) provider.sqlDeleteWebAuthnDevice = provider.db.Rebind(provider.sqlDeleteWebAuthnDevice)
provider.sqlDeleteWebAuthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebAuthnDeviceByUsername) provider.sqlDeleteWebAuthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebAuthnDeviceByUsername)
provider.sqlDeleteWebAuthnDeviceByUsernameAndDescription = provider.db.Rebind(provider.sqlDeleteWebAuthnDeviceByUsernameAndDescription) provider.sqlDeleteWebAuthnDeviceByUsernameAndDisplayName = provider.db.Rebind(provider.sqlDeleteWebAuthnDeviceByUsernameAndDisplayName)
provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice) provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice)
provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice) provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice)

View File

@ -174,7 +174,7 @@ func schemaEncryptionChangeKeyWebAuthn(ctx context.Context, provider *SQLProvide
return fmt.Errorf("error selecting WebAuthn devices: %w", err) return fmt.Errorf("error selecting WebAuthn devices: %w", err)
} }
query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebAuthnDevicePublicKey, tableWebAuthnDevices)) query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebAuthnDevicesEncryptedData, tableWebAuthnDevices))
for _, d := range devices { for _, d := range devices {
if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil { if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil {

View File

@ -120,48 +120,41 @@ const (
const ( const (
queryFmtSelectWebAuthnDevices = ` queryFmtSelectWebAuthnDevices = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
LIMIT ? LIMIT ?
OFFSET ?;` OFFSET ?;`
queryFmtSelectWebAuthnDevicesEncryptedData = `
SELECT id, public_key
FROM %s;`
queryFmtSelectWebAuthnDevicesByUsername = ` queryFmtSelectWebAuthnDevicesByUsername = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
WHERE username = ?;` WHERE username = ?;`
queryFmtUpdateWebAuthnDevicePublicKey = ` queryFmtSelectWebAuthnDevicesByRPIDByUsername = `
UPDATE %s SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
SET public_key = ? FROM %s
WHERE rpid = ? AND username = ?;`
queryFmtSelectWebAuthnDeviceByID = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s
WHERE id = ?;` WHERE id = ?;`
queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID = `
UPDATE %s
SET description = ?
WHERE username = ? AND id = ?;`
queryFmtUpdateWebAuthnDeviceRecordSignIn = ` queryFmtUpdateWebAuthnDeviceRecordSignIn = `
UPDATE %s UPDATE %s
SET SET
rpid = ?, last_used_at = ?, sign_count = ?, rpid = ?, last_used_at = ?, sign_count = ?, discoverable = ?, present = ?, verified = ?, backup_eligible = ?, backup_state = ?,
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
WHERE id = ?;` WHERE id = ?;`
queryFmtUpdateWebAuthnDeviceRecordSignInByUsername = ` queryFmtInsertWebAuthnDevice = `
UPDATE %s INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
SET VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
rpid = ?, last_used_at = ?, sign_count = ?,
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
WHERE username = ? AND kid = ?;`
queryFmtUpsertWebAuthnDevice = `
REPLACE INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
queryFmtUpsertWebAuthnDevicePostgreSQL = `
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (username, description)
DO UPDATE SET created_at = $1, last_used_at = $2, rpid = $3, kid = $6, public_key = $7, attestation_type = $8, transport = $9, aaguid = $10, sign_count = $11, clone_warning = $12;`
queryFmtDeleteWebAuthnDevice = ` queryFmtDeleteWebAuthnDevice = `
DELETE FROM %s DELETE FROM %s
@ -174,6 +167,26 @@ const (
queryFmtDeleteWebAuthnDeviceByUsernameAndDescription = ` queryFmtDeleteWebAuthnDeviceByUsernameAndDescription = `
DELETE FROM %s DELETE FROM %s
WHERE username = ? AND description = ?;` WHERE username = ? AND description = ?;`
queryFmtSelectWebAuthnDevicesEncryptedData = `
SELECT id, public_key
FROM %s;`
queryFmtUpdateWebAuthnDevicesEncryptedData = `
UPDATE %s
SET public_key = ?
WHERE id = ?;`
)
const (
queryFmtInsertWebAuthnUser = `
INSERT INTO %s (rpid, username, userid)
VALUES (?, ?, ?);`
queryFmtSelectWebAuthnUser = `
SELECT id, rpid, username, userid
FROM %s
WHERE rpid = ? AND username = ?;`
) )
const ( const (

View File

@ -49,8 +49,12 @@ func (s *BackendProtectionScenario) AssertRequestStatusCode(method, url string,
func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() { func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() {
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/assertion", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodGet, fmt.Sprintf("%s/api/secondfactor/webauthn/credentials", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/attestation", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPut, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/register", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/register", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodDelete, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/1", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPut, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/1", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/user/info/2fa_method", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/user/info/2fa_method", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodGet, fmt.Sprintf("%s/api/user/info", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodGet, fmt.Sprintf("%s/api/user/info", AutheliaBaseURL), fasthttp.StatusForbidden)
@ -58,8 +62,6 @@ func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() {
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/start", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/start", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/finish", AutheliaBaseURL), fasthttp.StatusForbidden) s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/finish", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/identity/start", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/identity/finish", AutheliaBaseURL), fasthttp.StatusForbidden)
} }
func (s *BackendProtectionScenario) TestInvalidEndpointsReturn404() { func (s *BackendProtectionScenario) TestInvalidEndpointsReturn404() {

View File

@ -2,6 +2,10 @@
"name": "authelia", "name": "authelia",
"version": "4.37.5", "version": "4.37.5",
"private": true, "private": true,
"engines": {
"node": ">=18.4.0",
"pnpm": "8"
},
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"allowedVersions": { "allowedVersions": {
@ -21,6 +25,8 @@
"@mui/icons-material": "5.11.16", "@mui/icons-material": "5.11.16",
"@mui/material": "5.13.2", "@mui/material": "5.13.2",
"@mui/styles": "5.13.2", "@mui/styles": "5.13.2",
"@simplewebauthn/browser": "7.2.0",
"@simplewebauthn/typescript-types": "7.0.0",
"axios": "1.4.0", "axios": "1.4.0",
"broadcast-channel": "5.1.0", "broadcast-channel": "5.1.0",
"classnames": "2.3.2", "classnames": "2.3.2",

View File

@ -31,6 +31,12 @@ dependencies:
'@mui/styles': '@mui/styles':
specifier: 5.13.2 specifier: 5.13.2
version: 5.13.2(@types/react@18.2.7)(react@18.2.0) version: 5.13.2(@types/react@18.2.7)(react@18.2.0)
'@simplewebauthn/browser':
specifier: 7.2.0
version: 7.2.0
'@simplewebauthn/typescript-types':
specifier: 7.0.0
version: 7.0.0
axios: axios:
specifier: 1.4.0 specifier: 1.4.0
version: 1.4.0 version: 1.4.0
@ -499,7 +505,6 @@ packages:
/@babel/parser@7.21.4: /@babel/parser@7.21.4:
resolution: {integrity: sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==} resolution: {integrity: sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true
dependencies: dependencies:
'@babel/types': 7.21.4 '@babel/types': 7.21.4
dev: true dev: true
@ -1552,7 +1557,6 @@ packages:
/@commitlint/cli@17.6.3: /@commitlint/cli@17.6.3:
resolution: {integrity: sha512-ItSz2fd4F+CujgIbQOfNNerDF1eFlsBGEfp9QcCb1kxTYMuKTYZzA6Nu1YRRrIaaWwe2E7awUGpIMrPoZkOG3A==} resolution: {integrity: sha512-ItSz2fd4F+CujgIbQOfNNerDF1eFlsBGEfp9QcCb1kxTYMuKTYZzA6Nu1YRRrIaaWwe2E7awUGpIMrPoZkOG3A==}
engines: {node: '>=v14'} engines: {node: '>=v14'}
hasBin: true
dependencies: dependencies:
'@commitlint/format': 17.4.4 '@commitlint/format': 17.4.4
'@commitlint/lint': 17.6.3 '@commitlint/lint': 17.6.3
@ -2535,6 +2539,16 @@ packages:
resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==} resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==}
dev: true dev: true
/@simplewebauthn/browser@7.2.0:
resolution: {integrity: sha512-HHIvRPpqKy0UV/BsGAmx4rQRZuZTUFYLLH65FwpSOslqHruiHx3Ql/bq7A75bjWuJ296a+4BIAq3+SPaII01TQ==}
dependencies:
'@simplewebauthn/typescript-types': 7.0.0
dev: false
/@simplewebauthn/typescript-types@7.0.0:
resolution: {integrity: sha512-bV+xACCFTsrLR/23ozHO06ZllHZaxC8LlI5YCo79GvU2BrN+rePDU2yXwZIYndNWcMQwRdndRdAhpafOh9AC/g==}
dev: false
/@sinclair/typebox@0.25.24: /@sinclair/typebox@0.25.24:
resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==}
dev: true dev: true
@ -3185,7 +3199,6 @@ packages:
/JSONStream@1.3.5: /JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
dependencies: dependencies:
jsonparse: 1.3.1 jsonparse: 1.3.1
through: 2.3.8 through: 2.3.8
@ -3215,7 +3228,6 @@ packages:
/acorn@8.8.2: /acorn@8.8.2:
resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true
dev: true dev: true
/ajv@6.12.6: /ajv@6.12.6:
@ -3508,7 +3520,6 @@ packages:
/browserslist@4.21.5: /browserslist@4.21.5:
resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies: dependencies:
caniuse-lite: 1.0.30001477 caniuse-lite: 1.0.30001477
electron-to-chromium: 1.4.357 electron-to-chromium: 1.4.357
@ -3710,7 +3721,6 @@ packages:
/conventional-commits-parser@3.2.4: /conventional-commits-parser@3.2.4:
resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==} resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true
dependencies: dependencies:
JSONStream: 1.3.5 JSONStream: 1.3.5
is-text-path: 1.0.1 is-text-path: 1.0.1
@ -4281,7 +4291,6 @@ packages:
/esbuild@0.15.18: /esbuild@0.15.18:
resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
hasBin: true
requiresBuild: true requiresBuild: true
optionalDependencies: optionalDependencies:
'@esbuild/android-arm': 0.15.18 '@esbuild/android-arm': 0.15.18
@ -4311,7 +4320,6 @@ packages:
/esbuild@0.17.19: /esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
hasBin: true
requiresBuild: true requiresBuild: true
optionalDependencies: optionalDependencies:
'@esbuild/android-arm': 0.17.19 '@esbuild/android-arm': 0.17.19
@ -4362,7 +4370,6 @@ packages:
/eslint-config-prettier@8.8.0(eslint@8.41.0): /eslint-config-prettier@8.8.0(eslint@8.41.0):
resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==}
hasBin: true
peerDependencies: peerDependencies:
eslint: '>=7.0.0' eslint: '>=7.0.0'
dependencies: dependencies:
@ -4658,7 +4665,6 @@ packages:
/eslint@8.41.0: /eslint@8.41.0:
resolution: {integrity: sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==} resolution: {integrity: sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
hasBin: true
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.41.0) '@eslint-community/eslint-utils': 4.4.0(eslint@8.41.0)
'@eslint-community/regexpp': 4.5.0 '@eslint-community/regexpp': 4.5.0
@ -4715,7 +4721,6 @@ packages:
/esprima@4.0.1: /esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true
dev: true dev: true
/esquery@1.5.0: /esquery@1.5.0:
@ -5047,7 +5052,6 @@ packages:
/git-raw-commits@2.0.11: /git-raw-commits@2.0.11:
resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true
dependencies: dependencies:
dargs: 7.0.0 dargs: 7.0.0
lodash: 4.17.21 lodash: 4.17.21
@ -5259,7 +5263,6 @@ packages:
/husky@8.0.3: /husky@8.0.3:
resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true
dev: true dev: true
/hyphenate-style-name@1.0.4: /hyphenate-style-name@1.0.4:
@ -5402,7 +5405,6 @@ packages:
/is-docker@2.2.1: /is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true
dev: true dev: true
/is-extglob@2.1.1: /is-extglob@2.1.1:
@ -5657,7 +5659,6 @@ packages:
/js-yaml@3.14.1: /js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
dependencies: dependencies:
argparse: 1.0.10 argparse: 1.0.10
esprima: 4.0.1 esprima: 4.0.1
@ -5665,20 +5666,17 @@ packages:
/js-yaml@4.1.0: /js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
dev: true dev: true
/jsesc@0.5.0: /jsesc@0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
dev: true dev: true
/jsesc@2.5.2: /jsesc@2.5.2:
resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true
dev: true dev: true
/json-parse-even-better-errors@2.3.1: /json-parse-even-better-errors@2.3.1:
@ -5698,7 +5696,6 @@ packages:
/json5@1.0.2: /json5@1.0.2:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
dependencies: dependencies:
minimist: 1.2.8 minimist: 1.2.8
dev: true dev: true
@ -5706,7 +5703,6 @@ packages:
/json5@2.2.3: /json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true
dev: true dev: true
/jsonc-parser@3.2.0: /jsonc-parser@3.2.0:
@ -5891,7 +5887,6 @@ packages:
/loose-envify@1.4.0: /loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
@ -5916,7 +5911,6 @@ packages:
/lz-string@1.5.0: /lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
dev: true dev: true
/magic-string@0.30.0: /magic-string@0.30.0:
@ -6015,7 +6009,6 @@ packages:
/mime@1.6.0: /mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true
dev: true dev: true
/mimic-fn@2.1.0: /mimic-fn@2.1.0:
@ -6070,7 +6063,6 @@ packages:
/nanoid@3.3.6: /nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true dev: true
/natural-compare-lite@1.4.0: /natural-compare-lite@1.4.0:
@ -6400,7 +6392,6 @@ packages:
/prettier@2.8.8: /prettier@2.8.8:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
hasBin: true
dev: true dev: true
/pretty-format@27.5.1: /pretty-format@27.5.1:
@ -6696,7 +6687,6 @@ packages:
/regjsparser@0.9.1: /regjsparser@0.9.1:
resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==}
hasBin: true
dependencies: dependencies:
jsesc: 0.5.0 jsesc: 0.5.0
dev: true dev: true
@ -6729,7 +6719,6 @@ packages:
/resolve@1.22.2: /resolve@1.22.2:
resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
hasBin: true
dependencies: dependencies:
is-core-module: 2.12.0 is-core-module: 2.12.0
path-parse: 1.0.7 path-parse: 1.0.7
@ -6737,7 +6726,6 @@ packages:
/resolve@2.0.0-next.4: /resolve@2.0.0-next.4:
resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==}
hasBin: true
dependencies: dependencies:
is-core-module: 2.12.0 is-core-module: 2.12.0
path-parse: 1.0.7 path-parse: 1.0.7
@ -6751,14 +6739,12 @@ packages:
/rimraf@3.0.2: /rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
hasBin: true
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
/rollup@2.79.1: /rollup@2.79.1:
resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
hasBin: true
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
dev: true dev: true
@ -6766,7 +6752,6 @@ packages:
/rollup@3.21.0: /rollup@3.21.0:
resolution: {integrity: sha512-ANPhVcyeHvYdQMUyCbczy33nbLzI7RzrBje4uvNiTDJGIMtlKoOStmympwr9OtS1LZxiDmE2wvxHyVhoLtf1KQ==} resolution: {integrity: sha512-ANPhVcyeHvYdQMUyCbczy33nbLzI7RzrBje4uvNiTDJGIMtlKoOStmympwr9OtS1LZxiDmE2wvxHyVhoLtf1KQ==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'} engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
dev: true dev: true
@ -6800,18 +6785,15 @@ packages:
/semver@5.7.1: /semver@5.7.1:
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
hasBin: true
dev: true dev: true
/semver@6.3.0: /semver@6.3.0:
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
hasBin: true
dev: true dev: true
/semver@7.5.0: /semver@7.5.0:
resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==} resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true
dependencies: dependencies:
lru-cache: 6.0.0 lru-cache: 6.0.0
dev: true dev: true
@ -7180,7 +7162,6 @@ packages:
/ts-node@10.9.1(@types/node@20.2.5)(typescript@5.0.4): /ts-node@10.9.1(@types/node@20.2.5)(typescript@5.0.4):
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
hasBin: true
peerDependencies: peerDependencies:
'@swc/core': '>=1.2.50' '@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50' '@swc/wasm': '>=1.2.50'
@ -7212,7 +7193,6 @@ packages:
/tsconfck@2.1.1(typescript@5.0.4): /tsconfck@2.1.1(typescript@5.0.4):
resolution: {integrity: sha512-ZPCkJBKASZBmBUNqGHmRhdhM8pJYDdOXp4nRgj/O0JwUwsMq50lCDRQP/M5GBNAA0elPrq4gAeu4dkaVCuKWww==} resolution: {integrity: sha512-ZPCkJBKASZBmBUNqGHmRhdhM8pJYDdOXp4nRgj/O0JwUwsMq50lCDRQP/M5GBNAA0elPrq4gAeu4dkaVCuKWww==}
engines: {node: ^14.13.1 || ^16 || >=18} engines: {node: ^14.13.1 || ^16 || >=18}
hasBin: true
peerDependencies: peerDependencies:
typescript: ^4.3.5 || ^5.0.0 typescript: ^4.3.5 || ^5.0.0
peerDependenciesMeta: peerDependenciesMeta:
@ -7300,7 +7280,6 @@ packages:
/typescript@5.0.4: /typescript@5.0.4:
resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
hasBin: true
dev: true dev: true
/ufo@1.1.1: /ufo@1.1.1:
@ -7355,7 +7334,6 @@ packages:
/update-browserslist-db@1.0.10(browserslist@4.21.5): /update-browserslist-db@1.0.10(browserslist@4.21.5):
resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==} resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
hasBin: true
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
dependencies: dependencies:
@ -7398,7 +7376,6 @@ packages:
/vite-node@0.31.1(@types/node@20.2.5): /vite-node@0.31.1(@types/node@20.2.5):
resolution: {integrity: sha512-BajE/IsNQ6JyizPzu9zRgHrBwczkAs0erQf/JRpgTIESpKvNj9/Gd0vxX905klLkb0I0SJVCKbdrl5c6FnqYKA==} resolution: {integrity: sha512-BajE/IsNQ6JyizPzu9zRgHrBwczkAs0erQf/JRpgTIESpKvNj9/Gd0vxX905klLkb0I0SJVCKbdrl5c6FnqYKA==}
engines: {node: '>=v14.18.0'} engines: {node: '>=v14.18.0'}
hasBin: true
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.3.4 debug: 4.3.4
@ -7477,7 +7454,6 @@ packages:
/vite@3.2.5(@types/node@18.16.5): /vite@3.2.5(@types/node@18.16.5):
resolution: {integrity: sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==} resolution: {integrity: sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies: peerDependencies:
'@types/node': '>= 14' '@types/node': '>= 14'
less: '*' less: '*'
@ -7511,7 +7487,6 @@ packages:
/vite@4.3.9(@types/node@20.2.5): /vite@4.3.9(@types/node@20.2.5):
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==} resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies: peerDependencies:
'@types/node': '>= 14' '@types/node': '>= 14'
less: '*' less: '*'
@ -7543,7 +7518,6 @@ packages:
/vitest-preview@0.0.1: /vitest-preview@0.0.1:
resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==} resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==}
hasBin: true
dependencies: dependencies:
'@types/express': 4.17.17 '@types/express': 4.17.17
'@types/node': 18.16.5 '@types/node': 18.16.5
@ -7562,7 +7536,6 @@ packages:
/vitest@0.31.1(happy-dom@9.20.3): /vitest@0.31.1(happy-dom@9.20.3):
resolution: {integrity: sha512-/dOoOgzoFk/5pTvg1E65WVaobknWREN15+HF+0ucudo3dDG/vCZoXTQrjIfEaWvQXmqScwkRodrTbM/ScMpRcQ==} resolution: {integrity: sha512-/dOoOgzoFk/5pTvg1E65WVaobknWREN15+HF+0ucudo3dDG/vCZoXTQrjIfEaWvQXmqScwkRodrTbM/ScMpRcQ==}
engines: {node: '>=v14.18.0'} engines: {node: '>=v14.18.0'}
hasBin: true
peerDependencies: peerDependencies:
'@edge-runtime/vm': '*' '@edge-runtime/vm': '*'
'@vitest/browser': '*' '@vitest/browser': '*'
@ -7697,7 +7670,6 @@ packages:
/which@2.0.2: /which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
dev: true dev: true
@ -7705,7 +7677,6 @@ packages:
/why-is-node-running@2.2.2: /why-is-node-running@2.2.2:
resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==}
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true
dependencies: dependencies:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2

View File

@ -12,9 +12,9 @@ import {
IndexRoute, IndexRoute,
LogoutRoute, LogoutRoute,
RegisterOneTimePasswordRoute, RegisterOneTimePasswordRoute,
RegisterWebAuthnRoute,
ResetPasswordStep1Route, ResetPasswordStep1Route,
ResetPasswordStep2Route, ResetPasswordStep2Route,
SettingsRoute,
} from "@constants/Routes"; } from "@constants/Routes";
import NotificationsContext from "@hooks/NotificationsContext"; import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications"; import { Notification } from "@models/Notifications";
@ -28,13 +28,13 @@ import {
getTheme, getTheme,
} from "@utils/Configuration"; } from "@utils/Configuration";
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword"; import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
import RegisterWebAuthn from "@views/DeviceRegistration/RegisterWebAuthn";
import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage"; import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView"; import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
import LoginPortal from "@views/LoginPortal/LoginPortal"; import LoginPortal from "@views/LoginPortal/LoginPortal";
import SignOut from "@views/LoginPortal/SignOut/SignOut"; import SignOut from "@views/LoginPortal/SignOut/SignOut";
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1"; import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2"; import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
import SettingsRouter from "@views/Settings/SettingsRouter";
import "@fortawesome/fontawesome-svg-core/styles.css"; import "@fortawesome/fontawesome-svg-core/styles.css";
@ -89,10 +89,10 @@ const App: React.FC<Props> = (props: Props) => {
<Routes> <Routes>
<Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} /> <Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} />
<Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} /> <Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} />
<Route path={RegisterWebAuthnRoute} element={<RegisterWebAuthn />} />
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} /> <Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
<Route path={LogoutRoute} element={<SignOut />} /> <Route path={LogoutRoute} element={<SignOut />} />
<Route path={ConsentRoute} element={<ConsentView />} /> <Route path={ConsentRoute} element={<ConsentView />} />
<Route path={`${SettingsRoute}/*`} element={<SettingsRouter />} />
<Route <Route
path={`${IndexRoute}*`} path={`${IndexRoute}*`}
element={ element={

View File

@ -0,0 +1,47 @@
import React, { Fragment } from "react";
import { Divider, Grid, Link, Theme } from "@mui/material";
import { grey } from "@mui/material/colors";
import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import { getPrivacyPolicyEnabled } from "@utils/Configuration";
export interface Props {}
const url = "https://www.authelia.com";
const Brand = function (props: Props) {
const { t: translate } = useTranslation();
const styles = useStyles();
const privacyEnabled = getPrivacyPolicyEnabled();
return (
<Grid item container xs={12} alignItems="center" justifyContent="center">
<Grid item xs={4}>
<Link href={url} target="_blank" underline="hover" className={styles.links}>
{translate("Powered by")} Authelia
</Link>
</Grid>
{privacyEnabled ? (
<Fragment>
<Divider orientation="vertical" flexItem variant="middle" />
<Grid item xs={4}>
<PrivacyPolicyLink className={styles.links} />
</Grid>
</Fragment>
) : null}
</Grid>
);
};
export default Brand;
const useStyles = makeStyles((theme: Theme) => ({
links: {
fontSize: "0.7em",
color: grey[500],
},
}));

View File

@ -19,7 +19,7 @@ const PasswordMeter = function (props: Props) {
const [progressColor] = useState(["#D32F2F", "#FF5722", "#FFEB3B", "#AFB42B", "#62D32F"]); const [progressColor] = useState(["#D32F2F", "#FF5722", "#FFEB3B", "#AFB42B", "#62D32F"]);
const [passwordScore, setPasswordScore] = useState(0); const [passwordScore, setPasswordScore] = useState(0);
const [maxScores, setMaxScores] = useState(0); const [maxScores, setMaxScores] = useState(0);
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const password = props.value; const password = props.value;
@ -114,7 +114,7 @@ const PasswordMeter = function (props: Props) {
return ( return (
<Box className={styles.progressContainer}> <Box className={styles.progressContainer}>
<Box title={feedback} className={classnames(styles.progressBar)} /> <Box title={feedback === null ? "" : feedback} className={classnames(styles.progressBar)} />
</Box> </Box>
); );
}; };

View File

@ -6,10 +6,11 @@ import { usePersistentStorageValue } from "@hooks/PersistentStorage";
import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration"; import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration";
const PrivacyPolicyDrawer = function (props: DrawerProps) { const PrivacyPolicyDrawer = function (props: DrawerProps) {
const { t: translate } = useTranslation();
const privacyEnabled = getPrivacyPolicyEnabled(); const privacyEnabled = getPrivacyPolicyEnabled();
const privacyRequireAccept = getPrivacyPolicyRequireAccept(); const privacyRequireAccept = getPrivacyPolicyRequireAccept();
const [accepted, setAccepted] = usePersistentStorageValue<boolean>("privacy-policy-accepted", false); const [accepted, setAccepted] = usePersistentStorageValue<boolean>("privacy-policy-accepted", false);
const { t: translate } = useTranslation();
return privacyEnabled && privacyRequireAccept && !accepted ? ( return privacyEnabled && privacyRequireAccept && !accepted ? (
<Drawer {...props} anchor="bottom" open={!accepted}> <Drawer {...props} anchor="bottom" open={!accepted}>

View File

@ -6,10 +6,10 @@ import { useTranslation } from "react-i18next";
import { getPrivacyPolicyURL } from "@utils/Configuration"; import { getPrivacyPolicyURL } from "@utils/Configuration";
const PrivacyPolicyLink = function (props: LinkProps) { const PrivacyPolicyLink = function (props: LinkProps) {
const hrefPrivacyPolicy = getPrivacyPolicyURL();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const hrefPrivacyPolicy = getPrivacyPolicyURL();
return ( return (
<Fragment> <Fragment>
<Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover"> <Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover">

View File

@ -0,0 +1,39 @@
import React, { useEffect } from "react";
import { Box, Theme, useTheme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import { useTimer } from "@hooks/Timer";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
interface Props {
timeout: number;
}
export default function WebAuthnRegisterIcon(props: Props) {
const theme = useTheme();
const [timerPercent, triggerTimer] = useTimer(props.timeout);
const styles = makeStyles((theme: Theme) => ({
icon: {
display: "inline-block",
},
progressBar: {
marginTop: theme.spacing(),
},
}))();
useEffect(() => {
triggerTimer();
}, [triggerTimer]);
return (
<Box className={styles.icon} sx={{ minHeight: 101 }}>
<IconWithContext icon={<FingerTouchIcon size={64} animated strong />}>
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
</Box>
);
}

View File

@ -0,0 +1,68 @@
import React, { useEffect } from "react";
import { Box, Button, Theme, useTheme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import FailureIcon from "@components/FailureIcon";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import { useTimer } from "@hooks/Timer";
import { WebAuthnTouchState } from "@models/WebAuthn";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
interface Props {
onRetryClick: () => void;
webauthnTouchState: WebAuthnTouchState;
}
export default function WebAuthnTryIcon(props: Props) {
const touchTimeout = 30;
const theme = useTheme();
const [timerPercent, triggerTimer, clearTimer] = useTimer(touchTimeout * 1000 - 500);
const styles = makeStyles((theme: Theme) => ({
icon: {
display: "inline-block",
},
progressBar: {
marginTop: theme.spacing(),
},
}))();
const handleRetryClick = () => {
clearTimer();
triggerTimer();
props.onRetryClick();
};
useEffect(() => {
triggerTimer();
}, [triggerTimer]);
const touch = (
<IconWithContext
icon={<FingerTouchIcon size={64} animated strong />}
className={props.webauthnTouchState === WebAuthnTouchState.WaitTouch ? undefined : "hidden"}
>
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
);
const failure = (
<IconWithContext
icon={<FailureIcon />}
className={props.webauthnTouchState === WebAuthnTouchState.Failure ? undefined : "hidden"}
>
<Button color="secondary" onClick={handleRetryClick}>
Retry
</Button>
</IconWithContext>
);
return (
<Box className={styles.icon} sx={{ minHeight: 101 }}>
{touch}
{failure}
</Box>
);
}

View File

@ -2,13 +2,15 @@ export const IndexRoute: string = "/";
export const AuthenticatedRoute: string = "/authenticated"; export const AuthenticatedRoute: string = "/authenticated";
export const ConsentRoute: string = "/consent"; export const ConsentRoute: string = "/consent";
export const SecondFactorRoute: string = "/2fa/"; export const SecondFactorRoute: string = "/2fa";
export const SecondFactorWebAuthnSubRoute: string = "webauthn"; export const SecondFactorWebAuthnSubRoute: string = "/webauthn";
export const SecondFactorTOTPSubRoute: string = "one-time-password"; export const SecondFactorTOTPSubRoute: string = "/one-time-password";
export const SecondFactorPushSubRoute: string = "push-notification"; export const SecondFactorPushSubRoute: string = "/push-notification";
export const ResetPasswordStep1Route: string = "/reset-password/step1"; export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2"; export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterWebAuthnRoute: string = "/webauthn/register";
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register"; export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
export const LogoutRoute: string = "/logout"; export const LogoutRoute: string = "/logout";
export const SettingsRoute: string = "/settings";
export const SettingsTwoFactorAuthenticationSubRoute: string = "/two-factor-authentication";

View File

@ -0,0 +1,29 @@
import { useCallback } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
export function useRouterNavigate() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
return useCallback(
(
pathname: string,
preserveSearchParams: boolean = true,
searchParamsOverride: URLSearchParams | undefined = undefined,
) => {
if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) {
navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` });
} else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) {
navigate({ pathname: pathname, search: `?${searchParams.toString()}` });
} else {
navigate({ pathname: pathname });
}
},
[navigate, searchParams],
);
}
function URLSearchParamsHasValues(params?: URLSearchParams) {
return params ? !params.entries().next().done : false;
}

View File

@ -0,0 +1,6 @@
import { useRemoteCall } from "@hooks/RemoteCall";
import { getUserWebAuthnDevices } from "@services/UserWebAuthnDevices";
export function useUserWebAuthnDevices() {
return useRemoteCall(getUserWebAuthnDevices, []);
}

View File

@ -31,7 +31,7 @@ i18n.use(Backend)
loadPath: basePath + "/locales/{{lng}}/{{ns}}.json", loadPath: basePath + "/locales/{{lng}}/{{ns}}.json",
}, },
load: "all", load: "all",
ns: ["portal"], ns: ["portal", "settings"],
defaultNS: "portal", defaultNS: "portal",
fallbackLng: { fallbackLng: {
default: ["en"], default: ["en"],

View File

@ -1,89 +1,110 @@
import React, { Fragment, ReactNode, useEffect } from "react"; import React, { ReactNode, useEffect } from "react";
import { Container, Divider, Grid, Link, Theme } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings";
import { grey } from "@mui/material/colors"; import { AppBar, Box, Container, Grid, IconButton, Theme, Toolbar, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles"; import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ReactComponent as UserSvg } from "@assets/images/user.svg"; import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import Brand from "@components/Brand";
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer"; import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import TypographyWithTooltip from "@components/TypographyWithTooltip"; import TypographyWithTooltip from "@components/TypographyWithTooltip";
import { getLogoOverride, getPrivacyPolicyEnabled } from "@utils/Configuration"; import { SettingsRoute } from "@constants/Routes";
import { getLogoOverride } from "@utils/Configuration";
export interface Props { export interface Props {
id?: string; id?: string;
children?: ReactNode; children?: ReactNode;
title?: string; title?: string | null;
titleTooltip?: string; titleTooltip?: string | null;
subtitle?: string; subtitle?: string | null;
subtitleTooltip?: string; subtitleTooltip?: string | null;
showBrand?: boolean; showBrand?: boolean;
showSettings?: boolean;
} }
const url = "https://www.authelia.com";
const LoginLayout = function (props: Props) { const LoginLayout = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const navigate = useNavigate();
const styles = useStyles();
const logo = getLogoOverride() ? ( const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} /> <img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
) : ( ) : (
<UserSvg className={styles.icon} /> <UserSvg className={styles.icon} />
); );
const privacyEnabled = getPrivacyPolicyEnabled();
useEffect(() => { useEffect(() => {
document.title = `${translate("Login")} - Authelia`; document.title = `${translate("Login")} - Authelia`;
}, [translate]); }, [translate]);
const handleSettingsClick = () => {
navigate({
pathname: SettingsRoute,
});
};
return ( return (
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center"> <Box>
<Container maxWidth="xs" className={styles.rootContainer}> <AppBar position="static" color="transparent" elevation={0}>
<Grid container> <Toolbar variant="dense">
<Grid item xs={12}> <Typography style={{ flexGrow: 1 }} />
{logo} {props.showSettings ? (
</Grid> <IconButton
{props.title ? ( size="large"
<Grid item xs={12}> edge="start"
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} /> color="inherit"
</Grid> aria-label="menu"
sx={{ mr: 2 }}
onClick={handleSettingsClick}
>
<SettingsIcon />
</IconButton>
) : null} ) : null}
{props.subtitle ? ( </Toolbar>
</AppBar>
<Grid
id={props.id}
className={styles.root}
container
spacing={0}
alignItems="center"
justifyContent="center"
>
<Container maxWidth="xs" className={styles.rootContainer}>
<Grid container>
<Grid item xs={12}> <Grid item xs={12}>
<TypographyWithTooltip {logo}
variant={"h6"}
value={props.subtitle}
tooltip={props.subtitleTooltip}
/>
</Grid> </Grid>
) : null} {props.title ? (
<Grid item xs={12} className={styles.body}> <Grid item xs={12}>
{props.children} <TypographyWithTooltip
</Grid> variant={"h5"}
{props.showBrand ? ( value={props.title}
<Grid item container xs={12} alignItems="center" justifyContent="center"> tooltip={props.titleTooltip !== null ? props.titleTooltip : undefined}
<Grid item xs={4}> />
<Link href={url} target="_blank" underline="hover" className={styles.footerLinks}>
{translate("Powered by")} Authelia
</Link>
</Grid> </Grid>
{privacyEnabled ? ( ) : null}
<Fragment> {props.subtitle ? (
<Divider orientation="vertical" flexItem variant="middle" /> <Grid item xs={12}>
<Grid item xs={4}> <TypographyWithTooltip
<PrivacyPolicyLink className={styles.footerLinks} /> variant={"h6"}
</Grid> value={props.subtitle}
</Fragment> tooltip={props.subtitleTooltip !== null ? props.subtitleTooltip : undefined}
) : null} />
</Grid>
) : null}
<Grid item xs={12} className={styles.body}>
{props.children}
</Grid> </Grid>
) : null} {props.showBrand ? <Brand /> : null}
</Grid> </Grid>
</Container> </Container>
<PrivacyPolicyDrawer /> <PrivacyPolicyDrawer />
</Grid> </Grid>
</Box>
); );
}; };
@ -110,8 +131,4 @@ const useStyles = makeStyles((theme: Theme) => ({
paddingTop: theme.spacing(), paddingTop: theme.spacing(),
paddingBottom: theme.spacing(), paddingBottom: theme.spacing(),
}, },
footerLinks: {
fontSize: "0.7em",
color: grey[500],
},
})); }));

View File

@ -0,0 +1,125 @@
import React, { ReactNode, useEffect } from "react";
import { Dashboard } from "@mui/icons-material";
import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood";
import {
AppBar,
Box,
Button,
Drawer,
Grid,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
import { useRouterNavigate } from "@hooks/RouterNavigate";
export interface Props {
id?: string;
children?: ReactNode;
title?: string;
titlePrefix?: string;
drawerWidth?: number;
}
const defaultDrawerWidth = 240;
const SettingsLayout = function (props: Props) {
const { t: translate } = useTranslation("settings");
const navigate = useRouterNavigate();
useEffect(() => {
if (props.title) {
if (props.titlePrefix) {
document.title = `${props.titlePrefix} - ${props.title} - Authelia`;
} else {
document.title = `${props.title} - Authelia`;
}
} else {
if (props.titlePrefix) {
document.title = `${props.titlePrefix} - ${translate("Settings")} - Authelia`;
} else {
document.title = `${translate("Settings")} - Authelia`;
}
}
}, [props.title, props.titlePrefix, translate]);
const drawerWidth = props.drawerWidth === undefined ? defaultDrawerWidth : props.drawerWidth;
return (
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }}>Authelia {translate("Settings")}</Typography>
<Button
variant="contained"
color="success"
onClick={() => {
navigate(IndexRoute);
}}
>
{translate("Close")}
</Button>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: "border-box" },
}}
>
<Toolbar variant="dense" />
<Box sx={{ overflow: "auto" }}>
<List>
<SettingsMenuItem pathname={SettingsRoute} text={translate("Overview")} icon={<Dashboard />} />
<SettingsMenuItem
pathname={`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`}
text={translate("Two-Factor Authentication")}
icon={<SystemSecurityUpdateGoodIcon />}
/>
</List>
</Box>
</Drawer>
<Grid container id={props.id} spacing={0}>
<Grid item xs={12}>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar variant="dense" />
{props.children}
</Box>
</Grid>
</Grid>
</Box>
);
};
export default SettingsLayout;
interface SettingsMenuItemProps {
pathname: string;
text: string;
icon: ReactNode;
}
const SettingsMenuItem = function (props: SettingsMenuItemProps) {
const selected = window.location.pathname === props.pathname || window.location.pathname === props.pathname + "/";
const navigate = useRouterNavigate();
return (
<ListItem disablePadding onClick={!selected ? () => navigate(props.pathname) : undefined}>
<ListItemButton selected={selected}>
<ListItemIcon>{props.icon}</ListItemIcon>
<ListItemText primary={props.text} />
</ListItemButton>
</ListItem>
);
};

View File

@ -1,5 +1,12 @@
import {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/typescript-types";
export interface PublicKeyCredentialCreationOptionsStatus { export interface PublicKeyCredentialCreationOptionsStatus {
options?: PublicKeyCredentialCreationOptions; options?: PublicKeyCredentialCreationOptionsJSON;
status: number; status: number;
} }
@ -7,15 +14,8 @@ export interface CredentialCreation {
publicKey: PublicKeyCredentialCreationOptionsJSON; publicKey: PublicKeyCredentialCreationOptionsJSON;
} }
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<PublicKeyCredentialCreationOptions, "challenge" | "excludeCredentials" | "user"> {
challenge: string;
excludeCredentials?: PublicKeyCredentialDescriptorJSON[];
user: PublicKeyCredentialUserEntityJSON;
}
export interface PublicKeyCredentialRequestOptionsStatus { export interface PublicKeyCredentialRequestOptionsStatus {
options?: PublicKeyCredentialRequestOptions; options?: PublicKeyCredentialRequestOptionsJSON;
status: number; status: number;
} }
@ -23,63 +23,6 @@ export interface CredentialRequest {
publicKey: PublicKeyCredentialRequestOptionsJSON; publicKey: PublicKeyCredentialRequestOptionsJSON;
} }
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<PublicKeyCredentialRequestOptions, "allowCredentials" | "challenge"> {
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
challenge: string;
}
export interface PublicKeyCredentialDescriptorJSON extends Omit<PublicKeyCredentialDescriptor, "id"> {
id: string;
}
export interface PublicKeyCredentialUserEntityJSON extends Omit<PublicKeyCredentialUserEntity, "id"> {
id: string;
}
export interface AuthenticatorAssertionResponseJSON
extends Omit<AuthenticatorAssertionResponse, "authenticatorData" | "clientDataJSON" | "signature" | "userHandle"> {
authenticatorData: string;
clientDataJSON: string;
signature: string;
userHandle: string;
}
export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}
export interface AttestationPublicKeyCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
export interface AuthenticatorAttestationResponseJSON
extends Omit<AuthenticatorAttestationResponseFuture, "clientDataJSON" | "attestationObject"> {
clientDataJSON: string;
attestationObject: string;
}
export interface AttestationPublicKeyCredentialJSON
extends Omit<AttestationPublicKeyCredential, "response" | "rawId" | "getClientExtensionResults"> {
rawId: string;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
export interface PublicKeyCredentialJSON
extends Omit<PublicKeyCredential, "rawId" | "response" | "getClientExtensionResults"> {
rawId: string;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
response: AuthenticatorAssertionResponseJSON;
targetURL?: string;
workflow?: string;
workflowID?: string;
}
export enum AttestationResult { export enum AttestationResult {
Success = 1, Success = 1,
Failure, Failure,
@ -93,13 +36,8 @@ export enum AttestationResult {
FailureToken, FailureToken,
} }
export interface AttestationPublicKeyCredentialResult { export interface RegistrationResult {
credential?: AttestationPublicKeyCredential; response?: RegistrationResponseJSON;
result: AttestationResult;
}
export interface AttestationPublicKeyCredentialResultJSON {
credential?: AttestationPublicKeyCredentialJSON;
result: AttestationResult; result: AttestationResult;
} }
@ -113,19 +51,97 @@ export enum AssertionResult {
FailureUnknownSecurity, FailureUnknownSecurity,
FailureWebAuthnNotSupported, FailureWebAuthnNotSupported,
FailureChallenge, FailureChallenge,
FailureUnrecognized,
} }
export interface DiscoverableAssertionResult { export function AssertionResultFailureString(result: AssertionResult) {
result: AssertionResult; switch (result) {
username: string; case AssertionResult.Success:
return "";
case AssertionResult.FailureUserConsent:
return "You cancelled the assertion request.";
case AssertionResult.FailureU2FFacetID:
return "The server responded with an invalid Facet ID for the URL.";
case AssertionResult.FailureSyntax:
return "The assertion challenge was rejected as malformed or incompatible by your browser.";
case AssertionResult.FailureWebAuthnNotSupported:
return "Your browser does not support the WebAuthn protocol.";
case AssertionResult.FailureUnrecognized:
return "This device is not registered.";
case AssertionResult.FailureUnknownSecurity:
return "An unknown security error occurred.";
case AssertionResult.FailureUnknown:
return "An unknown error occurred.";
default:
return "An unexpected error occurred.";
}
} }
export interface AssertionPublicKeyCredentialResult { export function AttestationResultFailureString(result: AttestationResult) {
credential?: PublicKeyCredential; switch (result) {
case AttestationResult.FailureToken:
return "You must open the link from the same device and browser that initiated the registration process.";
case AttestationResult.FailureSupport:
return "Your browser does not appear to support the configuration.";
case AttestationResult.FailureSyntax:
return "The attestation challenge was rejected as malformed or incompatible by your browser.";
case AttestationResult.FailureWebAuthnNotSupported:
return "Your browser does not support the WebAuthn protocol.";
case AttestationResult.FailureUserConsent:
return "You cancelled the attestation request.";
case AttestationResult.FailureUserVerificationOrResidentKey:
return "Your device does not support user verification or resident keys but this was required.";
case AttestationResult.FailureExcluded:
return "You have registered this device already.";
case AttestationResult.FailureUnknown:
return "An unknown error occurred.";
}
return "";
}
export interface AuthenticationResult {
response?: AuthenticationResponseJSON;
result: AssertionResult; result: AssertionResult;
} }
export interface AssertionPublicKeyCredentialResultJSON { export interface WebAuthnDevice {
credential?: PublicKeyCredentialJSON; id: string;
result: AssertionResult; created_at: string;
last_used_at?: string;
rpid: string;
description: string;
kid: Uint8Array;
aaguid?: string;
attestation_type: string;
attachment: string;
transports: null | string[];
sign_count: number;
clone_warning: boolean;
discoverable: boolean;
present: boolean;
verified: boolean;
backup_eligible: boolean;
backup_state: boolean;
public_key: Uint8Array;
}
export function toTransportName(transport: string) {
switch (transport.toLowerCase()) {
case "internal":
return "Internal";
case "ble":
return "Bluetooth";
case "nfc":
case "usb":
return transport.toUpperCase();
default:
return transport;
}
}
export enum WebAuthnTouchState {
WaitTouch = 1,
InProgress = 2,
Failure = 3,
} }

View File

@ -11,11 +11,12 @@ export const FirstFactorPath = basePath + "/api/firstfactor";
export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start"; export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start";
export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish"; export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish";
export const WebAuthnIdentityStartPath = basePath + "/api/secondfactor/webauthn/identity/start"; export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register";
export const WebAuthnIdentityFinishPath = basePath + "/api/secondfactor/webauthn/identity/finish";
export const WebAuthnAttestationPath = basePath + "/api/secondfactor/webauthn/attestation";
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion"; export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn";
export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials";
export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential";
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices"; export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device"; export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
@ -39,21 +40,30 @@ export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
export const ConfigurationPath = basePath + "/api/configuration"; export const ConfigurationPath = basePath + "/api/configuration";
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy"; export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
export interface AuthenticationErrorResponse extends ErrorResponse {
authentication: boolean;
elevation: boolean;
}
export interface ErrorResponse { export interface ErrorResponse {
status: "KO"; status: "KO";
message: string; message: string;
} }
export interface Response<T> { export interface Response<T> extends OKResponse {
status: "OK";
data: T; data: T;
} }
export interface OptionalDataResponse<T> { export interface OptionalDataResponse<T> extends OKResponse {
status: "OK";
data?: T; data?: T;
} }
export interface OKResponse {
status: "OK";
}
export type AuthenticationResponse<T> = Response<T> | AuthenticationErrorResponse;
export type AuthenticationOKResponse = OKResponse | AuthenticationErrorResponse;
export type OptionalDataServiceResponse<T> = OptionalDataResponse<T> | ErrorResponse; export type OptionalDataServiceResponse<T> = OptionalDataResponse<T> | ErrorResponse;
export type ServiceResponse<T> = Response<T> | ErrorResponse; export type ServiceResponse<T> = Response<T> | ErrorResponse;
@ -78,3 +88,7 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
} }
return { errored: false, message: null }; return { errored: false, message: null };
} }
export function validateStatusAuthentication(status: number): boolean {
return (status >= 200 && status < 300) || status === 401 || status === 403;
}

View File

@ -8,6 +8,7 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
if (res.status !== 200 || hasServiceError(res).errored) { if (res.status !== 200 || hasServiceError(res).errored) {
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`); throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
} }
return toData<T>(res); return toData<T>(res);
} }
@ -32,3 +33,21 @@ export async function Get<T = undefined>(path: string): Promise<T> {
} }
return d; return d;
} }
export async function GetWithOptionalData<T = undefined>(path: string): Promise<T | null> {
const res = await axios.get<ServiceResponse<T>>(path);
if (res.status !== 200 || hasServiceError(res).errored) {
throw new Error(`Failed GET from ${path}. Code: ${res.status}.`);
}
const d = toData<T>(res);
if (d === null) {
return null;
}
if (!d) {
throw new Error("unexpected type of response");
}
return d;
}

View File

@ -1,4 +1,4 @@
import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath, WebAuthnIdentityStartPath } from "@services/Api"; import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath } from "@services/Api";
import { Post, PostWithOptionalResponse } from "@services/Client"; import { Post, PostWithOptionalResponse } from "@services/Client";
export async function initiateTOTPRegistrationProcess() { export async function initiateTOTPRegistrationProcess() {
@ -13,7 +13,3 @@ interface CompleteTOTPRegistrationResponse {
export async function completeTOTPRegistrationProcess(processToken: string) { export async function completeTOTPRegistrationProcess(processToken: string) {
return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken }); return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken });
} }
export async function initiateWebAuthnRegistrationProcess() {
return PostWithOptionalResponse(WebAuthnIdentityStartPath);
}

View File

@ -0,0 +1,13 @@
import { WebAuthnDevice } from "@models/WebAuthn";
import { WebAuthnDevicesPath } from "@services/Api";
import { GetWithOptionalData } from "@services/Client";
export async function getUserWebAuthnDevices(): Promise<WebAuthnDevice[]> {
const res = await GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
if (res === null) {
return [];
}
return res;
}

View File

@ -1,31 +1,32 @@
import axios, { AxiosResponse } from "axios"; import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
import {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/typescript-types";
import axios, { AxiosError, AxiosResponse } from "axios";
import { import {
AssertionPublicKeyCredentialResult,
AssertionResult, AssertionResult,
AttestationPublicKeyCredential,
AttestationPublicKeyCredentialJSON,
AttestationPublicKeyCredentialResult,
AttestationResult, AttestationResult,
AuthenticatorAttestationResponseFuture, AuthenticationResult,
CredentialCreation, CredentialCreation,
CredentialRequest, CredentialRequest,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsStatus, PublicKeyCredentialCreationOptionsStatus,
PublicKeyCredentialDescriptorJSON,
PublicKeyCredentialJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialRequestOptionsStatus, PublicKeyCredentialRequestOptionsStatus,
RegistrationResult,
} from "@models/WebAuthn"; } from "@models/WebAuthn";
import { import {
AuthenticationOKResponse,
OptionalDataServiceResponse, OptionalDataServiceResponse,
ServiceResponse, ServiceResponse,
WebAuthnAssertionPath, WebAuthnAssertionPath,
WebAuthnAttestationPath, WebAuthnDevicePath,
WebAuthnIdentityFinishPath, WebAuthnRegistrationPath,
validateStatusAuthentication,
} from "@services/Api"; } from "@services/Api";
import { SignInResponse } from "@services/SignIn"; import { SignInResponse } from "@services/SignIn";
import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from "@utils/Base64";
export function isWebAuthnSecure(): boolean { export function isWebAuthnSecure(): boolean {
if (window.isSecureContext) { if (window.isSecureContext) {
@ -47,120 +48,6 @@ export async function isWebAuthnPlatformAuthenticatorAvailable(): Promise<boolea
return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} }
function arrayBufferEncode(value: ArrayBuffer): string {
return getBase64WebEncodingFromBytes(new Uint8Array(value));
}
function arrayBufferDecode(value: string): ArrayBuffer {
return getBytesFromBase64(value);
}
function decodePublicKeyCredentialDescriptor(
descriptor: PublicKeyCredentialDescriptorJSON,
): PublicKeyCredentialDescriptor {
return {
id: arrayBufferDecode(descriptor.id),
type: descriptor.type,
transports: descriptor.transports,
};
}
function decodePublicKeyCredentialCreationOptions(
options: PublicKeyCredentialCreationOptionsJSON,
): PublicKeyCredentialCreationOptions {
return {
attestation: options.attestation,
authenticatorSelection: options.authenticatorSelection,
challenge: arrayBufferDecode(options.challenge),
excludeCredentials: options.excludeCredentials?.map(decodePublicKeyCredentialDescriptor),
extensions: options.extensions,
pubKeyCredParams: options.pubKeyCredParams,
rp: options.rp,
timeout: options.timeout,
user: {
displayName: options.user.displayName,
id: arrayBufferDecode(options.user.id),
name: options.user.name,
},
};
}
function decodePublicKeyCredentialRequestOptions(
options: PublicKeyCredentialRequestOptionsJSON,
): PublicKeyCredentialRequestOptions {
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined = undefined;
if (options.allowCredentials?.length !== 0) {
allowCredentials = options.allowCredentials?.map(decodePublicKeyCredentialDescriptor);
}
return {
allowCredentials: allowCredentials,
challenge: arrayBufferDecode(options.challenge),
extensions: options.extensions,
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
};
}
function encodeAttestationPublicKeyCredential(
credential: AttestationPublicKeyCredential,
): AttestationPublicKeyCredentialJSON {
const response = credential.response as AuthenticatorAttestationResponseFuture;
let transports: AuthenticatorTransport[] | undefined;
if (response?.getTransports !== undefined && typeof response.getTransports === "function") {
transports = response.getTransports();
}
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferEncode(credential.rawId),
clientExtensionResults: credential.getClientExtensionResults(),
response: {
attestationObject: arrayBufferEncode(response.attestationObject),
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
},
transports: transports,
};
}
function encodeAssertionPublicKeyCredential(
credential: PublicKeyCredential,
targetURL: string | undefined,
workflow: string | undefined,
workflowID: string | undefined,
): PublicKeyCredentialJSON {
const response = credential.response as AuthenticatorAssertionResponse;
let userHandle: string;
if (response.userHandle == null) {
userHandle = "";
} else {
userHandle = arrayBufferEncode(response.userHandle);
}
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferEncode(credential.rawId),
clientExtensionResults: credential.getClientExtensionResults(),
response: {
authenticatorData: arrayBufferEncode(response.authenticatorData),
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
signature: arrayBufferEncode(response.signature),
userHandle: userHandle,
},
targetURL: targetURL,
workflow: workflow,
workflowID: workflowID,
};
}
function getAttestationResultFromDOMException(exception: DOMException): AttestationResult { function getAttestationResultFromDOMException(exception: DOMException): AttestationResult {
// Docs for this section: // Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-make-cred // https://w3c.github.io/webauthn/#sctn-op-make-cred
@ -174,6 +61,7 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
case "InvalidStateError": case "InvalidStateError":
// § 6.3.2 Step 3. // § 6.3.2 Step 3.
return AttestationResult.FailureExcluded; return AttestationResult.FailureExcluded;
case "AbortError":
case "NotAllowedError": case "NotAllowedError":
// § 6.3.2 Step 3 and Step 6. // § 6.3.2 Step 3 and Step 6.
return AttestationResult.FailureUserConsent; return AttestationResult.FailureUserConsent;
@ -181,14 +69,14 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
// § 6.3.2 Step 4. // § 6.3.2 Step 4.
return AttestationResult.FailureUserVerificationOrResidentKey; return AttestationResult.FailureUserVerificationOrResidentKey;
default: default:
console.error(`Unhandled DOMException occurred during WebAuthN attestation: ${exception}`); console.error(`Unhandled DOMException occurred during WebAuthn attestation: ${exception}`);
return AttestationResult.FailureUnknown; return AttestationResult.FailureUnknown;
} }
} }
function getAssertionResultFromDOMException( function getAssertionResultFromDOMException(
exception: DOMException, exception: DOMException,
requestOptions: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptionsJSON,
): AssertionResult { ): AssertionResult {
// Docs for this section: // Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-get-assertion // https://w3c.github.io/webauthn/#sctn-op-get-assertion
@ -196,28 +84,40 @@ function getAssertionResultFromDOMException(
case "UnknownError": case "UnknownError":
// § 6.3.3 Step 1 and Step 12. // § 6.3.3 Step 1 and Step 12.
return AssertionResult.FailureSyntax; return AssertionResult.FailureSyntax;
case "InvalidStateError":
// § 6.3.2 Step 3.
return AssertionResult.FailureUnrecognized;
case "AbortError":
case "NotAllowedError": case "NotAllowedError":
// § 6.3.3 Step 6 and Step 7. // § 6.3.3 Step 6 and Step 7.
return AssertionResult.FailureUserConsent; return AssertionResult.FailureUserConsent;
case "SecurityError": case "SecurityError":
if (requestOptions.extensions?.appid !== undefined) { if (options.extensions?.appid !== undefined) {
// § 10.1 and 10.2 Step 3. // § 10.1 and 10.2 Step 3.
return AssertionResult.FailureU2FFacetID; return AssertionResult.FailureU2FFacetID;
} else { } else {
return AssertionResult.FailureUnknownSecurity; return AssertionResult.FailureUnknownSecurity;
} }
default: default:
console.error(`Unhandled DOMException occurred during WebAuthN assertion: ${exception}`); console.error(`Unhandled DOMException occurred during WebAuthn assertion: ${exception}`);
return AssertionResult.FailureUnknown; return AssertionResult.FailureUnknown;
} }
} }
async function getAttestationCreationOptions(token: string): Promise<PublicKeyCredentialCreationOptionsStatus> { export async function getAttestationCreationOptions(
let response: AxiosResponse<ServiceResponse<CredentialCreation>>; description: string,
): Promise<PublicKeyCredentialCreationOptionsStatus> {
response = await axios.post<ServiceResponse<CredentialCreation>>(WebAuthnIdentityFinishPath, { const response = await axios.put<ServiceResponse<CredentialCreation>>(
token: token, WebAuthnRegistrationPath,
}); {
description: description,
},
{
validateStatus: function (status) {
return status < 300 || status === 409;
},
},
);
if (response.data.status !== "OK" || response.data.data == null) { if (response.data.status !== "OK" || response.data.data == null) {
return { return {
@ -226,12 +126,12 @@ async function getAttestationCreationOptions(token: string): Promise<PublicKeyCr
} }
return { return {
options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey), options: response.data.data.publicKey,
status: response.status, status: response.status,
}; };
} }
export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> { export async function getAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialRequest>>; let response: AxiosResponse<ServiceResponse<CredentialRequest>>;
response = await axios.get<ServiceResponse<CredentialRequest>>(WebAuthnAssertionPath); response = await axios.get<ServiceResponse<CredentialRequest>>(WebAuthnAssertionPath);
@ -243,67 +143,57 @@ export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialR
} }
return { return {
options: decodePublicKeyCredentialRequestOptions(response.data.data.publicKey), options: response.data.data.publicKey,
status: response.status, status: response.status,
}; };
} }
async function getAttestationPublicKeyCredentialResult( export async function startWebAuthnRegistration(options: PublicKeyCredentialCreationOptionsJSON) {
creationOptions: PublicKeyCredentialCreationOptions, const result: RegistrationResult = {
): Promise<AttestationPublicKeyCredentialResult> { result: AttestationResult.Failure,
const result: AttestationPublicKeyCredentialResult = {
result: AttestationResult.Success,
}; };
try { try {
result.credential = (await navigator.credentials.create({ result.response = await startRegistration(options);
publicKey: creationOptions,
})) as AttestationPublicKeyCredential;
} catch (e) { } catch (e) {
result.result = AttestationResult.Failure;
const exception = e as DOMException; const exception = e as DOMException;
if (exception !== undefined) { if (exception !== undefined) {
result.result = getAttestationResultFromDOMException(exception); result.result = getAttestationResultFromDOMException(exception);
console.error(exception);
return result; return result;
} else { } else {
console.error(`Unhandled exception occurred during WebAuthN attestation: ${e}`); console.error(`Unhandled exception occurred during WebAuthn attestation: ${e}`);
} }
} }
if (result.credential == null) { if (result.response != null) {
result.result = AttestationResult.Failure;
} else {
result.result = AttestationResult.Success; result.result = AttestationResult.Success;
} }
return result; return result;
} }
export async function getAssertionPublicKeyCredentialResult( export async function getAuthenticationResult(options: PublicKeyCredentialRequestOptionsJSON) {
requestOptions: PublicKeyCredentialRequestOptions, const result: AuthenticationResult = {
): Promise<AssertionPublicKeyCredentialResult> {
const result: AssertionPublicKeyCredentialResult = {
result: AssertionResult.Success, result: AssertionResult.Success,
}; };
try { try {
result.credential = (await navigator.credentials.get({ publicKey: requestOptions })) as PublicKeyCredential; result.response = await startAuthentication(options);
} catch (e) { } catch (e) {
result.result = AssertionResult.Failure;
const exception = e as DOMException; const exception = e as DOMException;
if (exception !== undefined) { if (exception !== undefined) {
result.result = getAssertionResultFromDOMException(exception, requestOptions); result.result = getAssertionResultFromDOMException(exception, options);
console.error(exception);
return result; return result;
} else { } else {
console.error(`Unhandled exception occurred during WebAuthN assertion: ${e}`); console.error(`Unhandled exception occurred during WebAuthn authentication: ${e}`);
} }
} }
if (result.credential == null) { if (result.response == null) {
result.result = AssertionResult.Failure; result.result = AssertionResult.Failure;
} else { } else {
result.result = AssertionResult.Success; result.result = AssertionResult.Success;
@ -312,82 +202,62 @@ export async function getAssertionPublicKeyCredentialResult(
return result; return result;
} }
async function postAttestationPublicKeyCredentialResult( async function postRegistrationResponse(
credential: AttestationPublicKeyCredential, response: RegistrationResponseJSON,
): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> { ): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> {
const credentialJSON = encodeAttestationPublicKeyCredential(credential); return axios.post<OptionalDataServiceResponse<any>>(WebAuthnRegistrationPath, response);
return axios.post<OptionalDataServiceResponse<any>>(WebAuthnAttestationPath, credentialJSON);
} }
export async function postAssertionPublicKeyCredentialResult( export async function postAuthenticationResponse(
credential: PublicKeyCredential, response: AuthenticationResponseJSON,
targetURL: string | undefined, targetURL: string | undefined,
workflow?: string, workflow?: string,
workflowID?: string, workflowID?: string,
): Promise<AxiosResponse<ServiceResponse<SignInResponse>>> { ) {
const credentialJSON = encodeAssertionPublicKeyCredential(credential, targetURL, workflow, workflowID); return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, {
response: response,
return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, credentialJSON); targetURL: targetURL,
workflow: workflow,
workflowID: workflowID,
});
} }
export async function performAttestationCeremony(token: string): Promise<AttestationResult> { export async function finishRegistration(response: RegistrationResponseJSON) {
const attestationCreationOpts = await getAttestationCreationOptions(token); let result = {
status: AttestationResult.Failure,
message: "Device registration failed.",
};
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) { try {
if (attestationCreationOpts.status === 403) { const resp = await postRegistrationResponse(response);
return AttestationResult.FailureToken; if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) {
return {
status: AttestationResult.Success,
message: "",
};
}
} catch (error) {
if (error instanceof AxiosError && error.response !== undefined) {
result.message = error.response.data.message;
} }
return AttestationResult.Failure;
} }
const attestationResult = await getAttestationPublicKeyCredentialResult(attestationCreationOpts.options); return result;
if (attestationResult.result !== AttestationResult.Success) {
return attestationResult.result;
} else if (attestationResult.credential == null) {
return AttestationResult.Failure;
}
const response = await postAttestationPublicKeyCredentialResult(attestationResult.credential);
if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
return AttestationResult.Success;
}
return AttestationResult.Failure;
} }
export async function performAssertionCeremony( export async function deleteUserWebAuthnDevice(deviceID: string) {
targetURL?: string, return await axios<AuthenticationOKResponse>({
workflow?: string, method: "DELETE",
workflowID?: string, url: `${WebAuthnDevicePath}/${deviceID}`,
): Promise<AssertionResult> { validateStatus: validateStatusAuthentication,
const assertionRequestOpts = await getAssertionRequestOptions(); });
}
if (assertionRequestOpts.status !== 200 || assertionRequestOpts.options == null) {
return AssertionResult.FailureChallenge; export async function updateUserWebAuthnDevice(deviceID: string, description: string) {
} return await axios<AuthenticationOKResponse>({
method: "PUT",
const assertionResult = await getAssertionPublicKeyCredentialResult(assertionRequestOpts.options); url: `${WebAuthnDevicePath}/${deviceID}`,
data: { description: description },
if (assertionResult.result !== AssertionResult.Success) { validateStatus: validateStatusAuthentication,
return assertionResult.result; });
} else if (assertionResult.credential == null) {
return AssertionResult.Failure;
}
const response = await postAssertionPublicKeyCredentialResult(
assertionResult.credential,
targetURL,
workflow,
workflowID,
);
if (response.data.status === "OK" && response.status === 200) {
return AssertionResult.Success;
}
return AssertionResult.Failure;
} }

View File

@ -1,209 +0,0 @@
/*
This file is a work taken from the following location: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
MIT License
Copyright (c) 2020 Egor Nepomnyaschih
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
// This constant can also be computed with the following algorithm:
const base64Chars = [],
A = "A".charCodeAt(0),
a = "a".charCodeAt(0),
n = "0".charCodeAt(0);
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(A + i));
}
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(a + i));
}
for (let i = 0; i < 10; ++i) {
base64Chars.push(String.fromCharCode(n + i));
}
base64Chars.push("+");
base64Chars.push("/");
*/
const base64Chars = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"+",
"/",
];
/*
// This constant can also be computed with the following algorithm:
const l = 256, base64codes = new Uint8Array(l);
for (let i = 0; i < l; ++i) {
base64codes[i] = 255; // invalid character
}
base64Chars.forEach((char, index) => {
base64codes[char.charCodeAt(0)] = index;
});
base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to prevent an error
*/
const base64Codes = [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255, 255,
255, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255, 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, 30, 31,
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
];
function getBase64Code(charCode: number) {
if (charCode >= base64Codes.length) {
throw new Error("Unable to parse base64 string.");
}
const code = base64Codes[charCode];
if (code === 255) {
throw new Error("Unable to parse base64 string.");
}
return code;
}
export function getBase64FromBytes(bytes: number[] | Uint8Array): string {
let result = "",
i,
l = bytes.length;
for (i = 2; i < l; i += 3) {
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)];
result += base64Chars[bytes[i] & 0x3f];
}
if (i === l + 1) {
// 1 octet yet to write
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[(bytes[i - 2] & 0x03) << 4];
result += "==";
}
if (i === l) {
// 2 octets yet to write
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[(bytes[i - 1] & 0x0f) << 2];
result += "=";
}
return result;
}
export function getBase64WebEncodingFromBytes(bytes: number[] | Uint8Array): string {
return getBase64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
export function getBytesFromBase64(str: string): Uint8Array {
if (str.length % 4 !== 0) {
throw new Error("Unable to parse base64 string.");
}
const index = str.indexOf("=");
if (index !== -1 && index < str.length - 2) {
throw new Error("Unable to parse base64 string.");
}
let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0,
n = str.length,
result = new Uint8Array(3 * (n / 4)),
buffer;
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
buffer =
(getBase64Code(str.charCodeAt(i)) << 18) |
(getBase64Code(str.charCodeAt(i + 1)) << 12) |
(getBase64Code(str.charCodeAt(i + 2)) << 6) |
getBase64Code(str.charCodeAt(i + 3));
result[j] = buffer >> 16;
result[j + 1] = (buffer >> 8) & 0xff;
result[j + 2] = buffer & 0xff;
}
return result.subarray(0, result.length - missingOctets);
}

View File

@ -20,15 +20,18 @@ import LoginLayout from "@layouts/LoginLayout";
import { completeTOTPRegistrationProcess } from "@services/RegisterDevice"; import { completeTOTPRegistrationProcess } from "@services/RegisterDevice";
const RegisterOneTimePassword = function () { const RegisterOneTimePassword = function () {
const { t: translate } = useTranslation();
const styles = useStyles(); const styles = useStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const { createSuccessNotification, createErrorNotification } = useNotifications();
// The secret retrieved from the API is all is ok. // The secret retrieved from the API is all is ok.
const [secretURL, setSecretURL] = useState("empty"); const [secretURL, setSecretURL] = useState("empty");
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined); const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
const { createSuccessNotification, createErrorNotification } = useNotifications();
const [hasErrored, setHasErrored] = useState(false); const [hasErrored, setHasErrored] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t: translate } = useTranslation();
// Get the token from the query param to give it back to the API when requesting // Get the token from the query param to give it back to the API when requesting
// the secret for OTP. // the secret for OTP.

View File

@ -1,112 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button, Theme, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import { useNavigate } from "react-router-dom";
import FingerTouchIcon from "@components/FingerTouchIcon";
import { IdentityToken } from "@constants/SearchParams";
import { useNotifications } from "@hooks/NotificationsContext";
import { useQueryParam } from "@hooks/QueryParam";
import LoginLayout from "@layouts/LoginLayout";
import { AttestationResult } from "@models/WebAuthn";
import { FirstFactorPath } from "@services/Api";
import { performAttestationCeremony } from "@services/WebAuthn";
const RegisterWebAuthn = function () {
const styles = useStyles();
const navigate = useNavigate();
const { createErrorNotification } = useNotifications();
const [, setRegistrationInProgress] = useState(false);
const processToken = useQueryParam(IdentityToken);
const handleBackClick = () => {
navigate(FirstFactorPath);
};
const attestation = useCallback(async () => {
if (!processToken) {
return;
}
try {
setRegistrationInProgress(true);
const result = await performAttestationCeremony(processToken);
setRegistrationInProgress(false);
switch (result) {
case AttestationResult.Success:
navigate(FirstFactorPath);
break;
case AttestationResult.FailureToken:
createErrorNotification(
"You must open the link from the same device and browser that initiated the registration process.",
);
break;
case AttestationResult.FailureSupport:
createErrorNotification("Your browser does not appear to support the configuration.");
break;
case AttestationResult.FailureSyntax:
createErrorNotification(
"The attestation challenge was rejected as malformed or incompatible by your browser.",
);
break;
case AttestationResult.FailureWebAuthnNotSupported:
createErrorNotification("Your browser does not support the WebAuthN protocol.");
break;
case AttestationResult.FailureUserConsent:
createErrorNotification("You cancelled the attestation request.");
break;
case AttestationResult.FailureUserVerificationOrResidentKey:
createErrorNotification(
"Your device does not support user verification or resident keys but this was required.",
);
break;
case AttestationResult.FailureExcluded:
createErrorNotification("You have registered this device already.");
break;
case AttestationResult.FailureUnknown:
createErrorNotification("An unknown error occurred.");
break;
}
} catch (err) {
console.error(err);
createErrorNotification(
"Failed to register your device. The identity verification process might have timed out.",
);
}
}, [processToken, createErrorNotification, navigate]);
useEffect(() => {
attestation();
}, [attestation]);
return (
<LoginLayout title="Touch Security Key">
<div className={styles.icon}>
<FingerTouchIcon size={64} animated />
</div>
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
<Button color="primary" onClick={handleBackClick}>
Retry
</Button>
<Button color="primary" onClick={handleBackClick}>
Cancel
</Button>
</LoginLayout>
);
};
export default RegisterWebAuthn;
const useStyles = makeStyles((theme: Theme) => ({
icon: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
instruction: {
paddingBottom: theme.spacing(4),
},
}));

View File

@ -6,6 +6,7 @@ import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
const LoadingPage = function () { const LoadingPage = function () {
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
return <BaseLoadingPage message={translate("Loading")} />; return <BaseLoadingPage message={translate("Loading")} />;
}; };

View File

@ -7,8 +7,10 @@ import { useTranslation } from "react-i18next";
import SuccessIcon from "@components/SuccessIcon"; import SuccessIcon from "@components/SuccessIcon";
const Authenticated = function () { const Authenticated = function () {
const styles = useStyles();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const styles = useStyles();
return ( return (
<div id="authenticated-stage"> <div id="authenticated-stage">
<div className={styles.iconContainer}> <div className={styles.iconContainer}>

View File

@ -14,10 +14,12 @@ export interface Props {
} }
const AuthenticatedView = function (props: Props) { const AuthenticatedView = function (props: Props) {
const styles = useStyles();
const navigate = useNavigate();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const navigate = useNavigate();
const styles = useStyles();
const handleLogoutClick = () => { const handleLogoutClick = () => {
navigate(SignOutRoute); navigate(SignOutRoute);
}; };

View File

@ -47,23 +47,26 @@ function scopeNameToAvatar(id: string) {
} }
const ConsentView = function (props: Props) { const ConsentView = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
const { createErrorNotification, resetNotification } = useNotifications();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirect = useRedirector(); const redirect = useRedirector();
const consentID = searchParams.get(Identifier); const consentID = searchParams.get(Identifier);
const { createErrorNotification, resetNotification } = useNotifications();
const [response, setResponse] = useState<ConsentGetResponseBody | undefined>(undefined); const [response, setResponse] = useState<ConsentGetResponseBody | undefined>(undefined);
const [error, setError] = useState<any>(undefined); const [error, setError] = useState<any>(undefined);
const [preConfigure, setPreConfigure] = useState(false); const [preConfigure, setPreConfigure] = useState(false);
const styles = useStyles();
const handlePreConfigureChanged = () => { const handlePreConfigureChanged = () => {
setPreConfigure((preConfigure) => !preConfigure); setPreConfigure((preConfigure) => !preConfigure);
}; };
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
useEffect(() => { useEffect(() => {
fetchUserInfo(); fetchUserInfo();
}, [fetchUserInfo]); }, [fetchUserInfo]);
@ -167,7 +170,7 @@ const ConsentView = function (props: Props) {
<div className={styles.scopesListContainer}> <div className={styles.scopesListContainer}>
<List className={styles.scopesList}> <List className={styles.scopesList}>
{response?.scopes.map((scope: string) => ( {response?.scopes.map((scope: string) => (
<Tooltip title={"Scope " + scope}> <Tooltip title={translate("Scope", { name: scope })}>
<ListItem id={"scope-" + scope} dense> <ListItem id={"scope-" + scope} dense>
<ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon> <ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon>
<ListItemText primary={translateScopeNameToDescription(scope)} /> <ListItemText primary={translateScopeNameToDescription(scope)} />
@ -180,10 +183,7 @@ const ConsentView = function (props: Props) {
{response?.pre_configuration ? ( {response?.pre_configuration ? (
<Grid item xs={12}> <Grid item xs={12}>
<Tooltip <Tooltip
title={ title={translate("This saves this consent as a pre-configured consent for future use")}
translate("This saves this consent as a pre-configured consent for future use") ||
"This saves this consent as a pre-configured consent for future use"
}
> >
<FormControlLabel <FormControlLabel
control={ control={

View File

@ -30,23 +30,27 @@ export interface Props {
} }
const FirstFactorForm = function (props: Props) { const FirstFactorForm = function (props: Props) {
const styles = useStyles(); const { t: translate } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const redirectionURL = useQueryParam(RedirectionURL); const redirectionURL = useQueryParam(RedirectionURL);
const requestMethod = useQueryParam(RequestMethod); const requestMethod = useQueryParam(RequestMethod);
const [workflow] = useWorkflow(); const [workflow] = useWorkflow();
const { createErrorNotification } = useNotifications();
const loginChannel = useMemo(() => new BroadcastChannel<boolean>("login"), []); const loginChannel = useMemo(() => new BroadcastChannel<boolean>("login"), []);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [usernameError, setUsernameError] = useState(false); const [usernameError, setUsernameError] = useState(false);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState(false); const [passwordError, setPasswordError] = useState(false);
const { createErrorNotification } = useNotifications();
// TODO (PR: #806, Issue: #511) potentially refactor // TODO (PR: #806, Issue: #511) potentially refactor
const usernameRef = useRef() as MutableRefObject<HTMLInputElement>; const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>; const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
const { t: translate } = useTranslation();
const styles = useStyles();
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => usernameRef.current.focus(), 10); const timeout = setTimeout(() => usernameRef.current.focus(), 10);
@ -122,7 +126,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setUsernameError(false)} onFocus={() => setUsernameError(false)}
autoCapitalize="none" autoCapitalize="none"
autoComplete="username" autoComplete="username"
onKeyPress={(ev) => { onKeyDown={(ev) => {
if (ev.key === "Enter") { if (ev.key === "Enter") {
if (!username.length) { if (!username.length) {
setUsernameError(true); setUsernameError(true);
@ -152,7 +156,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setPasswordError(false)} onFocus={() => setPasswordError(false)}
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
onKeyPress={(ev) => { onKeyDown={(ev) => {
if (ev.key === "Enter") { if (ev.key === "Enter") {
if (!username.length) { if (!username.length) {
usernameRef.current.focus(); usernameRef.current.focus();
@ -174,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
disabled={disabled} disabled={disabled}
checked={rememberMe} checked={rememberMe}
onChange={handleRememberMeChange} onChange={handleRememberMeChange}
onKeyPress={(ev) => { onKeyDown={(ev) => {
if (ev.key === "Enter") { if (ev.key === "Enter") {
if (!username.length) { if (!username.length) {
usernameRef.current.focus(); usernameRef.current.focus();

View File

@ -1,6 +1,6 @@
import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react"; import React, { Fragment, ReactNode, useEffect, useState } from "react";
import { Route, Routes, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Route, Routes, useLocation } from "react-router-dom";
import { import {
AuthenticatedRoute, AuthenticatedRoute,
@ -15,6 +15,7 @@ import { useConfiguration } from "@hooks/Configuration";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useRedirector } from "@hooks/Redirector"; import { useRedirector } from "@hooks/Redirector";
import { useRouterNavigate } from "@hooks/RouterNavigate";
import { useAutheliaState } from "@hooks/State"; import { useAutheliaState } from "@hooks/State";
import { useUserInfoPOST } from "@hooks/UserInfo"; import { useUserInfoPOST } from "@hooks/UserInfo";
import { SecondFactorMethod } from "@models/Methods"; import { SecondFactorMethod } from "@models/Methods";
@ -37,7 +38,6 @@ const RedirectionErrorMessage =
"Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct."; "Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.";
const LoginPortal = function (props: Props) { const LoginPortal = function (props: Props) {
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const redirectionURL = useQueryParam(RedirectionURL); const redirectionURL = useQueryParam(RedirectionURL);
const { createErrorNotification } = useNotifications(); const { createErrorNotification } = useNotifications();
@ -48,24 +48,8 @@ const LoginPortal = function (props: Props) {
const [state, fetchState, , fetchStateError] = useAutheliaState(); const [state, fetchState, , fetchStateError] = useAutheliaState();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration(); const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration();
const [searchParams] = useSearchParams();
const redirect = useCallback( const navigate = useRouterNavigate();
(
pathname: string,
preserveSearchParams: boolean = true,
searchParamsOverride: URLSearchParams | undefined = undefined,
) => {
if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) {
navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` });
} else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) {
navigate({ pathname: pathname, search: `?${searchParams.toString()}` });
} else {
navigate({ pathname: pathname });
}
},
[navigate, searchParams],
);
// Fetch the state when portal is mounted. // Fetch the state when portal is mounted.
useEffect(() => { useEffect(() => {
@ -138,17 +122,17 @@ const LoginPortal = function (props: Props) {
if (state.authentication_level === AuthenticationLevel.Unauthenticated) { if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(false); setFirstFactorDisabled(false);
redirect(IndexRoute); navigate(IndexRoute);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) { } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) {
if (configuration.available_methods.size === 0) { if (configuration.available_methods.size === 0) {
redirect(AuthenticatedRoute, false); navigate(AuthenticatedRoute, false);
} else { } else {
if (userInfo.method === SecondFactorMethod.WebAuthn) { if (userInfo.method === SecondFactorMethod.WebAuthn) {
redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`); navigate(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) { } else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`); navigate(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
} else { } else {
redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`); navigate(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
} }
} }
} }
@ -156,7 +140,7 @@ const LoginPortal = function (props: Props) {
}, [ }, [
state, state,
redirectionURL, redirectionURL,
redirect, navigate,
userInfo, userInfo,
setFirstFactorDisabled, setFirstFactorDisabled,
configuration, configuration,
@ -205,7 +189,7 @@ const LoginPortal = function (props: Props) {
} }
/> />
<Route <Route
path={`${SecondFactorRoute}*`} path={`${SecondFactorRoute}/*`}
element={ element={
state && userInfo && configuration ? ( state && userInfo && configuration ? (
<SecondFactorForm <SecondFactorForm
@ -245,7 +229,3 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) {
</Fragment> </Fragment>
); );
} }
function URLSearchParamsHasValues(params?: URLSearchParams) {
return params ? !params.entries().next().done : false;
}

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useState } from "react"; import React, { ReactNode, useState } from "react";
import { Button, Container, Grid, Theme, Typography } from "@mui/material"; import { Box, Button, Container, Grid, Theme, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles"; import makeStyles from "@mui/styles/makeStyles";
import PushNotificationIcon from "@components/PushNotificationIcon"; import PushNotificationIcon from "@components/PushNotificationIcon";
@ -127,12 +127,12 @@ function DeviceItem(props: DeviceItemProps) {
variant="contained" variant="contained"
onClick={props.onSelect} onClick={props.onSelect}
> >
<div className={style.icon}> <Box className={style.icon}>
<PushNotificationIcon width={32} height={32} /> <PushNotificationIcon width={32} height={32} />
</div> </Box>
<div> <Box>
<Typography>{props.device.name}</Typography> <Typography>{props.device.name}</Typography>
</div> </Box>
</Button> </Button>
</Grid> </Grid>
); );
@ -172,12 +172,12 @@ function MethodItem(props: MethodItemProps) {
variant="contained" variant="contained"
onClick={props.onSelect} onClick={props.onSelect}
> >
<div className={style.icon}> <Box className={style.icon}>
<PushNotificationIcon width={32} height={32} /> <PushNotificationIcon width={32} height={32} />
</div> </Box>
<div> <Box>
<Typography>{props.method}</Typography> <Typography>{props.method}</Typography>
</div> </Box>
</Button> </Button>
</Grid> </Grid>
); );

View File

@ -1,6 +1,6 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Theme } from "@mui/material"; import { Box, Theme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles"; import makeStyles from "@mui/styles/makeStyles";
import classnames from "classnames"; import classnames from "classnames";
@ -30,12 +30,12 @@ const IconWithContext = function (props: IconWithContextProps) {
}))(); }))();
return ( return (
<div className={classnames(props.className, styles.root)}> <Box className={classnames(props.className, styles.root)}>
<div className={styles.iconContainer}> <Box className={styles.iconContainer}>
<div className={styles.icon}>{props.icon}</div> <Box className={styles.icon}>{props.icon}</Box>
</div> </Box>
<div className={styles.context}>{props.children}</div> <Box className={styles.context}>{props.children}</Box>
</div> </Box>
); );
}; };

View File

@ -28,14 +28,15 @@ export interface Props {
} }
const DefaultMethodContainer = function (props: Props) { const DefaultMethodContainer = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const styles = useStyles();
const registerMessage = props.registered const registerMessage = props.registered
? props.title === "Push Notification" ? props.title === "Push Notification"
? "" ? ""
: translate("Lost your device?") : translate("Manage devices")
: translate("Register device"); : translate("Register device");
const selectMessage = translate("Select a Device");
let container: ReactNode; let container: ReactNode;
let stateClass: string = ""; let stateClass: string = "";
@ -62,7 +63,7 @@ const DefaultMethodContainer = function (props: Props) {
</div> </div>
{props.onSelectClick && props.registered ? ( {props.onSelectClick && props.registered ? (
<Link component="button" id="selection-link" onClick={props.onSelectClick} underline="hover"> <Link component="button" id="selection-link" onClick={props.onSelectClick} underline="hover">
{selectMessage} {translate("Select a Device")}
</Link> </Link>
) : null} ) : null}
{(props.onRegisterClick && props.title !== "Push Notification") || {(props.onRegisterClick && props.title !== "Push Notification") ||

View File

@ -42,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) {
{props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? ( {props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? (
<MethodItem <MethodItem
id="webauthn-option" id="webauthn-option"
method={translate("Security Key - WebAuthN")} method={translate("Security Key - WebAuthn")}
icon={<FingerTouchIcon size={32} />} icon={<FingerTouchIcon size={32} />}
onClick={() => props.onClick(SecondFactorMethod.WebAuthn)} onClick={() => props.onClick(SecondFactorMethod.WebAuthn)}
/> />
@ -59,7 +59,7 @@ const MethodSelectionDialog = function (props: Props) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button color="primary" onClick={props.onClose}> <Button color="primary" onClick={props.onClose}>
Close {translate("Close")}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -6,9 +6,12 @@ import { useTranslation } from "react-i18next";
import { Route, Routes, useNavigate } from "react-router-dom"; import { Route, Routes, useNavigate } from "react-router-dom";
import { import {
RegisterOneTimePasswordRoute,
SecondFactorPushSubRoute, SecondFactorPushSubRoute,
SecondFactorTOTPSubRoute, SecondFactorTOTPSubRoute,
SecondFactorWebAuthnSubRoute, SecondFactorWebAuthnSubRoute,
SettingsRoute,
SettingsTwoFactorAuthenticationSubRoute,
LogoutRoute as SignOutRoute, LogoutRoute as SignOutRoute,
} from "@constants/Routes"; } from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
@ -16,7 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
import { Configuration } from "@models/Configuration"; import { Configuration } from "@models/Configuration";
import { SecondFactorMethod } from "@models/Methods"; import { SecondFactorMethod } from "@models/Methods";
import { UserInfo } from "@models/UserInfo"; import { UserInfo } from "@models/UserInfo";
import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice"; import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo"; import { setPreferred2FAMethod } from "@services/UserInfo";
import { isWebAuthnSupported } from "@services/WebAuthn"; import { isWebAuthnSupported } from "@services/WebAuthn";
@ -48,20 +51,24 @@ const SecondFactorForm = function (props: Props) {
setStateWebAuthnSupported(isWebAuthnSupported()); setStateWebAuthnSupported(isWebAuthnSupported());
}, [setStateWebAuthnSupported]); }, [setStateWebAuthnSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => { const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
return async () => { return async () => {
if (registrationInProgress) { if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
return; navigate(redirectRoute);
} else {
if (registrationInProgress) {
return;
}
setRegistrationInProgress(true);
try {
await initiateRegistrationFunc();
createInfoNotification(translate("An email has been sent to your address to complete the process"));
} catch (err) {
console.error(err);
createErrorNotification(translate("There was a problem initiating the registration process"));
}
setRegistrationInProgress(false);
} }
setRegistrationInProgress(true);
try {
await initiateRegistrationFunc();
createInfoNotification(translate("An email has been sent to your address to complete the process"));
} catch (err) {
console.error(err);
createErrorNotification(translate("There was a problem initiating the registration process"));
}
setRegistrationInProgress(false);
}; };
}; };
@ -85,7 +92,12 @@ const SecondFactorForm = function (props: Props) {
}; };
return ( return (
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand> <LoginLayout
id="second-factor-stage"
title={`${translate("Hi")} ${props.userInfo.display_name}`}
showBrand
showSettings
>
{props.configuration.available_methods.size > 1 ? ( {props.configuration.available_methods.size > 1 ? (
<MethodSelectionDialog <MethodSelectionDialog
open={methodSelectionOpen} open={methodSelectionOpen}
@ -117,7 +129,10 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already // Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp} registered={props.userInfo.has_totp}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)} onRegisterClick={initiateRegistration(
initiateTOTPRegistrationProcess,
RegisterOneTimePasswordRoute,
)}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} onSignInSuccess={props.onAuthenticationSuccess}
/> />
@ -131,7 +146,9 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a WebAuthn device registered already // Whether the user has a WebAuthn device registered already
registered={props.userInfo.has_webauthn} registered={props.userInfo.has_webauthn}
onRegisterClick={initiateRegistration(initiateWebAuthnRegistrationProcess)} onRegisterClick={() => {
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
}}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} onSignInSuccess={props.onAuthenticationSuccess}
/> />

View File

@ -1,32 +1,15 @@
import React, { Fragment, useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, Theme, useTheme } from "@mui/material"; import WebAuthnTryIcon from "@components/WebAuthnTryIcon";
import makeStyles from "@mui/styles/makeStyles";
import FailureIcon from "@components/FailureIcon";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import { RedirectionURL } from "@constants/SearchParams"; import { RedirectionURL } from "@constants/SearchParams";
import { useIsMountedRef } from "@hooks/Mounted"; import { useIsMountedRef } from "@hooks/Mounted";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useTimer } from "@hooks/Timer";
import { useWorkflow } from "@hooks/Workflow"; import { useWorkflow } from "@hooks/Workflow";
import { AssertionResult } from "@models/WebAuthn"; import { AssertionResult, AssertionResultFailureString, WebAuthnTouchState } from "@models/WebAuthn";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { import { getAuthenticationOptions, getAuthenticationResult, postAuthenticationResponse } from "@services/WebAuthn";
getAssertionPublicKeyCredentialResult,
getAssertionRequestOptions,
postAssertionPublicKeyCredentialResult,
} from "@services/WebAuthn";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State {
WaitTouch = 1,
InProgress = 2,
Failure = 3,
}
export interface Props { export interface Props {
id: string; id: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
@ -38,13 +21,10 @@ export interface Props {
} }
const WebAuthnMethod = function (props: Props) { const WebAuthnMethod = function (props: Props) {
const signInTimeout = 30; const [state, setState] = useState(WebAuthnTouchState.WaitTouch);
const [state, setState] = useState(State.WaitTouch);
const styles = useStyles();
const redirectionURL = useQueryParam(RedirectionURL); const redirectionURL = useQueryParam(RedirectionURL);
const [workflow, workflowID] = useWorkflow(); const [workflow, workflowID] = useWorkflow();
const mounted = useIsMountedRef(); const mounted = useIsMountedRef();
const [timerPercent, triggerTimer] = useTimer(signInTimeout * 1000 - 500);
const { onSignInSuccess, onSignInError } = props; const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current; const onSignInErrorCallback = useRef(onSignInError).current;
@ -57,70 +37,40 @@ const WebAuthnMethod = function (props: Props) {
} }
try { try {
triggerTimer(); setState(WebAuthnTouchState.WaitTouch);
setState(State.WaitTouch); const optionsStatus = await getAuthenticationOptions();
const assertionRequestResponse = await getAssertionRequestOptions();
if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) { if (optionsStatus.status !== 200 || optionsStatus.options == null) {
setState(State.Failure); setState(WebAuthnTouchState.Failure);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
return; return;
} }
const result = await getAssertionPublicKeyCredentialResult(assertionRequestResponse.options); const result = await getAuthenticationResult(optionsStatus.options);
if (result.result !== AssertionResult.Success) { if (result.result !== AssertionResult.Success) {
if (!mounted.current) return; if (!mounted.current) return;
switch (result.result) {
case AssertionResult.FailureUserConsent: setState(WebAuthnTouchState.Failure);
onSignInErrorCallback(new Error("You cancelled the assertion request."));
break; onSignInErrorCallback(new Error(AssertionResultFailureString(result.result)));
case AssertionResult.FailureU2FFacetID:
onSignInErrorCallback(new Error("The server responded with an invalid Facet ID for the URL."));
break;
case AssertionResult.FailureSyntax:
onSignInErrorCallback(
new Error(
"The assertion challenge was rejected as malformed or incompatible by your browser.",
),
);
break;
case AssertionResult.FailureWebAuthnNotSupported:
onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol."));
break;
case AssertionResult.FailureUnknownSecurity:
onSignInErrorCallback(new Error("An unknown security error occurred."));
break;
case AssertionResult.FailureUnknown:
onSignInErrorCallback(new Error("An unknown error occurred."));
break;
default:
onSignInErrorCallback(new Error("An unexpected error occurred."));
break;
}
setState(State.Failure);
return; return;
} }
if (result.credential == null) { if (result.response == null) {
onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data.")); onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data."));
setState(State.Failure); setState(WebAuthnTouchState.Failure);
return; return;
} }
if (!mounted.current) return; if (!mounted.current) return;
setState(State.InProgress); setState(WebAuthnTouchState.InProgress);
const response = await postAssertionPublicKeyCredentialResult( const response = await postAuthenticationResponse(result.response, redirectionURL, workflow, workflowID);
result.credential,
redirectionURL,
workflow,
workflowID,
);
if (response.data.status === "OK" && response.status === 200) { if (response.data.status === "OK" && response.status === 200) {
onSignInSuccessCallback(response.data.data ? response.data.data.redirect : undefined); onSignInSuccessCallback(response.data.data ? response.data.data.redirect : undefined);
@ -130,14 +80,14 @@ const WebAuthnMethod = function (props: Props) {
if (!mounted.current) return; if (!mounted.current) return;
onSignInErrorCallback(new Error("The server rejected the security key.")); onSignInErrorCallback(new Error("The server rejected the security key."));
setState(State.Failure); setState(WebAuthnTouchState.Failure);
} catch (err) { } catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime, // If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component. // the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return; if (!mounted.current) return;
console.error(err); console.error(err);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
setState(State.Failure); setState(WebAuthnTouchState.Failure);
} }
}, [ }, [
onSignInErrorCallback, onSignInErrorCallback,
@ -146,7 +96,6 @@ const WebAuthnMethod = function (props: Props) {
workflow, workflow,
workflowID, workflowID,
mounted, mounted,
triggerTimer,
props.authenticationLevel, props.authenticationLevel,
props.registered, props.registered,
]); ]);
@ -172,59 +121,9 @@ const WebAuthnMethod = function (props: Props) {
state={methodState} state={methodState}
onRegisterClick={props.onRegisterClick} onRegisterClick={props.onRegisterClick}
> >
<div className={styles.icon}> <WebAuthnTryIcon onRetryClick={doInitiateSignIn} webauthnTouchState={state} />
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
</div>
</MethodContainer> </MethodContainer>
); );
}; };
export default WebAuthnMethod; export default WebAuthnMethod;
const useStyles = makeStyles((theme: Theme) => ({
icon: {
display: "inline-block",
},
}));
interface IconProps {
state: State;
timer: number;
onRetryClick: () => void;
}
function Icon(props: IconProps) {
const state = props.state as State;
const theme = useTheme();
const styles = makeStyles((theme: Theme) => ({
progressBar: {
marginTop: theme.spacing(),
},
}))();
const touch = (
<IconWithContext
icon={<FingerTouchIcon size={64} animated strong />}
className={state === State.WaitTouch ? undefined : "hidden"}
>
<LinearProgressBar value={props.timer} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
);
const failure = (
<IconWithContext icon={<FailureIcon />} className={state === State.Failure ? undefined : "hidden"}>
<Button color="secondary" onClick={props.onRetryClick}>
Retry
</Button>
</IconWithContext>
);
return (
<Fragment>
{touch}
{failure}
</Fragment>
);
}

Some files were not shown because too many files have changed in this diff Show More