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 +
@@ -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 = () => {