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 (