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'
security:
- 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 }}
{{- if .Duo }}
/api/secondfactor/duo:
@ -1433,6 +1472,13 @@ paths:
{{- end }}
components:
parameters:
deviceID:
in: path
name: deviceID
schema:
type: integer
required: true
description: Numeric WebAuthn Device ID
originalMethodParam:
name: X-Original-Method
in: header
@ -1917,6 +1963,9 @@ components:
type: string
format: byte
webauthn.CredentialAttestationResponse:
type: object
properties:
credential:
allOf:
- $ref: '#/components/schemas/webauthn.PublicKeyCredential'
- type: object
@ -1934,6 +1983,8 @@ components:
attestationObject:
type: string
format: byte
description:
type: string
webauthn.CredentialAssertionResponse:
allOf:
- $ref: '#/components/schemas/webauthn.PublicKeyCredential'
@ -1971,6 +2022,11 @@ components:
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}$'
example: '3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c'
webauthn.DeviceUpdateRequest:
type: object
properties:
description:
type: string
webauthn.PublicKeyCredentialCreationOptions:
type: object
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 |
| 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 |
| 10 | 4.38.0 | WebAuthn adjustments for multi-cookie domain changes |

View File

@ -2,7 +2,7 @@
title: "Database Schema"
description: "Authelia Development Database Schema Guidelines"
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
images: []
menu:

View File

@ -45,14 +45,14 @@ Easy, right?!
## 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
frontend in the near future. Subscribe to [this issue](https://github.com/authelia/authelia/issues/275) for updates.
Yes, as of v4.38.0 and above Authelia supprots registering multiple WebAuthn credentials as per the
[roadmap](../../../roadmap/active/webauthn.md#multi-device-registration).
### 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?

View File

@ -2,7 +2,7 @@
title: "Database Integrations"
description: "A database integration reference guide"
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
images: []
menu:

View File

@ -2,7 +2,7 @@
title: "Integrations"
description: "A collection of integration reference guides"
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
images: []
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-rod/rod v0.113.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/mock v1.6.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-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/webauthn v0.5.0 h1:Tbmp37AGIhYbQmcy2hEffo3U3cgPClqvxJ7cLUnF7Rc=
github.com/go-webauthn/webauthn v0.5.0/go.mod h1:0CBq/jNfPS9l033j4AxMk8K8MluiMsde9uGNSPFLEVE=
github.com/go-webauthn/webauthn v0.8.2 h1:8KLIbpldjz9KVGHfqEgJNbkhd7bbRXhNw4QWFJE15oA=
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.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
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]
devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, user)
devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, "", user)
switch {
case len(devices) == 0 || (err != nil && errors.Is(err, storage.ErrNoWebAuthnDevice)):

View File

@ -68,6 +68,7 @@ const (
messageAuthenticationFailed = "Authentication failed. Check your credentials."
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
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."
messageMFAValidationFailed = "Authentication failed, please retry later."
messagePasswordWeak = "Your supplied password does not meet the password policy requirements"

View File

@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"encoding/json"
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@ -11,35 +13,21 @@ import (
"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"
)
// WebauthnIdentityStart the handler for initiating the identity validation.
var WebauthnIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{
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) {
// WebAuthnRegistrationPUT returns the attestation challenge from the server.
func WebAuthnRegistrationPUT(ctx *middlewares.AutheliaCtx) {
var (
w *webauthn.WebAuthn
user *model.WebAuthnUser
userSession session.UserSession
bodyJSON bodyRegisterWebAuthnPUTRequest
err error
)
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)
@ -47,40 +35,90 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
}
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)
return
}
if user, err = getWebAuthnUser(ctx, userSession); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge 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)
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.Errorf("Unable to parse %s registration request PUT data for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
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 {
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)
return
}
if err = ctx.SetJSONBody(credentialCreation); err != nil {
if err = ctx.SetJSONBody(creation); err != nil {
ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -89,8 +127,8 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
}
}
// WebAuthnAttestationPOST processes the attestation challenge response from the client.
func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
// WebAuthnRegistrationPOST processes the attestation challenge response from the client.
func WebAuthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
var (
err error
w *webauthn.WebAuthn
@ -98,75 +136,89 @@ func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
userSession session.UserSession
attestationResponse *protocol.ParsedCredentialCreationData
response *protocol.ParsedCredentialCreationData
credential *webauthn.Credential
)
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)
return
}
if userSession.WebAuthn == 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)
if userSession.WebAuthn == nil || userSession.WebAuthn.SessionData == nil {
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
}
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)
return
}
if attestationResponse, 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)
if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
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
}
if user, err = getWebAuthnUser(ctx, userSession); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
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
}
if credential, err = w.CreateCredential(user, *userSession.WebAuthn, attestationResponse); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if credential, err = w.CreateCredential(user, *userSession.WebAuthn.SessionData, response); err != nil {
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
}
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 {
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
}
userSession.WebAuthn = 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.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 {
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)
return
}
if user, err = getWebAuthnUser(ctx, userSession); err != nil {
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s user details during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
extensions := map[string]any{}
if user.HasFIDOU2F() {
extensions["appid"] = w.Config.RPOrigins[0]
}
var opts = []webauthn.LoginOption{
webauthn.WithAllowedCredentials(user.WebAuthnCredentialDescriptors()),
}
extensions := map[string]any{}
if user.HasFIDOU2F() {
extensions["appid"] = w.Config.RPOrigin
}
if len(extensions) != 0 {
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 {
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if assertion, data.SessionData, err = w.BeginLogin(user, opts...); err != nil {
ctx.Logger.Errorf("Unable to create %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
userSession.WebAuthn = &data
if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
@ -115,8 +120,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
return
}
if userSession.WebAuthn == 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)
if userSession.WebAuthn == nil || userSession.WebAuthn.SessionData == nil {
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)
@ -124,7 +129,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
}
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)
@ -137,23 +142,23 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
user *model.WebAuthnUser
)
if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil {
ctx.Logger.Errorf("Unable to parse %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
if user, err = getWebAuthnUser(ctx, userSession); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s credentials for authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
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)
respondUnauthorized(ctx, messageMFAValidationFailed)
@ -169,8 +174,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
found = true
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceSignIn(ctx, device.ID, device.RPID, device.LastUsedAt, device.SignCount, device.CloneWarning); err != nil {
ctx.Logger.Errorf("Unable to save %s device signin count for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceSignIn(ctx, device); err != nil {
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)
@ -182,7 +187,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
}
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)
@ -204,11 +209,11 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
}
userSession.SetTwoFactorWebAuthn(ctx.Clock.Now(),
assertionResponse.Response.AuthenticatorData.Flags.UserPresent(),
assertionResponse.Response.AuthenticatorData.Flags.UserVerified())
assertionResponse.Response.AuthenticatorData.Flags.HasUserPresent(),
assertionResponse.Response.AuthenticatorData.Flags.HasUserVerified())
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)

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
import (
"encoding/json"
"net/http"
"net/url"
@ -35,6 +36,16 @@ type bodySignWebAuthnRequest struct {
TargetURL string `json:"targetURL"`
Workflow string `json:"workflow"`
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.

View File

@ -1,28 +1,42 @@
package handlers
import (
"fmt"
"net/url"
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/authelia/authelia/v4/internal/middlewares"
"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) {
if user, err = ctx.Providers.StorageProvider.LoadWebAuthnUser(ctx, rpid, username); err != nil {
return nil, err
}
if user == nil {
user = &model.WebAuthnUser{
Username: userSession.Username,
DisplayName: userSession.DisplayName,
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 == "" {
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
}
@ -31,33 +45,72 @@ func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSessi
func newWebAuthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) {
var (
u *url.URL
origin *url.URL
)
if u, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil {
if origin, err = ctx.GetOrigin(); err != nil {
return nil, err
}
rpID := u.Hostname()
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
config := &webauthn.Config{
RPID: origin.Hostname(),
RPDisplayName: ctx.Configuration.WebAuthn.DisplayName,
RPID: rpID,
RPOrigin: origin,
RPIcon: "",
RPOrigins: []string{origin.String()},
AttestationPreference: ctx.Configuration.WebAuthn.ConveyancePreference,
AuthenticatorSelection: protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.CrossPlatform,
UserVerification: ctx.Configuration.WebAuthn.UserVerification,
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)
}
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"
"github.com/go-webauthn/webauthn/protocol"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/random"
"github.com/authelia/authelia/v4/internal/session"
)
@ -22,10 +24,14 @@ func TestWebAuthnGetUser(t *testing.T) {
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,
RPID: "https://example.com",
RPID: "example.com",
Username: "john",
Description: "Primary",
KID: model.NewBase64([]byte("abc123")),
@ -48,12 +54,12 @@ func TestWebAuthnGetUser(t *testing.T) {
},
}, nil)
user, err := getWebAuthnUser(ctx.Ctx, userSession)
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err)
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.Username)
@ -65,7 +71,107 @@ func TestWebAuthnGetUser(t *testing.T) {
require.Len(t, user.Devices, 2)
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, "Primary", user.Devices[0].Description)
assert.Equal(t, "", user.Devices[0].Transport)
@ -107,7 +213,11 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
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,
RPID: "example.com",
@ -121,7 +231,7 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
},
}, nil)
user, err := getWebAuthnUser(ctx.Ctx, userSession)
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err)
require.NotNil(t, user)
@ -137,9 +247,15 @@ func TestWebAuthnGetUserWithErr(t *testing.T) {
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.Nil(t, user)
@ -165,5 +281,5 @@ func TestWebAuthnNewWebAuthnShouldReturnErrWhenWebAuthnNotConfigured(t *testing.
w, err := newWebAuthn(ctx.Ctx)
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.
func (ctx *AutheliaCtx) SetJSONError(message string) {
if replyErr := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); replyErr != nil {
ctx.Logger.Error(replyErr)
if err := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); err != nil {
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.
func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) {
issuerURL = &url.URL{

View File

@ -95,26 +95,22 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
}
}
// IdentityVerificationFinish the middleware for finishing the identity validation process.
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) {
var finishBody IdentityVerificationFinishBody
func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
var bodyJSON IdentityVerificationFinishBody
b := ctx.PostBody()
err := json.Unmarshal(b, &finishBody)
err := json.Unmarshal(ctx.PostBody(), &bodyJSON)
if err != nil {
ctx.Error(err, messageOperationFailed)
return
return nil, err
}
if finishBody.Token == "" {
if bodyJSON.Token == "" {
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
return
return nil, err
}
token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
token, err := jwt.ParseWithClaims(bodyJSON.Token, &model.IdentityVerificationClaim{},
func(token *jwt.Token) (any, error) {
return []byte(ctx.Configuration.JWTSecret), nil
})
@ -124,19 +120,30 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0:
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
return
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
return nil, err
default:
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
return
return nil, err
}
}
ctx.Error(err, messageOperationFailed)
return nil, err
}
return token, nil
}
// IdentityVerificationFinish the middleware for finishing the identity validation process.
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) {
token, err := identityVerificationValidateToken(ctx)
if token == nil || err != nil {
return
}
@ -176,8 +183,7 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
return
}
err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP()))
if err != nil {
if err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP())); err != nil {
ctx.Error(err, messageOperationFailed)
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"`
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)
}
// 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.
func (m *MockStorage) LoadWebAuthnDevices(arg0 context.Context, arg1, arg2 int) ([]model.WebAuthnDevice, error) {
m.ctrl.T.Helper()
@ -435,18 +450,33 @@ func (mr *MockStorageMockRecorder) LoadWebAuthnDevices(arg0, arg1, arg2 interfac
}
// 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()
ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1)
ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1, arg2)
ret0, _ := ret[0].([]model.WebAuthnDevice)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// 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()
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.
@ -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)
}
// 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.
func (m *MockStorage) SchemaEncryptionChangeKey(arg0 context.Context, arg1 string) error {
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)
}
// UpdateWebAuthnDeviceSignIn mocks base method.
func (m *MockStorage) UpdateWebAuthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error {
// UpdateWebAuthnDeviceDescription mocks base method.
func (m *MockStorage) UpdateWebAuthnDeviceDescription(arg0 context.Context, arg1 string, arg2 int, arg3 string) error {
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)
return ret0
}
// 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()
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(),
PublicKey: device.PublicKey,
AttestationType: device.AttestationType,
Flags: webauthn.CredentialFlags{
UserPresent: device.Present,
UserVerified: device.Verified,
BackupEligible: device.BackupEligible,
BackupState: device.BackupState,
},
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: device.SignCount,
CloneWarning: device.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(device.Attachment),
},
}
@ -128,9 +135,15 @@ func NewWebAuthnDeviceFromCredential(rpid, username, description string, credent
Description: description,
KID: NewBase64(credential.ID),
AttestationType: credential.AttestationType,
Attachment: string(credential.Authenticator.Attachment),
Transport: strings.Join(transport, ","),
SignCount: credential.Authenticator.SignCount,
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,
}
@ -153,9 +166,15 @@ type WebAuthnDevice struct {
KID Base64 `db:"kid"`
AAGUID uuid.NullUUID `db:"aaguid"`
AttestationType string `db:"attestation_type"`
Attachment string `db:"attachment"`
Transport string `db:"transport"`
SignCount uint32 `db:"sign_count"`
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"`
}
@ -171,7 +190,7 @@ func (d *WebAuthnDevice) UpdateSignInInfo(config *webauthn.Config, now time.Time
switch d.AttestationType {
case attestationTypeFIDOU2F:
d.RPID = config.RPOrigin
d.RPID = config.RPOrigins[0]
default:
d.RPID = config.RPID
}
@ -210,8 +229,13 @@ func (d *WebAuthnDevice) ToData() WebAuthnDeviceData {
KID: d.KID.String(),
AAGUID: d.DataValueAAGUID(),
AttestationType: d.AttestationType,
Attachment: d.Attachment,
SignCount: d.SignCount,
CloneWarning: d.CloneWarning,
Present: d.Present,
Verified: d.Verified,
BackupEligible: d.BackupEligible,
BackupState: d.BackupState,
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey),
}
@ -269,9 +293,15 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
d.Username = o.Username
d.Description = o.Description
d.AttestationType = o.AttestationType
d.Attachment = o.Attachment
d.Transport = strings.Join(o.Transports, ",")
d.SignCount = o.SignCount
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 {
d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt}
@ -291,9 +321,15 @@ type WebAuthnDeviceData struct {
KID string `json:"kid" yaml:"kid"`
AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"`
AttestationType string `json:"attestation_type" yaml:"attestation_type"`
Attachment string `json:"attachment" yaml:"attachment"`
Transports []string `json:"transports" yaml:"transports"`
SignCount uint32 `json:"sign_count" yaml:"sign_count"`
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"`
}
@ -304,9 +340,15 @@ func (d *WebAuthnDeviceData) ToDevice() (device *WebAuthnDevice, err error) {
Username: d.Username,
Description: d.Description,
AttestationType: d.AttestationType,
Attachment: d.Attachment,
Transport: strings.Join(d.Transports, ","),
SignCount: d.SignCount,
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 {

View File

@ -174,6 +174,11 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
WithPostMiddlewares(middlewares.Require1FA).
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.GET("/api/health", middlewareAPI(handlers.HealthGET))
@ -256,13 +261,15 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
}
if !config.WebAuthn.Disable {
// WebAuthn Endpoints.
r.POST("/api/secondfactor/webauthn/identity/start", middleware1FA(handlers.WebauthnIdentityStart))
r.POST("/api/secondfactor/webauthn/identity/finish", middleware1FA(handlers.WebauthnIdentityFinish))
r.POST("/api/secondfactor/webauthn/attestation", middleware1FA(handlers.WebAuthnAttestationPOST))
r.GET("/api/secondfactor/webauthn", middleware1FA(handlers.WebAuthnAssertionGET))
r.POST("/api/secondfactor/webauthn", middleware1FA(handlers.WebAuthnAssertionPOST))
r.GET("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionGET))
r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionPOST))
// Management of the webauthn devices.
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
"Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction",
"Cancel": "Cancel",
"Client ID": "Client ID: {{client_id}}",
"Close": "Close",
"Consent Request": "Consent Request",
"Contact your administrator to register a device": "Contact your administrator to register a device.",
"Could not obtain user settings": "Could not obtain user settings",
@ -50,8 +51,9 @@
"Reset password?": "Reset password?",
"Reset": "Reset",
"Scan QR Code": "Scan QR Code",
"Scope": "Scope {{name}}",
"Secret": "Secret",
"Security Key - WebAuthN": "Security Key - WebAuthN",
"Security Key - WebAuthn": "Security Key - WebAuthn",
"Select a Device": "Select a Device",
"Sign in": "Sign in",
"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",
"Scan QR Code": "Escanear Código QR",
"Secret": "Secreto",
"Security Key - WebAuthN": "Clave de seguridad - WebAuthN",
"Security Key - WebAuthn": "Clave de seguridad - WebAuthn",
"Select a Device": "Seleccionar Dispositivo",
"Sign in": "Iniciar Sesión",
"Sign out": "Cerrar Sesión",

View File

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

View File

@ -50,7 +50,7 @@
"Reset": "Réinitialiser",
"Scan QR Code": "Scannez le QR Code",
"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",
"Sign in": "Se connecter",
"Sign out": "Se déconnecter",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ type UserSession struct {
AuthenticationMethodRefs oidc.AuthenticationMethodsReferences
// 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
// while doing the query actually updating the password.
@ -45,7 +45,13 @@ type UserSession struct {
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 {
Username string
Email string

View File

@ -12,6 +12,7 @@ const (
tableUserOpaqueIdentifier = "user_opaque_identifier"
tableUserPreferences = "user_preferences"
tableWebAuthnDevices = "webauthn_devices"
tableWebAuthnUsers = "webauthn_users"
tableOAuth2BlacklistedJTI = "oauth2_blacklisted_jti"
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 (
// This is the latest schema version for the purpose of tests.
LatestVersion = 9
LatestVersion = 10
)
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)
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)
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)
DeleteWebAuthnDeviceByUsername(ctx context.Context, username, description string) (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)
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),
sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations),
sqlUpsertWebAuthnDevice: fmt.Sprintf(queryFmtUpsertWebAuthnDevice, tableWebAuthnDevices),
sqlInsertWebAuthnUser: fmt.Sprintf(queryFmtInsertWebAuthnUser, tableWebAuthnUsers),
sqlSelectWebAuthnUser: fmt.Sprintf(queryFmtSelectWebAuthnUser, tableWebAuthnUsers),
sqlInsertWebAuthnDevice: fmt.Sprintf(queryFmtInsertWebAuthnDevice, tableWebAuthnDevices),
sqlSelectWebAuthnDevices: fmt.Sprintf(queryFmtSelectWebAuthnDevices, tableWebAuthnDevices),
sqlSelectWebAuthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByUsername, tableWebAuthnDevices),
sqlSelectWebAuthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByRPIDByUsername, tableWebAuthnDevices),
sqlSelectWebAuthnDeviceByID: fmt.Sprintf(queryFmtSelectWebAuthnDeviceByID, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignIn, tableWebAuthnDevices),
sqlUpdateWebAuthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignInByUsername, tableWebAuthnDevices),
sqlDeleteWebAuthnDevice: fmt.Sprintf(queryFmtDeleteWebAuthnDevice, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsername, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices),
sqlDeleteWebAuthnDeviceByUsernameAndDisplayName: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
@ -164,17 +167,23 @@ type SQLProvider struct {
sqlUpdateTOTPConfigRecordSignIn string
sqlUpdateTOTPConfigRecordSignInByUsername string
// Table: webauthn_users.
sqlInsertWebAuthnUser string
sqlSelectWebAuthnUser string
// Table: webauthn_devices.
sqlUpsertWebAuthnDevice string
sqlInsertWebAuthnDevice string
sqlSelectWebAuthnDevices string
sqlSelectWebAuthnDevicesByUsername string
sqlSelectWebAuthnDevicesByRPIDByUsername string
sqlSelectWebAuthnDeviceByID string
sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID string
sqlUpdateWebAuthnDeviceRecordSignIn string
sqlUpdateWebAuthnDeviceRecordSignInByUsername string
sqlDeleteWebAuthnDevice string
sqlDeleteWebAuthnDeviceByUsername string
sqlDeleteWebAuthnDeviceByUsernameAndDescription string
sqlDeleteWebAuthnDeviceByUsernameAndDisplayName string
// Table: duo_devices.
sqlUpsertDuoDevice string
@ -365,7 +374,7 @@ func (p *SQLProvider) LoadUserOpaqueIdentifier(ctx context.Context, identifier u
case errors.Is(err, sql.ErrNoRows):
return nil, nil
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):
return nil, nil
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) {
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) {
return nil, ErrNoTOTPConfiguration
}
@ -881,28 +890,65 @@ func (p *SQLProvider) LoadTOTPConfigurations(ctx context.Context, limit, page in
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.
func (p *SQLProvider) SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error) {
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)
}
if _, err = p.db.ExecContext(ctx, p.sqlUpsertWebAuthnDevice,
device.CreatedAt, device.LastUsedAt,
device.RPID, device.Username, device.Description,
device.KID, device.PublicKey,
device.AttestationType, device.Transport, device.AAGUID, device.SignCount, device.CloneWarning,
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebAuthnDevice,
device.CreatedAt, device.LastUsedAt, device.RPID, device.Username, device.Description,
device.KID, device.AAGUID, device.AttestationType, device.Attachment, device.Transport,
device.SignCount, device.CloneWarning, device.Discoverable, device.Present, device.Verified,
device.BackupEligible, device.BackupState, device.PublicKey,
); 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
}
// 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) {
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn, rpid, lastUsedAt, signCount, cloneWarning, id); err != nil {
return fmt.Errorf("error updating WebAuthn signin metadata for id '%x': %w", id, err)
func (p *SQLProvider) UpdateWebAuthnDeviceSignIn(ctx context.Context, device model.WebAuthnDevice) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn,
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
@ -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.
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 {
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 {
return fmt.Errorf("error deleting WebAuthn devices for username '%s': %w", username, err)
}
} else {
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDescription, username, description); err != nil {
return fmt.Errorf("error deleting WebAuthn device with username '%s' and description '%s': %w", username, description, err)
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDisplayName, username, displayname); err != nil {
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
}
// LoadWebAuthnDevicesByUsername loads all WebAuthn devices registration for a given username.
func (p *SQLProvider) LoadWebAuthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebAuthnDevice, err error) {
if err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByUsername, username); err != nil {
// LoadWebAuthnDeviceByID loads a WebAuthn device registration for a given id.
func (p *SQLProvider) LoadWebAuthnDeviceByID(ctx context.Context, id int) (device *model.WebAuthnDevice, err error) {
device = &model.WebAuthnDevice{}
if err = p.db.GetContext(ctx, device, p.sqlSelectWebAuthnDeviceByID, id); err != nil {
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)

View File

@ -30,7 +30,6 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
// Specific alterations to this provider.
// 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.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations)
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.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.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.sqlUpdateWebAuthnDeviceRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignInByUsername)
provider.sqlDeleteWebAuthnDevice = provider.db.Rebind(provider.sqlDeleteWebAuthnDevice)
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.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)
}
query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebAuthnDevicePublicKey, tableWebAuthnDevices))
query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebAuthnDevicesEncryptedData, tableWebAuthnDevices))
for _, d := range devices {
if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil {

View File

@ -120,48 +120,41 @@ const (
const (
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
LIMIT ?
OFFSET ?;`
queryFmtSelectWebAuthnDevicesEncryptedData = `
SELECT id, public_key
FROM %s;`
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
WHERE username = ?;`
queryFmtUpdateWebAuthnDevicePublicKey = `
UPDATE %s
SET public_key = ?
queryFmtSelectWebAuthnDevicesByRPIDByUsername = `
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 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 = ?;`
queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID = `
UPDATE %s
SET description = ?
WHERE username = ? AND id = ?;`
queryFmtUpdateWebAuthnDeviceRecordSignIn = `
UPDATE %s
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
WHERE id = ?;`
queryFmtUpdateWebAuthnDeviceRecordSignInByUsername = `
UPDATE %s
SET
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;`
queryFmtInsertWebAuthnDevice = `
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
queryFmtDeleteWebAuthnDevice = `
DELETE FROM %s
@ -174,6 +167,26 @@ const (
queryFmtDeleteWebAuthnDeviceByUsernameAndDescription = `
DELETE FROM %s
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 (

View File

@ -49,8 +49,12 @@ func (s *BackendProtectionScenario) AssertRequestStatusCode(method, url string,
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/webauthn/assertion", AutheliaBaseURL), fasthttp.StatusForbidden)
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/attestation", 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", 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.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/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() {

View File

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

View File

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

View File

@ -12,9 +12,9 @@ import {
IndexRoute,
LogoutRoute,
RegisterOneTimePasswordRoute,
RegisterWebAuthnRoute,
ResetPasswordStep1Route,
ResetPasswordStep2Route,
SettingsRoute,
} from "@constants/Routes";
import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications";
@ -28,13 +28,13 @@ import {
getTheme,
} from "@utils/Configuration";
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
import RegisterWebAuthn from "@views/DeviceRegistration/RegisterWebAuthn";
import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
import LoginPortal from "@views/LoginPortal/LoginPortal";
import SignOut from "@views/LoginPortal/SignOut/SignOut";
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
import SettingsRouter from "@views/Settings/SettingsRouter";
import "@fortawesome/fontawesome-svg-core/styles.css";
@ -89,10 +89,10 @@ const App: React.FC<Props> = (props: Props) => {
<Routes>
<Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} />
<Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} />
<Route path={RegisterWebAuthnRoute} element={<RegisterWebAuthn />} />
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
<Route path={LogoutRoute} element={<SignOut />} />
<Route path={ConsentRoute} element={<ConsentView />} />
<Route path={`${SettingsRoute}/*`} element={<SettingsRouter />} />
<Route
path={`${IndexRoute}*`}
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 [passwordScore, setPasswordScore] = useState(0);
const [maxScores, setMaxScores] = useState(0);
const [feedback, setFeedback] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
const password = props.value;
@ -114,7 +114,7 @@ const PasswordMeter = function (props: Props) {
return (
<Box className={styles.progressContainer}>
<Box title={feedback} className={classnames(styles.progressBar)} />
<Box title={feedback === null ? "" : feedback} className={classnames(styles.progressBar)} />
</Box>
);
};

View File

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

View File

@ -6,10 +6,10 @@ import { useTranslation } from "react-i18next";
import { getPrivacyPolicyURL } from "@utils/Configuration";
const PrivacyPolicyLink = function (props: LinkProps) {
const hrefPrivacyPolicy = getPrivacyPolicyURL();
const { t: translate } = useTranslation();
const hrefPrivacyPolicy = getPrivacyPolicyURL();
return (
<Fragment>
<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 ConsentRoute: string = "/consent";
export const SecondFactorRoute: string = "/2fa/";
export const SecondFactorWebAuthnSubRoute: string = "webauthn";
export const SecondFactorTOTPSubRoute: string = "one-time-password";
export const SecondFactorPushSubRoute: string = "push-notification";
export const SecondFactorRoute: string = "/2fa";
export const SecondFactorWebAuthnSubRoute: string = "/webauthn";
export const SecondFactorTOTPSubRoute: string = "/one-time-password";
export const SecondFactorPushSubRoute: string = "/push-notification";
export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterWebAuthnRoute: string = "/webauthn/register";
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
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",
},
load: "all",
ns: ["portal"],
ns: ["portal", "settings"],
defaultNS: "portal",
fallbackLng: {
default: ["en"],

View File

@ -1,46 +1,78 @@
import React, { Fragment, ReactNode, useEffect } from "react";
import React, { ReactNode, useEffect } from "react";
import { Container, Divider, Grid, Link, Theme } from "@mui/material";
import { grey } from "@mui/material/colors";
import SettingsIcon from "@mui/icons-material/Settings";
import { AppBar, Box, Container, Grid, IconButton, Theme, Toolbar, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import Brand from "@components/Brand";
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import TypographyWithTooltip from "@components/TypographyWithTooltip";
import { getLogoOverride, getPrivacyPolicyEnabled } from "@utils/Configuration";
import { SettingsRoute } from "@constants/Routes";
import { getLogoOverride } from "@utils/Configuration";
export interface Props {
id?: string;
children?: ReactNode;
title?: string;
titleTooltip?: string;
subtitle?: string;
subtitleTooltip?: string;
title?: string | null;
titleTooltip?: string | null;
subtitle?: string | null;
subtitleTooltip?: string | null;
showBrand?: boolean;
showSettings?: boolean;
}
const url = "https://www.authelia.com";
const LoginLayout = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const navigate = useNavigate();
const styles = useStyles();
const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
) : (
<UserSvg className={styles.icon} />
);
const privacyEnabled = getPrivacyPolicyEnabled();
useEffect(() => {
document.title = `${translate("Login")} - Authelia`;
}, [translate]);
const handleSettingsClick = () => {
navigate({
pathname: SettingsRoute,
});
};
return (
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center">
<Box>
<AppBar position="static" color="transparent" elevation={0}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }} />
{props.showSettings ? (
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={handleSettingsClick}
>
<SettingsIcon />
</IconButton>
) : null}
</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}>
@ -48,7 +80,11 @@ const LoginLayout = function (props: Props) {
</Grid>
{props.title ? (
<Grid item xs={12}>
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} />
<TypographyWithTooltip
variant={"h5"}
value={props.title}
tooltip={props.titleTooltip !== null ? props.titleTooltip : undefined}
/>
</Grid>
) : null}
{props.subtitle ? (
@ -56,34 +92,19 @@ const LoginLayout = function (props: Props) {
<TypographyWithTooltip
variant={"h6"}
value={props.subtitle}
tooltip={props.subtitleTooltip}
tooltip={props.subtitleTooltip !== null ? props.subtitleTooltip : undefined}
/>
</Grid>
) : null}
<Grid item xs={12} className={styles.body}>
{props.children}
</Grid>
{props.showBrand ? (
<Grid item container xs={12} alignItems="center" justifyContent="center">
<Grid item xs={4}>
<Link href={url} target="_blank" underline="hover" className={styles.footerLinks}>
{translate("Powered by")} Authelia
</Link>
</Grid>
{privacyEnabled ? (
<Fragment>
<Divider orientation="vertical" flexItem variant="middle" />
<Grid item xs={4}>
<PrivacyPolicyLink className={styles.footerLinks} />
</Grid>
</Fragment>
) : null}
</Grid>
) : null}
{props.showBrand ? <Brand /> : null}
</Grid>
</Container>
<PrivacyPolicyDrawer />
</Grid>
</Box>
);
};
@ -110,8 +131,4 @@ const useStyles = makeStyles((theme: Theme) => ({
paddingTop: 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 {
options?: PublicKeyCredentialCreationOptions;
options?: PublicKeyCredentialCreationOptionsJSON;
status: number;
}
@ -7,15 +14,8 @@ export interface CredentialCreation {
publicKey: PublicKeyCredentialCreationOptionsJSON;
}
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<PublicKeyCredentialCreationOptions, "challenge" | "excludeCredentials" | "user"> {
challenge: string;
excludeCredentials?: PublicKeyCredentialDescriptorJSON[];
user: PublicKeyCredentialUserEntityJSON;
}
export interface PublicKeyCredentialRequestOptionsStatus {
options?: PublicKeyCredentialRequestOptions;
options?: PublicKeyCredentialRequestOptionsJSON;
status: number;
}
@ -23,63 +23,6 @@ export interface CredentialRequest {
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 {
Success = 1,
Failure,
@ -93,13 +36,8 @@ export enum AttestationResult {
FailureToken,
}
export interface AttestationPublicKeyCredentialResult {
credential?: AttestationPublicKeyCredential;
result: AttestationResult;
}
export interface AttestationPublicKeyCredentialResultJSON {
credential?: AttestationPublicKeyCredentialJSON;
export interface RegistrationResult {
response?: RegistrationResponseJSON;
result: AttestationResult;
}
@ -113,19 +51,97 @@ export enum AssertionResult {
FailureUnknownSecurity,
FailureWebAuthnNotSupported,
FailureChallenge,
FailureUnrecognized,
}
export interface DiscoverableAssertionResult {
result: AssertionResult;
username: string;
export function AssertionResultFailureString(result: AssertionResult) {
switch (result) {
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 {
credential?: PublicKeyCredential;
export function AttestationResultFailureString(result: AttestationResult) {
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;
}
export interface AssertionPublicKeyCredentialResultJSON {
credential?: PublicKeyCredentialJSON;
result: AssertionResult;
export interface WebAuthnDevice {
id: string;
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 CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish";
export const WebAuthnIdentityStartPath = basePath + "/api/secondfactor/webauthn/identity/start";
export const WebAuthnIdentityFinishPath = basePath + "/api/secondfactor/webauthn/identity/finish";
export const WebAuthnAttestationPath = basePath + "/api/secondfactor/webauthn/attestation";
export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register";
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 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 PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
export interface AuthenticationErrorResponse extends ErrorResponse {
authentication: boolean;
elevation: boolean;
}
export interface ErrorResponse {
status: "KO";
message: string;
}
export interface Response<T> {
status: "OK";
export interface Response<T> extends OKResponse {
data: T;
}
export interface OptionalDataResponse<T> {
status: "OK";
export interface OptionalDataResponse<T> extends OKResponse {
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 ServiceResponse<T> = Response<T> | ErrorResponse;
@ -78,3 +88,7 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
}
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) {
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
}
return toData<T>(res);
}
@ -32,3 +33,21 @@ export async function Get<T = undefined>(path: string): Promise<T> {
}
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";
export async function initiateTOTPRegistrationProcess() {
@ -13,7 +13,3 @@ interface CompleteTOTPRegistrationResponse {
export async function completeTOTPRegistrationProcess(processToken: string) {
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 {
AssertionPublicKeyCredentialResult,
AssertionResult,
AttestationPublicKeyCredential,
AttestationPublicKeyCredentialJSON,
AttestationPublicKeyCredentialResult,
AttestationResult,
AuthenticatorAttestationResponseFuture,
AuthenticationResult,
CredentialCreation,
CredentialRequest,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsStatus,
PublicKeyCredentialDescriptorJSON,
PublicKeyCredentialJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialRequestOptionsStatus,
RegistrationResult,
} from "@models/WebAuthn";
import {
AuthenticationOKResponse,
OptionalDataServiceResponse,
ServiceResponse,
WebAuthnAssertionPath,
WebAuthnAttestationPath,
WebAuthnIdentityFinishPath,
WebAuthnDevicePath,
WebAuthnRegistrationPath,
validateStatusAuthentication,
} from "@services/Api";
import { SignInResponse } from "@services/SignIn";
import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from "@utils/Base64";
export function isWebAuthnSecure(): boolean {
if (window.isSecureContext) {
@ -47,120 +48,6 @@ export async function isWebAuthnPlatformAuthenticatorAvailable(): Promise<boolea
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 {
// Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-make-cred
@ -174,6 +61,7 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
case "InvalidStateError":
// § 6.3.2 Step 3.
return AttestationResult.FailureExcluded;
case "AbortError":
case "NotAllowedError":
// § 6.3.2 Step 3 and Step 6.
return AttestationResult.FailureUserConsent;
@ -181,14 +69,14 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
// § 6.3.2 Step 4.
return AttestationResult.FailureUserVerificationOrResidentKey;
default:
console.error(`Unhandled DOMException occurred during WebAuthN attestation: ${exception}`);
console.error(`Unhandled DOMException occurred during WebAuthn attestation: ${exception}`);
return AttestationResult.FailureUnknown;
}
}
function getAssertionResultFromDOMException(
exception: DOMException,
requestOptions: PublicKeyCredentialRequestOptions,
options: PublicKeyCredentialRequestOptionsJSON,
): AssertionResult {
// Docs for this section:
// https://w3c.github.io/webauthn/#sctn-op-get-assertion
@ -196,28 +84,40 @@ function getAssertionResultFromDOMException(
case "UnknownError":
// § 6.3.3 Step 1 and Step 12.
return AssertionResult.FailureSyntax;
case "InvalidStateError":
// § 6.3.2 Step 3.
return AssertionResult.FailureUnrecognized;
case "AbortError":
case "NotAllowedError":
// § 6.3.3 Step 6 and Step 7.
return AssertionResult.FailureUserConsent;
case "SecurityError":
if (requestOptions.extensions?.appid !== undefined) {
if (options.extensions?.appid !== undefined) {
// § 10.1 and 10.2 Step 3.
return AssertionResult.FailureU2FFacetID;
} else {
return AssertionResult.FailureUnknownSecurity;
}
default:
console.error(`Unhandled DOMException occurred during WebAuthN assertion: ${exception}`);
console.error(`Unhandled DOMException occurred during WebAuthn assertion: ${exception}`);
return AssertionResult.FailureUnknown;
}
}
async function getAttestationCreationOptions(token: string): Promise<PublicKeyCredentialCreationOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
response = await axios.post<ServiceResponse<CredentialCreation>>(WebAuthnIdentityFinishPath, {
token: token,
});
export async function getAttestationCreationOptions(
description: string,
): Promise<PublicKeyCredentialCreationOptionsStatus> {
const response = await axios.put<ServiceResponse<CredentialCreation>>(
WebAuthnRegistrationPath,
{
description: description,
},
{
validateStatus: function (status) {
return status < 300 || status === 409;
},
},
);
if (response.data.status !== "OK" || response.data.data == null) {
return {
@ -226,12 +126,12 @@ async function getAttestationCreationOptions(token: string): Promise<PublicKeyCr
}
return {
options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey),
options: response.data.data.publicKey,
status: response.status,
};
}
export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> {
export async function getAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialRequest>>;
response = await axios.get<ServiceResponse<CredentialRequest>>(WebAuthnAssertionPath);
@ -243,67 +143,57 @@ export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialR
}
return {
options: decodePublicKeyCredentialRequestOptions(response.data.data.publicKey),
options: response.data.data.publicKey,
status: response.status,
};
}
async function getAttestationPublicKeyCredentialResult(
creationOptions: PublicKeyCredentialCreationOptions,
): Promise<AttestationPublicKeyCredentialResult> {
const result: AttestationPublicKeyCredentialResult = {
result: AttestationResult.Success,
export async function startWebAuthnRegistration(options: PublicKeyCredentialCreationOptionsJSON) {
const result: RegistrationResult = {
result: AttestationResult.Failure,
};
try {
result.credential = (await navigator.credentials.create({
publicKey: creationOptions,
})) as AttestationPublicKeyCredential;
result.response = await startRegistration(options);
} catch (e) {
result.result = AttestationResult.Failure;
const exception = e as DOMException;
if (exception !== undefined) {
result.result = getAttestationResultFromDOMException(exception);
console.error(exception);
return result;
} else {
console.error(`Unhandled exception occurred during WebAuthN attestation: ${e}`);
console.error(`Unhandled exception occurred during WebAuthn attestation: ${e}`);
}
}
if (result.credential == null) {
result.result = AttestationResult.Failure;
} else {
if (result.response != null) {
result.result = AttestationResult.Success;
}
return result;
}
export async function getAssertionPublicKeyCredentialResult(
requestOptions: PublicKeyCredentialRequestOptions,
): Promise<AssertionPublicKeyCredentialResult> {
const result: AssertionPublicKeyCredentialResult = {
export async function getAuthenticationResult(options: PublicKeyCredentialRequestOptionsJSON) {
const result: AuthenticationResult = {
result: AssertionResult.Success,
};
try {
result.credential = (await navigator.credentials.get({ publicKey: requestOptions })) as PublicKeyCredential;
result.response = await startAuthentication(options);
} catch (e) {
result.result = AssertionResult.Failure;
const exception = e as DOMException;
if (exception !== undefined) {
result.result = getAssertionResultFromDOMException(exception, requestOptions);
result.result = getAssertionResultFromDOMException(exception, options);
console.error(exception);
return result;
} 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;
} else {
result.result = AssertionResult.Success;
@ -312,82 +202,62 @@ export async function getAssertionPublicKeyCredentialResult(
return result;
}
async function postAttestationPublicKeyCredentialResult(
credential: AttestationPublicKeyCredential,
async function postRegistrationResponse(
response: RegistrationResponseJSON,
): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> {
const credentialJSON = encodeAttestationPublicKeyCredential(credential);
return axios.post<OptionalDataServiceResponse<any>>(WebAuthnAttestationPath, credentialJSON);
return axios.post<OptionalDataServiceResponse<any>>(WebAuthnRegistrationPath, response);
}
export async function postAssertionPublicKeyCredentialResult(
credential: PublicKeyCredential,
export async function postAuthenticationResponse(
response: AuthenticationResponseJSON,
targetURL: string | undefined,
workflow?: string,
workflowID?: string,
): Promise<AxiosResponse<ServiceResponse<SignInResponse>>> {
const credentialJSON = encodeAssertionPublicKeyCredential(credential, targetURL, workflow, workflowID);
return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, credentialJSON);
) {
return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, {
response: response,
targetURL: targetURL,
workflow: workflow,
workflowID: workflowID,
});
}
export async function performAttestationCeremony(token: string): Promise<AttestationResult> {
const attestationCreationOpts = await getAttestationCreationOptions(token);
export async function finishRegistration(response: RegistrationResponseJSON) {
let result = {
status: AttestationResult.Failure,
message: "Device registration failed.",
};
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) {
if (attestationCreationOpts.status === 403) {
return AttestationResult.FailureToken;
try {
const resp = await postRegistrationResponse(response);
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);
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;
return result;
}
export async function performAssertionCeremony(
targetURL?: string,
workflow?: string,
workflowID?: string,
): Promise<AssertionResult> {
const assertionRequestOpts = await getAssertionRequestOptions();
if (assertionRequestOpts.status !== 200 || assertionRequestOpts.options == null) {
return AssertionResult.FailureChallenge;
}
const assertionResult = await getAssertionPublicKeyCredentialResult(assertionRequestOpts.options);
if (assertionResult.result !== AssertionResult.Success) {
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;
export async function deleteUserWebAuthnDevice(deviceID: string) {
return await axios<AuthenticationOKResponse>({
method: "DELETE",
url: `${WebAuthnDevicePath}/${deviceID}`,
validateStatus: validateStatusAuthentication,
});
}
export async function updateUserWebAuthnDevice(deviceID: string, description: string) {
return await axios<AuthenticationOKResponse>({
method: "PUT",
url: `${WebAuthnDevicePath}/${deviceID}`,
data: { description: description },
validateStatus: validateStatusAuthentication,
});
}

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";
const RegisterOneTimePassword = function () {
const { t: translate } = useTranslation();
const styles = useStyles();
const navigate = useNavigate();
const { createSuccessNotification, createErrorNotification } = useNotifications();
// The secret retrieved from the API is all is ok.
const [secretURL, setSecretURL] = useState("empty");
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
const { createSuccessNotification, createErrorNotification } = useNotifications();
const [hasErrored, setHasErrored] = 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
// 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 { t: translate } = useTranslation();
return <BaseLoadingPage message={translate("Loading")} />;
};

View File

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

View File

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

View File

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

View File

@ -30,23 +30,27 @@ export interface Props {
}
const FirstFactorForm = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const navigate = useNavigate();
const redirectionURL = useQueryParam(RedirectionURL);
const requestMethod = useQueryParam(RequestMethod);
const [workflow] = useWorkflow();
const { createErrorNotification } = useNotifications();
const loginChannel = useMemo(() => new BroadcastChannel<boolean>("login"), []);
const [rememberMe, setRememberMe] = useState(false);
const [username, setUsername] = useState("");
const [usernameError, setUsernameError] = useState(false);
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState(false);
const { createErrorNotification } = useNotifications();
// TODO (PR: #806, Issue: #511) potentially refactor
const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
const { t: translate } = useTranslation();
const styles = useStyles();
useEffect(() => {
const timeout = setTimeout(() => usernameRef.current.focus(), 10);
@ -122,7 +126,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setUsernameError(false)}
autoCapitalize="none"
autoComplete="username"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
setUsernameError(true);
@ -152,7 +156,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setPasswordError(false)}
type="password"
autoComplete="current-password"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
@ -174,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
disabled={disabled}
checked={rememberMe}
onChange={handleRememberMeChange}
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
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 {
AuthenticatedRoute,
@ -15,6 +15,7 @@ import { useConfiguration } from "@hooks/Configuration";
import { useNotifications } from "@hooks/NotificationsContext";
import { useQueryParam } from "@hooks/QueryParam";
import { useRedirector } from "@hooks/Redirector";
import { useRouterNavigate } from "@hooks/RouterNavigate";
import { useAutheliaState } from "@hooks/State";
import { useUserInfoPOST } from "@hooks/UserInfo";
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.";
const LoginPortal = function (props: Props) {
const navigate = useNavigate();
const location = useLocation();
const redirectionURL = useQueryParam(RedirectionURL);
const { createErrorNotification } = useNotifications();
@ -48,24 +48,8 @@ const LoginPortal = function (props: Props) {
const [state, fetchState, , fetchStateError] = useAutheliaState();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration();
const [searchParams] = useSearchParams();
const redirect = 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],
);
const navigate = useRouterNavigate();
// Fetch the state when portal is mounted.
useEffect(() => {
@ -138,17 +122,17 @@ const LoginPortal = function (props: Props) {
if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(false);
redirect(IndexRoute);
navigate(IndexRoute);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) {
if (configuration.available_methods.size === 0) {
redirect(AuthenticatedRoute, false);
navigate(AuthenticatedRoute, false);
} else {
if (userInfo.method === SecondFactorMethod.WebAuthn) {
redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
navigate(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
navigate(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
} else {
redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
navigate(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
}
}
}
@ -156,7 +140,7 @@ const LoginPortal = function (props: Props) {
}, [
state,
redirectionURL,
redirect,
navigate,
userInfo,
setFirstFactorDisabled,
configuration,
@ -205,7 +189,7 @@ const LoginPortal = function (props: Props) {
}
/>
<Route
path={`${SecondFactorRoute}*`}
path={`${SecondFactorRoute}/*`}
element={
state && userInfo && configuration ? (
<SecondFactorForm
@ -245,7 +229,3 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) {
</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 { 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 PushNotificationIcon from "@components/PushNotificationIcon";
@ -127,12 +127,12 @@ function DeviceItem(props: DeviceItemProps) {
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<Box className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
</Box>
<Box>
<Typography>{props.device.name}</Typography>
</div>
</Box>
</Button>
</Grid>
);
@ -172,12 +172,12 @@ function MethodItem(props: MethodItemProps) {
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<Box className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
</Box>
<Box>
<Typography>{props.method}</Typography>
</div>
</Box>
</Button>
</Grid>
);

View File

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

View File

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

View File

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

View File

@ -6,9 +6,12 @@ import { useTranslation } from "react-i18next";
import { Route, Routes, useNavigate } from "react-router-dom";
import {
RegisterOneTimePasswordRoute,
SecondFactorPushSubRoute,
SecondFactorTOTPSubRoute,
SecondFactorWebAuthnSubRoute,
SettingsRoute,
SettingsTwoFactorAuthenticationSubRoute,
LogoutRoute as SignOutRoute,
} from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext";
@ -16,7 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
import { Configuration } from "@models/Configuration";
import { SecondFactorMethod } from "@models/Methods";
import { UserInfo } from "@models/UserInfo";
import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice";
import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice";
import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo";
import { isWebAuthnSupported } from "@services/WebAuthn";
@ -48,8 +51,11 @@ const SecondFactorForm = function (props: Props) {
setStateWebAuthnSupported(isWebAuthnSupported());
}, [setStateWebAuthnSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
return async () => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
navigate(redirectRoute);
} else {
if (registrationInProgress) {
return;
}
@ -62,6 +68,7 @@ const SecondFactorForm = function (props: Props) {
createErrorNotification(translate("There was a problem initiating the registration process"));
}
setRegistrationInProgress(false);
}
};
};
@ -85,7 +92,12 @@ const SecondFactorForm = function (props: Props) {
};
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 ? (
<MethodSelectionDialog
open={methodSelectionOpen}
@ -117,7 +129,10 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
onRegisterClick={initiateRegistration(
initiateTOTPRegistrationProcess,
RegisterOneTimePasswordRoute,
)}
onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess}
/>
@ -131,7 +146,9 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel}
// Whether the user has a WebAuthn device registered already
registered={props.userInfo.has_webauthn}
onRegisterClick={initiateRegistration(initiateWebAuthnRegistrationProcess)}
onRegisterClick={() => {
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
}}
onSignInError={(err) => createErrorNotification(err.message)}
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 makeStyles from "@mui/styles/makeStyles";
import FailureIcon from "@components/FailureIcon";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import WebAuthnTryIcon from "@components/WebAuthnTryIcon";
import { RedirectionURL } from "@constants/SearchParams";
import { useIsMountedRef } from "@hooks/Mounted";
import { useQueryParam } from "@hooks/QueryParam";
import { useTimer } from "@hooks/Timer";
import { useWorkflow } from "@hooks/Workflow";
import { AssertionResult } from "@models/WebAuthn";
import { AssertionResult, AssertionResultFailureString, WebAuthnTouchState } from "@models/WebAuthn";
import { AuthenticationLevel } from "@services/State";
import {
getAssertionPublicKeyCredentialResult,
getAssertionRequestOptions,
postAssertionPublicKeyCredentialResult,
} from "@services/WebAuthn";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
import { getAuthenticationOptions, getAuthenticationResult, postAuthenticationResponse } from "@services/WebAuthn";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State {
WaitTouch = 1,
InProgress = 2,
Failure = 3,
}
export interface Props {
id: string;
authenticationLevel: AuthenticationLevel;
@ -38,13 +21,10 @@ export interface Props {
}
const WebAuthnMethod = function (props: Props) {
const signInTimeout = 30;
const [state, setState] = useState(State.WaitTouch);
const styles = useStyles();
const [state, setState] = useState(WebAuthnTouchState.WaitTouch);
const redirectionURL = useQueryParam(RedirectionURL);
const [workflow, workflowID] = useWorkflow();
const mounted = useIsMountedRef();
const [timerPercent, triggerTimer] = useTimer(signInTimeout * 1000 - 500);
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current;
@ -57,70 +37,40 @@ const WebAuthnMethod = function (props: Props) {
}
try {
triggerTimer();
setState(State.WaitTouch);
const assertionRequestResponse = await getAssertionRequestOptions();
setState(WebAuthnTouchState.WaitTouch);
const optionsStatus = await getAuthenticationOptions();
if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) {
setState(State.Failure);
if (optionsStatus.status !== 200 || optionsStatus.options == null) {
setState(WebAuthnTouchState.Failure);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
return;
}
const result = await getAssertionPublicKeyCredentialResult(assertionRequestResponse.options);
const result = await getAuthenticationResult(optionsStatus.options);
if (result.result !== AssertionResult.Success) {
if (!mounted.current) return;
switch (result.result) {
case AssertionResult.FailureUserConsent:
onSignInErrorCallback(new Error("You cancelled the assertion request."));
break;
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);
setState(WebAuthnTouchState.Failure);
onSignInErrorCallback(new Error(AssertionResultFailureString(result.result)));
return;
}
if (result.credential == null) {
if (result.response == null) {
onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data."));
setState(State.Failure);
setState(WebAuthnTouchState.Failure);
return;
}
if (!mounted.current) return;
setState(State.InProgress);
setState(WebAuthnTouchState.InProgress);
const response = await postAssertionPublicKeyCredentialResult(
result.credential,
redirectionURL,
workflow,
workflowID,
);
const response = await postAuthenticationResponse(result.response, redirectionURL, workflow, workflowID);
if (response.data.status === "OK" && response.status === 200) {
onSignInSuccessCallback(response.data.data ? response.data.data.redirect : undefined);
@ -130,14 +80,14 @@ const WebAuthnMethod = function (props: Props) {
if (!mounted.current) return;
onSignInErrorCallback(new Error("The server rejected the security key."));
setState(State.Failure);
setState(WebAuthnTouchState.Failure);
} catch (err) {
// 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.
if (!mounted.current) return;
console.error(err);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
setState(State.Failure);
setState(WebAuthnTouchState.Failure);
}
}, [
onSignInErrorCallback,
@ -146,7 +96,6 @@ const WebAuthnMethod = function (props: Props) {
workflow,
workflowID,
mounted,
triggerTimer,
props.authenticationLevel,
props.registered,
]);
@ -172,59 +121,9 @@ const WebAuthnMethod = function (props: Props) {
state={methodState}
onRegisterClick={props.onRegisterClick}
>
<div className={styles.icon}>
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
</div>
<WebAuthnTryIcon onRetryClick={doInitiateSignIn} webauthnTouchState={state} />
</MethodContainer>
);
};
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