From 8f05846e214df843ad8b996525b65ebef02a5686 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Thu, 3 Mar 2022 22:20:43 +1100 Subject: [PATCH] feat: webauthn (#2707) This implements Webauthn. Old devices can be used to authenticate via the appid compatibility layer which should be automatic. New devices will be registered via Webauthn, and devices which do not support FIDO2 will no longer be able to be registered. At this time it does not fully support multiple devices (backend does, frontend doesn't allow registration of additional devices). Does not support passwordless. --- README.md | 12 +- api/openapi.yml | 406 +++++++++++++----- config.template.yml | 27 +- docs/configuration/one-time-password.md | 13 + docs/configuration/storage/migrations.md | 9 +- docs/configuration/webauthn.md | 109 +++++ docs/features/2fa/index.md | 9 +- docs/features/2fa/security-key.md | 59 +-- docs/index.md | 9 +- docs/security/measures.md | 8 +- go.mod | 9 +- go.sum | 43 +- internal/authentication/const.go | 6 +- internal/configuration/config.template.yml | 27 +- internal/configuration/decode_hooks.go | 7 +- internal/configuration/provider_test.go | 86 ++-- .../configuration/schema/configuration.go | 3 +- internal/configuration/schema/totp.go | 1 + internal/configuration/schema/webauthn.go | 27 ++ .../configuration/validator/configuration.go | 2 + internal/configuration/validator/const.go | 19 + internal/configuration/validator/ntp.go | 2 +- internal/configuration/validator/ntp_test.go | 25 +- .../configuration/validator/regulation.go | 4 +- .../validator/regulation_test.go | 8 +- .../configuration/validator/session_test.go | 32 +- internal/configuration/validator/totp.go | 6 - internal/configuration/validator/totp_test.go | 8 +- internal/configuration/validator/webauthn.go | 34 ++ .../configuration/validator/webauthn_test.go | 98 +++++ internal/handlers/const.go | 4 +- internal/handlers/errors.go | 13 - internal/handlers/handler_configuration.go | 25 +- .../handlers/handler_configuration_test.go | 233 ++++++---- .../handlers/handler_register_u2f_step1.go | 70 --- .../handler_register_u2f_step1_test.go | 73 ---- .../handlers/handler_register_u2f_step2.go | 63 --- .../handlers/handler_register_webauthn.go | 158 +++++++ internal/handlers/handler_sign_totp.go | 10 + internal/handlers/handler_sign_totp_test.go | 64 ++- internal/handlers/handler_sign_u2f_step1.go | 93 ---- .../handlers/handler_sign_u2f_step1_test.go | 43 -- internal/handlers/handler_sign_u2f_step2.go | 82 ---- .../handlers/handler_sign_u2f_step2_test.go | 196 --------- internal/handlers/handler_sign_webauthn.go | 204 +++++++++ internal/handlers/handler_user_info_test.go | 32 +- internal/handlers/types.go | 12 +- internal/handlers/u2f.go | 32 -- internal/handlers/webauthn.go | 63 +++ internal/handlers/webauthn_test.go | 167 +++++++ internal/logging/logger_test.go | 32 ++ internal/mocks/generate.go | 1 - internal/mocks/storage.go | 100 +++-- internal/mocks/u2f_verifier.go | 49 --- internal/models/const.go | 8 + internal/models/totp_configuration.go | 22 +- internal/models/types.go | 63 ++- internal/models/types_test.go | 109 +++++ internal/models/user_info.go | 4 +- internal/models/webauthn.go | 167 +++++++ internal/regulation/const.go | 7 +- internal/server/server.go | 45 +- internal/session/types.go | 16 +- internal/storage/const.go | 7 +- internal/storage/errors.go | 4 +- .../V0001.Initial_Schema.mysql.up.sql | 2 +- .../migrations/V0002.Webauthn.mysql.down.sql | 37 ++ .../migrations/V0002.Webauthn.mysql.up.sql | 47 ++ .../V0002.Webauthn.postgres.down.sql | 37 ++ .../migrations/V0002.Webauthn.postgres.up.sql | 47 ++ .../migrations/V0002.Webauthn.sqlite.down.sql | 37 ++ .../migrations/V0002.Webauthn.sqlite.up.sql | 47 ++ internal/storage/provider.go | 8 +- internal/storage/sql_provider.go | 244 +++++------ .../storage/sql_provider_backend_postgres.go | 14 +- .../storage/sql_provider_backend_sqlite.go | 30 +- internal/storage/sql_provider_encryption.go | 12 +- internal/storage/sql_provider_queries.go | 74 ++-- internal/storage/sql_provider_schema_pre1.go | 16 +- internal/suites/const.go | 2 +- .../scenario_backend_protection_test.go | 9 +- internal/suites/suite_cli_test.go | 27 +- internal/suites/suite_duo_push_test.go | 1 + internal/suites/suite_standalone_test.go | 2 +- internal/totp/helpers_test.go | 15 + internal/totp/provider.go | 2 +- internal/totp/totp.go | 5 +- internal/totp/totp_test.go | 89 ++++ web/package.json | 3 +- web/pnpm-lock.yaml | 6 - web/src/App.tsx | 16 +- web/src/constants/Routes.ts | 6 +- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/es.json | 2 +- web/src/models/Configuration.ts | 1 - web/src/models/Methods.ts | 4 +- web/src/models/UserInfo.ts | 2 +- web/src/models/Webauthn.ts | 129 ++++++ web/src/services/Api.ts | 15 +- web/src/services/Configuration.ts | 1 - web/src/services/OneTimePassword.ts | 4 +- web/src/services/PushNotification.ts | 4 +- web/src/services/RegisterDevice.ts | 32 +- web/src/services/SecurityKey.ts | 32 -- web/src/services/UserInfo.ts | 14 +- web/src/services/Webauthn.ts | 378 ++++++++++++++++ web/src/utils/Base64.ts | 209 +++++++++ .../RegisterOneTimePassword.tsx | 4 +- .../RegisterSecurityKey.tsx | 91 ---- .../DeviceRegistration/RegisterWebauthn.tsx | 111 +++++ .../LoginPortal/ConsentView/ConsentView.tsx | 4 +- web/src/views/LoginPortal/LoginPortal.tsx | 18 +- .../SecondFactor/MethodSelectionDialog.tsx | 10 +- .../SecondFactor/SecondFactorForm.tsx | 36 +- ...curityKeyMethod.tsx => WebauthnMethod.tsx} | 98 ++++- web/src/views/LoginPortal/SignOut/SignOut.tsx | 4 +- .../ResetPassword/ResetPasswordStep1.tsx | 4 +- .../ResetPassword/ResetPasswordStep2.tsx | 6 +- 118 files changed, 3780 insertions(+), 1649 deletions(-) create mode 100644 docs/configuration/webauthn.md create mode 100644 internal/configuration/schema/webauthn.go create mode 100644 internal/configuration/validator/webauthn.go create mode 100644 internal/configuration/validator/webauthn_test.go delete mode 100644 internal/handlers/errors.go delete mode 100644 internal/handlers/handler_register_u2f_step1.go delete mode 100644 internal/handlers/handler_register_u2f_step1_test.go delete mode 100644 internal/handlers/handler_register_u2f_step2.go create mode 100644 internal/handlers/handler_register_webauthn.go delete mode 100644 internal/handlers/handler_sign_u2f_step1.go delete mode 100644 internal/handlers/handler_sign_u2f_step1_test.go delete mode 100644 internal/handlers/handler_sign_u2f_step2.go delete mode 100644 internal/handlers/handler_sign_u2f_step2_test.go create mode 100644 internal/handlers/handler_sign_webauthn.go delete mode 100644 internal/handlers/u2f.go create mode 100644 internal/handlers/webauthn.go create mode 100644 internal/handlers/webauthn_test.go delete mode 100644 internal/mocks/u2f_verifier.go create mode 100644 internal/models/const.go create mode 100644 internal/models/types_test.go create mode 100644 internal/models/webauthn.go create mode 100644 internal/storage/migrations/V0002.Webauthn.mysql.down.sql create mode 100644 internal/storage/migrations/V0002.Webauthn.mysql.up.sql create mode 100644 internal/storage/migrations/V0002.Webauthn.postgres.down.sql create mode 100644 internal/storage/migrations/V0002.Webauthn.postgres.up.sql create mode 100644 internal/storage/migrations/V0002.Webauthn.sqlite.down.sql create mode 100644 internal/storage/migrations/V0002.Webauthn.sqlite.up.sql create mode 100644 internal/totp/helpers_test.go create mode 100644 internal/totp/totp_test.go create mode 100644 web/src/models/Webauthn.ts delete mode 100644 web/src/services/SecurityKey.ts create mode 100644 web/src/services/Webauthn.ts create mode 100644 web/src/utils/Base64.ts delete mode 100644 web/src/views/DeviceRegistration/RegisterSecurityKey.tsx create mode 100644 web/src/views/DeviceRegistration/RegisterWebauthn.tsx rename web/src/views/LoginPortal/SecondFactor/{SecurityKeyMethod.tsx => WebauthnMethod.tsx} (56%) diff --git a/README.md b/README.md index 68f42504..22be6932 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Here is what Authelia's portal looks like: This is a list of the key features of Authelia: * Several second factor methods: - * **[Security Key (U2F)](https://www.authelia.com/docs/features/2fa/security-key)** with [Yubikey]. - * **[Time-based One-Time password](https://www.authelia.com/docs/features/2fa/one-time-password)** - with [Google Authenticator]. + * **[Security Keys](https://www.authelia.com/docs/features/2fa/security-key)** that support [FIDO2] [Webauthn] with devices like a [YubiKey]. + * **[Time-based One-Time password](https://www.authelia.com/docs/features/2fa/one-time-password)** + with compatible authenticator applications. * **[Mobile Push Notifications](https://www.authelia.com/docs/features/2fa/push-notifications)** with [Duo](https://duo.com/). * Password reset with identity verification using email confirmation. @@ -345,10 +345,10 @@ for providing us with free licenses to their great tools. [Apache 2.0]: https://www.apache.org/licenses/LICENSE-2.0 [TOTP]: https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm -[Security Key]: https://www.yubico.com/about/background/fido/ -[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/ +[FIDO2]: https://www.yubico.com/authentication-standards/fido2/ +[YubiKey]: https://www.yubico.com/products/yubikey-5-overview/ +[Webauthn]: https://www.yubico.com/authentication-standards/webauthn/ [auth_request]: https://nginx.org/en/docs/http/ngx_http_auth_request_module.html -[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en [config.template.yml]: ./config.template.yml [nginx]: https://www.nginx.com/ [Traefik]: https://traefik.io/ diff --git a/api/openapi.yml b/api/openapi.yml index 239d19b2..1ee42c61 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -23,7 +23,7 @@ tags: - name: User Information description: User configuration endpoints - name: Second Factor - description: TOTP, U2F and Duo endpoints + description: TOTP, Webauthn and Duo endpoints paths: /api/configuration: get: @@ -430,35 +430,34 @@ paths: $ref: '#/components/schemas/middlewares.ErrorResponse' security: - authelia_auth: [] - /api/secondfactor/u2f/sign_request: - post: + /api/secondfactor/webauthn/assertion: + get: tags: - Second Factor - summary: Second Factor Authentication - U2F (Request) - description: This endpoint starts the second factor authentication process with the U2F key. + summary: Second Factor Authentication - Webauthn (Request) + description: This endpoint starts the second factor authentication process with the FIDO2 Webauthn credential. responses: "200": description: Successful Operation content: application/json: schema: - $ref: '#/components/schemas/u2f.WebSignRequest' + $ref: '#/components/schemas/webauthn.PublicKeyCredentialRequestOptions' "401": description: Unauthorized security: - authelia_auth: [] - /api/secondfactor/u2f/sign: post: tags: - Second Factor - summary: Second Factor Authentication - U2F - description: "This endpoint completes second factor authentication with a U2F key." + summary: Second Factor Authentication - Webauthn + description: This endpoint completes the second factor authentication process with the FIDO2 Webauthn credential. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/handlers.signU2FRequestBody" + $ref: "#/components/schemas/webauthn.CredentialAssertionResponse" responses: "200": description: Successful Operation @@ -470,16 +469,17 @@ paths: description: Unauthorized security: - authelia_auth: [] - /api/secondfactor/u2f/identity/start: + /api/secondfactor/webauthn/identity/start: post: tags: - Second Factor - summary: Identity Verification U2F Token Creation + summary: Identity Verification Webauthn Credential Creation description: > - This endpoint performs identity verification to begin the U2F device registration process. + This endpoint performs identity verification to begin the FIDO2 Webauthn credential attestation process + (registration). The session generated from this endpoint must be utilised for the subsequent steps in the - `/api/secondfactor/u2f/identity/finish` and `/api/secondfactor/u2f/register` endpoints. + `/api/secondfactor/webauthn/identity/finish` and `/api/secondfactor/webauthn/attestation` endpoints. responses: "200": description: Successful Operation @@ -489,17 +489,17 @@ paths: $ref: '#/components/schemas/middlewares.OkResponse' security: - authelia_auth: [] - /api/secondfactor/u2f/identity/finish: + /api/secondfactor/webauthn/identity/finish: post: tags: - Second Factor - summary: Identity Verification U2F Token Validation + summary: Identity Verification FIDO2 Webauthn Credential Validation description: > - This endpoint performs identity and token verification, upon success generates a U2F device registration - challenge. + This endpoint performs identity and token verification, upon success generates a FIDO2 Webauthn device + attestation challenge (registration). - The session cookie generated from the `/api/secondfactor/u2f/identity/start` endpoint must be utilised for the - subsequent steps here and in the `/api/secondfactor/u2f/register` endpoint. + The session cookie generated from the `/api/secondfactor/webauthn/identity/start` endpoint must be utilised + for the subsequent steps here and in the `/api/secondfactor/webauthn/attestation` endpoint. requestBody: required: true content: @@ -512,21 +512,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/u2f.WebRegisterRequest' + $ref: '#/components/schemas/webauthn.PublicKeyCredentialCreationOptions' security: - authelia_auth: [] - /api/secondfactor/u2f/register: + /api/secondfactor/webauthn/attestation: post: tags: - Second Factor - summary: U2F Device Registration - description: This endpoint performs U2F device registration. + summary: Webauthn Credential Attestation + description: This endpoint performs Webauthn credential attestation (registration). requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/u2f.RegisterResponse' + $ref: '#/components/schemas/webauthn.CredentialAttestationResponse' responses: "200": description: Successful Operation @@ -653,12 +653,13 @@ components: properties: available_methods: type: array + description: List of available 2FA methods. If no methods exist 2FA is disabled. items: - type: string - example: [totp, u2f, mobile_push] - second_factor_enabled: - type: boolean - description: If second factor is enabled. + enum: + - "totp" + - "webauthn" + - "mobile_push" + example: [totp, webauthn, mobile_push] handlers.DuoDeviceBody: required: - device @@ -781,24 +782,6 @@ components: targetURL: type: string example: https://secure.example.com - handlers.signU2FRequestBody: - type: object - properties: - targetURL: - type: string - example: https://secure.example.com - signResponse: - type: object - properties: - clientData: - type: string - example: 6prxyWqSsR6MXFchtQRzwZVTedWq7Zdc6XreLt6xRDXKeqJN7vzKAfYcKwRD3AT57bP4YFL4hbxat4LUysBNss - keyHandle: - type: string - example: pWgBrwr9meS5vArdffPtD4Px6AqZS7MfGEf776Rz438ujwHjeXwQEZuK53sRQ4wjeAgRCW4wX9VRj8dyKjc273 - signatureData: - type: string - example: p3Pe26B6T2E7EEEc59P4p869qwxy8cQAU2ttyGtGrQHb4XL2ZxCpWrawsSHNSTRZQd7jEW59Y3Ku9vSNRzj7Ly handlers.StateResponse: type: object properties: @@ -846,9 +829,12 @@ components: example: John Doe method: type: string - enum: [totp, u2f, mobile_push] + enum: + - "totp" + - "webauthn" + - "mobile_push" example: totp - has_u2f: + has_webauthn: type: boolean example: false has_totp: @@ -883,7 +869,10 @@ components: properties: method: type: string - enum: [totp, u2f, mobile_push] + enum: + - "totp" + - "webauthn" + - "mobile_push" example: totp middlewares.ErrorResponse: type: object @@ -910,16 +899,70 @@ components: example: OK data: type: object - u2f.RegisterResponse: + webauthn.PublicKeyCredential: type: object properties: - version: + rawId: type: string - registrationData: + format: byte + id: type: string - clientData: + type: type: string - u2f.WebRegisterRequest: + webauthn.AuthenticatorResponse: + type: object + properties: + clientDataJSON: + type: string + format: byte + webauthn.CredentialAttestationResponse: + allOf: + - $ref: '#/components/schemas/webauthn.PublicKeyCredential' + - type: object + properties: + clientExtensionResults: + type: object + properties: + appidExclude: + type: boolean + response: + allOf: + - $ref: '#/components/schemas/webauthn.AuthenticatorResponse' + - type: object + properties: + attestationObject: + type: string + format: byte + webauthn.CredentialAssertionResponse: + allOf: + - $ref: '#/components/schemas/webauthn.PublicKeyCredential' + - type: object + properties: + response: + allOf: + - $ref: '#/components/schemas/webauthn.AuthenticatorResponse' + - type: object + required: [authenticatorData, clientDataJSON, signature] + properties: + authenticatorData: + type: string + format: byte + clientDataJSON: + type: string + format: byte + clientExtensionResults: + type: object + properties: + appid: + type: boolean + example: false + signature: + type: string + format: byte + userHandle: + type: string + format: byte + webauthn.PublicKeyCredentialCreationOptions: type: object properties: status: @@ -928,35 +971,50 @@ components: data: type: object properties: - appId: - type: string - example: https://auth.example.com - registerRequests: - type: array - items: - type: object - properties: - version: - type: string - example: U2F_V2 - challenge: - type: string - example: XGYKUzSmTpM1KxxpekArviW0w0OU2pwwRAocgn8TkVQ - registeredKeys: - type: array - items: - type: object - properties: - appId: - type: string - example: https://auth.example.com - version: - type: string - example: U2F_V2 - keyHandle: - type: string - example: pWgBrwr9meS5vArdffPtD4Px6AqZS7MfGEf776Rz438ujwHjeXwQEZuK53sRQ4wjeAgRCW4wX9VRj8dyKjc273 - u2f.WebSignRequest: + publicKey: + allOf: + - $ref: '#/components/schemas/webauthn.AttestationType' + - $ref: '#/components/schemas/webauthn.AuthenticatorSelectionCriteria' + - $ref: '#/components/schemas/webauthn.CredentialUserEntity' + - $ref: '#/components/schemas/webauthn.CredentialRPEntity' + - type: object + required: + - "challenge" + - "pubKeyCredParams" + properties: + challenge: + type: string + format: byte + pubKeyCredParams: + type: array + items: + type: object + required: + - "alg" + - "type" + properties: + alg: + type: integer + type: + type: string + example: public-key + enum: + - "public-key" + timeout: + type: integer + example: 60000 + excludeCredentials: + type: array + items: + allOf: + - $ref: '#/components/schemas/webauthn.CredentialDescriptor' + extensions: + type: object + properties: + appidExclude: + type: string + example: https://auth.example.com + webauthn.PublicKeyCredentialRequestOptions: type: object properties: status: @@ -965,26 +1023,172 @@ components: data: type: object properties: - appId: + publicKey: + allOf: + - $ref: '#/components/schemas/webauthn.UserVerification' + - type: object + required: + - "challenge" + properties: + challenge: + type: string + timeout: + type: integer + example: 60000 + rpId: + type: string + example: auth.example.com + allowCredentials: + type: array + items: + allOf: + - $ref: '#/components/schemas/webauthn.CredentialDescriptor' + extensions: + type: object + properties: + appid: + type: string + example: https://auth.example.com + webauthn.Transports: + type: object + properties: + transports: + type: array + items: + type: string + example: + - "usb" + - "nfc" + enum: + - "usb" + - "nfc" + - "ble" + - "internal" + webauthn.UserVerification: + type: object + properties: + userVerification: + type: string + example: preferred + enum: + - "required" + - "preferred" + - "discouraged" + webauthn.AttestationType: + type: object + properties: + attestation: + type: string + example: direct + enum: + - "none" + - "indirect" + - "direct" + webauthn.AuthenticatorSelectionCriteria: + type: object + properties: + authenticatorSelection: + type: object + properties: + authenticatorAttachment: type: string - example: https://auth.example.com - challenge: + example: cross-platform + enum: + - "platform" + - "cross-platform" + residentKey: type: string - example: XGYKUzSmTpM1KxxpekArviW0w0OU2pwwRAocgn8TkVQ - registeredKeys: + example: discouraged + enum: + - "discouraged" + - "preferred" + - "required" + requireResidentKey: + type: boolean + webauthn.CredentialDescriptor: + allOf: + - $ref: '#/components/schemas/webauthn.Transports' + - type: object + required: + - "id" + - "type" + properties: + id: + type: string + format: byte + type: + type: string + example: public-key + enum: + - "public-key" + webauthn.CredentialEntity: + type: object + required: + - "id" + - "name" + properties: + id: + type: string + name: + type: string + icon: + type: string + webauthn.CredentialRPEntity: + type: object + required: + - "rp" + properties: + rp: + allOf: + - $ref: '#/components/schemas/webauthn.CredentialEntity' + webauthn.CredentialUserEntity: + type: object + required: + - "user" + properties: + user: + allOf: + - $ref: '#/components/schemas/webauthn.CredentialEntity' + - type: object + required: + - "displayName" + properties: + displayName: + type: string + webauthn.AuthenticationExtensionsClientOutputs: + type: object + properties: + clientExtensionResults: + type: object + properties: + appid: + type: boolean + example: true + appidExclude: + type: boolean + example: false + uvm: type: array items: - type: object - properties: - appId: - type: string - example: https://auth.example.com - version: - type: string - example: U2F_V2 - keyHandle: - type: string - example: pWgBrwr9meS5vArdffPtD4Px6AqZS7MfGEf776Rz438ujwHjeXwQEZuK53sRQ4wjeAgRCW4wX9VRj8dyKjc273 + type: string + format: byte + credProps: + type: object + properties: + rk: + type: boolean + example: false + largeBlob: + type: object + properties: + supported: + type: boolean + example: false + blob: + type: string + written: + type: boolean + example: false securitySchemes: authelia_auth: type: apiKey diff --git a/config.template.yml b/config.template.yml index 5149041a..95c16da8 100644 --- a/config.template.yml +++ b/config.template.yml @@ -98,6 +98,9 @@ log: ## ## Parameters used for TOTP generation. totp: + ## Disable TOTP. + disable: false + ## The issuer name displayed in the Authenticator application of your choice. issuer: authelia.com @@ -121,6 +124,28 @@ totp: skew: 1 ## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation. +## +## WebAuthn Configuration +## +## Parameters used for WebAuthn. +webauthn: + ## Disable Webauthn. + disable: false + + ## Adjust the interaction timeout for Webauthn dialogues. + timeout: 60s + + ## The display name the browser should show the user for when using Webauthn to login/register. + display_name: Authelia + + ## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device. + ## Options are none, indirect, direct. + attestation_conveyance_preference: indirect + + ## User verification controls if the user must make a gesture or action to confirm they are present. + ## Options are required, preferred, discouraged. + user_verification: preferred + ## ## Duo Push API Configuration ## @@ -576,7 +601,7 @@ storage: ## ## Notification Provider ## -## Notifications are sent to users when they require a password reset, a U2F registration or a TOTP registration. +## Notifications are sent to users when they require a password reset, a Webauthn registration or a TOTP registration. ## The available providers are: filesystem, smtp. You must use only one of these providers. notifier: ## You can disable the notifier startup check by setting this to true. diff --git a/docs/configuration/one-time-password.md b/docs/configuration/one-time-password.md index 56b264ba..48b4a2a8 100644 --- a/docs/configuration/one-time-password.md +++ b/docs/configuration/one-time-password.md @@ -14,6 +14,7 @@ full example of TOTP configuration below, as well as sections describing them. ## Configuration ```yaml totp: + disable: false issuer: authelia.com algorithm: sha1 digits: 6 @@ -23,6 +24,18 @@ totp: ## Options +### disable +
+type: boolean +{: .label .label-config .label-purple } +default: false +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +This disables One-Time Password (TOTP) if set to true. + ### issuer
type: string diff --git a/docs/configuration/storage/migrations.md b/docs/configuration/storage/migrations.md index 00d1ed97..30c142e3 100644 --- a/docs/configuration/storage/migrations.md +++ b/docs/configuration/storage/migrations.md @@ -18,7 +18,8 @@ This means all Authelia versions between two schema versions use the first schem For example for version pre1, it is used for all versions between it and the version 1 schema, so 4.0.0 to 4.32.2. In this instance if you wanted to downgrade to pre1 you would need to use an Authelia binary with version 4.33.0 or higher. -|Schema Version|Authelia Version|Notes | -|:------------:|:--------------:|:----------------------------------------------------------:| -|pre1 |4.0.0 |Downgrading to this version requires you use the --pre1 flag| -|1 |4.33.0 | | +| Schema Version | Authelia Version | Notes | +|:--------------:|:----------------:|:-------------------------------------------------------------------------------------------------:| +| pre1 | 4.0.0 | Downgrading to this version requires you use the --pre1 flag | +| 1 | 4.33.0 | Initial migration managed version | +| 2 | 4.34.0 | Webauthn - added webauthn_devices table, altered totp_config to include device created/used dates | diff --git a/docs/configuration/webauthn.md b/docs/configuration/webauthn.md new file mode 100644 index 00000000..678f9385 --- /dev/null +++ b/docs/configuration/webauthn.md @@ -0,0 +1,109 @@ +--- +layout: default +title: Webauthn +parent: Configuration +nav_order: 16 +--- + +The Webauthn section has tunable options for the Webauthn implementation. + +## Configuration +```yaml +webauthn: + disable: false + display_name: Authelia + attestation_conveyance_preference: indirect + user_verification: preferred + timeout: 60s +``` + +## Options + +### disable +
+type: boolean +{: .label .label-config .label-purple } +default: false +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +This disables Webauthn if set to true. + +### display_name +
+type: string +{: .label .label-config .label-purple } +default: Authelia +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Sets the display name which is sent to the client to be displayed. It's up to individual browsers and potentially +individual operating systems if and how they display this information. + +See the [W3C Webauthn Documentation](https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialentity-name) for more information. + +### attestation_conveyance_preference +
+type: string +{: .label .label-config .label-purple } +default: indirect +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Sets the conveyance preference. Conveyancing allows collection of attestation statements about the authenticator such as +the AAGUID. The AAGUID indicates the model of the device. + +See the [W3C Webauthn Documentation](https://www.w3.org/TR/webauthn-2/#enum-attestation-convey) for more information. + +Available Options: + +| Value | Description | +|:--------:|:---------------------------------------------------------------------------------------------------------------------------------------------:| +| none | The client will be instructed not to perform conveyancing | +| indirect | The client will be instructed to perform conveyancing but the client can choose how to do this including using a third party anonymization CA | +| direct | The client will be instructed to perform conveyancing with an attestation statement directly signed by the device | + +### user_verification +
+type: string +{: .label .label-config .label-purple } +default: preferred +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +Sets the user verification preference. + +See the [W3C Webauthn Documentation](https://www.w3.org/TR/webauthn-2/#enum-userVerificationRequirement) for more information. + +Available Options: + +| Value | Description | +|:-----------:|:------------------------------------------------------------------------------------------------------:| +| discouraged | The client will be discouraged from asking for user verification | +| preferred | The client if compliant will ask the user for verification if the device supports it | +| required | The client will ask the user for verification or will fail if the device does not support verification | + +### timeout +
+type: string (duration) +{: .label .label-config .label-purple } +default: 60s +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +This adjusts the requested timeout for a Webauthn interaction. The period of time is in +[duration notation format](index.md#duration-notation-format). + +## FAQ + +See the [Security Key FAQ](../features/2fa/security-key.md#faq) for the FAQ. diff --git a/docs/features/2fa/index.md b/docs/features/2fa/index.md index d6d92a7a..06dba31e 100644 --- a/docs/features/2fa/index.md +++ b/docs/features/2fa/index.md @@ -10,8 +10,8 @@ has_children: true There are multiple supported options for the second factor. -* Time-based One-Time passwords with [Google Authenticator] -* Security Keys with tokens like [Yubikey]. +* Time-based One-Time passwords with compatible authenticator applications. +* Security Keys that support [FIDO2] [Webauthn] with devices like a [YubiKey]. * Push notifications on your mobile using [Duo].

@@ -20,5 +20,6 @@ There are multiple supported options for the second factor. [Duo]: https://duo.com/ -[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/ -[Google Authenticator]: https://google-authenticator.com/ \ No newline at end of file +[FIDO2]: https://www.yubico.com/authentication-standards/fido2/ +[Webauthn]: https://www.yubico.com/authentication-standards/webauthn/ +[YubiKey]: https://www.yubico.com/products/yubikey-5-overview/ \ No newline at end of file diff --git a/docs/features/2fa/security-key.md b/docs/features/2fa/security-key.md index 50af8c7a..54d72de7 100644 --- a/docs/features/2fa/security-key.md +++ b/docs/features/2fa/security-key.md @@ -8,35 +8,34 @@ grand_parent: Features # Security Keys -**Authelia** supports hardware-based second factors leveraging security keys like +**Authelia** supports hardware-based second factors leveraging [FIDO2] [Webauthn] compatible security keys like [YubiKey]'s. -Security keys are among the most secure second factor. This method is already -supported by many major applications and platforms like Google, Facebook, Github, -some banks, and much more... +Security keys are among the most secure second factor. This method is already supported by many major applications and +platforms like Google, Facebook, GitHub, some banks, and much more.

-Normally, the protocol requires your security key to be enrolled on each site before -being able to authenticate with it. Since Authelia provides Single Sign-On, your users -will need to enroll their device only once to get access to all your applications. +Normally, the protocol requires your security key to be enrolled on each site before being able to authenticate with it. +Since Authelia provides Single Sign-On, your users will need to enroll their device only once to get access to all your +applications.

-After having successfully passed the first factor, select *Security Key* method and -click on *Register device* link. This will send you an email to verify your identity. +After having successfully passed the first factor, select *Security Key* method and click on *Register device* link. +This will send you an email to verify your identity. *NOTE: This e-mail has likely been sent to the mailbox at https://mail.example.com:8080/ if you're testing Authelia.* -Confirm your identity by clicking on **Register** and you'll be asked to -touch the token of your security key to complete the enrollment. +Confirm your identity by clicking on **Register** and you'll be asked to touch the token of your security key to +complete the enrollment. -Upon successful enrollment, you can authenticate using your security key -by simply touching the token again when requested: +Upon successful enrollment, you can authenticate using your security key by simply touching the token again when +requested:

@@ -44,20 +43,32 @@ by simply touching the token again when requested: Easy, right?! - -## Limitations - -Users currently can only enroll a single U2F device in **Authelia**. -Multiple single type device enrollment will be available when [this issue](https://github.com/authelia/authelia/issues/275) has been resolved. - - ## FAQ +### Can I register multiple FIDO2 Webauthn devices? + +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. + +### Can I perform a passwordless login? + +Not at this time. We will tackle this at a later date. + ### Why don't I have access to the *Security Key* option? -U2F protocol is a new protocol that is only supported by recent browsers -and might even be enabled on some of them. Please be sure your browser -supports U2F and that the feature is enabled to make the option -available in **Authelia**. +The [Webauthn] protocol is a new protocol that is only supported by modern browsers. Please ensure your browser is up to +date, supports [Webauthn], and that the feature is not disabled if the option is not available to you in **Authelia**. +### Can my FIDO U2F device operate with Authelia? + +At the present time there is no plan to support [FIDO U2F] within Authelia. We do implement a backwards compatible appid +extension within **Authelia** however this only works for devices registered before the upgrade to the [FIDO2] [Webauthn] +protocol. + +If there was sufficient interest in supporting registration of old U2F / FIDO devices in **Authelia** we would consider +adding support for this after or at the same time of the multi-device enhancements. + +[FIDO U2F]: https://www.yubico.com/authentication-standards/fido-u2f/ +[FIDO2]: https://www.yubico.com/authentication-standards/fido2/ +[Webauthn]: https://www.yubico.com/authentication-standards/webauthn/ [YubiKey]: https://www.yubico.com/products/yubikey-5-overview/ diff --git a/docs/index.md b/docs/index.md index 3da01405..759d141c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,8 +29,8 @@ protect your apps. Multiple 2-factor methods are available for satisfying every users. -* Time-based One-Time passwords with [Google Authenticator]. -* Security Keys with tokens like [Yubikey]. +* Time-based One-Time passwords with compatible authenticator applications. +* Security Keys that support [FIDO2] [Webauthn] with devices like a [YubiKey]. * Push notifications on your mobile using [Duo]. **Authelia** is available as Docker images, static binaries and AUR packages @@ -49,5 +49,6 @@ so that you can test it in minutes. Let's begin with the [Duo]: https://duo.com/ -[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/ -[Google Authenticator]: https://google-authenticator.com/ +[FIDO2]: https://www.yubico.com/authentication-standards/fido2/ +[Webauthn]: https://www.yubico.com/authentication-standards/webauthn/ +[YubiKey]: https://www.yubico.com/products/yubikey-5-overview/ diff --git a/docs/security/measures.md b/docs/security/measures.md index 90ba7b19..556357f9 100644 --- a/docs/security/measures.md +++ b/docs/security/measures.md @@ -89,10 +89,10 @@ that users who have access to the database do not also have access to this key. The encrypted data in the database is as follows: -|Table |Column |Rational | -|:-----------------:|:--------:|:----------------------------------------------------------------------------------------------------:| -|totp_configurations|secret |Prevents a [Leaked Database](#leaked-database) or [Bad Actors](#bad-actors) from compromising security| -|u2f_devices |public_key|Prevents [Bad Actors](#bad-actors) from compromising security | +| Table | Column | Rational | +|:-------------------:|:----------:|:------------------------------------------------------------------------------------------------------:| +| totp_configurations | secret | Prevents a [Leaked Database](#leaked-database) or [Bad Actors](#bad-actors) from compromising security | +| webauthn_devices | public_key | Prevents [Bad Actors](#bad-actors) from compromising security | ### Leaked Database diff --git a/go.mod b/go.mod index 10fcbd6e..079686cd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/deckarep/golang-set v1.8.0 + github.com/duo-labs/webauthn v0.0.0-20210727191636-9f1b88ef44cc github.com/duosecurity/duo_api_golang v0.0.0-20220201180708-96a8851a8448 github.com/fasthttp/router v1.4.6 github.com/fasthttp/session/v2 v2.4.7 @@ -30,7 +31,6 @@ require ( github.com/spf13/cobra v1.3.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 - github.com/tstranex/u2f v1.0.0 github.com/valyala/fasthttp v1.33.0 golang.org/x/text v0.3.7 gopkg.in/square/go-jose.v2 v2.6.0 @@ -42,17 +42,20 @@ require ( github.com/andybalholm/brotli v1.0.4 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cloudflare/cfssl v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgraph-io/ristretto v0.1.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.0 // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/fxamacker/cbor/v2 v2.4.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect github.com/go-redis/redis/v8 v8.11.4 // indirect github.com/gobuffalo/pop/v5 v5.3.3 // indirect github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/certificate-transparency-go v1.0.21 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -88,6 +91,7 @@ require ( github.com/subosito/gotenv v1.2.0 // indirect github.com/tinylib/msgp v1.1.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/ysmood/goob v0.3.1 // indirect github.com/ysmood/gson v0.6.4 // indirect github.com/ysmood/leakless v0.7.0 // indirect @@ -96,7 +100,7 @@ require ( go.opentelemetry.io/otel v0.20.0 // indirect go.opentelemetry.io/otel/metric v0.20.0 // indirect go.opentelemetry.io/otel/trace v0.20.0 // indirect - golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect + golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect golang.org/x/mod v0.5.0 // indirect golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect @@ -110,6 +114,7 @@ require ( ) replace ( + github.com/duo-labs/webauthn => github.com/authelia/webauthn v0.0.0-20220220015615-e607391e7e09 github.com/mattn/go-sqlite3 v2.0.3+incompatible => github.com/mattn/go-sqlite3 v1.14.11 github.com/tidwall/gjson => github.com/tidwall/gjson v1.11.0 ) diff --git a/go.sum b/go.sum index 25094a6a..bd8753bc 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= +bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -57,6 +58,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go v4.0.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= +github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 h1:vdT7QwBhJJEVNFMBNhRSFDRCB6O16T28VhvqRgqFyn8= github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4/go.mod h1:SvXOG8ElV28oAiG9zv91SDe5+9PfIr7PPccpr8YyXNs= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= @@ -78,6 +81,7 @@ github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/ github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -101,6 +105,8 @@ github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:o github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/authelia/webauthn v0.0.0-20220220015615-e607391e7e09 h1:QaQybILdKa95iPRh6nzNwyqjnRMFq/YFc2z7E5ikEdM= +github.com/authelia/webauthn v0.0.0-20220220015615-e607391e7e09/go.mod h1:mUL5Zt6ReLbdDClw2lvyl5apOOLyG0whwAxh550tXRE= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.23.19/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -133,6 +139,7 @@ github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4r github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -146,6 +153,11 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY= +github.com/cloudflare/cfssl v1.5.0 h1:vFJDAvQgFSRbCn9zg8KpSrrEZrBAQ4KO5oNK7SXEyb0= +github.com/cloudflare/cfssl v1.5.0/go.mod h1:sPPkBS5L8l8sRc/IOO1jG51Xb34u+TYhL6P//JdODMQ= +github.com/cloudflare/go-metrics v0.0.0-20151117154305-6a9aea36fb41/go.mod h1:eaZPlJWD+G9wseg1BuRXlHnjntPMrywMsyxf+LTOdP4= +github.com/cloudflare/redoctober v0.0.0-20171127175943-746a508df14c/go.mod h1:6Se34jNoqrd8bTxrmJB2Bg2aoZ2CdSXonils9NsiNgo= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -189,6 +201,7 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= +github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -255,6 +268,9 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= +github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/getsentry/raven-go v0.0.0-20180121060056-563b81fc02b7/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= @@ -606,6 +622,7 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -653,6 +670,8 @@ github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -871,6 +890,7 @@ github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmhodges/clock v0.0.0-20160418191101-880ee4c33548/go.mod h1:hGT6jSUVzF6no3QaDSMLGLEHtHSBSefs+MgcDWnmhmo= github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= @@ -911,6 +931,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20150923205031-648daed35d49/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/kisom/goutils v1.1.0/go.mod h1:+UBTfd78habUYWFbNWTJNG+jNG/i/lGURakr4A/yNRw= github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.14.1 h1:hLQYb23E8/fO+1u53d02A97a8UnsddcvYzq4ERRU4ds= github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -933,6 +955,7 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1051,6 +1074,7 @@ github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGE github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/moul/http2curl v0.0.0-20170919181001-9ac6cf4d929b/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= @@ -1063,6 +1087,7 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -1375,8 +1400,6 @@ github.com/tinylib/msgp v1.1.6 h1:i+SbKraHhnrf9M5MYmvQhFnbLhAXSDWF8WWsuyRdocw= github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= -github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -1394,7 +1417,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.33.0 h1:mHBKd98J5NcXuBddgjvim1i3kWzlng1SzLhrnBOU9g8= github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -1418,6 +1446,11 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= +github.com/zmap/zcrypto v0.0.0-20200513165325-16679db567ff/go.mod h1:TxpejqcVKQjQaVVmMGfzx5HnmFMdIU+vLtaCyPBfGI4= +github.com/zmap/zcrypto v0.0.0-20200911161511-43ff0ea04f21/go.mod h1:TxpejqcVKQjQaVVmMGfzx5HnmFMdIU+vLtaCyPBfGI4= +github.com/zmap/zlint/v2 v2.2.1/go.mod h1:ixPWsdq8qLxYRpNUTbcKig3R7WgmspsHGLhCCs6rFAM= go.elastic.co/apm v1.8.0/go.mod h1:tCw6CkOJgkWnzEthFN9HUP1uL3Gjc/Ur6m7gRPLaoH0= go.elastic.co/apm v1.13.0/go.mod h1:dylGv2HKR0tiCV+wliJz1KHtDyuD8SPe69oV7VyK6WY= go.elastic.co/apm/module/apmhttp v1.8.0/go.mod h1:9LPFlEON51/lRbnWDfqAWErihIiAFDUMfMV27YjoWQ8= @@ -1516,18 +1549,21 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200124225646-8b5121be2f68/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= +golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1621,6 +1657,7 @@ golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 7e38b121..9bc072a4 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -19,8 +19,8 @@ const ( const ( // TOTP Method using Time-Based One-Time Password applications like Google Authenticator. TOTP = "totp" - // U2F Method using U2F devices like Yubikeys. - U2F = "u2f" + // Webauthn Method using Webauthn devices like YubiKeys. + Webauthn = "webauthn" // Push Method using Duo application to receive push notifications. Push = "mobile_push" ) @@ -37,7 +37,7 @@ const ( ) // PossibleMethods is the set of all possible 2FA methods. -var PossibleMethods = []string{TOTP, U2F, Push} +var PossibleMethods = []string{TOTP, Webauthn, Push} // CryptAlgo the crypt representation of an algorithm used in the prefix of the hash. type CryptAlgo string diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 5149041a..95c16da8 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -98,6 +98,9 @@ log: ## ## Parameters used for TOTP generation. totp: + ## Disable TOTP. + disable: false + ## The issuer name displayed in the Authenticator application of your choice. issuer: authelia.com @@ -121,6 +124,28 @@ totp: skew: 1 ## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation. +## +## WebAuthn Configuration +## +## Parameters used for WebAuthn. +webauthn: + ## Disable Webauthn. + disable: false + + ## Adjust the interaction timeout for Webauthn dialogues. + timeout: 60s + + ## The display name the browser should show the user for when using Webauthn to login/register. + display_name: Authelia + + ## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device. + ## Options are none, indirect, direct. + attestation_conveyance_preference: indirect + + ## User verification controls if the user must make a gesture or action to confirm they are present. + ## Options are required, preferred, discouraged. + user_verification: preferred + ## ## Duo Push API Configuration ## @@ -576,7 +601,7 @@ storage: ## ## Notification Provider ## -## Notifications are sent to users when they require a password reset, a U2F registration or a TOTP registration. +## Notifications are sent to users when they require a password reset, a Webauthn registration or a TOTP registration. ## The available providers are: filesystem, smtp. You must use only one of these providers. notifier: ## You can disable the notifier startup check by setting this to true. diff --git a/internal/configuration/decode_hooks.go b/internal/configuration/decode_hooks.go index cc9aac69..f8ce99f6 100644 --- a/internal/configuration/decode_hooks.go +++ b/internal/configuration/decode_hooks.go @@ -25,15 +25,14 @@ func StringToMailAddressFunc() mapstructure.DecodeHookFunc { } var ( - mailAddress *mail.Address + parsedAddress *mail.Address ) - mailAddress, err = mail.ParseAddress(dataStr) - if err != nil { + if parsedAddress, err = mail.ParseAddress(dataStr); err != nil { return nil, fmt.Errorf("could not parse '%s' as a RFC5322 address: %w", dataStr, err) } - return *mailAddress, nil + return *parsedAddress, nil } } diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index 84690aa0..2325d92f 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -22,18 +22,18 @@ func TestShouldErrorSecretNotExist(t *testing.T) { dir, err := os.MkdirTemp("", "authelia-test-secret-not-exist") assert.NoError(t, err) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", filepath.Join(dir, "jwt"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"DUO_API_SECRET_KEY_FILE", filepath.Join(dir, "duo"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", filepath.Join(dir, "session"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", filepath.Join(dir, "authentication"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"NOTIFIER_SMTP_PASSWORD_FILE", filepath.Join(dir, "notifier"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_REDIS_PASSWORD_FILE", filepath.Join(dir, "redis"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE", filepath.Join(dir, "redis-sentinel"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD_FILE", filepath.Join(dir, "mysql"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_POSTGRES_PASSWORD_FILE", filepath.Join(dir, "postgres"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SERVER_TLS_KEY_FILE", filepath.Join(dir, "tls"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE", filepath.Join(dir, "oidc-key"))) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE", filepath.Join(dir, "oidc-hmac"))) + testSetEnv(t, "JWT_SECRET_FILE", filepath.Join(dir, "jwt")) + testSetEnv(t, "DUO_API_SECRET_KEY_FILE", filepath.Join(dir, "duo")) + testSetEnv(t, "SESSION_SECRET_FILE", filepath.Join(dir, "session")) + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", filepath.Join(dir, "authentication")) + testSetEnv(t, "NOTIFIER_SMTP_PASSWORD_FILE", filepath.Join(dir, "notifier")) + testSetEnv(t, "SESSION_REDIS_PASSWORD_FILE", filepath.Join(dir, "redis")) + testSetEnv(t, "SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE", filepath.Join(dir, "redis-sentinel")) + testSetEnv(t, "STORAGE_MYSQL_PASSWORD_FILE", filepath.Join(dir, "mysql")) + testSetEnv(t, "STORAGE_POSTGRES_PASSWORD_FILE", filepath.Join(dir, "postgres")) + testSetEnv(t, "SERVER_TLS_KEY_FILE", filepath.Join(dir, "tls")) + testSetEnv(t, "IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE", filepath.Join(dir, "oidc-key")) + testSetEnv(t, "IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE", filepath.Join(dir, "oidc-hmac")) val := schema.NewStructValidator() _, _, err = Load(val, NewEnvironmentSource(DefaultEnvPrefix, DefaultEnvDelimiter), NewSecretsSource(DefaultEnvPrefix, DefaultEnvDelimiter)) @@ -76,10 +76,10 @@ func TestLoadShouldReturnErrWithoutSources(t *testing.T) { func TestShouldHaveNotifier(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + testSetEnv(t, "SESSION_SECRET", "abc") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD", "abc") + testSetEnv(t, "JWT_SECRET", "abc") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc") val := schema.NewStructValidator() _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -93,10 +93,10 @@ func TestShouldHaveNotifier(t *testing.T) { func TestShouldValidateConfigurationWithEnv(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + testSetEnv(t, "SESSION_SECRET", "abc") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD", "abc") + testSetEnv(t, "JWT_SECRET", "abc") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc") val := schema.NewStructValidator() _, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -109,12 +109,12 @@ func TestShouldValidateConfigurationWithEnv(t *testing.T) { func TestShouldNotIgnoreInvalidEnvs(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "an env session secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "an env storage mysql password")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL", "a bad env")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "an env jwt secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_URL", "an env authentication backend ldap password")) + testSetEnv(t, "SESSION_SECRET", "an env session secret") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD", "an env storage mysql password") + testSetEnv(t, "STORAGE_MYSQL", "a bad env") + testSetEnv(t, "JWT_SECRET", "an env jwt secret") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_URL", "an env authentication backend ldap password") val := schema.NewStructValidator() keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -132,12 +132,12 @@ func TestShouldNotIgnoreInvalidEnvs(t *testing.T) { func TestShouldValidateAndRaiseErrorsOnNormalConfigurationAndSecret(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "an env session secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "an env storage mysql password")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_ENCRYPTION_KEY", "a_very_bad_encryption_key")) + testSetEnv(t, "SESSION_SECRET", "an env session secret") + testSetEnv(t, "SESSION_SECRET_FILE", "./test_resources/example_secret") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD", "an env storage mysql password") + testSetEnv(t, "JWT_SECRET_FILE", "./test_resources/example_secret") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "an env authentication backend ldap password") + testSetEnv(t, "STORAGE_ENCRYPTION_KEY", "a_very_bad_encryption_key") val := schema.NewStructValidator() _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -181,11 +181,11 @@ func TestShouldRaiseIOErrOnUnreadableFile(t *testing.T) { func TestShouldValidateConfigurationWithEnvSecrets(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", "./test_resources/example_secret")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_ENCRYPTION_KEY_FILE", "./test_resources/example_secret")) + testSetEnv(t, "SESSION_SECRET_FILE", "./test_resources/example_secret") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD_FILE", "./test_resources/example_secret") + testSetEnv(t, "JWT_SECRET_FILE", "./test_resources/example_secret") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE", "./test_resources/example_secret") + testSetEnv(t, "STORAGE_ENCRYPTION_KEY_FILE", "./test_resources/example_secret") val := schema.NewStructValidator() _, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -204,10 +204,10 @@ func TestShouldValidateConfigurationWithEnvSecrets(t *testing.T) { func TestShouldValidateAndRaiseErrorsOnBadConfiguration(t *testing.T) { testReset() - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"SESSION_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"STORAGE_MYSQL_PASSWORD", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"JWT_SECRET", "abc")) - assert.NoError(t, os.Setenv(DefaultEnvPrefix+"AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")) + testSetEnv(t, "SESSION_SECRET", "abc") + testSetEnv(t, "STORAGE_MYSQL_PASSWORD", "abc") + testSetEnv(t, "JWT_SECRET", "abc") + testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc") val := schema.NewStructValidator() keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config_bad_keys.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) @@ -336,6 +336,10 @@ func TestShouldNotLoadDirectoryConfiguration(t *testing.T) { assert.EqualError(t, val.Errors()[0], fmt.Sprintf("failed to load configuration from yaml file(%s) source: %s", dir, expectedErr)) } +func testSetEnv(t *testing.T, key, value string) { + assert.NoError(t, os.Setenv(DefaultEnvPrefix+key, value)) +} + func testReset() { testUnsetEnvName("STORAGE_MYSQL") testUnsetEnvName("JWT_SECRET") diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index b79d2a34..bf75813d 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -11,7 +11,7 @@ type Configuration struct { IdentityProviders IdentityProvidersConfiguration `koanf:"identity_providers"` AuthenticationBackend AuthenticationBackendConfiguration `koanf:"authentication_backend"` Session SessionConfiguration `koanf:"session"` - TOTP *TOTPConfiguration `koanf:"totp"` + TOTP TOTPConfiguration `koanf:"totp"` DuoAPI *DuoAPIConfiguration `koanf:"duo_api"` AccessControl AccessControlConfiguration `koanf:"access_control"` NTP NTPConfiguration `koanf:"ntp"` @@ -19,4 +19,5 @@ type Configuration struct { Storage StorageConfiguration `koanf:"storage"` Notifier *NotifierConfiguration `koanf:"notifier"` Server ServerConfiguration `koanf:"server"` + Webauthn WebauthnConfiguration `koanf:"webauthn"` } diff --git a/internal/configuration/schema/totp.go b/internal/configuration/schema/totp.go index e743153c..4d8be808 100644 --- a/internal/configuration/schema/totp.go +++ b/internal/configuration/schema/totp.go @@ -2,6 +2,7 @@ package schema // TOTPConfiguration represents the configuration related to TOTP options. type TOTPConfiguration struct { + Disable bool `koanf:"disable"` Issuer string `koanf:"issuer"` Algorithm string `koanf:"algorithm"` Digits uint `koanf:"digits"` diff --git a/internal/configuration/schema/webauthn.go b/internal/configuration/schema/webauthn.go new file mode 100644 index 00000000..c1deea48 --- /dev/null +++ b/internal/configuration/schema/webauthn.go @@ -0,0 +1,27 @@ +package schema + +import ( + "time" + + "github.com/duo-labs/webauthn/protocol" +) + +// WebauthnConfiguration represents the webauthn config. +type WebauthnConfiguration struct { + Disable bool `koanf:"disable"` + DisplayName string `koanf:"display_name"` + + ConveyancePreference protocol.ConveyancePreference `koanf:"attestation_conveyance_preference"` + UserVerification protocol.UserVerificationRequirement `koanf:"user_verification"` + + Timeout time.Duration `koanf:"timeout"` +} + +// DefaultWebauthnConfiguration describes the default values for the WebauthnConfiguration. +var DefaultWebauthnConfiguration = WebauthnConfiguration{ + DisplayName: "Authelia", + Timeout: time.Second * 60, + + ConveyancePreference: protocol.PreferIndirectAttestation, + UserVerification: protocol.VerificationPreferred, +} diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index b23182ca..dd536b28 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -39,6 +39,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc ValidateTOTP(config, validator) + ValidateWebauthn(config, validator) + ValidateAuthenticationBackend(&config.AuthenticationBackend, validator) ValidateAccessControl(config, validator) diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 2d9c9f58..9174732e 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -3,6 +3,8 @@ package validator import ( "regexp" + "github.com/duo-labs/webauthn/protocol" + "github.com/authelia/authelia/v4/internal/oidc" ) @@ -150,6 +152,12 @@ const ( "configured to an unsafe value, it should be above 8 but it's configured to %d" ) +// Webauthn Error constants. +const ( + errFmtWebauthnConveyancePreference = "webauthn: option 'attestation_conveyance_preference' must be one of '%s' but it is configured as '%s'" + errFmtWebauthnUserVerification = "webauthn: option 'user_verification' must be one of 'discouraged', 'preferred', 'required' but it is configured as '%s'" +) + // Access Control error constants. const ( errFmtAccessControlDefaultPolicyValue = "access control: option 'default_policy' must be one of '%s' but it is " + @@ -245,6 +253,9 @@ var validSessionSameSiteValues = []string{"none", "lax", "strict"} var validLoLevels = []string{"trace", "debug", "info", "warn", "error"} +var validWebauthnConveyancePreferences = []string{string(protocol.PreferNoAttestation), string(protocol.PreferIndirectAttestation), string(protocol.PreferDirectAttestation)} +var validWebauthnUserVerificationRequirement = []string{string(protocol.VerificationDiscouraged), string(protocol.VerificationPreferred), string(protocol.VerificationRequired)} + var validACLRuleMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"} var validACLRulePolicies = []string{policyBypass, policyOneFactor, policyTwoFactor, policyDeny} @@ -285,12 +296,20 @@ var ValidKeys = []string{ "server.headers.csp_template", // TOTP Keys. + "totp.disable", "totp.issuer", "totp.algorithm", "totp.digits", "totp.period", "totp.skew", + // Webauthn Keys. + "webauthn.disable", + "webauthn.display_name", + "webauthn.attestation_conveyance_preference", + "webauthn.user_verification", + "webauthn.timeout", + // DUO API Keys. "duo_api.hostname", "duo_api.enable_self_enrollment", diff --git a/internal/configuration/validator/ntp.go b/internal/configuration/validator/ntp.go index acd2cd0c..d3914c96 100644 --- a/internal/configuration/validator/ntp.go +++ b/internal/configuration/validator/ntp.go @@ -18,7 +18,7 @@ func ValidateNTP(config *schema.Configuration, validator *schema.StructValidator validator.Push(fmt.Errorf(errFmtNTPVersion, config.NTP.Version)) } - if config.NTP.MaximumDesync == 0 { + if config.NTP.MaximumDesync <= 0 { config.NTP.MaximumDesync = schema.DefaultNTPConfiguration.MaximumDesync } } diff --git a/internal/configuration/validator/ntp_test.go b/internal/configuration/validator/ntp_test.go index 8855e2a0..9780245f 100644 --- a/internal/configuration/validator/ntp_test.go +++ b/internal/configuration/validator/ntp_test.go @@ -15,7 +15,7 @@ func newDefaultNTPConfig() schema.Configuration { } } -func TestShouldSetDefaultNtpAddress(t *testing.T) { +func TestShouldSetDefaultNTPValues(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultNTPConfig() @@ -23,21 +23,16 @@ func TestShouldSetDefaultNtpAddress(t *testing.T) { assert.Len(t, validator.Errors(), 0) assert.Equal(t, schema.DefaultNTPConfiguration.Address, config.NTP.Address) + assert.Equal(t, schema.DefaultNTPConfiguration.Version, config.NTP.Version) + assert.Equal(t, schema.DefaultNTPConfiguration.MaximumDesync, config.NTP.MaximumDesync) + assert.Equal(t, schema.DefaultNTPConfiguration.DisableStartupCheck, config.NTP.DisableStartupCheck) } func TestShouldSetDefaultNtpVersion(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultNTPConfig() - ValidateNTP(&config, validator) - - assert.Len(t, validator.Errors(), 0) - assert.Equal(t, schema.DefaultNTPConfiguration.Version, config.NTP.Version) -} - -func TestShouldSetDefaultNtpMaximumDesync(t *testing.T) { - validator := schema.NewStructValidator() - config := newDefaultNTPConfig() + config.NTP.MaximumDesync = -1 ValidateNTP(&config, validator) @@ -45,16 +40,6 @@ func TestShouldSetDefaultNtpMaximumDesync(t *testing.T) { assert.Equal(t, schema.DefaultNTPConfiguration.MaximumDesync, config.NTP.MaximumDesync) } -func TestShouldSetDefaultNtpDisableStartupCheck(t *testing.T) { - validator := schema.NewStructValidator() - config := newDefaultNTPConfig() - - ValidateNTP(&config, validator) - - assert.Len(t, validator.Errors(), 0) - assert.Equal(t, schema.DefaultNTPConfiguration.DisableStartupCheck, config.NTP.DisableStartupCheck) -} - func TestShouldRaiseErrorOnInvalidNTPVersion(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultNTPConfig() diff --git a/internal/configuration/validator/regulation.go b/internal/configuration/validator/regulation.go index bde3cf27..0b5ba599 100644 --- a/internal/configuration/validator/regulation.go +++ b/internal/configuration/validator/regulation.go @@ -8,11 +8,11 @@ import ( // ValidateRegulation validates and update regulator configuration. func ValidateRegulation(config *schema.Configuration, validator *schema.StructValidator) { - if config.Regulation.FindTime == 0 { + if config.Regulation.FindTime <= 0 { config.Regulation.FindTime = schema.DefaultRegulationConfiguration.FindTime // 2 min. } - if config.Regulation.BanTime == 0 { + if config.Regulation.BanTime <= 0 { config.Regulation.BanTime = schema.DefaultRegulationConfiguration.BanTime // 5 min. } diff --git a/internal/configuration/validator/regulation_test.go b/internal/configuration/validator/regulation_test.go index 12bd6988..fd88a73f 100644 --- a/internal/configuration/validator/regulation_test.go +++ b/internal/configuration/validator/regulation_test.go @@ -17,7 +17,7 @@ func newDefaultRegulationConfig() schema.Configuration { return config } -func TestShouldSetDefaultRegulationBanTime(t *testing.T) { +func TestShouldSetDefaultRegulationTimeDurationsWhenUnset(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultRegulationConfig() @@ -25,12 +25,16 @@ func TestShouldSetDefaultRegulationBanTime(t *testing.T) { assert.Len(t, validator.Errors(), 0) assert.Equal(t, schema.DefaultRegulationConfiguration.BanTime, config.Regulation.BanTime) + assert.Equal(t, schema.DefaultRegulationConfiguration.FindTime, config.Regulation.FindTime) } -func TestShouldSetDefaultRegulationFindTime(t *testing.T) { +func TestShouldSetDefaultRegulationTimeDurationsWhenNegative(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultRegulationConfig() + config.Regulation.BanTime = -1 + config.Regulation.FindTime = -1 + ValidateRegulation(&config, validator) assert.Len(t, validator.Errors(), 0) diff --git a/internal/configuration/validator/session_test.go b/internal/configuration/validator/session_test.go index 2ceedd57..37588ae5 100644 --- a/internal/configuration/validator/session_test.go +++ b/internal/configuration/validator/session_test.go @@ -18,7 +18,7 @@ func newDefaultSessionConfig() schema.SessionConfiguration { return config } -func TestShouldSetDefaultSessionName(t *testing.T) { +func TestShouldSetDefaultSessionValues(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultSessionConfig() @@ -27,39 +27,25 @@ func TestShouldSetDefaultSessionName(t *testing.T) { assert.False(t, validator.HasWarnings()) assert.False(t, validator.HasErrors()) assert.Equal(t, schema.DefaultSessionConfiguration.Name, config.Name) + assert.Equal(t, schema.DefaultSessionConfiguration.Inactivity, config.Inactivity) + assert.Equal(t, schema.DefaultSessionConfiguration.Expiration, config.Expiration) + assert.Equal(t, schema.DefaultSessionConfiguration.RememberMeDuration, config.RememberMeDuration) + assert.Equal(t, schema.DefaultSessionConfiguration.SameSite, config.SameSite) } -func TestShouldSetDefaultSessionInactivity(t *testing.T) { +func TestShouldSetDefaultSessionValuesWhenNegative(t *testing.T) { validator := schema.NewStructValidator() config := newDefaultSessionConfig() + config.Expiration, config.Inactivity, config.RememberMeDuration = -1, -1, -1 + ValidateSession(&config, validator) assert.False(t, validator.HasWarnings()) assert.False(t, validator.HasErrors()) assert.Equal(t, schema.DefaultSessionConfiguration.Inactivity, config.Inactivity) -} - -func TestShouldSetDefaultSessionExpiration(t *testing.T) { - validator := schema.NewStructValidator() - config := newDefaultSessionConfig() - - ValidateSession(&config, validator) - - assert.False(t, validator.HasWarnings()) - assert.False(t, validator.HasErrors()) assert.Equal(t, schema.DefaultSessionConfiguration.Expiration, config.Expiration) -} - -func TestShouldSetDefaultSessionSameSite(t *testing.T) { - validator := schema.NewStructValidator() - config := newDefaultSessionConfig() - - ValidateSession(&config, validator) - - assert.False(t, validator.HasWarnings()) - assert.False(t, validator.HasErrors()) - assert.Equal(t, schema.DefaultSessionConfiguration.SameSite, config.SameSite) + assert.Equal(t, schema.DefaultSessionConfiguration.RememberMeDuration, config.RememberMeDuration) } func TestShouldHandleRedisConfigSuccessfully(t *testing.T) { diff --git a/internal/configuration/validator/totp.go b/internal/configuration/validator/totp.go index e29b3dea..dbbda448 100644 --- a/internal/configuration/validator/totp.go +++ b/internal/configuration/validator/totp.go @@ -10,12 +10,6 @@ import ( // ValidateTOTP validates and update TOTP configuration. func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) { - if config.TOTP == nil { - config.TOTP = &schema.DefaultTOTPConfiguration - - return - } - if config.TOTP.Issuer == "" { config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer } diff --git a/internal/configuration/validator/totp_test.go b/internal/configuration/validator/totp_test.go index 463e30b5..e430b3a9 100644 --- a/internal/configuration/validator/totp_test.go +++ b/internal/configuration/validator/totp_test.go @@ -14,7 +14,7 @@ import ( func TestShouldSetDefaultTOTPValues(t *testing.T) { validator := schema.NewStructValidator() config := &schema.Configuration{ - TOTP: &schema.TOTPConfiguration{}, + TOTP: schema.TOTPConfiguration{}, } ValidateTOTP(config, validator) @@ -30,7 +30,7 @@ func TestShouldNormalizeTOTPAlgorithm(t *testing.T) { validator := schema.NewStructValidator() config := &schema.Configuration{ - TOTP: &schema.TOTPConfiguration{ + TOTP: schema.TOTPConfiguration{ Algorithm: "sha1", }, } @@ -45,7 +45,7 @@ func TestShouldRaiseErrorWhenInvalidTOTPAlgorithm(t *testing.T) { validator := schema.NewStructValidator() config := &schema.Configuration{ - TOTP: &schema.TOTPConfiguration{ + TOTP: schema.TOTPConfiguration{ Algorithm: "sha3", }, } @@ -59,7 +59,7 @@ func TestShouldRaiseErrorWhenInvalidTOTPAlgorithm(t *testing.T) { func TestShouldRaiseErrorWhenInvalidTOTPValues(t *testing.T) { validator := schema.NewStructValidator() config := &schema.Configuration{ - TOTP: &schema.TOTPConfiguration{ + TOTP: schema.TOTPConfiguration{ Period: 5, Digits: 20, }, diff --git a/internal/configuration/validator/webauthn.go b/internal/configuration/validator/webauthn.go new file mode 100644 index 00000000..47aaa270 --- /dev/null +++ b/internal/configuration/validator/webauthn.go @@ -0,0 +1,34 @@ +package validator + +import ( + "fmt" + "strings" + + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/utils" +) + +// ValidateWebauthn validates and update Webauthn configuration. +func ValidateWebauthn(config *schema.Configuration, validator *schema.StructValidator) { + if config.Webauthn.DisplayName == "" { + config.Webauthn.DisplayName = schema.DefaultWebauthnConfiguration.DisplayName + } + + if config.Webauthn.Timeout <= 0 { + config.Webauthn.Timeout = schema.DefaultWebauthnConfiguration.Timeout + } + + switch { + case config.Webauthn.ConveyancePreference == "": + config.Webauthn.ConveyancePreference = schema.DefaultWebauthnConfiguration.ConveyancePreference + case !utils.IsStringInSlice(string(config.Webauthn.ConveyancePreference), validWebauthnConveyancePreferences): + validator.Push(fmt.Errorf(errFmtWebauthnConveyancePreference, strings.Join(validWebauthnConveyancePreferences, "', '"), config.Webauthn.ConveyancePreference)) + } + + switch { + case config.Webauthn.UserVerification == "": + config.Webauthn.UserVerification = schema.DefaultWebauthnConfiguration.UserVerification + case !utils.IsStringInSlice(string(config.Webauthn.UserVerification), validWebauthnUserVerificationRequirement): + validator.Push(fmt.Errorf(errFmtWebauthnUserVerification, config.Webauthn.UserVerification)) + } +} diff --git a/internal/configuration/validator/webauthn_test.go b/internal/configuration/validator/webauthn_test.go new file mode 100644 index 00000000..939152f2 --- /dev/null +++ b/internal/configuration/validator/webauthn_test.go @@ -0,0 +1,98 @@ +package validator + +import ( + "testing" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/v4/internal/configuration/schema" +) + +func TestWebauthnShouldSetDefaultValues(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.Configuration{ + Webauthn: schema.WebauthnConfiguration{}, + } + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 0) + assert.Equal(t, schema.DefaultWebauthnConfiguration.DisplayName, config.Webauthn.DisplayName) + assert.Equal(t, schema.DefaultWebauthnConfiguration.Timeout, config.Webauthn.Timeout) + assert.Equal(t, schema.DefaultWebauthnConfiguration.ConveyancePreference, config.Webauthn.ConveyancePreference) + assert.Equal(t, schema.DefaultWebauthnConfiguration.UserVerification, config.Webauthn.UserVerification) +} + +func TestWebauthnShouldSetDefaultTimeoutWhenNegative(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.Configuration{ + Webauthn: schema.WebauthnConfiguration{ + Timeout: -1, + }, + } + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 0) + assert.Equal(t, schema.DefaultWebauthnConfiguration.Timeout, config.Webauthn.Timeout) +} + +func TestWebauthnShouldNotSetDefaultValuesWhenConfigured(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.Configuration{ + Webauthn: schema.WebauthnConfiguration{ + DisplayName: "Test", + Timeout: time.Second * 50, + ConveyancePreference: protocol.PreferNoAttestation, + UserVerification: protocol.VerificationDiscouraged, + }, + } + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 0) + assert.Equal(t, "Test", config.Webauthn.DisplayName) + assert.Equal(t, time.Second*50, config.Webauthn.Timeout) + assert.Equal(t, protocol.PreferNoAttestation, config.Webauthn.ConveyancePreference) + assert.Equal(t, protocol.VerificationDiscouraged, config.Webauthn.UserVerification) + + config.Webauthn.ConveyancePreference = protocol.PreferIndirectAttestation + config.Webauthn.UserVerification = protocol.VerificationPreferred + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 0) + assert.Equal(t, protocol.PreferIndirectAttestation, config.Webauthn.ConveyancePreference) + assert.Equal(t, protocol.VerificationPreferred, config.Webauthn.UserVerification) + + config.Webauthn.ConveyancePreference = protocol.PreferDirectAttestation + config.Webauthn.UserVerification = protocol.VerificationRequired + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 0) + assert.Equal(t, protocol.PreferDirectAttestation, config.Webauthn.ConveyancePreference) + assert.Equal(t, protocol.VerificationRequired, config.Webauthn.UserVerification) +} + +func TestWebauthnShouldRaiseErrorsOnInvalidOptions(t *testing.T) { + validator := schema.NewStructValidator() + config := &schema.Configuration{ + Webauthn: schema.WebauthnConfiguration{ + DisplayName: "Test", + Timeout: time.Second * 50, + ConveyancePreference: "no", + UserVerification: "yes", + }, + } + + ValidateWebauthn(config, validator) + + require.Len(t, validator.Errors(), 2) + + assert.EqualError(t, validator.Errors()[0], "webauthn: option 'attestation_conveyance_preference' must be one of 'none', 'indirect', 'direct' but it is configured as 'no'") + assert.EqualError(t, validator.Errors()[1], "webauthn: option 'user_verification' must be one of 'discouraged', 'preferred', 'required' but it is configured as 'yes'") +} diff --git a/internal/handlers/const.go b/internal/handlers/const.go index fe67231e..795ab101 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -10,8 +10,8 @@ const ( // ActionTOTPRegistration is the string representation of the action for which the token has been produced. ActionTOTPRegistration = "RegisterTOTPDevice" - // ActionU2FRegistration is the string representation of the action for which the token has been produced. - ActionU2FRegistration = "RegisterU2FDevice" + // ActionWebauthnRegistration is the string representation of the action for which the token has been produced. + ActionWebauthnRegistration = "RegisterWebauthnDevice" // ActionResetPassword is the string representation of the action for which the token has been produced. ActionResetPassword = "ResetPassword" diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go deleted file mode 100644 index cd499ce3..00000000 --- a/internal/handlers/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -package handlers - -import "errors" - -// InternalError is the error message sent when there was an internal error but it should -// be hidden to the end user. In that case the error should be in the server logs. -const InternalError = "Internal error." - -// UnauthorizedError is the error message sent when the user is not authorized. -const UnauthorizedError = "You're not authorized." - -var errMissingXForwardedHost = errors.New("missing header X-Forwarded-Host") -var errMissingXForwardedProto = errors.New("missing header X-Forwarded-Proto") diff --git a/internal/handlers/handler_configuration.go b/internal/handlers/handler_configuration.go index 6f70ba7b..5474b861 100644 --- a/internal/handlers/handler_configuration.go +++ b/internal/handlers/handler_configuration.go @@ -7,20 +7,27 @@ import ( // ConfigurationGet get the configuration accessible to authenticated users. func ConfigurationGet(ctx *middlewares.AutheliaCtx) { - body := configurationBody{} - body.AvailableMethods = MethodList{authentication.TOTP, authentication.U2F} - - if ctx.Configuration.DuoAPI != nil { - body.AvailableMethods = append(body.AvailableMethods, authentication.Push) + body := configurationBody{ + AvailableMethods: make(MethodList, 0, 3), } - body.SecondFactorEnabled = ctx.Providers.Authorizer.IsSecondFactorEnabled() + if ctx.Providers.Authorizer.IsSecondFactorEnabled() { + if !ctx.Configuration.TOTP.Disable { + body.AvailableMethods = append(body.AvailableMethods, authentication.TOTP) + } + + if !ctx.Configuration.Webauthn.Disable { + body.AvailableMethods = append(body.AvailableMethods, authentication.Webauthn) + } + + if ctx.Configuration.DuoAPI != nil { + body.AvailableMethods = append(body.AvailableMethods, authentication.Push) + } + } - ctx.Logger.Tracef("Second factor enabled: %v", body.SecondFactorEnabled) ctx.Logger.Tracef("Available methods are %s", body.AvailableMethods) - err := ctx.SetJSONBody(body) - if err != nil { + if err := ctx.SetJSONBody(body); err != nil { ctx.Logger.Errorf("Unable to set configuration response in body: %s", err) } } diff --git a/internal/handlers/handler_configuration_test.go b/internal/handlers/handler_configuration_test.go index ab69e87f..f1f11e08 100644 --- a/internal/handlers/handler_configuration_test.go +++ b/internal/handlers/handler_configuration_test.go @@ -28,106 +28,171 @@ func (s *SecondFactorAvailableMethodsFixture) TearDownTest() { s.mock.Close() } -func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() { - expectedBody := configurationBody{ - AvailableMethods: []string{"totp", "u2f"}, - SecondFactorEnabled: false, - } - - ConfigurationGet(s.mock.Ctx) - s.mock.Assert200OK(s.T(), expectedBody) -} - -func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethodsAndMobilePush() { +func (s *SecondFactorAvailableMethodsFixture) TestShouldHaveAllConfiguredMethods() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: &schema.DuoAPIConfiguration{}, - } - expectedBody := configurationBody{ - AvailableMethods: []string{"totp", "u2f", "mobile_push"}, - SecondFactorEnabled: false, - } - - ConfigurationGet(s.mock.Ctx) - s.mock.Assert200OK(s.T(), expectedBody) -} - -func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsDisabledWhenNoRuleIsSetToTwoFactor() { - s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer( - &schema.Configuration{ - AccessControl: schema.AccessControlConfiguration{ - DefaultPolicy: "bypass", - Rules: []schema.ACLRule{ - { - Domains: []string{"example.com"}, - Policy: "deny", - }, - { - Domains: []string{"abc.example.com"}, - Policy: "single_factor", - }, - { - Domains: []string{"def.example.com"}, - Policy: "bypass", - }, - }, - }}) - ConfigurationGet(s.mock.Ctx) - s.mock.Assert200OK(s.T(), configurationBody{ - AvailableMethods: []string{"totp", "u2f"}, - SecondFactorEnabled: false, - }) -} - -func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenDefaultPolicySetToTwoFactor() { - s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ + TOTP: schema.TOTPConfiguration{ + Disable: false, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: false, + }, AccessControl: schema.AccessControlConfiguration{ - DefaultPolicy: "two_factor", + DefaultPolicy: "deny", Rules: []schema.ACLRule{ { Domains: []string{"example.com"}, - Policy: "deny", - }, - { - Domains: []string{"abc.example.com"}, - Policy: "single_factor", - }, - { - Domains: []string{"def.example.com"}, - Policy: "bypass", + Policy: "two_factor", }, }, - }}) + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + ConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), configurationBody{ - AvailableMethods: []string{"totp", "u2f"}, - SecondFactorEnabled: true, + AvailableMethods: []string{"totp", "webauthn", "mobile_push"}, }) } -func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenSomePolicySetToTwoFactor() { - s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer( - &schema.Configuration{ - AccessControl: schema.AccessControlConfiguration{ - DefaultPolicy: "bypass", - Rules: []schema.ACLRule{ - { - Domains: []string{"example.com"}, - Policy: "deny", - }, - { - Domains: []string{"abc.example.com"}, - Policy: "two_factor", - }, - { - Domains: []string{"def.example.com"}, - Policy: "bypass", - }, +func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveTOTPFromAvailableMethodsWhenDisabled() { + s.mock.Ctx.Configuration = schema.Configuration{ + DuoAPI: &schema.DuoAPIConfiguration{}, + TOTP: schema.TOTPConfiguration{ + Disable: true, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: false, + }, + AccessControl: schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"example.com"}, + Policy: "two_factor", }, - }}) + }, + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + ConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), configurationBody{ - AvailableMethods: []string{"totp", "u2f"}, - SecondFactorEnabled: true, + AvailableMethods: []string{"webauthn", "mobile_push"}, + }) +} + +func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveWebauthnFromAvailableMethodsWhenDisabled() { + s.mock.Ctx.Configuration = schema.Configuration{ + DuoAPI: &schema.DuoAPIConfiguration{}, + TOTP: schema.TOTPConfiguration{ + Disable: false, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: true, + }, + AccessControl: schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"example.com"}, + Policy: "two_factor", + }, + }, + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + + ConfigurationGet(s.mock.Ctx) + + s.mock.Assert200OK(s.T(), configurationBody{ + AvailableMethods: []string{"totp", "mobile_push"}, + }) +} + +func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveDuoFromAvailableMethodsWhenNotConfigured() { + s.mock.Ctx.Configuration = schema.Configuration{ + DuoAPI: nil, + TOTP: schema.TOTPConfiguration{ + Disable: false, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: false, + }, + AccessControl: schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"example.com"}, + Policy: "two_factor", + }, + }, + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + + ConfigurationGet(s.mock.Ctx) + + s.mock.Assert200OK(s.T(), configurationBody{ + AvailableMethods: []string{"totp", "webauthn"}, + }) +} + +func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenNoTwoFactorACLRulesConfigured() { + s.mock.Ctx.Configuration = schema.Configuration{ + DuoAPI: &schema.DuoAPIConfiguration{}, + TOTP: schema.TOTPConfiguration{ + Disable: false, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: false, + }, + AccessControl: schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"example.com"}, + Policy: "one_factor", + }, + }, + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + + ConfigurationGet(s.mock.Ctx) + + s.mock.Assert200OK(s.T(), configurationBody{ + AvailableMethods: []string{}, + }) +} + +func (s *SecondFactorAvailableMethodsFixture) TestShouldRemoveAllMethodsWhenAllDisabledOrNotConfigured() { + s.mock.Ctx.Configuration = schema.Configuration{ + DuoAPI: nil, + TOTP: schema.TOTPConfiguration{ + Disable: true, + }, + Webauthn: schema.WebauthnConfiguration{ + Disable: true, + }, + AccessControl: schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"example.com"}, + Policy: "two_factor", + }, + }, + }} + + s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration) + + ConfigurationGet(s.mock.Ctx) + + s.mock.Assert200OK(s.T(), configurationBody{ + AvailableMethods: []string{}, }) } diff --git a/internal/handlers/handler_register_u2f_step1.go b/internal/handlers/handler_register_u2f_step1.go deleted file mode 100644 index fc2304b9..00000000 --- a/internal/handlers/handler_register_u2f_step1.go +++ /dev/null @@ -1,70 +0,0 @@ -package handlers - -import ( - "fmt" - - "github.com/tstranex/u2f" - - "github.com/authelia/authelia/v4/internal/middlewares" -) - -var u2fConfig = &u2f.Config{ - // Chrome 66+ doesn't return the device's attestation - // certificate by default. - SkipAttestationVerify: true, -} - -// SecondFactorU2FIdentityStart the handler for initiating the identity validation. -var SecondFactorU2FIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{ - MailTitle: "Register your key", - MailButtonContent: "Register", - TargetEndpoint: "/security-key/register", - ActionClaim: ActionU2FRegistration, - IdentityRetrieverFunc: identityRetrieverFromSession, -}, nil) - -func secondFactorU2FIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { - if ctx.XForwardedProto() == nil { - ctx.Error(errMissingXForwardedProto, messageOperationFailed) - return - } - - if ctx.XForwardedHost() == nil { - ctx.Error(errMissingXForwardedHost, messageOperationFailed) - return - } - - appID := fmt.Sprintf("%s://%s", ctx.XForwardedProto(), ctx.XForwardedHost()) - ctx.Logger.Tracef("U2F appID is %s", appID) - - var trustedFacets = []string{appID} - - challenge, err := u2f.NewChallenge(appID, trustedFacets) - - if err != nil { - ctx.Error(fmt.Errorf("unable to generate new U2F challenge for registration: %s", err), messageOperationFailed) - return - } - - // Save the challenge in the user session. - userSession := ctx.GetSession() - userSession.U2FChallenge = challenge - err = ctx.SaveSession(userSession) - - if err != nil { - ctx.Error(fmt.Errorf("unable to save U2F challenge in session: %s", err), messageOperationFailed) - return - } - - err = ctx.SetJSONBody(u2f.NewWebRegisterRequest(challenge, []u2f.Registration{})) - if err != nil { - ctx.Logger.Errorf("Unable to create request to enrol new token: %s", err) - } -} - -// SecondFactorU2FIdentityFinish the handler for finishing the identity validation. -var SecondFactorU2FIdentityFinish = middlewares.IdentityVerificationFinish( - middlewares.IdentityVerificationFinishArgs{ - ActionClaim: ActionU2FRegistration, - IsTokenUserValidFunc: isTokenUserValidFor2FARegistration, - }, secondFactorU2FIdentityFinish) diff --git a/internal/handlers/handler_register_u2f_step1_test.go b/internal/handlers/handler_register_u2f_step1_test.go deleted file mode 100644 index 3a3a160b..00000000 --- a/internal/handlers/handler_register_u2f_step1_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package handlers - -import ( - "fmt" - "testing" - "time" - - "github.com/golang-jwt/jwt/v4" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/authelia/authelia/v4/internal/mocks" - "github.com/authelia/authelia/v4/internal/models" -) - -type HandlerRegisterU2FStep1Suite struct { - suite.Suite - - mock *mocks.MockAutheliaCtx -} - -func (s *HandlerRegisterU2FStep1Suite) SetupTest() { - s.mock = mocks.NewMockAutheliaCtx(s.T()) - - userSession := s.mock.Ctx.GetSession() - userSession.Username = testUsername - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) -} - -func (s *HandlerRegisterU2FStep1Suite) TearDownTest() { - s.mock.Close() -} - -func createToken(ctx *mocks.MockAutheliaCtx, username, action string, expiresAt time.Time) (data string, verification models.IdentityVerification) { - verification = models.NewIdentityVerification(uuid.New(), username, action, ctx.Ctx.RemoteIP()) - - verification.ExpiresAt = expiresAt - - claims := verification.ToIdentityVerificationClaim() - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - ss, _ := token.SignedString([]byte(ctx.Ctx.Configuration.JWTSecret)) - - return ss, verification -} - -func (s *HandlerRegisterU2FStep1Suite) TestShouldRaiseWhenXForwardedHostIsMissing() { - s.mock.Ctx.Request.Header.Add("X-Forwarded-Proto", "http") - token, verification := createToken(s.mock, "john", ActionU2FRegistration, - time.Now().Add(1*time.Minute)) - s.mock.Ctx.Request.SetBodyString(fmt.Sprintf("{\"token\":\"%s\"}", token)) - - s.mock.StorageMock.EXPECT(). - FindIdentityVerification(s.mock.Ctx, gomock.Eq(verification.JTI.String())). - Return(true, nil) - - s.mock.StorageMock.EXPECT(). - ConsumeIdentityVerification(s.mock.Ctx, gomock.Eq(verification.JTI.String()), gomock.Eq(models.NewNullIP(s.mock.Ctx.RemoteIP()))). - Return(nil) - - SecondFactorU2FIdentityFinish(s.mock.Ctx) - - assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "missing header X-Forwarded-Host", s.mock.Hook.LastEntry().Message) -} - -func TestShouldRunHandlerRegisterU2FStep1Suite(t *testing.T) { - suite.Run(t, new(HandlerRegisterU2FStep1Suite)) -} diff --git a/internal/handlers/handler_register_u2f_step2.go b/internal/handlers/handler_register_u2f_step2.go deleted file mode 100644 index 25124211..00000000 --- a/internal/handlers/handler_register_u2f_step2.go +++ /dev/null @@ -1,63 +0,0 @@ -package handlers - -import ( - "crypto/elliptic" - "fmt" - - "github.com/tstranex/u2f" - - "github.com/authelia/authelia/v4/internal/middlewares" - "github.com/authelia/authelia/v4/internal/models" -) - -// SecondFactorU2FRegister handler validating the client has successfully validated the challenge -// to complete the U2F registration. -func SecondFactorU2FRegister(ctx *middlewares.AutheliaCtx) { - responseBody := u2f.RegisterResponse{} - err := ctx.ParseBody(&responseBody) - - if err != nil { - ctx.Error(fmt.Errorf("unable to parse response body: %v", err), messageUnableToRegisterSecurityKey) - } - - userSession := ctx.GetSession() - - if userSession.U2FChallenge == nil { - ctx.Error(fmt.Errorf("U2F registration has not been initiated yet"), messageUnableToRegisterSecurityKey) - return - } - // Ensure the challenge is cleared if anything goes wrong. - defer func() { - userSession.U2FChallenge = nil - - err := ctx.SaveSession(userSession) - if err != nil { - ctx.Logger.Errorf("Unable to clear U2F challenge in session for user %s: %s", userSession.Username, err) - } - }() - - registration, err := u2f.Register(responseBody, *userSession.U2FChallenge, u2fConfig) - - if err != nil { - ctx.Error(fmt.Errorf("unable to verify U2F registration: %v", err), messageUnableToRegisterSecurityKey) - return - } - - ctx.Logger.Debugf("Register U2F device for user %s", userSession.Username) - - publicKey := elliptic.Marshal(elliptic.P256(), registration.PubKey.X, registration.PubKey.Y) - - err = ctx.Providers.StorageProvider.SaveU2FDevice(ctx, models.U2FDevice{ - Username: userSession.Username, - Description: "Primary", - KeyHandle: registration.KeyHandle, - PublicKey: publicKey}, - ) - - if err != nil { - ctx.Error(fmt.Errorf("unable to register U2F device for user %s: %v", userSession.Username, err), messageUnableToRegisterSecurityKey) - return - } - - ctx.ReplyOK() -} diff --git a/internal/handlers/handler_register_webauthn.go b/internal/handlers/handler_register_webauthn.go new file mode 100644 index 00000000..53acb510 --- /dev/null +++ b/internal/handlers/handler_register_webauthn.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "bytes" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/regulation" +) + +// SecondFactorWebauthnIdentityStart the handler for initiating the identity validation. +var SecondFactorWebauthnIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{ + MailTitle: "Register your key", + MailButtonContent: "Register", + TargetEndpoint: "/webauthn/register", + ActionClaim: ActionWebauthnRegistration, + IdentityRetrieverFunc: identityRetrieverFromSession, +}, nil) + +// SecondFactorWebauthnIdentityFinish the handler for finishing the identity validation. +var SecondFactorWebauthnIdentityFinish = middlewares.IdentityVerificationFinish( + middlewares.IdentityVerificationFinishArgs{ + ActionClaim: ActionWebauthnRegistration, + IsTokenUserValidFunc: isTokenUserValidFor2FARegistration, + }, SecondFactorWebauthnAttestationGET) + +// SecondFactorWebauthnAttestationGET returns the attestation challenge from the server. +func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) { + var ( + w *webauthn.WebAuthn + user *models.WebauthnUser + err error + ) + + userSession := ctx.GetSession() + + 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) + + 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) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Logger.Errorf(logFmtErrSessionSave, "attestation challenge", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + if err = ctx.SetJSONBody(credentialCreation); err != nil { + ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } +} + +// SecondFactorWebauthnAttestationPOST processes the attestation challenge response from the client. +func SecondFactorWebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { + var ( + err error + w *webauthn.WebAuthn + user *models.WebauthnUser + + attestationResponse *protocol.ParsedCredentialCreationData + credential *webauthn.Credential + ) + + userSession := ctx.GetSession() + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + 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) + + 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) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + device := models.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, "Primary", credential) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + ctx.ReplyOK() + ctx.SetStatusCode(fasthttp.StatusCreated) +} diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index 7657cc2d..e9b59f16 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -58,6 +58,16 @@ func SecondFactorTOTPPost(ctx *middlewares.AutheliaCtx) { return } + config.UpdateSignInInfo(ctx.Clock.Now()) + + if err = ctx.Providers.StorageProvider.UpdateTOTPConfigurationSignIn(ctx, config.ID, config.LastUsedAt); err != nil { + ctx.Logger.Errorf("Unable to save %s device sign in metadata for user '%s': %v", regulation.AuthTypeTOTP, userSession.Username, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + userSession.SetTwoFactor(ctx.Clock.Now()) if err = ctx.SaveSession(userSession); err != nil { diff --git a/internal/handlers/handler_sign_totp_test.go b/internal/handlers/handler_sign_totp_test.go index 84d3a3b2..ca28731d 100644 --- a/internal/handlers/handler_sign_totp_test.go +++ b/internal/handlers/handler_sign_totp_test.go @@ -2,18 +2,17 @@ package handlers import ( "encoding/json" + "errors" "regexp" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/tstranex/u2f" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/models" "github.com/authelia/authelia/v4/internal/regulation" - "github.com/authelia/authelia/v4/internal/session" ) type HandlerSignTOTPSuite struct { @@ -26,8 +25,6 @@ func (s *HandlerSignTOTPSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) userSession := s.mock.Ctx.GetSession() userSession.Username = testUsername - userSession.U2FChallenge = &u2f.Challenge{} - userSession.U2FRegistration = &session.U2FRegistration{} err := s.mock.Ctx.SaveSession(userSession) require.NoError(s.T(), err) } @@ -56,6 +53,10 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() { s.mock.TOTPMock.EXPECT().Validate(gomock.Eq("abc"), gomock.Eq(&config)).Return(true, nil) + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()) + s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL bodyBytes, err := json.Marshal(signTOTPRequestBody{ @@ -70,6 +71,42 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() { }) } +func (s *HandlerSignTOTPSuite) TestShouldFailWhenTOTPSignInInfoFailsToUpdate() { + config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} + + s.mock.StorageMock.EXPECT(). + LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). + Return(&config, nil) + + s.mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ + Username: "john", + Successful: true, + Banned: false, + Time: s.mock.Clock.Now(), + Type: regulation.AuthTypeTOTP, + RemoteIP: models.NewNullIPFromString("0.0.0.0"), + })) + + s.mock.TOTPMock.EXPECT().Validate(gomock.Eq("abc"), gomock.Eq(&config)).Return(true, nil) + + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()).Return(errors.New("failed to perform update")) + + s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL + + bodyBytes, err := json.Marshal(signTOTPRequestBody{ + Token: "abc", + }) + s.Require().NoError(err) + s.mock.Ctx.Request.SetBody(bodyBytes) + + SecondFactorTOTPPost(s.mock.Ctx) + s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.") +} + func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() { config := models.TOTPConfiguration{ID: 1, Username: "john", Digits: 6, Secret: []byte("secret"), Period: 30, Algorithm: "SHA1"} @@ -90,6 +127,10 @@ func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() { s.mock.TOTPMock.EXPECT().Validate(gomock.Eq("abc"), gomock.Eq(&config)).Return(true, nil) + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()) + bodyBytes, err := json.Marshal(signTOTPRequestBody{ Token: "abc", }) @@ -120,10 +161,15 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() { s.mock.TOTPMock.EXPECT().Validate(gomock.Eq("abc"), gomock.Eq(&config)).Return(true, nil) + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()) + bodyBytes, err := json.Marshal(signTOTPRequestBody{ Token: "abc", TargetURL: "https://mydomain.local", }) + s.Require().NoError(err) s.mock.Ctx.Request.SetBody(bodyBytes) @@ -135,7 +181,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() { func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() { s.mock.StorageMock.EXPECT(). - LoadTOTPConfiguration(s.mock.Ctx, gomock.Any()). + LoadTOTPConfiguration(s.mock.Ctx, "john"). Return(&models.TOTPConfiguration{Secret: []byte("secret")}, nil) s.mock.StorageMock. @@ -149,6 +195,10 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() { RemoteIP: models.NewNullIPFromString("0.0.0.0"), })) + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()) + s.mock.TOTPMock.EXPECT(). Validate(gomock.Eq("abc"), gomock.Eq(&models.TOTPConfiguration{Secret: []byte("secret")})). Return(true, nil) @@ -187,6 +237,10 @@ func (s *HandlerSignTOTPSuite) TestShouldRegenerateSessionForPreventingSessionFi Validate(gomock.Eq("abc"), gomock.Eq(&config)). Return(true, nil) + s.mock.StorageMock. + EXPECT(). + UpdateTOTPConfigurationSignIn(s.mock.Ctx, gomock.Any(), gomock.Any()) + bodyBytes, err := json.Marshal(signTOTPRequestBody{ Token: "abc", }) diff --git a/internal/handlers/handler_sign_u2f_step1.go b/internal/handlers/handler_sign_u2f_step1.go deleted file mode 100644 index 9949b5bd..00000000 --- a/internal/handlers/handler_sign_u2f_step1.go +++ /dev/null @@ -1,93 +0,0 @@ -package handlers - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "fmt" - - "github.com/tstranex/u2f" - - "github.com/authelia/authelia/v4/internal/middlewares" - "github.com/authelia/authelia/v4/internal/regulation" - "github.com/authelia/authelia/v4/internal/session" - "github.com/authelia/authelia/v4/internal/storage" -) - -// SecondFactorU2FSignGet handler for initiating a signing request. -func SecondFactorU2FSignGet(ctx *middlewares.AutheliaCtx) { - if ctx.XForwardedProto() == nil { - ctx.Error(errMissingXForwardedProto, messageMFAValidationFailed) - return - } - - if ctx.XForwardedHost() == nil { - ctx.Error(errMissingXForwardedHost, messageMFAValidationFailed) - return - } - - userSession := ctx.GetSession() - - appID := fmt.Sprintf("%s://%s", ctx.XForwardedProto(), ctx.XForwardedHost()) - - var trustedFacets = []string{appID} - - challenge, err := u2f.NewChallenge(appID, trustedFacets) - if err != nil { - ctx.Logger.Errorf("Unable to create %s challenge for user '%s': %+v", regulation.AuthTypeU2F, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - device, err := ctx.Providers.StorageProvider.LoadU2FDevice(ctx, userSession.Username) - if err != nil { - respondUnauthorized(ctx, messageMFAValidationFailed) - - if err == storage.ErrNoU2FDeviceHandle { - _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeU2F, fmt.Errorf("no registered U2F device")) - return - } - - ctx.Logger.Errorf("Could not load %s devices for user '%s': %+v", regulation.AuthTypeU2F, userSession.Username, err) - - return - } - - x, y := elliptic.Unmarshal(elliptic.P256(), device.PublicKey) - - registration := u2f.Registration{ - KeyHandle: device.KeyHandle, - PubKey: ecdsa.PublicKey{ - Curve: elliptic.P256(), - X: x, - Y: y, - }, - } - - // Save the challenge and registration for use in next request. - userSession.U2FRegistration = &session.U2FRegistration{ - KeyHandle: device.KeyHandle, - PublicKey: device.PublicKey, - } - - userSession.U2FChallenge = challenge - - if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf(logFmtErrSessionSave, "challenge and registration", regulation.AuthTypeU2F, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - signRequest := challenge.SignRequest([]u2f.Registration{registration}) - - if err = ctx.SetJSONBody(signRequest); err != nil { - ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeU2F, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } -} diff --git a/internal/handlers/handler_sign_u2f_step1_test.go b/internal/handlers/handler_sign_u2f_step1_test.go deleted file mode 100644 index 5381cbb2..00000000 --- a/internal/handlers/handler_sign_u2f_step1_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package handlers - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - - "github.com/authelia/authelia/v4/internal/mocks" -) - -type HandlerSignU2FStep1Suite struct { - suite.Suite - - mock *mocks.MockAutheliaCtx -} - -func (s *HandlerSignU2FStep1Suite) SetupTest() { - s.mock = mocks.NewMockAutheliaCtx(s.T()) -} - -func (s *HandlerSignU2FStep1Suite) TearDownTest() { - s.mock.Close() -} - -func (s *HandlerSignU2FStep1Suite) TestShouldRaiseWhenXForwardedProtoIsMissing() { - SecondFactorU2FSignGet(s.mock.Ctx) - - assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "missing header X-Forwarded-Host", s.mock.Hook.LastEntry().Message) -} - -func (s *HandlerSignU2FStep1Suite) TestShouldRaiseWhenXForwardedHostIsMissing() { - s.mock.Ctx.Request.Header.Add("X-Forwarded-Proto", "http") - SecondFactorU2FSignGet(s.mock.Ctx) - - assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "missing header X-Forwarded-Host", s.mock.Hook.LastEntry().Message) -} - -func TestShouldRunHandlerSignU2FStep1Suite(t *testing.T) { - suite.Run(t, new(HandlerSignU2FStep1Suite)) -} diff --git a/internal/handlers/handler_sign_u2f_step2.go b/internal/handlers/handler_sign_u2f_step2.go deleted file mode 100644 index c6705258..00000000 --- a/internal/handlers/handler_sign_u2f_step2.go +++ /dev/null @@ -1,82 +0,0 @@ -package handlers - -import ( - "errors" - - "github.com/authelia/authelia/v4/internal/middlewares" - "github.com/authelia/authelia/v4/internal/regulation" -) - -// SecondFactorU2FSignPost handler for completing a signing request. -func SecondFactorU2FSignPost(u2fVerifier U2FVerifier) middlewares.RequestHandler { - return func(ctx *middlewares.AutheliaCtx) { - var ( - requestBody signU2FRequestBody - err error - ) - - if err := ctx.ParseBody(&requestBody); err != nil { - ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeU2F, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - userSession := ctx.GetSession() - if userSession.U2FChallenge == nil { - _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeU2F, errors.New("session did not contain a challenge")) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if userSession.U2FRegistration == nil { - _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeU2F, errors.New("session did not contain a registration")) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if err = u2fVerifier.Verify(userSession.U2FRegistration.KeyHandle, userSession.U2FRegistration.PublicKey, - requestBody.SignResponse, *userSession.U2FChallenge); err != nil { - _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeU2F, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil { - ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeU2F, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if err = markAuthenticationAttempt(ctx, true, nil, userSession.Username, regulation.AuthTypeU2F, nil); err != nil { - respondUnauthorized(ctx, messageMFAValidationFailed) - return - } - - userSession.SetTwoFactor(ctx.Clock.Now()) - - err = ctx.SaveSession(userSession) - if err != nil { - ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeU2F, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if userSession.OIDCWorkflowSession != nil { - handleOIDCWorkflowResponse(ctx) - } else { - Handle2FAResponse(ctx, requestBody.TargetURL) - } - } -} diff --git a/internal/handlers/handler_sign_u2f_step2_test.go b/internal/handlers/handler_sign_u2f_step2_test.go deleted file mode 100644 index 0e050f36..00000000 --- a/internal/handlers/handler_sign_u2f_step2_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package handlers - -import ( - "encoding/json" - "regexp" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - "github.com/tstranex/u2f" - - "github.com/authelia/authelia/v4/internal/mocks" - "github.com/authelia/authelia/v4/internal/models" - "github.com/authelia/authelia/v4/internal/regulation" - "github.com/authelia/authelia/v4/internal/session" -) - -type HandlerSignU2FStep2Suite struct { - suite.Suite - - mock *mocks.MockAutheliaCtx -} - -func (s *HandlerSignU2FStep2Suite) SetupTest() { - s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession := s.mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.U2FChallenge = &u2f.Challenge{} - userSession.U2FRegistration = &session.U2FRegistration{} - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) -} - -func (s *HandlerSignU2FStep2Suite) TearDownTest() { - s.mock.Close() -} - -func (s *HandlerSignU2FStep2Suite) TestShouldRedirectUserToDefaultURL() { - u2fVerifier := mocks.NewMockU2FVerifier(s.mock.Ctrl) - - u2fVerifier.EXPECT(). - Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - s.mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ - Username: "john", - Successful: true, - Banned: false, - Time: s.mock.Clock.Now(), - Type: regulation.AuthTypeU2F, - RemoteIP: models.NewNullIPFromString("0.0.0.0"), - })) - - s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL - - bodyBytes, err := json.Marshal(signU2FRequestBody{ - SignResponse: u2f.SignResponse{}, - }) - s.Require().NoError(err) - s.mock.Ctx.Request.SetBody(bodyBytes) - - SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx) - s.mock.Assert200OK(s.T(), redirectResponse{ - Redirect: testRedirectionURL, - }) -} - -func (s *HandlerSignU2FStep2Suite) TestShouldNotReturnRedirectURL() { - u2fVerifier := mocks.NewMockU2FVerifier(s.mock.Ctrl) - - u2fVerifier.EXPECT(). - Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - s.mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ - Username: "john", - Successful: true, - Banned: false, - Time: s.mock.Clock.Now(), - Type: regulation.AuthTypeU2F, - RemoteIP: models.NewNullIPFromString("0.0.0.0"), - })) - - bodyBytes, err := json.Marshal(signU2FRequestBody{ - SignResponse: u2f.SignResponse{}, - }) - s.Require().NoError(err) - s.mock.Ctx.Request.SetBody(bodyBytes) - - SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx) - s.mock.Assert200OK(s.T(), nil) -} - -func (s *HandlerSignU2FStep2Suite) TestShouldRedirectUserToSafeTargetURL() { - u2fVerifier := mocks.NewMockU2FVerifier(s.mock.Ctrl) - - u2fVerifier.EXPECT(). - Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - s.mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ - Username: "john", - Successful: true, - Banned: false, - Time: s.mock.Clock.Now(), - Type: regulation.AuthTypeU2F, - RemoteIP: models.NewNullIPFromString("0.0.0.0"), - })) - - bodyBytes, err := json.Marshal(signU2FRequestBody{ - SignResponse: u2f.SignResponse{}, - TargetURL: "https://mydomain.local", - }) - s.Require().NoError(err) - s.mock.Ctx.Request.SetBody(bodyBytes) - - SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx) - s.mock.Assert200OK(s.T(), redirectResponse{ - Redirect: "https://mydomain.local", - }) -} - -func (s *HandlerSignU2FStep2Suite) TestShouldNotRedirectToUnsafeURL() { - u2fVerifier := mocks.NewMockU2FVerifier(s.mock.Ctrl) - - u2fVerifier.EXPECT(). - Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - s.mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ - Username: "john", - Successful: true, - Banned: false, - Time: s.mock.Clock.Now(), - Type: regulation.AuthTypeU2F, - RemoteIP: models.NewNullIPFromString("0.0.0.0"), - })) - - bodyBytes, err := json.Marshal(signU2FRequestBody{ - SignResponse: u2f.SignResponse{}, - TargetURL: "http://mydomain.local", - }) - s.Require().NoError(err) - s.mock.Ctx.Request.SetBody(bodyBytes) - - SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx) - s.mock.Assert200OK(s.T(), nil) -} - -func (s *HandlerSignU2FStep2Suite) TestShouldRegenerateSessionForPreventingSessionFixation() { - u2fVerifier := mocks.NewMockU2FVerifier(s.mock.Ctrl) - - u2fVerifier.EXPECT(). - Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - s.mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{ - Username: "john", - Successful: true, - Banned: false, - Time: s.mock.Clock.Now(), - Type: regulation.AuthTypeU2F, - RemoteIP: models.NewNullIPFromString("0.0.0.0"), - })) - - bodyBytes, err := json.Marshal(signU2FRequestBody{ - SignResponse: u2f.SignResponse{}, - }) - s.Require().NoError(err) - s.mock.Ctx.Request.SetBody(bodyBytes) - - r := regexp.MustCompile("^authelia_session=(.*); path=") - res := r.FindAllStringSubmatch(string(s.mock.Ctx.Response.Header.PeekCookie("authelia_session")), -1) - - SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx) - s.mock.Assert200OK(s.T(), nil) - - s.Assert().NotEqual( - res[0][1], - string(s.mock.Ctx.Request.Header.Cookie("authelia_session"))) -} - -func TestRunHandlerSignU2FStep2Suite(t *testing.T) { - suite.Run(t, new(HandlerSignU2FStep2Suite)) -} diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go new file mode 100644 index 00000000..0cdb2213 --- /dev/null +++ b/internal/handlers/handler_sign_webauthn.go @@ -0,0 +1,204 @@ +package handlers + +import ( + "bytes" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/regulation" +) + +// SecondFactorWebauthnAssertionGET handler starts the assertion ceremony. +func SecondFactorWebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { + var ( + w *webauthn.WebAuthn + user *models.WebauthnUser + err error + ) + + userSession := ctx.GetSession() + + 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) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + var opts = []webauthn.LoginOption{ + webauthn.WithAllowedCredentials(user.WebAuthnCredentialDescriptors()), + } + + extensions := make(map[string]interface{}) + + if user.HasFIDOU2F() { + extensions["appid"] = w.Config.RPOrigin + } + + if len(extensions) != 0 { + opts = append(opts, webauthn.WithAssertionExtensions(extensions)) + } + + var assertion *protocol.CredentialAssertion + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if err = ctx.SetJSONBody(assertion); err != nil { + ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } +} + +// SecondFactorWebauthnAssertionPOST handler completes the assertion ceremony after verifying the challenge. +func SecondFactorWebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { + var ( + err error + w *webauthn.WebAuthn + + requestBody signWebauthnRequestBody + ) + + if err = ctx.ParseBody(&requestBody); err != nil { + ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeWebauthn, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + userSession := ctx.GetSession() + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + var ( + assertionResponse *protocol.ParsedCredentialAssertionData + credential *webauthn.Credential + user *models.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) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if credential, err = w.ValidateLogin(user, *userSession.Webauthn, assertionResponse); err != nil { + _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebauthn, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + var found bool + + for _, device := range user.Devices { + if bytes.Equal(device.KID.Bytes(), credential.ID) { + device.UpdateSignInInfo(w.Config, ctx.Clock.Now(), credential.Authenticator.SignCount) + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + break + } + } + + 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) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil { + ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if err = markAuthenticationAttempt(ctx, true, nil, userSession.Username, regulation.AuthTypeWebauthn, nil); err != nil { + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + userSession.SetTwoFactor(ctx.Clock.Now()) + userSession.Webauthn = nil + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the assertion challenge and authentication time", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } + + if userSession.OIDCWorkflowSession != nil { + handleOIDCWorkflowResponse(ctx) + } else { + Handle2FAResponse(ctx, requestBody.TargetURL) + } +} diff --git a/internal/handlers/handler_user_info_test.go b/internal/handlers/handler_user_info_test.go index 11e253a3..e4ab6823 100644 --- a/internal/handlers/handler_user_info_test.go +++ b/internal/handlers/handler_user_info_test.go @@ -51,25 +51,25 @@ func TestMethodSetToU2F(t *testing.T) { }, { db: models.UserInfo{ - Method: "u2f", - HasU2F: true, - HasTOTP: true, + Method: "webauthn", + HasWebauthn: true, + HasTOTP: true, }, err: nil, }, { db: models.UserInfo{ - Method: "u2f", - HasU2F: true, - HasTOTP: false, + Method: "webauthn", + HasWebauthn: true, + HasTOTP: false, }, err: nil, }, { db: models.UserInfo{ - Method: "mobile_push", - HasU2F: false, - HasTOTP: false, + Method: "mobile_push", + HasWebauthn: false, + HasTOTP: false, }, err: nil, }, @@ -116,8 +116,8 @@ func TestMethodSetToU2F(t *testing.T) { assert.Equal(t, resp.api.Method, actualPreferences.Method) }) - t.Run("registered u2f", func(t *testing.T) { - assert.Equal(t, resp.api.HasU2F, actualPreferences.HasU2F) + t.Run("registered webauthn", func(t *testing.T) { + assert.Equal(t, resp.api.HasWebauthn, actualPreferences.HasWebauthn) }) t.Run("registered totp", func(t *testing.T) { @@ -205,14 +205,14 @@ func (s *SaveSuite) TestShouldReturnError500WhenBadMethodProvided() { MethodPreferencePost(s.mock.Ctx) s.mock.Assert200KO(s.T(), "Operation failed.") - assert.Equal(s.T(), "unknown method 'abc', it should be one of totp, u2f, mobile_push", s.mock.Hook.LastEntry().Message) + assert.Equal(s.T(), "unknown method 'abc', it should be one of totp, webauthn, mobile_push", s.mock.Hook.LastEntry().Message) assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level) } func (s *SaveSuite) TestShouldReturnError500WhenDatabaseFailsToSave() { - s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}")) + s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"webauthn\"}")) s.mock.StorageMock.EXPECT(). - SavePreferred2FAMethod(s.mock.Ctx, gomock.Eq("john"), gomock.Eq("u2f")). + SavePreferred2FAMethod(s.mock.Ctx, gomock.Eq("john"), gomock.Eq("webauthn")). Return(fmt.Errorf("Failure")) MethodPreferencePost(s.mock.Ctx) @@ -223,9 +223,9 @@ func (s *SaveSuite) TestShouldReturnError500WhenDatabaseFailsToSave() { } func (s *SaveSuite) TestShouldReturn200WhenMethodIsSuccessfullySaved() { - s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}")) + s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"webauthn\"}")) s.mock.StorageMock.EXPECT(). - SavePreferred2FAMethod(s.mock.Ctx, gomock.Eq("john"), gomock.Eq("u2f")). + SavePreferred2FAMethod(s.mock.Ctx, gomock.Eq("john"), gomock.Eq("webauthn")). Return(nil) MethodPreferencePost(s.mock.Ctx) diff --git a/internal/handlers/types.go b/internal/handlers/types.go index e26d9840..785d6f0d 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -1,8 +1,6 @@ package handlers import ( - "github.com/tstranex/u2f" - "github.com/authelia/authelia/v4/internal/authentication" ) @@ -13,8 +11,7 @@ type authorizationMatching int // configurationBody the content returned by the configuration endpoint. type configurationBody struct { - AvailableMethods MethodList `json:"available_methods"` - SecondFactorEnabled bool `json:"second_factor_enabled"` // whether second factor is enabled or not. + AvailableMethods MethodList `json:"available_methods"` } // signTOTPRequestBody model of the request body received by TOTP authentication endpoint. @@ -23,10 +20,9 @@ type signTOTPRequestBody struct { TargetURL string `json:"targetURL"` } -// signU2FRequestBody model of the request body of U2F authentication endpoint. -type signU2FRequestBody struct { - SignResponse u2f.SignResponse `json:"signResponse"` - TargetURL string `json:"targetURL"` +// signWebauthnRequestBody model of the request body of Webauthn authentication endpoint. +type signWebauthnRequestBody struct { + TargetURL string `json:"targetURL"` } type signDuoRequestBody struct { diff --git a/internal/handlers/u2f.go b/internal/handlers/u2f.go deleted file mode 100644 index 1638e842..00000000 --- a/internal/handlers/u2f.go +++ /dev/null @@ -1,32 +0,0 @@ -package handlers - -import ( - "crypto/elliptic" - - "github.com/tstranex/u2f" -) - -// U2FVerifier is the interface for verifying U2F keys. -type U2FVerifier interface { - Verify(keyHandle []byte, publicKey []byte, signResponse u2f.SignResponse, challenge u2f.Challenge) error -} - -// U2FVerifierImpl the production implementation for U2F key verification. -type U2FVerifierImpl struct{} - -// Verify verifies U2F keys. -func (uv *U2FVerifierImpl) Verify(keyHandle []byte, publicKey []byte, - signResponse u2f.SignResponse, challenge u2f.Challenge) error { - var registration u2f.Registration - registration.KeyHandle = keyHandle - x, y := elliptic.Unmarshal(elliptic.P256(), publicKey) - registration.PubKey.Curve = elliptic.P256() - registration.PubKey.X = x - registration.PubKey.Y = y - - // TODO(c.michaud): store the counter to help detecting cloned U2F keys. - _, err := registration.Authenticate( - signResponse, challenge, 0) - - return err -} diff --git a/internal/handlers/webauthn.go b/internal/handlers/webauthn.go new file mode 100644 index 00000000..509b24c3 --- /dev/null +++ b/internal/handlers/webauthn.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/session" +) + +func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *models.WebauthnUser, err error) { + user = &models.WebauthnUser{ + Username: userSession.Username, + DisplayName: userSession.DisplayName, + } + + if user.DisplayName == "" { + user.DisplayName = user.Username + } + + if user.Devices, err = ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, userSession.Username); err != nil { + return nil, err + } + + return user, nil +} + +func newWebauthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) { + var ( + u *url.URL + ) + + if u, err = ctx.GetOriginalURL(); err != nil { + return nil, err + } + + rpID := u.Hostname() + origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host) + + config := &webauthn.Config{ + RPDisplayName: ctx.Configuration.Webauthn.DisplayName, + RPID: rpID, + RPOrigin: origin, + RPIcon: "", + + AttestationPreference: ctx.Configuration.Webauthn.ConveyancePreference, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + AuthenticatorAttachment: protocol.CrossPlatform, + UserVerification: ctx.Configuration.Webauthn.UserVerification, + RequireResidentKey: protocol.ResidentKeyUnrequired(), + }, + + Timeout: int(ctx.Configuration.Webauthn.Timeout.Milliseconds()), + } + + ctx.Logger.Tracef("Creating new Webauthn RP instance with ID %s and Origin %s", config.RPID, config.RPOrigin) + + return webauthn.New(config) +} diff --git a/internal/handlers/webauthn_test.go b/internal/handlers/webauthn_test.go new file mode 100644 index 00000000..0070bd87 --- /dev/null +++ b/internal/handlers/webauthn_test.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "errors" + "testing" + + "github.com/duo-labs/webauthn/protocol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/models" + "github.com/authelia/authelia/v4/internal/session" +) + +func TestWebauthnGetUser(t *testing.T) { + ctx := mocks.NewMockAutheliaCtx(t) + + userSession := session.UserSession{ + Username: "john", + DisplayName: "John Smith", + } + + ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "john").Return([]models.WebauthnDevice{ + { + ID: 1, + RPID: "https://example.com", + Username: "john", + Description: "Primary", + KID: models.NewBase64([]byte("abc123")), + AttestationType: "fido-u2f", + PublicKey: []byte("data"), + SignCount: 0, + CloneWarning: false, + }, + { + ID: 2, + RPID: "example.com", + Username: "john", + Description: "Secondary", + KID: models.NewBase64([]byte("123abc")), + AttestationType: "packed", + Transport: "usb,nfc", + PublicKey: []byte("data"), + SignCount: 100, + CloneWarning: false, + }, + }, nil) + + user, err := getWebAuthnUser(ctx.Ctx, userSession) + + require.NoError(t, err) + require.NotNil(t, user) + + assert.Equal(t, []byte("john"), 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, "https://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, []byte("abc123"), 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, []byte("123abc"), 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 TestWebauthnGetUserWithoutDisplayName(t *testing.T) { + ctx := mocks.NewMockAutheliaCtx(t) + + userSession := session.UserSession{ + Username: "john", + } + + ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "john").Return([]models.WebauthnDevice{ + { + ID: 1, + RPID: "https://example.com", + Username: "john", + Description: "Primary", + KID: models.NewBase64([]byte("abc123")), + AttestationType: "fido-u2f", + PublicKey: []byte("data"), + SignCount: 0, + CloneWarning: false, + }, + }, nil) + + user, err := getWebAuthnUser(ctx.Ctx, userSession) + + require.NoError(t, err) + require.NotNil(t, user) + + assert.Equal(t, "john", user.WebAuthnDisplayName()) + assert.Equal(t, "john", user.DisplayName) +} + +func TestWebauthnGetUserWithErr(t *testing.T) { + ctx := mocks.NewMockAutheliaCtx(t) + + userSession := session.UserSession{ + Username: "john", + } + + ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "john").Return(nil, errors.New("not found")) + + user, err := getWebAuthnUser(ctx.Ctx, userSession) + + assert.EqualError(t, err, "not found") + assert.Nil(t, user) +} + +func TestWebauthnNewWebauthnShouldReturnErrWhenHeadersNotAvailable(t *testing.T) { + ctx := mocks.NewMockAutheliaCtx(t) + + w, err := newWebauthn(ctx.Ctx) + + assert.Nil(t, w) + assert.EqualError(t, err, "Missing header X-Forwarded-Host") +} + +func TestWebauthnNewWebauthnShouldReturnErrWhenWebauthnNotConfigured(t *testing.T) { + ctx := mocks.NewMockAutheliaCtx(t) + + ctx.Ctx.Request.Header.Set("X-Forwarded-Host", "example.com") + ctx.Ctx.Request.Header.Set("X-Forwarded-URI", "/") + ctx.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") + + w, err := newWebauthn(ctx.Ctx) + + assert.Nil(t, w) + assert.EqualError(t, err, "Configuration error: Missing RPDisplayName") +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 71a24447..cd1baead 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -5,8 +5,10 @@ import ( "io" "log" "os" + "runtime" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -81,3 +83,33 @@ func TestShouldFormatLogsAsJSON(t *testing.T) { assert.Contains(t, string(b), "{\"level\":\"info\",\"msg\":\"This is a test\",") } + +func TestShouldRaiseErrorOnInvalidFile(t *testing.T) { + err := InitializeLogger(schema.LogConfiguration{FilePath: "/not/a/valid/path/to.log"}, false) + + switch runtime.GOOS { + case "windows": + assert.EqualError(t, err, "open /not/a/valid/path/to.log: The system cannot find the path specified.") + default: + assert.EqualError(t, err, "open /not/a/valid/path/to.log: no such file or directory") + } +} + +func TestSetLevels(t *testing.T) { + assert.Equal(t, logrus.InfoLevel, logrus.GetLevel()) + + setLevelStr("error", false) + assert.Equal(t, logrus.ErrorLevel, logrus.GetLevel()) + + setLevelStr("warn", false) + assert.Equal(t, logrus.WarnLevel, logrus.GetLevel()) + + setLevelStr("info", false) + assert.Equal(t, logrus.InfoLevel, logrus.GetLevel()) + + setLevelStr("debug", false) + assert.Equal(t, logrus.DebugLevel, logrus.GetLevel()) + + setLevelStr("trace", false) + assert.Equal(t, logrus.TraceLevel, logrus.GetLevel()) +} diff --git a/internal/mocks/generate.go b/internal/mocks/generate.go index 884f0c06..4ec2697f 100644 --- a/internal/mocks/generate.go +++ b/internal/mocks/generate.go @@ -6,6 +6,5 @@ package mocks //go:generate mockgen -package mocks -destination user_provider.go -mock_names UserProvider=MockUserProvider github.com/authelia/authelia/v4/internal/authentication UserProvider //go:generate mockgen -package mocks -destination notifier.go -mock_names Notifier=MockNotifier github.com/authelia/authelia/v4/internal/notification Notifier //go:generate mockgen -package mocks -destination totp.go -mock_names Provider=MockTOTP github.com/authelia/authelia/v4/internal/totp Provider -//go:generate mockgen -package mocks -destination u2f_verifier.go -mock_names U2FVerifier=MockU2FVerifier github.com/authelia/authelia/v4/internal/handlers U2FVerifier //go:generate mockgen -package mocks -destination storage.go -mock_names Provider=MockStorage github.com/authelia/authelia/v4/internal/storage Provider //go:generate mockgen -package mocks -destination duo_api.go -mock_names API=MockAPI github.com/authelia/authelia/v4/internal/duo API diff --git a/internal/mocks/storage.go b/internal/mocks/storage.go index 0e301490..dce1d84a 100644 --- a/internal/mocks/storage.go +++ b/internal/mocks/storage.go @@ -197,36 +197,6 @@ func (mr *MockStorageMockRecorder) LoadTOTPConfigurations(arg0, arg1, arg2 inter return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadTOTPConfigurations", reflect.TypeOf((*MockStorage)(nil).LoadTOTPConfigurations), arg0, arg1, arg2) } -// LoadU2FDevice mocks base method. -func (m *MockStorage) LoadU2FDevice(arg0 context.Context, arg1 string) (*models.U2FDevice, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LoadU2FDevice", arg0, arg1) - ret0, _ := ret[0].(*models.U2FDevice) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// LoadU2FDevice indicates an expected call of LoadU2FDevice. -func (mr *MockStorageMockRecorder) LoadU2FDevice(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadU2FDevice", reflect.TypeOf((*MockStorage)(nil).LoadU2FDevice), arg0, arg1) -} - -// LoadU2FDevices mocks base method. -func (m *MockStorage) LoadU2FDevices(arg0 context.Context, arg1, arg2 int) ([]models.U2FDevice, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LoadU2FDevices", arg0, arg1, arg2) - ret0, _ := ret[0].([]models.U2FDevice) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// LoadU2FDevices indicates an expected call of LoadU2FDevices. -func (mr *MockStorageMockRecorder) LoadU2FDevices(arg0, arg1, arg2 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadU2FDevices", reflect.TypeOf((*MockStorage)(nil).LoadU2FDevices), arg0, arg1, arg2) -} - // LoadUserInfo mocks base method. func (m *MockStorage) LoadUserInfo(arg0 context.Context, arg1 string) (models.UserInfo, error) { m.ctrl.T.Helper() @@ -242,6 +212,36 @@ func (mr *MockStorageMockRecorder) LoadUserInfo(arg0, arg1 interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadUserInfo", reflect.TypeOf((*MockStorage)(nil).LoadUserInfo), arg0, arg1) } +// LoadWebauthnDevices mocks base method. +func (m *MockStorage) LoadWebauthnDevices(arg0 context.Context, arg1, arg2 int) ([]models.WebauthnDevice, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadWebauthnDevices", arg0, arg1, arg2) + ret0, _ := ret[0].([]models.WebauthnDevice) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadWebauthnDevices indicates an expected call of LoadWebauthnDevices. +func (mr *MockStorageMockRecorder) LoadWebauthnDevices(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebauthnDevices", reflect.TypeOf((*MockStorage)(nil).LoadWebauthnDevices), arg0, arg1, arg2) +} + +// LoadWebauthnDevicesByUsername mocks base method. +func (m *MockStorage) LoadWebauthnDevicesByUsername(arg0 context.Context, arg1 string) ([]models.WebauthnDevice, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadWebauthnDevicesByUsername", arg0, arg1) + ret0, _ := ret[0].([]models.WebauthnDevice) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadWebauthnDevicesByUsername indicates an expected call of LoadWebauthnDevicesByUsername. +func (mr *MockStorageMockRecorder) LoadWebauthnDevicesByUsername(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebauthnDevicesByUsername", reflect.TypeOf((*MockStorage)(nil).LoadWebauthnDevicesByUsername), arg0, arg1) +} + // SaveIdentityVerification mocks base method. func (m *MockStorage) SaveIdentityVerification(arg0 context.Context, arg1 models.IdentityVerification) error { m.ctrl.T.Helper() @@ -298,18 +298,18 @@ func (mr *MockStorageMockRecorder) SaveTOTPConfiguration(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveTOTPConfiguration", reflect.TypeOf((*MockStorage)(nil).SaveTOTPConfiguration), arg0, arg1) } -// SaveU2FDevice mocks base method. -func (m *MockStorage) SaveU2FDevice(arg0 context.Context, arg1 models.U2FDevice) error { +// SaveWebauthnDevice mocks base method. +func (m *MockStorage) SaveWebauthnDevice(arg0 context.Context, arg1 models.WebauthnDevice) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveU2FDevice", arg0, arg1) + ret := m.ctrl.Call(m, "SaveWebauthnDevice", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// SaveU2FDevice indicates an expected call of SaveU2FDevice. -func (mr *MockStorageMockRecorder) SaveU2FDevice(arg0, arg1 interface{}) *gomock.Call { +// SaveWebauthnDevice indicates an expected call of SaveWebauthnDevice. +func (mr *MockStorageMockRecorder) SaveWebauthnDevice(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveU2FDevice", reflect.TypeOf((*MockStorage)(nil).SaveU2FDevice), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebauthnDevice", reflect.TypeOf((*MockStorage)(nil).SaveWebauthnDevice), arg0, arg1) } // SchemaEncryptionChangeKey mocks base method. @@ -457,3 +457,31 @@ func (mr *MockStorageMockRecorder) StartupCheck() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartupCheck", reflect.TypeOf((*MockStorage)(nil).StartupCheck)) } + +// UpdateTOTPConfigurationSignIn mocks base method. +func (m *MockStorage) UpdateTOTPConfigurationSignIn(arg0 context.Context, arg1 int, arg2 *time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTOTPConfigurationSignIn", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTOTPConfigurationSignIn indicates an expected call of UpdateTOTPConfigurationSignIn. +func (mr *MockStorageMockRecorder) UpdateTOTPConfigurationSignIn(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + 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 *time.Time, arg4 uint32, arg5 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWebauthnDeviceSignIn", arg0, arg1, arg2, arg3, arg4, arg5) + 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 { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceSignIn), arg0, arg1, arg2, arg3, arg4, arg5) +} diff --git a/internal/mocks/u2f_verifier.go b/internal/mocks/u2f_verifier.go deleted file mode 100644 index 85715b55..00000000 --- a/internal/mocks/u2f_verifier.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/authelia/authelia/v4/internal/handlers (interfaces: U2FVerifier) - -// Package mocks is a generated GoMock package. -package mocks - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - u2f "github.com/tstranex/u2f" -) - -// MockU2FVerifier is a mock of U2FVerifier interface. -type MockU2FVerifier struct { - ctrl *gomock.Controller - recorder *MockU2FVerifierMockRecorder -} - -// MockU2FVerifierMockRecorder is the mock recorder for MockU2FVerifier. -type MockU2FVerifierMockRecorder struct { - mock *MockU2FVerifier -} - -// NewMockU2FVerifier creates a new mock instance. -func NewMockU2FVerifier(ctrl *gomock.Controller) *MockU2FVerifier { - mock := &MockU2FVerifier{ctrl: ctrl} - mock.recorder = &MockU2FVerifierMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockU2FVerifier) EXPECT() *MockU2FVerifierMockRecorder { - return m.recorder -} - -// Verify mocks base method. -func (m *MockU2FVerifier) Verify(arg0, arg1 []byte, arg2 u2f.SignResponse, arg3 u2f.Challenge) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Verify", arg0, arg1, arg2, arg3) - ret0, _ := ret[0].(error) - return ret0 -} - -// Verify indicates an expected call of Verify. -func (mr *MockU2FVerifierMockRecorder) Verify(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockU2FVerifier)(nil).Verify), arg0, arg1, arg2, arg3) -} diff --git a/internal/models/const.go b/internal/models/const.go new file mode 100644 index 00000000..db0326f7 --- /dev/null +++ b/internal/models/const.go @@ -0,0 +1,8 @@ +package models + +const ( + errFmtValueNil = "cannot value model type '%T' with value nil to driver.Value" + errFmtScanNil = "cannot scan model type '%T' from value nil: type doesn't support nil values" + errFmtScanInvalidType = "cannot scan model type '%T' from type '%T' with value '%v'" + errFmtScanInvalidTypeErr = "cannot scan model type '%T' from type '%T' with value '%v': %w" +) diff --git a/internal/models/totp_configuration.go b/internal/models/totp_configuration.go index 9e8105e2..268600db 100644 --- a/internal/models/totp_configuration.go +++ b/internal/models/totp_configuration.go @@ -4,19 +4,22 @@ import ( "image" "net/url" "strconv" + "time" "github.com/pquerna/otp" ) // TOTPConfiguration represents a users TOTP configuration row in the database. type TOTPConfiguration struct { - ID int `db:"id" json:"-"` - Username string `db:"username" json:"-"` - Issuer string `db:"issuer" json:"-"` - Algorithm string `db:"algorithm" json:"-"` - Digits uint `db:"digits" json:"digits"` - Period uint `db:"period" json:"period"` - Secret []byte `db:"secret" json:"-"` + ID int `db:"id" json:"-"` + CreatedAt time.Time `db:"created_at" json:"-"` + LastUsedAt *time.Time `db:"last_used_at" json:"-"` + Username string `db:"username" json:"-"` + Issuer string `db:"issuer" json:"-"` + Algorithm string `db:"algorithm" json:"-"` + Digits uint `db:"digits" json:"digits"` + Period uint `db:"period" json:"period"` + Secret []byte `db:"secret" json:"-"` } // URI shows the configuration in the URI representation. @@ -38,6 +41,11 @@ func (c TOTPConfiguration) URI() (uri string) { return u.String() } +// UpdateSignInInfo adjusts the values of the TOTPConfiguration after a sign in. +func (c *TOTPConfiguration) UpdateSignInInfo(now time.Time) { + c.LastUsedAt = &now +} + // Key returns the *otp.Key using TOTPConfiguration.URI with otp.NewKeyFromURL. func (c TOTPConfiguration) Key() (key *otp.Key, err error) { return otp.NewKeyFromURL(c.URI()) diff --git a/internal/models/types.go b/internal/models/types.go index 9e752b73..66defa19 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -2,7 +2,7 @@ package models import ( "database/sql/driver" - "errors" + "encoding/base64" "fmt" "net" ) @@ -26,6 +26,11 @@ func NewNullIPFromString(value string) (ip NullIP) { return NullIP{IP: net.ParseIP(value)} } +// NewBase64 returns a new Base64. +func NewBase64(data []byte) Base64 { + return Base64{data: data} +} + // IP is a type specific for storage of a net.IP in the database which can't be NULL. type IP struct { IP net.IP @@ -34,16 +39,16 @@ type IP struct { // Value is the IP implementation of the databases/sql driver.Valuer. func (ip IP) Value() (value driver.Value, err error) { if ip.IP == nil { - return nil, errors.New("cannot value nil IP to driver.Value") + return nil, fmt.Errorf(errFmtValueNil, ip) } - return driver.Value(ip.IP.String()), nil + return ip.IP.String(), nil } // Scan is the IP implementation of the sql.Scanner. func (ip *IP) Scan(src interface{}) (err error) { if src == nil { - return errors.New("cannot scan nil to type IP") + return fmt.Errorf(errFmtScanNil, ip) } var value string @@ -54,7 +59,7 @@ func (ip *IP) Scan(src interface{}) (err error) { case []byte: value = string(v) default: - return fmt.Errorf("invalid type %T for IP %v", src, src) + return fmt.Errorf(errFmtScanInvalidType, ip, src, src) } ip.IP = net.ParseIP(value) @@ -70,10 +75,10 @@ type NullIP struct { // Value is the NullIP implementation of the databases/sql driver.Valuer. func (ip NullIP) Value() (value driver.Value, err error) { if ip.IP == nil { - return driver.Value(nil), nil + return nil, nil } - return driver.Value(ip.IP.String()), nil + return ip.IP.String(), nil } // Scan is the NullIP implementation of the sql.Scanner. @@ -91,7 +96,7 @@ func (ip *NullIP) Scan(src interface{}) (err error) { case []byte: value = string(v) default: - return fmt.Errorf("invalid type %T for NullIP %v", src, src) + return fmt.Errorf(errFmtScanInvalidType, ip, src, src) } ip.IP = net.ParseIP(value) @@ -99,6 +104,48 @@ func (ip *NullIP) Scan(src interface{}) (err error) { return nil } +// Base64 saves bytes to the database as a base64 encoded string. +type Base64 struct { + data []byte +} + +// String returns the Base64 string encoded as base64. +func (b Base64) String() string { + return base64.StdEncoding.EncodeToString(b.data) +} + +// Bytes returns the Base64 string encoded as bytes. +func (b Base64) Bytes() []byte { + return b.data +} + +// Value is the Base64 implementation of the databases/sql driver.Valuer. +func (b Base64) Value() (value driver.Value, err error) { + return b.String(), nil +} + +// Scan is the Base64 implementation of the sql.Scanner. +func (b *Base64) Scan(src interface{}) (err error) { + if src == nil { + return fmt.Errorf(errFmtScanNil, b) + } + + switch v := src.(type) { + case string: + if b.data, err = base64.StdEncoding.DecodeString(v); err != nil { + return fmt.Errorf(errFmtScanInvalidTypeErr, b, src, src, err) + } + case []byte: + if b.data, err = base64.StdEncoding.DecodeString(string(v)); err != nil { + b.data = v + } + default: + return fmt.Errorf(errFmtScanInvalidType, b, src, src) + } + + return nil +} + // StartupCheck represents a provider that has a startup check. type StartupCheck interface { StartupCheck() (err error) diff --git a/internal/models/types_test.go b/internal/models/types_test.go new file mode 100644 index 00000000..559a54bb --- /dev/null +++ b/internal/models/types_test.go @@ -0,0 +1,109 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDatabaseModelTypeIP(t *testing.T) { + ip := IP{} + + value, err := ip.Value() + assert.Nil(t, value) + assert.EqualError(t, err, "cannot value model type 'models.IP' with value nil to driver.Value") + + err = ip.Scan("192.168.2.0") + assert.NoError(t, err) + + assert.True(t, ip.IP.IsPrivate()) + assert.False(t, ip.IP.IsLoopback()) + assert.Equal(t, "192.168.2.0", ip.IP.String()) + + value, err = ip.Value() + assert.NoError(t, err) + assert.Equal(t, "192.168.2.0", value) + + err = ip.Scan([]byte("127.0.0.0")) + assert.NoError(t, err) + + assert.False(t, ip.IP.IsPrivate()) + assert.True(t, ip.IP.IsLoopback()) + assert.Equal(t, "127.0.0.0", ip.IP.String()) + + err = ip.Scan(1) + + assert.EqualError(t, err, "cannot scan model type '*models.IP' from type 'int' with value '1'") + + err = ip.Scan(nil) + assert.EqualError(t, err, "cannot scan model type '*models.IP' from value nil: type doesn't support nil values") +} + +func TestDatabaseModelTypeNullIP(t *testing.T) { + ip := NullIP{} + + value, err := ip.Value() + assert.Nil(t, value) + assert.NoError(t, err) + + err = ip.Scan("192.168.2.0") + assert.NoError(t, err) + + assert.True(t, ip.IP.IsPrivate()) + assert.False(t, ip.IP.IsLoopback()) + assert.Equal(t, "192.168.2.0", ip.IP.String()) + + value, err = ip.Value() + assert.NoError(t, err) + assert.Equal(t, "192.168.2.0", value) + + err = ip.Scan([]byte("127.0.0.0")) + assert.NoError(t, err) + + assert.False(t, ip.IP.IsPrivate()) + assert.True(t, ip.IP.IsLoopback()) + assert.Equal(t, "127.0.0.0", ip.IP.String()) + + err = ip.Scan(1) + + assert.EqualError(t, err, "cannot scan model type '*models.NullIP' from type 'int' with value '1'") + + err = ip.Scan(nil) + assert.NoError(t, err) +} + +func TestDatabaseModelTypeBase64(t *testing.T) { + b64 := Base64{} + + value, err := b64.Value() + assert.Equal(t, "", value) + assert.NoError(t, err) + assert.Nil(t, b64.Bytes()) + + err = b64.Scan(nil) + assert.EqualError(t, err, "cannot scan model type '*models.Base64' from value nil: type doesn't support nil values") + + err = b64.Scan("###") + assert.EqualError(t, err, "cannot scan model type '*models.Base64' from type 'string' with value '###': illegal base64 data at input byte 0") + + err = b64.Scan(1) + assert.EqualError(t, err, "cannot scan model type '*models.Base64' from type 'int' with value '1'") + + err = b64.Scan("YXV0aGVsaWE=") + assert.NoError(t, err) + + assert.Equal(t, []byte("authelia"), b64.Bytes()) + assert.Equal(t, "YXV0aGVsaWE=", b64.String()) + + err = b64.Scan([]byte("c2VjdXJpdHk=")) + assert.NoError(t, err) + + assert.Equal(t, []byte("security"), b64.Bytes()) + assert.Equal(t, "c2VjdXJpdHk=", b64.String()) + + err = b64.Scan([]byte("###")) + assert.NoError(t, err) + + assert.Equal(t, []byte("###"), b64.Bytes()) + assert.Equal(t, "IyMj", b64.String()) +} diff --git a/internal/models/user_info.go b/internal/models/user_info.go index 9daadc4e..590f633a 100644 --- a/internal/models/user_info.go +++ b/internal/models/user_info.go @@ -11,8 +11,8 @@ type UserInfo struct { // True if a TOTP device has been registered. HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"` - // True if a security key has been registered. - HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"` + // True if a Webauthn device has been registered. + HasWebauthn bool `db:"has_webauthn" json:"has_webauthn" valid:"required"` // True if a duo device has been configured as the preferred. HasDuo bool `db:"has_duo" json:"has_duo" valid:"required"` diff --git a/internal/models/webauthn.go b/internal/models/webauthn.go new file mode 100644 index 00000000..6166148b --- /dev/null +++ b/internal/models/webauthn.go @@ -0,0 +1,167 @@ +package models + +import ( + "encoding/hex" + "strings" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + "github.com/google/uuid" +) + +const ( + attestationTypeFIDOU2F = "fido-u2f" +) + +// WebauthnUser is an object to represent a user for the Webauthn lib. +type WebauthnUser struct { + Username string + DisplayName string + Devices []WebauthnDevice +} + +// HasFIDOU2F returns true if the user has any attestation type `fido-u2f` devices. +func (w WebauthnUser) HasFIDOU2F() bool { + for _, c := range w.Devices { + if c.AttestationType == attestationTypeFIDOU2F { + return true + } + } + + return false +} + +// WebAuthnID implements the webauthn.User interface. +func (w WebauthnUser) WebAuthnID() []byte { + return []byte(w.Username) +} + +// WebAuthnName implements the webauthn.User interface. +func (w WebauthnUser) WebAuthnName() string { + return w.Username +} + +// WebAuthnDisplayName implements the webauthn.User interface. +func (w WebauthnUser) WebAuthnDisplayName() string { + return w.DisplayName +} + +// WebAuthnIcon implements the webauthn.User interface. +func (w WebauthnUser) WebAuthnIcon() string { + return "" +} + +// WebAuthnCredentials implements the webauthn.User interface. +func (w WebauthnUser) WebAuthnCredentials() (credentials []webauthn.Credential) { + credentials = make([]webauthn.Credential, len(w.Devices)) + + var credential webauthn.Credential + + for i, device := range w.Devices { + aaguid, err := device.AAGUID.MarshalBinary() + if err != nil { + continue + } + + credential = webauthn.Credential{ + ID: device.KID.Bytes(), + PublicKey: device.PublicKey, + AttestationType: device.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid, + SignCount: device.SignCount, + CloneWarning: device.CloneWarning, + }, + } + + transports := strings.Split(device.Transport, ",") + credential.Transport = []protocol.AuthenticatorTransport{} + + for _, t := range transports { + if t == "" { + continue + } + + credential.Transport = append(credential.Transport, protocol.AuthenticatorTransport(t)) + } + + credentials[i] = credential + } + + return credentials +} + +// WebAuthnCredentialDescriptors decodes the users credentials into protocol.CredentialDescriptor's. +func (w WebauthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) { + credentials := w.WebAuthnCredentials() + + descriptors = make([]protocol.CredentialDescriptor, len(credentials)) + + for i, credential := range credentials { + descriptors[i] = credential.Descriptor() + } + + return descriptors +} + +// NewWebauthnDeviceFromCredential creates a WebauthnDevice from a webauthn.Credential. +func NewWebauthnDeviceFromCredential(rpid, username, description string, credential *webauthn.Credential) (device WebauthnDevice) { + transport := make([]string, len(credential.Transport)) + + for i, t := range credential.Transport { + transport[i] = string(t) + } + + device = WebauthnDevice{ + RPID: rpid, + Username: username, + CreatedAt: time.Now(), + Description: description, + KID: NewBase64(credential.ID), + PublicKey: credential.PublicKey, + AttestationType: credential.AttestationType, + SignCount: credential.Authenticator.SignCount, + CloneWarning: credential.Authenticator.CloneWarning, + Transport: strings.Join(transport, ","), + } + + device.AAGUID, _ = uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID)) + + return device +} + +// WebauthnDevice represents a Webauthn Device in the database storage. +type WebauthnDevice struct { + ID int `db:"id"` + CreatedAt time.Time `db:"created_at"` + LastUsedAt *time.Time `db:"last_used_at"` + RPID string `db:"rpid"` + Username string `db:"username"` + Description string `db:"description"` + KID Base64 `db:"kid"` + PublicKey []byte `db:"public_key"` + AttestationType string `db:"attestation_type"` + Transport string `db:"transport"` + AAGUID uuid.UUID `db:"aaguid"` + SignCount uint32 `db:"sign_count"` + CloneWarning bool `db:"clone_warning"` +} + +// UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in. +func (w *WebauthnDevice) UpdateSignInInfo(config *webauthn.Config, now time.Time, signCount uint32) { + w.LastUsedAt = &now + + w.SignCount = signCount + + if w.RPID != "" { + return + } + + switch w.AttestationType { + case attestationTypeFIDOU2F: + w.RPID = config.RPOrigin + default: + w.RPID = config.RPID + } +} diff --git a/internal/regulation/const.go b/internal/regulation/const.go index 84b00a8a..91d93d80 100644 --- a/internal/regulation/const.go +++ b/internal/regulation/const.go @@ -12,11 +12,8 @@ const ( // AuthTypeTOTP is the string representing an auth log for second-factor authentication via TOTP. AuthTypeTOTP = "TOTP" - // AuthTypeU2F is the string representing an auth log for second-factor authentication via FIDO/CTAP1/U2F. - AuthTypeU2F = "U2F" - - // AuthTypeWebAuthn is the string representing an auth log for second-factor authentication via FIDO2/CTAP2/WebAuthn. - // TODO: Add WebAuthn. + // AuthTypeWebauthn is the string representing an auth log for second-factor authentication via FIDO2/CTAP2/WebAuthn. + AuthTypeWebauthn = "Webauthn" // AuthTypeDuo is the string representing an auth log for second-factor authentication via DUO. AuthTypeDuo = "Duo" diff --git a/internal/server/server.go b/internal/server/server.go index fe4574e5..d0dc79b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -89,31 +89,34 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr middlewares.RequireFirstFactor(handlers.UserInfoGet))) r.POST("/api/user/info/2fa_method", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.MethodPreferencePost))) - r.GET("/api/user/info/totp", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.UserTOTPGet))) - // TOTP related endpoints. - r.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityStart))) - r.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish))) - r.POST("/api/secondfactor/totp", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost))) + if !configuration.TOTP.Disable { + // TOTP related endpoints. + r.GET("/api/user/info/totp", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.UserTOTPGet))) - // U2F related endpoints. - r.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorU2FIdentityStart))) - r.POST("/api/secondfactor/u2f/identity/finish", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorU2FIdentityFinish))) + r.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityStart))) + r.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish))) + r.POST("/api/secondfactor/totp", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost))) + } - r.POST("/api/secondfactor/u2f/register", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorU2FRegister))) + if !configuration.Webauthn.Disable { + // Webauthn Endpoints. + r.POST("/api/secondfactor/webauthn/identity/start", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorWebauthnIdentityStart))) + r.POST("/api/secondfactor/webauthn/identity/finish", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorWebauthnIdentityFinish))) + r.POST("/api/secondfactor/webauthn/attestation", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorWebauthnAttestationPOST))) - r.POST("/api/secondfactor/u2f/sign_request", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignGet))) - - r.POST("/api/secondfactor/u2f/sign", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignPost(&handlers.U2FVerifierImpl{})))) + r.GET("/api/secondfactor/webauthn/assertion", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorWebauthnAssertionGET))) + r.POST("/api/secondfactor/webauthn/assertion", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.SecondFactorWebauthnAssertionPOST))) + } // Configure DUO api endpoint only if configuration exists. if configuration.DuoAPI != nil { diff --git a/internal/session/types.go b/internal/session/types.go index 0dc6ed16..f625a1b0 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -4,10 +4,10 @@ import ( "context" "time" + "github.com/duo-labs/webauthn/webauthn" "github.com/fasthttp/session/v2" "github.com/fasthttp/session/v2/providers/redis" "github.com/sirupsen/logrus" - "github.com/tstranex/u2f" "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" @@ -22,12 +22,6 @@ type ProviderConfig struct { providerName string } -// U2FRegistration is a serializable version of a U2F registration. -type U2FRegistration struct { - KeyHandle []byte - PublicKey []byte -} - // UserSession is the structure representing the session of a user. type UserSession struct { Username string @@ -43,12 +37,8 @@ type UserSession struct { FirstFactorAuthnTimestamp int64 SecondFactorAuthnTimestamp int64 - // The challenge generated in first step of U2F registration (after identity verification) or authentication. - // This is used reused in the second phase to check that the challenge has been completed. - U2FChallenge *u2f.Challenge - // The registration representing a U2F device in DB set after identity verification. - // This is used in second phase of a U2F authentication. - U2FRegistration *U2FRegistration + // Webauthn holds the session registration data for this session. + Webauthn *webauthn.SessionData // Represent an OIDC workflow session initiated by the client if not null. OIDCWorkflowSession *OIDCWorkflowSession diff --git a/internal/storage/const.go b/internal/storage/const.go index efb4389c..5b439e48 100644 --- a/internal/storage/const.go +++ b/internal/storage/const.go @@ -8,7 +8,7 @@ const ( tableUserPreferences = "user_preferences" tableIdentityVerification = "identity_verification" tableTOTPConfigurations = "totp_configurations" - tableU2FDevices = "u2f_devices" + tableWebauthnDevices = "webauthn_devices" tableDuoDevices = "duo_devices" tableAuthenticationLogs = "authentication_logs" tableMigrations = "migrations" @@ -25,6 +25,7 @@ const ( const ( tablePre1TOTPSecrets = "totp_secrets" tablePre1IdentityVerificationTokens = "identity_verification_tokens" + tablePre1U2FDevices = "u2f_devices" tablePre1Config = "config" @@ -40,9 +41,9 @@ const ( var tablesPre1 = []string{ tablePre1TOTPSecrets, tablePre1IdentityVerificationTokens, + tablePre1U2FDevices, tableUserPreferences, - tableU2FDevices, tableAuthenticationLogs, } @@ -55,7 +56,7 @@ const ( const ( // This is the latest schema version for the purpose of tests. - testLatestVersion = 1 + testLatestVersion = 2 ) const ( diff --git a/internal/storage/errors.go b/internal/storage/errors.go index 75044613..388b6913 100644 --- a/internal/storage/errors.go +++ b/internal/storage/errors.go @@ -11,8 +11,8 @@ var ( // ErrNoTOTPConfiguration error thrown when no TOTP configuration has been found in DB. ErrNoTOTPConfiguration = errors.New("no TOTP configuration for user") - // ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB. - ErrNoU2FDeviceHandle = errors.New("no U2F device handle found") + // ErrNoWebauthnDevice error thrown when no Webauthn device handle has been found in DB. + ErrNoWebauthnDevice = errors.New("no Webauthn device found") // ErrNoDuoDevice error thrown when no Duo device and method has been found in DB. ErrNoDuoDevice = errors.New("no Duo device and method saved") diff --git a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql index 0d2a4283..55bde1c5 100644 --- a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql +++ b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS authentication_logs ( username VARCHAR(100) NOT NULL, auth_type VARCHAR(8) NOT NULL DEFAULT '1FA', remote_ip VARCHAR(39) NULL DEFAULT NULL, - request_uri TEXT NOT NULL, + request_uri TEXT, request_method VARCHAR(8) NOT NULL DEFAULT '', PRIMARY KEY (id) ); diff --git a/internal/storage/migrations/V0002.Webauthn.mysql.down.sql b/internal/storage/migrations/V0002.Webauthn.mysql.down.sql new file mode 100644 index 00000000..318ca400 --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.mysql.down.sql @@ -0,0 +1,37 @@ +ALTER TABLE totp_configurations RENAME _bkp_DOWN_V0002_totp_configurations; +ALTER TABLE webauthn_devices RENAME _bkp_DOWN_V0002_webauthn_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id INTEGER AUTO_INCREMENT, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_DOWN_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS u2f_devices ( + id INTEGER AUTO_INCREMENT, + username VARCHAR(100) NOT NULL, + description VARCHAR(30) NOT NULL DEFAULT 'Primary', + key_handle BLOB NOT NULL, + public_key BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY (username, description) +); + +INSERT INTO u2f_devices (id, username, description, key_handle, public_key) +SELECT id, username, description, FROM_BASE64(kid), public_key +FROM _bkp_DOWN_V0002_webauthn_devices +WHERE attestation_type = 'fido-u2f'; + +UPDATE user_preferences +SET second_factor_method = 'u2f' +WHERE second_factor_method = 'webauthn'; diff --git a/internal/storage/migrations/V0002.Webauthn.mysql.up.sql b/internal/storage/migrations/V0002.Webauthn.mysql.up.sql new file mode 100644 index 00000000..13a273e3 --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.mysql.up.sql @@ -0,0 +1,47 @@ +ALTER TABLE totp_configurations RENAME _bkp_UP_V0002_totp_configurations; +ALTER TABLE u2f_devices RENAME _bkp_UP_V0002_u2f_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id INTEGER AUTO_INCREMENT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP NULL DEFAULT NULL, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_UP_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id INTEGER 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(100) 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, + PRIMARY KEY (id), + UNIQUE KEY (username, description), + UNIQUE KEY (kid) +); + +INSERT INTO webauthn_devices (id, rpid, username, description, kid, public_key, attestation_type, aaguid, sign_count) +SELECT id, '', username, description, TO_BASE64(key_handle), public_key, 'fido-u2f', '00000000-0000-0000-0000-000000000000', 0 +FROM _bkp_UP_V0002_u2f_devices; + +UPDATE user_preferences +SET second_factor_method = 'webauthn' +WHERE second_factor_method = 'u2f'; diff --git a/internal/storage/migrations/V0002.Webauthn.postgres.down.sql b/internal/storage/migrations/V0002.Webauthn.postgres.down.sql new file mode 100644 index 00000000..240ca9b3 --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.postgres.down.sql @@ -0,0 +1,37 @@ +ALTER TABLE totp_configurations RENAME TO _bkp_DOWN_V0002_totp_configurations; +ALTER TABLE webauthn_devices RENAME TO _bkp_DOWN_V0002_webauthn_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id SERIAL, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BYTEA NOT NULL, + PRIMARY KEY (id), + UNIQUE (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_DOWN_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS u2f_devices ( + id SERIAL, + username VARCHAR(100) NOT NULL, + description VARCHAR(30) NOT NULL DEFAULT 'Primary', + key_handle BYTEA NOT NULL, + public_key BYTEA NOT NULL, + PRIMARY KEY (id), + UNIQUE (username, description) +); + +INSERT INTO u2f_devices (id, username, description, key_handle, public_key) +SELECT id, username, description, DECODE(kid, 'base64'), public_key +FROM _bkp_DOWN_V0002_webauthn_devices +WHERE attestation_type = 'fido-u2f'; + +UPDATE user_preferences +SET second_factor_method = 'u2f' +WHERE second_factor_method = 'webauthn'; diff --git a/internal/storage/migrations/V0002.Webauthn.postgres.up.sql b/internal/storage/migrations/V0002.Webauthn.postgres.up.sql new file mode 100644 index 00000000..371f7a82 --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.postgres.up.sql @@ -0,0 +1,47 @@ +ALTER TABLE totp_configurations RENAME TO _bkp_UP_V0002_totp_configurations; +ALTER TABLE u2f_devices RENAME TO _bkp_UP_V0002_u2f_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id SERIAL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP WITH TIME ZONE NULL DEFAULT NULL, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BYTEA NOT NULL, + PRIMARY KEY (id), + UNIQUE (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_UP_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id SERIAL, + 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(100) 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, + PRIMARY KEY (id), + UNIQUE (username, description), + UNIQUE (kid) +); + +INSERT INTO webauthn_devices (id, rpid, username, description, kid, public_key, attestation_type, aaguid, sign_count) +SELECT id, '', username, description, ENCODE(key_handle::BYTEA, 'base64'), public_key, 'fido-u2f', '00000000-0000-0000-0000-000000000000', 0 +FROM _bkp_UP_V0002_u2f_devices; + +UPDATE user_preferences +SET second_factor_method = 'webauthn' +WHERE second_factor_method = 'u2f'; diff --git a/internal/storage/migrations/V0002.Webauthn.sqlite.down.sql b/internal/storage/migrations/V0002.Webauthn.sqlite.down.sql new file mode 100644 index 00000000..dd89c26a --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.sqlite.down.sql @@ -0,0 +1,37 @@ +ALTER TABLE totp_configurations RENAME TO _bkp_DOWN_V0002_totp_configurations; +ALTER TABLE webauthn_devices RENAME TO _bkp_DOWN_V0002_webauthn_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id INTEGER, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_DOWN_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS u2f_devices ( + id INTEGER, + username VARCHAR(100) NOT NULL, + description VARCHAR(30) NOT NULL DEFAULT 'Primary', + key_handle BLOB NOT NULL, + public_key BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE (username, description) +); + +INSERT INTO u2f_devices (id, username, description, key_handle, public_key) +SELECT id, username, description, B642BIN(kid), public_key +FROM _bkp_DOWN_V0002_webauthn_devices +WHERE attestation_type = 'fido-u2f'; + +UPDATE user_preferences +SET second_factor_method = 'u2f' +WHERE second_factor_method = 'webauthn'; diff --git a/internal/storage/migrations/V0002.Webauthn.sqlite.up.sql b/internal/storage/migrations/V0002.Webauthn.sqlite.up.sql new file mode 100644 index 00000000..0ebace9b --- /dev/null +++ b/internal/storage/migrations/V0002.Webauthn.sqlite.up.sql @@ -0,0 +1,47 @@ +ALTER TABLE totp_configurations RENAME TO _bkp_UP_V0002_totp_configurations; +ALTER TABLE u2f_devices RENAME TO _bkp_UP_V0002_u2f_devices; + +CREATE TABLE IF NOT EXISTS totp_configurations ( + id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP NULL DEFAULT NULL, + username VARCHAR(100) NOT NULL, + issuer VARCHAR(100), + algorithm VARCHAR(6) NOT NULL DEFAULT 'SHA1', + digits INTEGER NOT NULL DEFAULT 6, + period INTEGER NOT NULL DEFAULT 30, + secret BLOB NOT NULL, + PRIMARY KEY (id), + UNIQUE (username) +); + +INSERT INTO totp_configurations (id, username, issuer, algorithm, digits, period, secret) +SELECT id, username, issuer, algorithm, digits, period, secret +FROM _bkp_UP_V0002_totp_configurations; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id INTEGER, + 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(100) 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, + PRIMARY KEY (id), + UNIQUE (username, description), + UNIQUE (kid) +); + +INSERT INTO webauthn_devices (id, rpid, username, description, kid, public_key, attestation_type, aaguid, sign_count) +SELECT id, '', username, description, BIN2B64(key_handle), public_key, 'fido-u2f', '00000000-0000-0000-0000-000000000000', 0 +FROM _bkp_UP_V0002_u2f_devices; + +UPDATE user_preferences +SET second_factor_method = 'webauthn' +WHERE second_factor_method = 'u2f'; diff --git a/internal/storage/provider.go b/internal/storage/provider.go index b74ed841..76c9dc9b 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -22,13 +22,15 @@ type Provider interface { FindIdentityVerification(ctx context.Context, jti string) (found bool, err error) SaveTOTPConfiguration(ctx context.Context, config models.TOTPConfiguration) (err error) + UpdateTOTPConfigurationSignIn(ctx context.Context, id int, lastUsedAt *time.Time) (err error) DeleteTOTPConfiguration(ctx context.Context, username string) (err error) LoadTOTPConfiguration(ctx context.Context, username string) (config *models.TOTPConfiguration, err error) LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []models.TOTPConfiguration, err error) - SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) - LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error) - LoadU2FDevices(ctx context.Context, limit, page int) (devices []models.U2FDevice, err error) + SaveWebauthnDevice(ctx context.Context, device models.WebauthnDevice) (err error) + UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt *time.Time, signCount uint32, cloneWarning bool) (err error) + LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []models.WebauthnDevice, err error) + LoadWebauthnDevicesByUsername(ctx context.Context, username string) (devices []models.WebauthnDevice, err error) SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error) DeletePreferredDuoDevice(ctx context.Context, username string) (err error) diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index 42673d2b..cd702bdf 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -42,15 +42,19 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa sqlSelectTOTPConfig: fmt.Sprintf(queryFmtSelectTOTPConfiguration, tableTOTPConfigurations), sqlSelectTOTPConfigs: fmt.Sprintf(queryFmtSelectTOTPConfigurations, tableTOTPConfigurations), - sqlUpdateTOTPConfigSecret: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecret, tableTOTPConfigurations), - sqlUpdateTOTPConfigSecretByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecretByUsername, tableTOTPConfigurations), + sqlUpdateTOTPConfigSecret: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecret, tableTOTPConfigurations), + sqlUpdateTOTPConfigSecretByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigurationSecretByUsername, tableTOTPConfigurations), + sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations), + sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations), - sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices), - sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices), - sqlSelectU2FDevices: fmt.Sprintf(queryFmtSelectU2FDevices, tableU2FDevices), + sqlUpsertWebauthnDevice: fmt.Sprintf(queryFmtUpsertWebauthnDevice, tableWebauthnDevices), + sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices), + sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices), - sqlUpdateU2FDevicePublicKey: fmt.Sprintf(queryFmtUpdateU2FDevicePublicKey, tableU2FDevices), - sqlUpdateU2FDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateU2FDevicePublicKeyByUsername, tableU2FDevices), + sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices), + sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices), + sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices), + sqlUpdateWebauthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignInByUsername, tableWebauthnDevices), sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices), sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices), @@ -58,7 +62,7 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences), sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, tableUserPreferences), - sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableDuoDevices, tableUserPreferences), + sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableWebauthnDevices, tableDuoDevices, tableUserPreferences), sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations), sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations), @@ -100,16 +104,20 @@ type SQLProvider struct { sqlSelectTOTPConfig string sqlSelectTOTPConfigs string - sqlUpdateTOTPConfigSecret string - sqlUpdateTOTPConfigSecretByUsername string + sqlUpdateTOTPConfigSecret string + sqlUpdateTOTPConfigSecretByUsername string + sqlUpdateTOTPConfigRecordSignIn string + sqlUpdateTOTPConfigRecordSignInByUsername string - // Table: u2f_devices. - sqlUpsertU2FDevice string - sqlSelectU2FDevice string - sqlSelectU2FDevices string + // Table: webauthn_devices. + sqlUpsertWebauthnDevice string + sqlSelectWebauthnDevices string + sqlSelectWebauthnDevicesByUsername string - sqlUpdateU2FDevicePublicKey string - sqlUpdateU2FDevicePublicKeyByUsername string + sqlUpdateWebauthnDevicePublicKey string + sqlUpdateWebauthnDevicePublicKeyByUsername string + sqlUpdateWebauthnDeviceRecordSignIn string + sqlUpdateWebauthnDeviceRecordSignInByUsername string // Table: duo_devices. sqlUpsertDuoDevice string @@ -182,9 +190,11 @@ func (p *SQLProvider) StartupCheck() (err error) { // SavePreferred2FAMethod save the preferred method for 2FA to the database. func (p *SQLProvider) SavePreferred2FAMethod(ctx context.Context, username string, method string) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, method) + if _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, method); err != nil { + return fmt.Errorf("error upserting preferred two factor method for user '%s': %w", username, err) + } - return err + return nil } // LoadPreferred2FAMethod load the preferred method for 2FA from the database. @@ -228,7 +238,7 @@ func (p *SQLProvider) SaveIdentityVerification(ctx context.Context, verification if _, err = p.db.ExecContext(ctx, p.sqlInsertIdentityVerification, verification.JTI, verification.IssuedAt, verification.IssuedIP, verification.ExpiresAt, verification.Username, verification.Action); err != nil { - return fmt.Errorf("error inserting identity verification: %w", err) + return fmt.Errorf("error inserting identity verification for user '%s' with uuid '%s': %w", verification.Username, verification.JTI, err) } return nil @@ -267,12 +277,23 @@ func (p *SQLProvider) FindIdentityVerification(ctx context.Context, jti string) // SaveTOTPConfiguration save a TOTP configuration of a given user in the database. func (p *SQLProvider) SaveTOTPConfiguration(ctx context.Context, config models.TOTPConfiguration) (err error) { if config.Secret, err = p.encrypt(config.Secret); err != nil { - return fmt.Errorf("error encrypting the TOTP configuration secret: %v", err) + return fmt.Errorf("error encrypting the TOTP configuration secret for user '%s': %w", config.Username, err) } if _, err = p.db.ExecContext(ctx, p.sqlUpsertTOTPConfig, - config.Username, config.Issuer, config.Algorithm, config.Digits, config.Period, config.Secret); err != nil { - return fmt.Errorf("error upserting TOTP configuration: %w", err) + config.CreatedAt, config.LastUsedAt, + config.Username, config.Issuer, + config.Algorithm, config.Digits, config.Period, config.Secret); err != nil { + return fmt.Errorf("error upserting TOTP configuration for user '%s': %w", config.Username, err) + } + + return nil +} + +// UpdateTOTPConfigurationSignIn updates a registered Webauthn devices sign in information. +func (p *SQLProvider) UpdateTOTPConfigurationSignIn(ctx context.Context, id int, lastUsedAt *time.Time) (err error) { + if _, err = p.db.ExecContext(ctx, p.sqlUpdateTOTPConfigRecordSignIn, lastUsedAt, id); err != nil { + return fmt.Errorf("error updating TOTP configuration id %d: %w", id, err) } return nil @@ -281,7 +302,7 @@ func (p *SQLProvider) SaveTOTPConfiguration(ctx context.Context, config models.T // DeleteTOTPConfiguration delete a TOTP configuration from the database given a username. func (p *SQLProvider) DeleteTOTPConfiguration(ctx context.Context, username string) (err error) { if _, err = p.db.ExecContext(ctx, p.sqlDeleteTOTPConfig, username); err != nil { - return fmt.Errorf("error deleting TOTP configuration: %w", err) + return fmt.Errorf("error deleting TOTP configuration for user '%s': %w", username, err) } return nil @@ -296,11 +317,11 @@ func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string return nil, ErrNoTOTPConfiguration } - return nil, fmt.Errorf("error selecting TOTP configuration: %w", err) + return nil, fmt.Errorf("error selecting TOTP configuration for user '%s': %w", username, err) } if config.Secret, err = p.decrypt(config.Secret); err != nil { - return nil, fmt.Errorf("error decrypting the TOTP secret: %v", err) + return nil, fmt.Errorf("error decrypting the TOTP secret for user '%s': %w", username, err) } return config, nil @@ -308,35 +329,20 @@ func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string // LoadTOTPConfigurations load a set of TOTP configurations. func (p *SQLProvider) LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []models.TOTPConfiguration, err error) { - rows, err := p.db.QueryxContext(ctx, p.sqlSelectTOTPConfigs, limit, limit*page) - if err != nil { + configs = make([]models.TOTPConfiguration, 0, limit) + + if err = p.db.SelectContext(ctx, &configs, p.sqlSelectTOTPConfigs, limit, limit*page); err != nil { if errors.Is(err, sql.ErrNoRows) { - return configs, nil + return nil, nil } return nil, fmt.Errorf("error selecting TOTP configurations: %w", err) } - defer func() { - if err := rows.Close(); err != nil { - p.log.Errorf(logFmtErrClosingConn, err) + for i, c := range configs { + if configs[i].Secret, err = p.decrypt(c.Secret); err != nil { + return nil, fmt.Errorf("error decrypting TOTP configuration for user '%s': %w", c.Username, err) } - }() - - configs = make([]models.TOTPConfiguration, 0, limit) - - var config models.TOTPConfiguration - - for rows.Next() { - if err = rows.StructScan(&config); err != nil { - return nil, fmt.Errorf("error scanning TOTP configuration to struct: %w", err) - } - - if config.Secret, err = p.decrypt(config.Secret); err != nil { - return nil, fmt.Errorf("error decrypting the TOTP secret: %v", err) - } - - configs = append(configs, config) } return configs, nil @@ -351,90 +357,89 @@ func (p *SQLProvider) updateTOTPConfigurationSecret(ctx context.Context, config } if err != nil { - return fmt.Errorf("error updating TOTP configuration secret: %w", err) + return fmt.Errorf("error updating TOTP configuration secret for user '%s': %w", config.Username, err) } return nil } -// SaveU2FDevice saves a registered U2F device. -func (p *SQLProvider) SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) { +// SaveWebauthnDevice saves a registered Webauthn device. +func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device models.WebauthnDevice) (err error) { if device.PublicKey, err = p.encrypt(device.PublicKey); err != nil { - return fmt.Errorf("error encrypting the U2F device public key: %v", err) + return fmt.Errorf("error encrypting the Webauthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err) } - if _, err = p.db.ExecContext(ctx, p.sqlUpsertU2FDevice, device.Username, device.Description, device.KeyHandle, device.PublicKey); err != nil { - return fmt.Errorf("error upserting U2F device: %v", 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, + ); err != nil { + return fmt.Errorf("error upserting Webauthn device for user '%s' kid '%x': %w", device.Username, device.KID, err) } return nil } -// LoadU2FDevice loads a U2F device registration for a given username. -func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error) { - device = &models.U2FDevice{} - - if err = p.db.GetContext(ctx, device, p.sqlSelectU2FDevice, username); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNoU2FDeviceHandle - } - - return nil, fmt.Errorf("error selecting U2F device: %w", err) +// UpdateWebauthnDeviceSignIn updates a registered Webauthn devices sign in information. +func (p *SQLProvider) UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt *time.Time, 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) } - if device.PublicKey, err = p.decrypt(device.PublicKey); err != nil { - return nil, fmt.Errorf("error decrypting the U2F device public key: %v", err) - } - - return device, nil + return nil } -// LoadU2FDevices loads U2F device registrations. -func (p *SQLProvider) LoadU2FDevices(ctx context.Context, limit, page int) (devices []models.U2FDevice, err error) { - rows, err := p.db.QueryxContext(ctx, p.sqlSelectU2FDevices, limit, limit*page) - if err != nil { +// LoadWebauthnDevices loads Webauthn device registrations. +func (p *SQLProvider) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []models.WebauthnDevice, err error) { + devices = make([]models.WebauthnDevice, 0, limit) + + if err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebauthnDevices, limit, limit*page); err != nil { if errors.Is(err, sql.ErrNoRows) { - return devices, nil + return nil, nil } - return nil, fmt.Errorf("error selecting U2F devices: %w", err) + return nil, fmt.Errorf("error selecting Webauthn devices: %w", err) } - defer func() { - if err := rows.Close(); err != nil { - p.log.Errorf(logFmtErrClosingConn, err) + for i, device := range devices { + if devices[i].PublicKey, err = p.decrypt(device.PublicKey); err != nil { + return nil, fmt.Errorf("error decrypting Webauthn public key for user '%s': %w", device.Username, err) } - }() - - devices = make([]models.U2FDevice, 0, limit) - - var device models.U2FDevice - - for rows.Next() { - if err = rows.StructScan(&device); err != nil { - return nil, fmt.Errorf("error scanning U2F device to struct: %w", err) - } - - if device.PublicKey, err = p.decrypt(device.PublicKey); err != nil { - return nil, fmt.Errorf("error decrypting the U2F device public key: %v", err) - } - - devices = append(devices, device) } return devices, nil } -func (p *SQLProvider) updateU2FDevicePublicKey(ctx context.Context, device models.U2FDevice) (err error) { +// LoadWebauthnDevicesByUsername loads all webauthn devices registration for a given username. +func (p *SQLProvider) LoadWebauthnDevicesByUsername(ctx context.Context, username string) (devices []models.WebauthnDevice, err error) { + if err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebauthnDevicesByUsername, username); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoWebauthnDevice + } + + return nil, fmt.Errorf("error selecting Webauthn devices for user '%s': %w", username, err) + } + + for i, device := range devices { + if devices[i].PublicKey, err = p.decrypt(device.PublicKey); err != nil { + return nil, fmt.Errorf("error decrypting Webauthn public key for user '%s': %w", username, err) + } + } + + return devices, nil +} + +func (p *SQLProvider) updateWebauthnDevicePublicKey(ctx context.Context, device models.WebauthnDevice) (err error) { switch device.ID { case 0: - _, err = p.db.ExecContext(ctx, p.sqlUpdateU2FDevicePublicKeyByUsername, device.PublicKey, device.Username) + _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDevicePublicKeyByUsername, device.PublicKey, device.Username, device.KID) default: - _, err = p.db.ExecContext(ctx, p.sqlUpdateU2FDevicePublicKey, device.PublicKey, device.ID) + _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDevicePublicKey, device.PublicKey, device.ID) } if err != nil { - return fmt.Errorf("error updating U2F public key: %w", err) + return fmt.Errorf("error updating Webauthn public key for user '%s' kid '%x': %w", device.Username, device.KID, err) } return nil @@ -442,26 +447,32 @@ func (p *SQLProvider) updateU2FDevicePublicKey(ctx context.Context, device model // SavePreferredDuoDevice saves a Duo device. func (p *SQLProvider) SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlUpsertDuoDevice, device.Username, device.Device, device.Method) - return err + if _, err = p.db.ExecContext(ctx, p.sqlUpsertDuoDevice, device.Username, device.Device, device.Method); err != nil { + return fmt.Errorf("error upserting preferred duo device for user '%s': %w", device.Username, err) + } + + return nil } // DeletePreferredDuoDevice deletes a Duo device of a given user. func (p *SQLProvider) DeletePreferredDuoDevice(ctx context.Context, username string) (err error) { - _, err = p.db.ExecContext(ctx, p.sqlDeleteDuoDevice, username) - return err + if _, err = p.db.ExecContext(ctx, p.sqlDeleteDuoDevice, username); err != nil { + return fmt.Errorf("error deleting preferred duo device for user '%s': %w", username, err) + } + + return nil } // LoadPreferredDuoDevice loads a Duo device of a given user. func (p *SQLProvider) LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error) { device = &models.DuoDevice{} - if err := p.db.QueryRowxContext(ctx, p.sqlSelectDuoDevice, username).StructScan(device); err != nil { + if err = p.db.QueryRowxContext(ctx, p.sqlSelectDuoDevice, username).StructScan(device); err != nil { if err == sql.ErrNoRows { return nil, ErrNoDuoDevice } - return nil, err + return nil, fmt.Errorf("error selecting preferred duo device for user '%s': %w", username, err) } return device, nil @@ -472,7 +483,7 @@ func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt model if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt, attempt.Time, attempt.Successful, attempt.Banned, attempt.Username, attempt.Type, attempt.RemoteIP, attempt.RequestURI, attempt.RequestMethod); err != nil { - return fmt.Errorf("error inserting authentication attempt: %w", err) + return fmt.Errorf("error inserting authentication attempt for user '%s': %w", attempt.Username, err) } return nil @@ -480,31 +491,14 @@ func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt model // LoadAuthenticationLogs retrieve the latest failed authentications from the authentication log. func (p *SQLProvider) LoadAuthenticationLogs(ctx context.Context, username string, fromDate time.Time, limit, page int) (attempts []models.AuthenticationAttempt, err error) { - rows, err := p.db.QueryxContext(ctx, p.sqlSelectAuthenticationAttemptsByUsername, fromDate, username, limit, limit*page) - if err != nil { + attempts = make([]models.AuthenticationAttempt, 0, limit) + + if err = p.db.SelectContext(ctx, &attempts, p.sqlSelectAuthenticationAttemptsByUsername, fromDate, username, limit, limit*page); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoAuthenticationLogs } - return nil, fmt.Errorf("error selecting authentication logs: %w", err) - } - - defer func() { - if err := rows.Close(); err != nil { - p.log.Errorf(logFmtErrClosingConn, err) - } - }() - - var attempt models.AuthenticationAttempt - - attempts = make([]models.AuthenticationAttempt, 0, limit) - - for rows.Next() { - if err = rows.StructScan(&attempt); err != nil { - return nil, err - } - - attempts = append(attempts, attempt) + return nil, fmt.Errorf("error selecting authentication logs for user '%s': %w", username, err) } return attempts, nil diff --git a/internal/storage/sql_provider_backend_postgres.go b/internal/storage/sql_provider_backend_postgres.go index e371569b..36c959e7 100644 --- a/internal/storage/sql_provider_backend_postgres.go +++ b/internal/storage/sql_provider_backend_postgres.go @@ -26,7 +26,7 @@ func NewPostgreSQLProvider(config *schema.Configuration) (provider *PostgreSQLPr // Specific alterations to this provider. // PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead. - provider.sqlUpsertU2FDevice = fmt.Sprintf(queryFmtPostgresUpsertU2FDevice, tableU2FDevices) + provider.sqlUpsertWebauthnDevice = fmt.Sprintf(queryFmtPostgresUpsertWebauthnDevice, tableWebauthnDevices) provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtPostgresUpsertDuoDevice, tableDuoDevices) provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtPostgresUpsertTOTPConfiguration, tableTOTPConfigurations) provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtPostgresUpsertPreferred2FAMethod, tableUserPreferences) @@ -40,14 +40,18 @@ func NewPostgreSQLProvider(config *schema.Configuration) (provider *PostgreSQLPr provider.sqlInsertIdentityVerification = provider.db.Rebind(provider.sqlInsertIdentityVerification) provider.sqlConsumeIdentityVerification = provider.db.Rebind(provider.sqlConsumeIdentityVerification) provider.sqlSelectTOTPConfig = provider.db.Rebind(provider.sqlSelectTOTPConfig) + provider.sqlUpdateTOTPConfigRecordSignIn = provider.db.Rebind(provider.sqlUpdateTOTPConfigRecordSignIn) + provider.sqlUpdateTOTPConfigRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateTOTPConfigRecordSignInByUsername) provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig) provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs) provider.sqlUpdateTOTPConfigSecret = provider.db.Rebind(provider.sqlUpdateTOTPConfigSecret) provider.sqlUpdateTOTPConfigSecretByUsername = provider.db.Rebind(provider.sqlUpdateTOTPConfigSecretByUsername) - provider.sqlSelectU2FDevice = provider.db.Rebind(provider.sqlSelectU2FDevice) - provider.sqlSelectU2FDevices = provider.db.Rebind(provider.sqlSelectU2FDevices) - provider.sqlUpdateU2FDevicePublicKey = provider.db.Rebind(provider.sqlUpdateU2FDevicePublicKey) - provider.sqlUpdateU2FDevicePublicKeyByUsername = provider.db.Rebind(provider.sqlUpdateU2FDevicePublicKeyByUsername) + provider.sqlSelectWebauthnDevices = provider.db.Rebind(provider.sqlSelectWebauthnDevices) + provider.sqlSelectWebauthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebauthnDevicesByUsername) + provider.sqlUpdateWebauthnDevicePublicKey = provider.db.Rebind(provider.sqlUpdateWebauthnDevicePublicKey) + provider.sqlUpdateWebauthnDevicePublicKeyByUsername = provider.db.Rebind(provider.sqlUpdateWebauthnDevicePublicKeyByUsername) + provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn) + provider.sqlUpdateWebauthnDeviceRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignInByUsername) provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice) provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice) provider.sqlInsertAuthenticationAttempt = provider.db.Rebind(provider.sqlInsertAuthenticationAttempt) diff --git a/internal/storage/sql_provider_backend_sqlite.go b/internal/storage/sql_provider_backend_sqlite.go index 95b39897..e1f6d701 100644 --- a/internal/storage/sql_provider_backend_sqlite.go +++ b/internal/storage/sql_provider_backend_sqlite.go @@ -1,6 +1,10 @@ package storage import ( + "database/sql" + "encoding/base64" + + "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3" // Load the SQLite Driver used in the connection string. "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -14,7 +18,7 @@ type SQLiteProvider struct { // NewSQLiteProvider constructs a SQLite provider. func NewSQLiteProvider(config *schema.Configuration) (provider *SQLiteProvider) { provider = &SQLiteProvider{ - SQLProvider: NewSQLProvider(config, providerSQLite, "sqlite3", config.Storage.Local.Path), + SQLProvider: NewSQLProvider(config, providerSQLite, "sqlite3e", config.Storage.Local.Path), } // All providers have differing SELECT existing table statements. @@ -22,3 +26,27 @@ func NewSQLiteProvider(config *schema.Configuration) (provider *SQLiteProvider) return provider } + +func sqlite3BLOBToTEXTBase64(data []byte) (b64 string) { + return base64.StdEncoding.EncodeToString(data) +} + +func sqlite3TEXTBase64ToBLOB(b64 string) (data []byte, err error) { + return base64.StdEncoding.DecodeString(b64) +} + +func init() { + sql.Register("sqlite3e", &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) (err error) { + if err = conn.RegisterFunc("BIN2B64", sqlite3BLOBToTEXTBase64, true); err != nil { + return err + } + + if err = conn.RegisterFunc("B642BIN", sqlite3TEXTBase64ToBLOB, true); err != nil { + return err + } + + return nil + }, + }) +} diff --git a/internal/storage/sql_provider_encryption.go b/internal/storage/sql_provider_encryption.go index 70f2ade4..2faef181 100644 --- a/internal/storage/sql_provider_encryption.go +++ b/internal/storage/sql_provider_encryption.go @@ -26,7 +26,7 @@ func (p *SQLProvider) SchemaEncryptionChangeKey(ctx context.Context, encryptionK return err } - if err = p.schemaEncryptionChangeKeyU2F(ctx, tx, key); err != nil { + if err = p.schemaEncryptionChangeKeyWebauthn(ctx, tx, key); err != nil { return err } @@ -79,11 +79,11 @@ func (p *SQLProvider) schemaEncryptionChangeKeyTOTP(ctx context.Context, tx *sql return nil } -func (p *SQLProvider) schemaEncryptionChangeKeyU2F(ctx context.Context, tx *sqlx.Tx, key [32]byte) (err error) { - var devices []models.U2FDevice +func (p *SQLProvider) schemaEncryptionChangeKeyWebauthn(ctx context.Context, tx *sqlx.Tx, key [32]byte) (err error) { + var devices []models.WebauthnDevice for page := 0; true; page++ { - if devices, err = p.LoadU2FDevices(ctx, 10, page); err != nil { + if devices, err = p.LoadWebauthnDevices(ctx, 10, page); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) } @@ -100,7 +100,7 @@ func (p *SQLProvider) schemaEncryptionChangeKeyU2F(ctx context.Context, tx *sqlx return fmt.Errorf("rollback due to error: %w", err) } - if err = p.updateU2FDevicePublicKey(ctx, device); err != nil { + if err = p.updateWebauthnDevicePublicKey(ctx, device); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("rollback error %v: rollback due to error: %w", rollbackErr, err) } @@ -223,7 +223,7 @@ func (p *SQLProvider) schemaEncryptionCheckU2F(ctx context.Context) (err error) var rows *sqlx.Rows for page := 0; true; page++ { - if rows, err = p.db.QueryxContext(ctx, p.sqlSelectU2FDevices, pageSize, pageSize*page); err != nil { + if rows, err = p.db.QueryxContext(ctx, p.sqlSelectWebauthnDevices, pageSize, pageSize*page); err != nil { _ = rows.Close() return fmt.Errorf("error selecting U2F devices: %w", err) diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go index 8db6417f..43009413 100644 --- a/internal/storage/sql_provider_queries.go +++ b/internal/storage/sql_provider_queries.go @@ -35,7 +35,7 @@ const ( const ( queryFmtSelectUserInfo = ` - SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_u2f, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_duo + SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_webauthn, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_duo FROM %s WHERE username = ?;` @@ -96,14 +96,24 @@ const ( WHERE username = ?;` queryFmtUpsertTOTPConfiguration = ` - REPLACE INTO %s (username, issuer, algorithm, digits, period, secret) - VALUES (?, ?, ?, ?, ?, ?);` + REPLACE INTO %s (created_at, last_used_at, username, issuer, algorithm, digits, period, secret) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);` queryFmtPostgresUpsertTOTPConfiguration = ` - INSERT INTO %s (username, issuer, algorithm, digits, period, secret) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO %s (created_at, last_used_at, username, issuer, algorithm, digits, period, secret) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (username) - DO UPDATE SET issuer = $2, algorithm = $3, digits = $4, period = $5, secret = $6;` + DO UPDATE SET created_at = $1, last_used_at = $2, issuer = $4, algorithm = $5, digits = $6, period = $7, secret = $8;` + + queryFmtUpdateTOTPConfigRecordSignIn = ` + UPDATE %s + SET last_used_at = ? + WHERE id = ?;` + + queryFmtUpdateTOTPConfigRecordSignInByUsername = ` + UPDATE %s + SET last_used_at = ? + WHERE username = ?;` queryFmtDeleteTOTPConfiguration = ` DELETE FROM %s @@ -111,36 +121,50 @@ const ( ) const ( - queryFmtSelectU2FDevice = ` - SELECT id, username, key_handle, public_key - FROM %s - WHERE username = ?;` - - queryFmtSelectU2FDevices = ` - SELECT id, username, key_handle, public_key + queryFmtSelectWebauthnDevices = ` + SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning FROM %s LIMIT ? OFFSET ?;` - queryFmtUpdateU2FDevicePublicKey = ` + queryFmtSelectWebauthnDevicesByUsername = ` + SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning + FROM %s + WHERE username = ?;` + + queryFmtUpdateWebauthnDevicePublicKey = ` UPDATE %s SET public_key = ? WHERE id = ?;` - queryFmtUpdateUpdateU2FDevicePublicKeyByUsername = ` + queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername = ` UPDATE %s SET public_key = ? - WHERE username = ?;` + WHERE username = ? AND kid = ?;` - queryFmtUpsertU2FDevice = ` - REPLACE INTO %s (username, description, key_handle, public_key) - VALUES (?, ?, ?, ?);` + queryFmtUpdateWebauthnDeviceRecordSignIn = ` + UPDATE %s + SET + rpid = ?, last_used_at = ?, sign_count = ?, + clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END + WHERE id = ?;` - queryFmtPostgresUpsertU2FDevice = ` - INSERT INTO %s (username, description, key_handle, public_key) - VALUES ($1, $2, $3, $4) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` + + queryFmtPostgresUpsertWebauthnDevice = ` + 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 key_handle=$3, public_key=$4;` + 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;` ) const ( @@ -152,7 +176,7 @@ const ( INSERT INTO %s (username, device, method) VALUES ($1, $2, $3) ON CONFLICT (username) - DO UPDATE SET device=$2, method=$3;` + DO UPDATE SET device = $2, method = $3;` queryFmtDeleteDuoDevice = ` DELETE @@ -194,5 +218,5 @@ const ( INSERT INTO %s (name, value) VALUES ($1, $2) ON CONFLICT (name) - DO UPDATE SET value=$2;` + DO UPDATE SET value = $2;` ) diff --git a/internal/storage/sql_provider_schema_pre1.go b/internal/storage/sql_provider_schema_pre1.go index 5bf3ad08..d64841d1 100644 --- a/internal/storage/sql_provider_schema_pre1.go +++ b/internal/storage/sql_provider_schema_pre1.go @@ -29,7 +29,7 @@ func (p *SQLProvider) schemaMigratePre1To1(ctx context.Context) (err error) { tablePre1Config, tablePre1TOTPSecrets, tablePre1IdentityVerificationTokens, - tableU2FDevices, + tablePre1U2FDevices, tableUserPreferences, tableAuthenticationLogs, tableAlphaPreferences, @@ -93,7 +93,7 @@ func (p *SQLProvider) schemaMigratePre1Rename(ctx context.Context, tables, table } if p.name == providerPostgres { - if table == tableU2FDevices || table == tableUserPreferences { + if table == tablePre1U2FDevices || table == tableUserPreferences { if _, err = p.db.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE %s RENAME CONSTRAINT %s_pkey TO %s_pkey;`, tableNew, table, tableNew)); err != nil { continue @@ -132,7 +132,7 @@ func (p *SQLProvider) schemaMigratePre1To1Rollback(ctx context.Context, up bool) return err } - if p.name == providerPostgres && (tableNew == tableU2FDevices || tableNew == tableUserPreferences) { + if p.name == providerPostgres && (tableNew == tablePre1U2FDevices || tableNew == tableUserPreferences) { if _, err = p.db.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE %s RENAME CONSTRAINT %s_pkey TO %s_pkey;`, tableNew, table, tableNew)); err != nil { continue @@ -236,7 +236,7 @@ func (p *SQLProvider) schemaMigratePre1To1TOTP(ctx context.Context) (err error) } func (p *SQLProvider) schemaMigratePre1To1U2F(ctx context.Context) (err error) { - rows, err := p.db.Queryx(fmt.Sprintf(p.db.Rebind(queryFmtPre1To1SelectU2FDevices), tablePrefixBackup+tableU2FDevices)) + rows, err := p.db.Queryx(fmt.Sprintf(p.db.Rebind(queryFmtPre1To1SelectU2FDevices), tablePrefixBackup+tablePre1U2FDevices)) if err != nil { return err } @@ -276,7 +276,7 @@ func (p *SQLProvider) schemaMigratePre1To1U2F(ctx context.Context) (err error) { } for _, device := range devices { - _, err = p.db.ExecContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmtPre1To1InsertU2FDevice), tableU2FDevices), device.Username, device.KeyHandle, device.PublicKey) + _, err = p.db.ExecContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmtPre1To1InsertU2FDevice), tablePre1U2FDevices), device.Username, device.KeyHandle, device.PublicKey) if err != nil { return err } @@ -295,7 +295,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) { tableMigrations, tableTOTPConfigurations, tableIdentityVerification, - tableU2FDevices, + tablePre1U2FDevices, tableDuoDevices, tableUserPreferences, tableAuthenticationLogs, @@ -429,7 +429,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1TOTP(ctx context.Context) (err error) } func (p *SQLProvider) schemaMigrate1ToPre1U2F(ctx context.Context) (err error) { - rows, err := p.db.QueryxContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmt1ToPre1SelectU2FDevices), tablePrefixBackup+tableU2FDevices)) + rows, err := p.db.QueryxContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmt1ToPre1SelectU2FDevices), tablePrefixBackup+tablePre1U2FDevices)) if err != nil { return err } @@ -460,7 +460,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1U2F(ctx context.Context) (err error) { } for _, device := range devices { - _, err = p.db.ExecContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmt1ToPre1InsertU2FDevice), tableU2FDevices), device.Username, base64.StdEncoding.EncodeToString(device.KeyHandle), base64.StdEncoding.EncodeToString(device.PublicKey)) + _, err = p.db.ExecContext(ctx, fmt.Sprintf(p.db.Rebind(queryFmt1ToPre1InsertU2FDevice), tablePre1U2FDevices), device.Username, base64.StdEncoding.EncodeToString(device.KeyHandle), base64.StdEncoding.EncodeToString(device.PublicKey)) if err != nil { return err } diff --git a/internal/suites/const.go b/internal/suites/const.go index d4c3ce1f..4d2d9258 100644 --- a/internal/suites/const.go +++ b/internal/suites/const.go @@ -60,7 +60,7 @@ const ( var ( storageLocalTmpConfig = schema.Configuration{ - TOTP: &schema.TOTPConfiguration{ + TOTP: schema.TOTPConfiguration{ Issuer: "Authelia", Period: 6, }, diff --git a/internal/suites/scenario_backend_protection_test.go b/internal/suites/scenario_backend_protection_test.go index b9832d97..445d310d 100644 --- a/internal/suites/scenario_backend_protection_test.go +++ b/internal/suites/scenario_backend_protection_test.go @@ -41,18 +41,17 @@ func (s *BackendProtectionScenario) AssertRequestStatusCode(method, url string, func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() { s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/totp", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/register", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign_request", AutheliaBaseURL), 403) + s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/webauthn/assertion", AutheliaBaseURL), 403) + s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/webauthn/attestation", AutheliaBaseURL), 403) s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/user/info/2fa_method", AutheliaBaseURL), 403) s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/user/info", AutheliaBaseURL), 403) s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/configuration", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/start", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/finish", AutheliaBaseURL), 403) s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/totp/identity/start", AutheliaBaseURL), 403) s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/totp/identity/finish", AutheliaBaseURL), 403) + s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/webauthn/identity/start", AutheliaBaseURL), 403) + s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/webauthn/identity/finish", AutheliaBaseURL), 403) } func (s *BackendProtectionScenario) TestInvalidEndpointsReturn404() { diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go index 90ac14c0..2567866e 100644 --- a/internal/suites/suite_cli_test.go +++ b/internal/suites/suite_cli_test.go @@ -262,9 +262,17 @@ func (s *CLISuite) TestStorage02ShouldShowSchemaInfo() { output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config=/config/configuration.storage.yml"}) s.Assert().NoError(err) - pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`) - - s.Assert().Regexp(pattern, output) + s.Assert().Contains(output, "Schema Version: ") + s.Assert().Contains(output, "authentication_logs") + s.Assert().Contains(output, "identity_verification") + s.Assert().Contains(output, "duo_devices") + s.Assert().Contains(output, "user_preferences") + s.Assert().Contains(output, "migrations") + s.Assert().Contains(output, "encryption") + s.Assert().Contains(output, "encryption") + s.Assert().Contains(output, "webauthn_devices") + s.Assert().Contains(output, "totp_configurations") + s.Assert().Contains(output, "Schema Encryption Key: valid") } func (s *CLISuite) TestStorage03ShouldExportTOTP() { @@ -399,8 +407,17 @@ func (s *CLISuite) TestStorage04ShouldChangeEncryptionKey() { output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config=/config/configuration.storage.yml"}) s.Assert().NoError(err) - pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`) - s.Assert().Regexp(pattern, output) + s.Assert().Contains(output, "Schema Version: ") + s.Assert().Contains(output, "authentication_logs") + s.Assert().Contains(output, "identity_verification") + s.Assert().Contains(output, "duo_devices") + s.Assert().Contains(output, "user_preferences") + s.Assert().Contains(output, "migrations") + s.Assert().Contains(output, "encryption") + s.Assert().Contains(output, "encryption") + s.Assert().Contains(output, "webauthn_devices") + s.Assert().Contains(output, "totp_configurations") + s.Assert().Contains(output, "Schema Encryption Key: invalid") output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config=/config/configuration.storage.yml"}) s.Assert().NoError(err) diff --git a/internal/suites/suite_duo_push_test.go b/internal/suites/suite_duo_push_test.go index e6904ce7..274b3f08 100644 --- a/internal/suites/suite_duo_push_test.go +++ b/internal/suites/suite_duo_push_test.go @@ -462,6 +462,7 @@ func (s *DuoPushSuite) TestDuoPushRedirectionURLSuite() { func (s *DuoPushSuite) TestAvailableMethodsScenario() { suite.Run(s.T(), NewAvailableMethodsScenario([]string{ "TIME-BASED ONE-TIME PASSWORD", + "SECURITY KEY - WEBAUTHN", "PUSH NOTIFICATION", })) } diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index 44d3ddfc..9e50dc9a 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -288,7 +288,7 @@ func (s *StandaloneSuite) TestResetPasswordScenario() { } func (s *StandaloneSuite) TestAvailableMethodsScenario() { - suite.Run(s.T(), NewAvailableMethodsScenario([]string{"TIME-BASED ONE-TIME PASSWORD"})) + suite.Run(s.T(), NewAvailableMethodsScenario([]string{"TIME-BASED ONE-TIME PASSWORD", "SECURITY KEY - WEBAUTHN"})) } func (s *StandaloneSuite) TestRedirectionURLScenario() { diff --git a/internal/totp/helpers_test.go b/internal/totp/helpers_test.go new file mode 100644 index 00000000..19c551cb --- /dev/null +++ b/internal/totp/helpers_test.go @@ -0,0 +1,15 @@ +package totp + +import ( + "testing" + + "github.com/pquerna/otp" + "github.com/stretchr/testify/assert" +) + +func TestOTPStringToAlgo(t *testing.T) { + assert.Equal(t, otp.AlgorithmSHA1, otpStringToAlgo("SHA1")) + assert.Equal(t, otp.AlgorithmSHA256, otpStringToAlgo("SHA256")) + assert.Equal(t, otp.AlgorithmSHA512, otpStringToAlgo("SHA512")) + assert.Equal(t, otp.AlgorithmSHA1, otpStringToAlgo("")) +} diff --git a/internal/totp/provider.go b/internal/totp/provider.go index 76f4d2f6..35cc33a2 100644 --- a/internal/totp/provider.go +++ b/internal/totp/provider.go @@ -7,6 +7,6 @@ import ( // Provider for TOTP functionality. type Provider interface { Generate(username string) (config *models.TOTPConfiguration, err error) - GenerateCustom(username, algorithm string, digits, period, secretSize uint) (config *models.TOTPConfiguration, err error) + GenerateCustom(username string, algorithm string, digits, period, secretSize uint) (config *models.TOTPConfiguration, err error) Validate(token string, config *models.TOTPConfiguration) (valid bool, err error) } diff --git a/internal/totp/totp.go b/internal/totp/totp.go index 6d12e80b..1938553c 100644 --- a/internal/totp/totp.go +++ b/internal/totp/totp.go @@ -11,9 +11,9 @@ import ( ) // NewTimeBasedProvider creates a new totp.TimeBased which implements the totp.Provider. -func NewTimeBasedProvider(config *schema.TOTPConfiguration) (provider *TimeBased) { +func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) { provider = &TimeBased{ - config: config, + config: &config, } if config.Skew != nil { @@ -49,6 +49,7 @@ func (p TimeBased) GenerateCustom(username, algorithm string, digits, period, se } config = &models.TOTPConfiguration{ + CreatedAt: time.Now(), Username: username, Issuer: p.config.Issuer, Algorithm: algorithm, diff --git a/internal/totp/totp_test.go b/internal/totp/totp_test.go new file mode 100644 index 00000000..4b774f64 --- /dev/null +++ b/internal/totp/totp_test.go @@ -0,0 +1,89 @@ +package totp + +import ( + "encoding/base32" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/authelia/authelia/v4/internal/configuration/schema" +) + +func TestTOTPGenerateCustom(t *testing.T) { + totp := NewTimeBasedProvider(schema.TOTPConfiguration{ + Issuer: "Authelia", + Algorithm: "SHA1", + Digits: 6, + Period: 30, + }) + + assert.Equal(t, uint(1), totp.skew) + + config, err := totp.GenerateCustom("john", "SHA1", 6, 30, 32) + assert.NoError(t, err) + + assert.Equal(t, uint(6), config.Digits) + assert.Equal(t, uint(30), config.Period) + assert.Equal(t, "SHA1", config.Algorithm) + + assert.Less(t, time.Since(config.CreatedAt), time.Second) + assert.Greater(t, time.Since(config.CreatedAt), time.Second*-1) + + secret := make([]byte, base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(config.Secret))) + + _, err = base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, config.Secret) + assert.NoError(t, err) + assert.Len(t, secret, 32) + + config, err = totp.GenerateCustom("john", "SHA1", 6, 30, 42) + assert.NoError(t, err) + + assert.Equal(t, uint(6), config.Digits) + assert.Equal(t, uint(30), config.Period) + assert.Equal(t, "SHA1", config.Algorithm) + + assert.Less(t, time.Since(config.CreatedAt), time.Second) + assert.Greater(t, time.Since(config.CreatedAt), time.Second*-1) + + secret = make([]byte, base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(config.Secret))) + + _, err = base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, config.Secret) + assert.NoError(t, err) + assert.Len(t, secret, 42) + + _, err = totp.GenerateCustom("", "SHA1", 6, 30, 32) + assert.EqualError(t, err, "AccountName must be set") +} + +func TestTOTPGenerate(t *testing.T) { + skew := uint(2) + + totp := NewTimeBasedProvider(schema.TOTPConfiguration{ + Issuer: "Authelia", + Algorithm: "SHA256", + Digits: 8, + Period: 60, + Skew: &skew, + }) + + assert.Equal(t, uint(2), totp.skew) + + config, err := totp.Generate("john") + assert.NoError(t, err) + + assert.Equal(t, "Authelia", config.Issuer) + + assert.Less(t, time.Since(config.CreatedAt), time.Second) + assert.Greater(t, time.Since(config.CreatedAt), time.Second*-1) + + assert.Equal(t, uint(8), config.Digits) + assert.Equal(t, uint(60), config.Period) + assert.Equal(t, "SHA256", config.Algorithm) + + secret := make([]byte, base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(config.Secret))) + + _, err = base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, config.Secret) + assert.NoError(t, err) + assert.Len(t, secret, 32) +} diff --git a/web/package.json b/web/package.json index dd851df4..15a0787f 100644 --- a/web/package.json +++ b/web/package.json @@ -23,8 +23,7 @@ "react-i18next": "11.15.5", "react-loading": "2.0.3", "react-otp-input": "2.4.0", - "react-router-dom": "6.2.2", - "u2f-api": "1.2.1" + "react-router-dom": "6.2.2" }, "scripts": { "prepare": "cd .. && husky install .github", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8ccfd85f..f3fa282b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -51,7 +51,6 @@ specifiers: react-router-dom: 6.2.2 react-test-renderer: 17.0.2 typescript: 4.6.2 - u2f-api: 1.2.1 vite: 2.8.6 vite-plugin-eslint: 1.3.0 vite-plugin-istanbul: 2.5.0 @@ -80,7 +79,6 @@ dependencies: react-loading: 2.0.3_react@17.0.2 react-otp-input: 2.4.0_react-dom@17.0.2+react@17.0.2 react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2 - u2f-api: 1.2.1 devDependencies: '@commitlint/cli': 16.2.1 @@ -7846,10 +7844,6 @@ packages: hasBin: true dev: true - /u2f-api/1.2.1: - resolution: {integrity: sha512-4kfxen+mVxYkdJWHIS7c4HxsqNUs6fkrxlMLi7uQTVVYMVfTh73PhYoAmp1B0PRGELGDiKmXlssgqxcPvtpfSg==} - dev: false - /unbox-primitive/1.0.1: resolution: {integrity: sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==} dependencies: diff --git a/web/src/App.tsx b/web/src/App.tsx index 4da29361..fdde7ea6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,13 +6,13 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import NotificationBar from "@components/NotificationBar"; import { - FirstFactorRoute, + ConsentRoute, + IndexRoute, + LogoutRoute, + RegisterOneTimePasswordRoute, + RegisterWebauthnRoute, ResetPasswordStep2Route, ResetPasswordStep1Route, - RegisterSecurityKeyRoute, - RegisterOneTimePasswordRoute, - LogoutRoute, - ConsentRoute, } from "@constants/Routes"; import NotificationsContext from "@hooks/NotificationsContext"; import { Notification } from "@models/Notifications"; @@ -20,7 +20,7 @@ import * as themes from "@themes/index"; import { getBasePath } from "@utils/BasePath"; import { getDuoSelfEnrollment, getRememberMe, getResetPassword, getTheme } from "@utils/Configuration"; import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword"; -import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey"; +import RegisterWebauthn from "@views/DeviceRegistration/RegisterWebauthn"; import ConsentView from "@views/LoginPortal/ConsentView/ConsentView"; import LoginPortal from "@views/LoginPortal/LoginPortal"; import SignOut from "@views/LoginPortal/SignOut/SignOut"; @@ -67,12 +67,12 @@ const App: React.FC = () => { } /> } /> - } /> + } /> } /> } /> } /> ; - second_factor_enabled: boolean; } diff --git a/web/src/models/Methods.ts b/web/src/models/Methods.ts index e075e9c7..d1c0c52e 100644 --- a/web/src/models/Methods.ts +++ b/web/src/models/Methods.ts @@ -1,5 +1,5 @@ export enum SecondFactorMethod { TOTP = 1, - U2F = 2, - MobilePush = 3, + Webauthn, + MobilePush, } diff --git a/web/src/models/UserInfo.ts b/web/src/models/UserInfo.ts index c840f991..eb420b7b 100644 --- a/web/src/models/UserInfo.ts +++ b/web/src/models/UserInfo.ts @@ -3,7 +3,7 @@ import { SecondFactorMethod } from "@models/Methods"; export interface UserInfo { display_name: string; method: SecondFactorMethod; - has_u2f: boolean; + has_webauthn: boolean; has_totp: boolean; has_duo: boolean; } diff --git a/web/src/models/Webauthn.ts b/web/src/models/Webauthn.ts new file mode 100644 index 00000000..bdbfb2f6 --- /dev/null +++ b/web/src/models/Webauthn.ts @@ -0,0 +1,129 @@ +export interface PublicKeyCredentialCreationOptionsStatus { + options?: PublicKeyCredentialCreationOptions; + status: number; +} + +export interface CredentialCreation { + publicKey: PublicKeyCredentialCreationOptionsJSON; +} + +export interface PublicKeyCredentialCreationOptionsJSON + extends Omit { + challenge: string; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + user: PublicKeyCredentialUserEntityJSON; +} + +export interface PublicKeyCredentialRequestOptionsStatus { + options?: PublicKeyCredentialRequestOptions; + status: number; +} + +export interface CredentialRequest { + publicKey: PublicKeyCredentialRequestOptionsJSON; +} + +export interface PublicKeyCredentialRequestOptionsJSON + extends Omit { + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + challenge: string; +} + +export interface PublicKeyCredentialDescriptorJSON extends Omit { + id: string; +} + +export interface PublicKeyCredentialUserEntityJSON extends Omit { + id: string; +} + +export interface AuthenticatorAssertionResponseJSON + extends Omit { + 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 { + clientDataJSON: string; + attestationObject: string; +} + +export interface AttestationPublicKeyCredentialJSON + extends Omit { + rawId: string; + response: AuthenticatorAttestationResponseJSON; + clientExtensionResults: AuthenticationExtensionsClientOutputs; + transports?: AuthenticatorTransport[]; +} + +export interface PublicKeyCredentialJSON + extends Omit { + rawId: string; + clientExtensionResults: AuthenticationExtensionsClientOutputs; + response: AuthenticatorAssertionResponseJSON; + targetURL?: string; +} + +export enum AttestationResult { + Success = 1, + Failure, + FailureExcluded, + FailureUserConsent, + FailureUserVerificationOrResidentKey, + FailureSyntax, + FailureSupport, + FailureUnknown, + FailureWebauthnNotSupported, + FailureToken, +} + +export interface AttestationPublicKeyCredentialResult { + credential?: AttestationPublicKeyCredential; + result: AttestationResult; +} + +export interface AttestationPublicKeyCredentialResultJSON { + credential?: AttestationPublicKeyCredentialJSON; + result: AttestationResult; +} + +export enum AssertionResult { + Success = 1, + Failure, + FailureUserConsent, + FailureU2FFacetID, + FailureSyntax, + FailureUnknown, + FailureUnknownSecurity, + FailureWebauthnNotSupported, + FailureChallenge, +} + +export interface DiscoverableAssertionResult { + result: AssertionResult; + username: string; +} + +export interface AssertionPublicKeyCredentialResult { + credential?: PublicKeyCredential; + result: AssertionResult; +} + +export interface AssertionPublicKeyCredentialResultJSON { + credential?: PublicKeyCredentialJSON; + result: AssertionResult; +} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 53e1c091..938132d7 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -11,12 +11,11 @@ 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 InitiateU2FRegistrationPath = basePath + "/api/secondfactor/u2f/identity/start"; -export const CompleteU2FRegistrationStep1Path = basePath + "/api/secondfactor/u2f/identity/finish"; -export const CompleteU2FRegistrationStep2Path = basePath + "/api/secondfactor/u2f/register"; +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 InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request"; -export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign"; +export const WebauthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion"; export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices"; export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device"; @@ -48,6 +47,12 @@ export interface Response { data: T; } +export interface OptionalDataResponse { + status: "OK"; + data?: T; +} + +export type OptionalDataServiceResponse = OptionalDataResponse | ErrorResponse; export type ServiceResponse = Response | ErrorResponse; function toErrorResponse(resp: AxiosResponse>): ErrorResponse | undefined { diff --git a/web/src/services/Configuration.ts b/web/src/services/Configuration.ts index 7e0c2468..bf8117e8 100644 --- a/web/src/services/Configuration.ts +++ b/web/src/services/Configuration.ts @@ -5,7 +5,6 @@ import { toEnum, Method2FA } from "@services/UserInfo"; interface ConfigurationPayload { available_methods: Method2FA[]; - second_factor_enabled: boolean; } export async function getConfiguration(): Promise { diff --git a/web/src/services/OneTimePassword.ts b/web/src/services/OneTimePassword.ts index 72707ded..b188956c 100644 --- a/web/src/services/OneTimePassword.ts +++ b/web/src/services/OneTimePassword.ts @@ -2,13 +2,13 @@ import { CompleteTOTPSignInPath } from "@services/Api"; import { PostWithOptionalResponse } from "@services/Client"; import { SignInResponse } from "@services/SignIn"; -interface CompleteU2FSigninBody { +interface CompleteTOTPSigninBody { token: string; targetURL?: string; } export function completeTOTPSignIn(passcode: string, targetURL: string | undefined) { - const body: CompleteU2FSigninBody = { token: `${passcode}` }; + const body: CompleteTOTPSigninBody = { token: `${passcode}` }; if (targetURL) { body.targetURL = targetURL; } diff --git a/web/src/services/PushNotification.ts b/web/src/services/PushNotification.ts index d00681a9..7a73f908 100644 --- a/web/src/services/PushNotification.ts +++ b/web/src/services/PushNotification.ts @@ -5,12 +5,12 @@ import { } from "@services/Api"; import { Get, PostWithOptionalResponse } from "@services/Client"; -interface CompleteU2FSigninBody { +interface CompletePushSigninBody { targetURL?: string; } export function completePushNotificationSignIn(targetURL: string | undefined) { - const body: CompleteU2FSigninBody = {}; + const body: CompletePushSigninBody = {}; if (targetURL) { body.targetURL = targetURL; } diff --git a/web/src/services/RegisterDevice.ts b/web/src/services/RegisterDevice.ts index 82e9c9b2..e4f9397e 100644 --- a/web/src/services/RegisterDevice.ts +++ b/web/src/services/RegisterDevice.ts @@ -1,12 +1,4 @@ -import U2fApi from "u2f-api"; - -import { - InitiateTOTPRegistrationPath, - CompleteTOTPRegistrationPath, - InitiateU2FRegistrationPath, - CompleteU2FRegistrationStep1Path, - CompleteU2FRegistrationStep2Path, -} from "@services/Api"; +import { InitiateTOTPRegistrationPath, CompleteTOTPRegistrationPath, WebauthnIdentityStartPath } from "@services/Api"; import { Post, PostWithOptionalResponse } from "@services/Client"; export async function initiateTOTPRegistrationProcess() { @@ -22,24 +14,6 @@ export async function completeTOTPRegistrationProcess(processToken: string) { return Post(CompleteTOTPRegistrationPath, { token: processToken }); } -export async function initiateU2FRegistrationProcess() { - return PostWithOptionalResponse(InitiateU2FRegistrationPath); -} - -interface U2RRegistrationStep1Response { - appId: string; - registerRequests: [ - { - version: string; - challenge: string; - }, - ]; -} - -export async function completeU2FRegistrationProcessStep1(processToken: string) { - return Post(CompleteU2FRegistrationStep1Path, { token: processToken }); -} - -export async function completeU2FRegistrationProcessStep2(response: U2fApi.RegisterResponse) { - return PostWithOptionalResponse(CompleteU2FRegistrationStep2Path, response); +export async function initiateWebauthnRegistrationProcess() { + return PostWithOptionalResponse(WebauthnIdentityStartPath); } diff --git a/web/src/services/SecurityKey.ts b/web/src/services/SecurityKey.ts deleted file mode 100644 index 4cab3f1f..00000000 --- a/web/src/services/SecurityKey.ts +++ /dev/null @@ -1,32 +0,0 @@ -import u2fApi from "u2f-api"; - -import { InitiateU2FSignInPath, CompleteU2FSignInPath } from "@services/Api"; -import { Post, PostWithOptionalResponse } from "@services/Client"; -import { SignInResponse } from "@services/SignIn"; - -interface InitiateU2FSigninResponse { - appId: string; - challenge: string; - registeredKeys: { - appId: string; - keyHandle: string; - version: string; - }[]; -} - -export async function initiateU2FSignin() { - return Post(InitiateU2FSignInPath); -} - -interface CompleteU2FSigninBody { - signResponse: u2fApi.SignResponse; - targetURL?: string; -} - -export function completeU2FSignin(signResponse: u2fApi.SignResponse, targetURL: string | undefined) { - const body: CompleteU2FSigninBody = { signResponse }; - if (targetURL) { - body.targetURL = targetURL; - } - return PostWithOptionalResponse(CompleteU2FSignInPath, body); -} diff --git a/web/src/services/UserInfo.ts b/web/src/services/UserInfo.ts index bccc6a86..08118c9a 100644 --- a/web/src/services/UserInfo.ts +++ b/web/src/services/UserInfo.ts @@ -1,14 +1,14 @@ import { SecondFactorMethod } from "@models/Methods"; import { UserInfo } from "@models/UserInfo"; -import { UserInfoPath, UserInfo2FAMethodPath } from "@services/Api"; +import { UserInfo2FAMethodPath, UserInfoPath } from "@services/Api"; import { Get, PostWithOptionalResponse } from "@services/Client"; -export type Method2FA = "u2f" | "totp" | "mobile_push"; +export type Method2FA = "webauthn" | "totp" | "mobile_push"; export interface UserInfoPayload { display_name: string; method: Method2FA; - has_u2f: boolean; + has_webauthn: boolean; has_totp: boolean; has_duo: boolean; } @@ -19,10 +19,10 @@ export interface MethodPreferencePayload { export function toEnum(method: Method2FA): SecondFactorMethod { switch (method) { - case "u2f": - return SecondFactorMethod.U2F; case "totp": return SecondFactorMethod.TOTP; + case "webauthn": + return SecondFactorMethod.Webauthn; case "mobile_push": return SecondFactorMethod.MobilePush; } @@ -30,10 +30,10 @@ export function toEnum(method: Method2FA): SecondFactorMethod { export function toString(method: SecondFactorMethod): Method2FA { switch (method) { - case SecondFactorMethod.U2F: - return "u2f"; case SecondFactorMethod.TOTP: return "totp"; + case SecondFactorMethod.Webauthn: + return "webauthn"; case SecondFactorMethod.MobilePush: return "mobile_push"; } diff --git a/web/src/services/Webauthn.ts b/web/src/services/Webauthn.ts new file mode 100644 index 00000000..176fd840 --- /dev/null +++ b/web/src/services/Webauthn.ts @@ -0,0 +1,378 @@ +import axios, { AxiosResponse } from "axios"; + +import { + AssertionPublicKeyCredentialResult, + AssertionResult, + AttestationPublicKeyCredential, + AttestationPublicKeyCredentialJSON, + AttestationPublicKeyCredentialResult, + AttestationResult, + AuthenticatorAttestationResponseFuture, + CredentialCreation, + CredentialRequest, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialCreationOptionsStatus, + PublicKeyCredentialDescriptorJSON, + PublicKeyCredentialJSON, + PublicKeyCredentialRequestOptionsJSON, + PublicKeyCredentialRequestOptionsStatus, +} from "@models/Webauthn"; +import { + OptionalDataServiceResponse, + ServiceResponse, + WebauthnAssertionPath, + WebauthnAttestationPath, + WebauthnIdentityFinishPath, +} from "@services/Api"; +import { SignInResponse } from "@services/SignIn"; +import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from "@utils/Base64"; + +export function isWebauthnSecure(): boolean { + if (window.isSecureContext) { + return true; + } + + return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; +} + +export function isWebauthnSupported(): boolean { + return window?.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === "function"; +} + +export async function isWebauthnPlatformAuthenticatorAvailable(): Promise { + if (!isWebauthnSupported()) { + return false; + } + + 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, +): 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, + }; +} + +function getAttestationResultFromDOMException(exception: DOMException): AttestationResult { + // Docs for this section: + // https://w3c.github.io/webauthn/#sctn-op-make-cred + switch (exception.name) { + case "UnknownError": + // § 6.3.2 Step 1 and Step 8. + return AttestationResult.FailureSyntax; + case "NotSupportedError": + // § 6.3.2 Step 2. + return AttestationResult.FailureSupport; + case "InvalidStateError": + // § 6.3.2 Step 3. + return AttestationResult.FailureExcluded; + case "NotAllowedError": + // § 6.3.2 Step 3 and Step 6. + return AttestationResult.FailureUserConsent; + case "ConstraintError": + // § 6.3.2 Step 4. + return AttestationResult.FailureUserVerificationOrResidentKey; + default: + console.error(`Unhandled DOMException occurred during WebAuthN attestation: ${exception}`); + return AttestationResult.FailureUnknown; + } +} + +function getAssertionResultFromDOMException( + exception: DOMException, + requestOptions: PublicKeyCredentialRequestOptions, +): AssertionResult { + // Docs for this section: + // https://w3c.github.io/webauthn/#sctn-op-get-assertion + switch (exception.name) { + case "UnknownError": + // § 6.3.3 Step 1 and Step 12. + return AssertionResult.FailureSyntax; + case "NotAllowedError": + // § 6.3.3 Step 6 and Step 7. + return AssertionResult.FailureUserConsent; + case "SecurityError": + if (requestOptions.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}`); + return AssertionResult.FailureUnknown; + } +} + +async function getAttestationCreationOptions(token: string): Promise { + let response: AxiosResponse>; + + response = await axios.post>(WebauthnIdentityFinishPath, { + token: token, + }); + + if (response.data.status !== "OK" || response.data.data == null) { + return { + status: response.status, + }; + } + + return { + options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey), + status: response.status, + }; +} + +export async function getAssertionRequestOptions(): Promise { + let response: AxiosResponse>; + + response = await axios.get>(WebauthnAssertionPath); + + if (response.data.status !== "OK" || response.data.data == null) { + return { + status: response.status, + }; + } + + return { + options: decodePublicKeyCredentialRequestOptions(response.data.data.publicKey), + status: response.status, + }; +} + +async function getAttestationPublicKeyCredentialResult( + creationOptions: PublicKeyCredentialCreationOptions, +): Promise { + const result: AttestationPublicKeyCredentialResult = { + result: AttestationResult.Success, + }; + + try { + result.credential = (await navigator.credentials.create({ + publicKey: creationOptions, + })) as AttestationPublicKeyCredential; + } catch (e) { + result.result = AttestationResult.Failure; + + const exception = e as DOMException; + if (exception !== undefined) { + result.result = getAttestationResultFromDOMException(exception); + + return result; + } else { + console.error(`Unhandled exception occurred during WebAuthN attestation: ${e}`); + } + } + + if (result.credential == null) { + result.result = AttestationResult.Failure; + } else { + result.result = AttestationResult.Success; + } + + return result; +} + +export async function getAssertionPublicKeyCredentialResult( + requestOptions: PublicKeyCredentialRequestOptions, +): Promise { + const result: AssertionPublicKeyCredentialResult = { + result: AssertionResult.Success, + }; + + try { + result.credential = (await navigator.credentials.get({ publicKey: requestOptions })) as PublicKeyCredential; + } catch (e) { + result.result = AssertionResult.Failure; + + const exception = e as DOMException; + if (exception !== undefined) { + result.result = getAssertionResultFromDOMException(exception, requestOptions); + + return result; + } else { + console.error(`Unhandled exception occurred during WebAuthN assertion: ${e}`); + } + } + + if (result.credential == null) { + result.result = AssertionResult.Failure; + } else { + result.result = AssertionResult.Success; + } + + return result; +} + +async function postAttestationPublicKeyCredentialResult( + credential: AttestationPublicKeyCredential, +): Promise>> { + const credentialJSON = encodeAttestationPublicKeyCredential(credential); + + return axios.post>(WebauthnAttestationPath, credentialJSON); +} + +export async function postAssertionPublicKeyCredentialResult( + credential: PublicKeyCredential, + targetURL: string | undefined, +): Promise>> { + const credentialJSON = encodeAssertionPublicKeyCredential(credential, targetURL); + + return axios.post>(WebauthnAssertionPath, credentialJSON); +} + +export async function performAttestationCeremony(token: string): Promise { + const attestationCreationOpts = await getAttestationCreationOptions(token); + + if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) { + if (attestationCreationOpts.status === 403) { + return AttestationResult.FailureToken; + } + + 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; +} + +export async function performAssertionCeremony(targetURL: string | undefined): Promise { + 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); + + if (response.data.status === "OK" && response.status === 200) { + return AssertionResult.Success; + } + + return AssertionResult.Failure; +} diff --git a/web/src/utils/Base64.ts b/web/src/utils/Base64.ts new file mode 100644 index 00000000..e66dfaf7 --- /dev/null +++ b/web/src/utils/Base64.ts @@ -0,0 +1,209 @@ +/* + +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); +} diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx index b26eca2b..ff0db417 100644 --- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx +++ b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx @@ -11,7 +11,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import AppStoreBadges from "@components/AppStoreBadges"; import { GoogleAuthenticator } from "@constants/constants"; -import { FirstFactorRoute } from "@constants/Routes"; +import { IndexRoute } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; import LoginLayout from "@layouts/LoginLayout"; import { completeTOTPRegistrationProcess } from "@services/RegisterDevice"; @@ -34,7 +34,7 @@ const RegisterOneTimePassword = function () { const processToken = extractIdentityToken(location.search); const handleDoneClick = () => { - navigate(FirstFactorRoute); + navigate(IndexRoute); }; const completeRegistrationProcess = useCallback(async () => { diff --git a/web/src/views/DeviceRegistration/RegisterSecurityKey.tsx b/web/src/views/DeviceRegistration/RegisterSecurityKey.tsx deleted file mode 100644 index 9ae8ff20..00000000 --- a/web/src/views/DeviceRegistration/RegisterSecurityKey.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useState, useEffect, useCallback } from "react"; - -import { makeStyles, Typography, Button } from "@material-ui/core"; -import { useLocation, useNavigate } from "react-router-dom"; -import u2fApi from "u2f-api"; - -import FingerTouchIcon from "@components/FingerTouchIcon"; -import { useNotifications } from "@hooks/NotificationsContext"; -import LoginLayout from "@layouts/LoginLayout"; -import { FirstFactorPath } from "@services/Api"; -import { completeU2FRegistrationProcessStep1, completeU2FRegistrationProcessStep2 } from "@services/RegisterDevice"; -import { extractIdentityToken } from "@utils/IdentityToken"; - -const RegisterSecurityKey = function () { - const style = useStyles(); - const navigate = useNavigate(); - const location = useLocation(); - const { createErrorNotification } = useNotifications(); - const [, setRegistrationInProgress] = useState(false); - - const processToken = extractIdentityToken(location.search); - - const handleBackClick = () => { - navigate(FirstFactorPath); - }; - - const registerStep1 = useCallback(async () => { - if (!processToken) { - return; - } - try { - setRegistrationInProgress(true); - const res = await completeU2FRegistrationProcessStep1(processToken); - const registerRequests: u2fApi.RegisterRequest[] = []; - for (var i in res.registerRequests) { - const r = res.registerRequests[i]; - registerRequests.push({ - appId: res.appId, - challenge: r.challenge, - version: r.version, - }); - } - const registerResponse = await u2fApi.register(registerRequests, [], 60); - await completeU2FRegistrationProcessStep2(registerResponse); - setRegistrationInProgress(false); - navigate(FirstFactorPath); - } catch (err) { - console.error(err); - if ((err as Error).message.includes("Request failed with status code 403")) { - createErrorNotification( - "You must open the link from the same device and browser that initiated the registration process", - ); - } else { - createErrorNotification( - "Failed to register your security key. The identity verification process might have timed out.", - ); - } - } - }, [processToken, createErrorNotification, navigate]); - - useEffect(() => { - registerStep1(); - }, [registerStep1]); - - return ( - -

- -
- Touch the token on your security key - - - - ); -}; - -export default RegisterSecurityKey; - -const useStyles = makeStyles((theme) => ({ - icon: { - paddingTop: theme.spacing(4), - paddingBottom: theme.spacing(4), - }, - instruction: { - paddingBottom: theme.spacing(4), - }, -})); diff --git a/web/src/views/DeviceRegistration/RegisterWebauthn.tsx b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx new file mode 100644 index 00000000..1c4ef2df --- /dev/null +++ b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { Button, makeStyles, Typography } from "@material-ui/core"; +import { useLocation, useNavigate } from "react-router-dom"; + +import FingerTouchIcon from "@components/FingerTouchIcon"; +import { useNotifications } from "@hooks/NotificationsContext"; +import LoginLayout from "@layouts/LoginLayout"; +import { AttestationResult } from "@models/Webauthn"; +import { FirstFactorPath } from "@services/Api"; +import { performAttestationCeremony } from "@services/Webauthn"; +import { extractIdentityToken } from "@utils/IdentityToken"; + +const RegisterWebauthn = function () { + const style = useStyles(); + const navigate = useNavigate(); + const location = useLocation(); + const { createErrorNotification } = useNotifications(); + const [, setRegistrationInProgress] = useState(false); + + const processToken = extractIdentityToken(location.search); + + 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 ( + +
+ +
+ Touch the token on your security key + + +
+ ); +}; + +export default RegisterWebauthn; + +const useStyles = makeStyles((theme) => ({ + icon: { + paddingTop: theme.spacing(4), + paddingBottom: theme.spacing(4), + }, + instruction: { + paddingBottom: theme.spacing(4), + }, +})); diff --git a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx index 0b3fb63a..d159f617 100644 --- a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx +++ b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx @@ -15,7 +15,7 @@ import { AccountBox, CheckBox, Contacts, Drafts, Group } from "@material-ui/icon import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { FirstFactorRoute } from "@constants/Routes"; +import { IndexRoute } from "@constants/Routes"; import { useRequestedScopes } from "@hooks/Consent"; import { useNotifications } from "@hooks/NotificationsContext"; import { useRedirector } from "@hooks/Redirector"; @@ -50,7 +50,7 @@ const ConsentView = function (props: Props) { useEffect(() => { if (err) { - navigate(FirstFactorRoute); + navigate(IndexRoute); console.error(`Unable to display consent screen: ${err.message}`); } }, [navigate, resetNotification, createErrorNotification, err]); diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index a0c46e20..3aa22446 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -4,11 +4,11 @@ import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { AuthenticatedRoute, - FirstFactorRoute, + IndexRoute, SecondFactorPushSubRoute, SecondFactorRoute, SecondFactorTOTPSubRoute, - SecondFactorU2FSubRoute, + SecondFactorWebauthnSubRoute, } from "@constants/Routes"; import { useConfiguration } from "@hooks/Configuration"; import { useNotifications } from "@hooks/NotificationsContext"; @@ -100,7 +100,7 @@ const LoginPortal = function (props: Props) { if ( redirectionURL && ((configuration && - !configuration.second_factor_enabled && + configuration.available_methods.size === 0 && state.authentication_level >= AuthenticationLevel.OneFactor) || state.authentication_level === AuthenticationLevel.TwoFactor) ) { @@ -123,13 +123,13 @@ const LoginPortal = function (props: Props) { if (state.authentication_level === AuthenticationLevel.Unauthenticated) { setFirstFactorDisabled(false); - redirect(`${FirstFactorRoute}${redirectionSuffix}`); + redirect(`${IndexRoute}${redirectionSuffix}`); } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) { - if (!configuration.second_factor_enabled) { + if (configuration.available_methods.size === 0) { redirect(AuthenticatedRoute); } else { - if (userInfo.method === SecondFactorMethod.U2F) { - redirect(`${SecondFactorRoute}${SecondFactorU2FSubRoute}${redirectionSuffix}`); + if (userInfo.method === SecondFactorMethod.Webauthn) { + redirect(`${SecondFactorRoute}${SecondFactorWebauthnSubRoute}${redirectionSuffix}`); } else if (userInfo.method === SecondFactorMethod.MobilePush) { redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}${redirectionSuffix}`); } else { @@ -163,12 +163,12 @@ const LoginPortal = function (props: Props) { const firstFactorReady = state !== undefined && state.authentication_level === AuthenticationLevel.Unauthenticated && - location.pathname === FirstFactorRoute; + location.pathname === IndexRoute; return ( ; - u2fSupported: boolean; + webauthnSupported: boolean; onClose: () => void; onClick: (method: SecondFactorMethod) => void; @@ -47,12 +47,12 @@ const MethodSelectionDialog = function (props: Props) { onClick={() => props.onClick(SecondFactorMethod.TOTP)} /> ) : null} - {props.methods.has(SecondFactorMethod.U2F) && props.u2fSupported ? ( + {props.methods.has(SecondFactorMethod.Webauthn) && props.webauthnSupported ? ( } - onClick={() => props.onClick(SecondFactorMethod.U2F)} + onClick={() => props.onClick(SecondFactorMethod.Webauthn)} /> ) : null} {props.methods.has(SecondFactorMethod.MobilePush) ? ( diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index 605cf954..9740a89e 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -3,28 +3,26 @@ import React, { useState, useEffect } from "react"; import { Grid, makeStyles, Button } from "@material-ui/core"; import { useTranslation } from "react-i18next"; import { Route, Routes, useNavigate } from "react-router-dom"; -import u2fApi from "u2f-api"; import { LogoutRoute as SignOutRoute, SecondFactorPushSubRoute, SecondFactorTOTPSubRoute, - SecondFactorU2FSubRoute, + SecondFactorWebauthnSubRoute, } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; import LoginLayout from "@layouts/LoginLayout"; import { Configuration } from "@models/Configuration"; import { SecondFactorMethod } from "@models/Methods"; import { UserInfo } from "@models/UserInfo"; -import { initiateTOTPRegistrationProcess, initiateU2FRegistrationProcess } from "@services/RegisterDevice"; +import { initiateTOTPRegistrationProcess, initiateWebauthnRegistrationProcess } from "@services/RegisterDevice"; import { AuthenticationLevel } from "@services/State"; import { setPreferred2FAMethod } from "@services/UserInfo"; +import { isWebauthnSupported } from "@services/Webauthn"; import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog"; import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod"; -import SecurityKeyMethod from "@views/LoginPortal/SecondFactor/SecurityKeyMethod"; - -const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the process"; +import WebauthnMethod from "@views/LoginPortal/SecondFactor/WebauthnMethod"; export interface Props { authenticationLevel: AuthenticationLevel; @@ -42,16 +40,12 @@ const SecondFactorForm = function (props: Props) { const [methodSelectionOpen, setMethodSelectionOpen] = useState(false); const { createInfoNotification, createErrorNotification } = useNotifications(); const [registrationInProgress, setRegistrationInProgress] = useState(false); - const [u2fSupported, setU2fSupported] = useState(false); + const [webauthnSupported, setWebauthnSupported] = useState(false); const { t: translate } = useTranslation("Portal"); - // Check that U2F is supported. useEffect(() => { - u2fApi.ensureSupport().then( - () => setU2fSupported(true), - () => console.error("U2F not supported"), - ); - }, [setU2fSupported]); + setWebauthnSupported(isWebauthnSupported()); + }, [setWebauthnSupported]); const initiateRegistration = (initiateRegistrationFunc: () => Promise) => { return async () => { @@ -61,7 +55,7 @@ const SecondFactorForm = function (props: Props) { setRegistrationInProgress(true); try { await initiateRegistrationFunc(); - createInfoNotification(translate(EMAIL_SENT_NOTIFICATION)); + createInfoNotification(translate("An email has been sent to your address to complete the process")); } catch (err) { console.error(err); createErrorNotification(translate("There was a problem initiating the registration process")); @@ -94,7 +88,7 @@ const SecondFactorForm = function (props: Props) { setMethodSelectionOpen(false)} onClick={handleMethodSelected} /> @@ -125,14 +119,14 @@ const SecondFactorForm = function (props: Props) { } /> createErrorNotification(err.message)} onSignInSuccess={props.onAuthenticationSuccess} /> diff --git a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx b/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx similarity index 56% rename from web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx rename to web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx index ab61a304..3ac06d38 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx @@ -1,8 +1,7 @@ -import React, { useCallback, useEffect, useRef, useState, Fragment } from "react"; +import React, { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { makeStyles, Button, useTheme } from "@material-ui/core"; +import { Button, makeStyles, useTheme } from "@material-ui/core"; import { CSSProperties } from "@material-ui/styles"; -import u2fApi from "u2f-api"; import FailureIcon from "@components/FailureIcon"; import FingerTouchIcon from "@components/FingerTouchIcon"; @@ -10,14 +9,19 @@ import LinearProgressBar from "@components/LinearProgressBar"; import { useIsMountedRef } from "@hooks/Mounted"; import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useTimer } from "@hooks/Timer"; -import { initiateU2FSignin, completeU2FSignin } from "@services/SecurityKey"; +import { AssertionResult } from "@models/Webauthn"; import { AuthenticationLevel } from "@services/State"; +import { + getAssertionPublicKeyCredentialResult, + getAssertionRequestOptions, + postAssertionPublicKeyCredentialResult, +} from "@services/Webauthn"; import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; export enum State { WaitTouch = 1, - SigninInProgress = 2, + InProgress = 2, Failure = 3, } @@ -31,7 +35,7 @@ export interface Props { onSignInSuccess: (redirectURL: string | undefined) => void; } -const SecurityKeyMethod = function (props: Props) { +const WebauthnMethod = function (props: Props) { const signInTimeout = 30; const [state, setState] = useState(State.WaitTouch); const style = useStyles(); @@ -52,25 +56,73 @@ const SecurityKeyMethod = function (props: Props) { try { triggerTimer(); setState(State.WaitTouch); - const signRequest = await initiateU2FSignin(); - const signRequests: u2fApi.SignRequest[] = []; - for (var i in signRequest.registeredKeys) { - const r = signRequest.registeredKeys[i]; - signRequests.push({ - appId: signRequest.appId, - challenge: signRequest.challenge, - keyHandle: r.keyHandle, - version: r.version, - }); + const assertionRequestResponse = await getAssertionRequestOptions(); + + if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) { + setState(State.Failure); + onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); + + return; } - const signResponse = await u2fApi.sign(signRequests, signInTimeout); - // 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. + + const result = await getAssertionPublicKeyCredentialResult(assertionRequestResponse.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); + + return; + } + + if (result.credential == null) { + onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data.")); + setState(State.Failure); + + return; + } + if (!mounted.current) return; - setState(State.SigninInProgress); - const res = await completeU2FSignin(signResponse, redirectionURL); - onSignInSuccessCallback(res ? res.redirect : undefined); + setState(State.InProgress); + + const response = await postAssertionPublicKeyCredentialResult(result.credential, redirectionURL); + + if (response.data.status === "OK" && response.status === 200) { + onSignInSuccessCallback(response.data.data ? response.data.data.redirect : undefined); + return; + } + + if (!mounted.current) return; + + onSignInErrorCallback(new Error("The server rejected the security key.")); + setState(State.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. @@ -117,7 +169,7 @@ const SecurityKeyMethod = function (props: Props) { ); }; -export default SecurityKeyMethod; +export default WebauthnMethod; const useStyles = makeStyles(() => ({ icon: { diff --git a/web/src/views/LoginPortal/SignOut/SignOut.tsx b/web/src/views/LoginPortal/SignOut/SignOut.tsx index 4346f76d..f0c64fe6 100644 --- a/web/src/views/LoginPortal/SignOut/SignOut.tsx +++ b/web/src/views/LoginPortal/SignOut/SignOut.tsx @@ -4,7 +4,7 @@ import { Typography, makeStyles } from "@material-ui/core"; import { useTranslation } from "react-i18next"; import { Navigate } from "react-router-dom"; -import { FirstFactorRoute } from "@constants/Routes"; +import { IndexRoute } from "@constants/Routes"; import { useIsMountedRef } from "@hooks/Mounted"; import { useNotifications } from "@hooks/NotificationsContext"; import { useRedirectionURL } from "@hooks/RedirectionURL"; @@ -50,7 +50,7 @@ const SignOut = function (props: Props) { if (redirectionURL && safeRedirect) { redirector(redirectionURL); } else { - return ; + return ; } } diff --git a/web/src/views/ResetPassword/ResetPasswordStep1.tsx b/web/src/views/ResetPassword/ResetPasswordStep1.tsx index ce6f7718..6c676180 100644 --- a/web/src/views/ResetPassword/ResetPasswordStep1.tsx +++ b/web/src/views/ResetPassword/ResetPasswordStep1.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; -import { FirstFactorRoute } from "@constants/Routes"; +import { IndexRoute } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; import LoginLayout from "@layouts/LoginLayout"; import { initiateResetPasswordProcess } from "@services/ResetPassword"; @@ -37,7 +37,7 @@ const ResetPasswordStep1 = function () { }; const handleCancelClick = () => { - navigate(FirstFactorRoute); + navigate(IndexRoute); }; return ( diff --git a/web/src/views/ResetPassword/ResetPasswordStep2.tsx b/web/src/views/ResetPassword/ResetPasswordStep2.tsx index eb0f96b1..b1e1aa0a 100644 --- a/web/src/views/ResetPassword/ResetPasswordStep2.tsx +++ b/web/src/views/ResetPassword/ResetPasswordStep2.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; -import { FirstFactorRoute } from "@constants/Routes"; +import { IndexRoute } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; import LoginLayout from "@layouts/LoginLayout"; import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword"; @@ -71,7 +71,7 @@ const ResetPasswordStep2 = function () { try { await resetPassword(password1); createSuccessNotification(translate("Password has been reset")); - setTimeout(() => navigate(FirstFactorRoute), 1500); + setTimeout(() => navigate(IndexRoute), 1500); setFormDisabled(true); } catch (err) { console.error(err); @@ -87,7 +87,7 @@ const ResetPasswordStep2 = function () { const handleResetClick = () => doResetPassword(); - const handleCancelClick = () => navigate(FirstFactorRoute); + const handleCancelClick = () => navigate(IndexRoute); return (