From 01b77384f965fb2172bbf7c5ac00d086e437efa1 Mon Sep 17 00:00:00 2001
From: Philipp Staiger <9325003+lippl@users.noreply.github.com>
Date: Wed, 1 Dec 2021 04:32:58 +0100
Subject: [PATCH] feat(duo): multi device selection (#2137)
Allow users to select and save the preferred duo device and method, depending on availability in the duo account. A default enrollment URL is provided and adjusted if returned by the duo API. This allows auto-enrollment if enabled by the administrator.
Closes #594. Closes #1039.
---
.github/commit-msg | 2 +-
api/openapi.yml | 97 ++-
config.template.yml | 1 +
docs/configuration/duo-push-notifications.md | 13 +
docs/features/2fa/push-notifications.md | 1 +
internal/commands/storage_run.go | 12 +-
internal/configuration/config.template.yml | 1 +
internal/configuration/schema/duo.go | 7 +-
internal/configuration/validator/const.go | 1 +
internal/duo/const.go | 16 +
internal/duo/duo.go | 47 +-
internal/duo/types.go | 50 +-
internal/handlers/const.go | 9 +-
internal/handlers/duo.go | 49 ++
.../handlers/handler_register_duo_device.go | 120 ++++
.../handler_register_duo_device_test.go | 172 ++++++
internal/handlers/handler_sign_duo.go | 306 ++++++++--
internal/handlers/handler_sign_duo_test.go | 563 ++++++++++++++++--
internal/handlers/types.go | 47 ++
internal/mocks/mock_duo_api.go | 42 +-
internal/models/duo_device.go | 9 +
internal/models/model_userinfo.go | 7 +-
internal/server/const.go | 6 +-
internal/server/server.go | 17 +-
internal/server/template.go | 12 +-
internal/storage/const.go | 2 +-
internal/storage/errors.go | 9 +-
.../V0001.Initial_Schema.all.down.sql | 1 +
.../V0001.Initial_Schema.mysql.up.sql | 9 +
.../V0001.Initial_Schema.postgres.up.sql | 9 +
.../V0001.Initial_Schema.sqlite.up.sql | 9 +
internal/storage/provider.go | 12 +-
internal/storage/provider_mock.go | 56 +-
internal/storage/sql_provider.go | 42 +-
internal/storage/sql_provider_queries.go | 19 +-
internal/storage/sql_provider_schema.go | 5 +-
internal/storage/sql_provider_schema_pre1.go | 2 +-
internal/suites/DuoPush/configuration.yml | 3 +-
internal/suites/DuoPush/docker-compose.yml | 2 +
.../suites/OneFactorOnly/configuration.yml | 2 +-
internal/suites/action_2fa_methods.go | 17 +
internal/suites/const.go | 9 +-
internal/suites/docker.go | 2 +-
internal/suites/duo.go | 21 +
internal/suites/environment.go | 2 +-
.../suites/example/compose/duo-api/duo_api.js | 89 ++-
.../example/compose/duo-api/duo_client.js | 15 +-
internal/suites/suite_cli_test.go | 6 +-
internal/suites/suite_duo_push.go | 15 +-
internal/suites/suite_duo_push_test.go | 332 ++++++++++-
internal/suites/suite_kubernetes.go | 2 +-
internal/suites/utils.go | 2 +-
internal/utils/strings.go | 11 +
internal/utils/strings_test.go | 9 +
web/.env.development | 1 +
web/.env.production | 1 +
web/index.html | 2 +-
web/package.json | 5 +-
web/src/App.tsx | 10 +-
web/src/models/UserInfo.ts | 1 +
web/src/services/Api.ts | 3 +
web/src/services/PushNotification.ts | 41 +-
web/src/services/UserPreferences.ts | 1 +
web/src/setupTests.js | 1 +
web/src/utils/Configuration.ts | 4 +
web/src/views/LoginPortal/LoginPortal.tsx | 2 +
.../SecondFactor/DeviceSelectionContainer.tsx | 183 ++++++
.../SecondFactor/MethodContainer.tsx | 32 +-
.../SecondFactor/OneTimePasswordMethod.tsx | 1 +
.../SecondFactor/PushNotificationMethod.tsx | 133 ++++-
.../SecondFactor/SecondFactorForm.tsx | 9 +-
.../SecondFactor/SecurityKeyMethod.tsx | 1 +
web/vite.config.ts | 2 +-
73 files changed, 2503 insertions(+), 251 deletions(-)
create mode 100644 internal/duo/const.go
create mode 100644 internal/handlers/duo.go
create mode 100644 internal/handlers/handler_register_duo_device.go
create mode 100644 internal/handlers/handler_register_duo_device_test.go
create mode 100644 internal/models/duo_device.go
create mode 100644 web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx
diff --git a/.github/commit-msg b/.github/commit-msg
index 3c0661adc..6437e5825 100755
--- a/.github/commit-msg
+++ b/.github/commit-msg
@@ -2,4 +2,4 @@
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/required-apps"
-cd web && ${PMGR} commitlint --edit $1
+cd web && ${PMGR} commit
diff --git a/api/openapi.yml b/api/openapi.yml
index c27dece2e..1a525d387 100644
--- a/api/openapi.yml
+++ b/api/openapi.yml
@@ -540,6 +540,46 @@ paths:
description: Unauthorized
security:
- authelia_auth: []
+ /api/secondfactor/duo_devices:
+ get:
+ tags:
+ - Second Factor
+ summary: Second Factor Authentication - Duo Mobile Push
+ description: This endpoint retreives a users available devices and capabilities from Duo.
+ responses:
+ "200":
+ description: Successful Operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/handlers.DuoDevicesResponse'
+ "401":
+ description: Unauthorized
+ security:
+ - authelia_auth: []
+ /api/secondfactor/duo_device:
+ post:
+ tags:
+ - Second Factor
+ summary: Second Factor Authentication - Duo Mobile Push
+ description: This endpoint updates the users preferred Duo device and method.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/handlers.DuoDeviceBody'
+ responses:
+ "200":
+ description: Successful Operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.OkResponse'
+ "401":
+ description: Unauthorized
+ security:
+ - authelia_auth: []
components:
parameters:
originalURLParam:
@@ -603,13 +643,19 @@ components:
totp_period:
type: integer
example: 30
- handlers.logoutRequestBody:
+ handlers.DuoDeviceBody:
+ required:
+ - device
+ - method
type: object
properties:
- targetURL:
+ device:
type: string
- example: https://redirect.example.com
- handlers.logoutResponseBody:
+ example: ABCDE123456789FGHIJK
+ method:
+ type: string
+ example: push
+ handlers.DuoDevicesResponse:
type: object
properties:
status:
@@ -618,9 +664,25 @@ components:
data:
type: object
properties:
- safeTargetURL:
- type: boolean
- example: true
+ result:
+ type: string
+ example: auth
+ devices:
+ type: array
+ items:
+ type: object
+ properties:
+ device:
+ type: string
+ example: ABCDE123456789FGHIJK
+ display_name:
+ type: string
+ example: iOS (+XX XXX XXX 123)
+ capabilities:
+ type: array
+ items:
+ type: string
+ example: push
handlers.firstFactorRequestBody:
required:
- username
@@ -642,6 +704,24 @@ components:
keepMeLoggedIn:
type: boolean
example: true
+ handlers.logoutRequestBody:
+ type: object
+ properties:
+ targetURL:
+ type: string
+ example: https://redirect.example.com
+ handlers.logoutResponseBody:
+ type: object
+ properties:
+ status:
+ type: string
+ example: OK
+ data:
+ type: object
+ properties:
+ safeTargetURL:
+ type: boolean
+ example: true
handlers.redirectResponse:
type: object
properties:
@@ -758,6 +838,9 @@ components:
has_totp:
type: boolean
example: true
+ has_duo:
+ type: boolean
+ example: true
handlers.UserInfo.MethodBody:
required:
- method
diff --git a/config.template.yml b/config.template.yml
index 732dfdb52..ac4682134 100644
--- a/config.template.yml
+++ b/config.template.yml
@@ -111,6 +111,7 @@ duo_api:
integration_key: ABCDEF
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl
+ enable_self_enrollment: false
##
## NTP Configuration
diff --git a/docs/configuration/duo-push-notifications.md b/docs/configuration/duo-push-notifications.md
index a0604b0ad..b69c93960 100644
--- a/docs/configuration/duo-push-notifications.md
+++ b/docs/configuration/duo-push-notifications.md
@@ -24,6 +24,7 @@ duo_api:
hostname: api-123456789.example.com
integration_key: ABCDEF
secret_key: 1234567890abcdefghifjkl
+ enable_self_enrollment: false
```
The secret key is shown as an example, you also have the option to set it using an environment
@@ -67,4 +68,16 @@ required: yes
The secret [Duo] key used to verify your application is valid.
+### enable_self_enrollment
+
+type: boolean
+{: .label .label-config .label-purple }
+default: false
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+Enables [Duo] device self-enrollment from within the Authelia portal.
+
[Duo]: https://duo.com/
diff --git a/docs/features/2fa/push-notifications.md b/docs/features/2fa/push-notifications.md
index 7b53439d9..5443fb912 100644
--- a/docs/features/2fa/push-notifications.md
+++ b/docs/features/2fa/push-notifications.md
@@ -41,6 +41,7 @@ option.
You should now receive a notification on your mobile phone with all the details
about the authentication request.
+In case you have multiple devices available, you will be asked to select your preferred device.
## Limitation
diff --git a/internal/commands/storage_run.go b/internal/commands/storage_run.go
index 0af2e7906..be7915e2b 100644
--- a/internal/commands/storage_run.go
+++ b/internal/commands/storage_run.go
@@ -367,7 +367,12 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
return provider.SchemaMigrate(ctx, true, storage.SchemaLatest)
}
default:
- if !cmd.Flags().Changed("target") {
+ pre1, err := cmd.Flags().GetBool("pre1")
+ if err != nil {
+ return err
+ }
+
+ if !cmd.Flags().Changed("target") && !pre1 {
return errors.New("must set target")
}
@@ -375,11 +380,6 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
return err
}
- pre1, err := cmd.Flags().GetBool("pre1")
- if err != nil {
- return err
- }
-
switch {
case pre1:
return provider.SchemaMigrate(ctx, false, -1)
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml
index 732dfdb52..ac4682134 100644
--- a/internal/configuration/config.template.yml
+++ b/internal/configuration/config.template.yml
@@ -111,6 +111,7 @@ duo_api:
integration_key: ABCDEF
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl
+ enable_self_enrollment: false
##
## NTP Configuration
diff --git a/internal/configuration/schema/duo.go b/internal/configuration/schema/duo.go
index 520c724e0..55e81d217 100644
--- a/internal/configuration/schema/duo.go
+++ b/internal/configuration/schema/duo.go
@@ -2,7 +2,8 @@ package schema
// DuoAPIConfiguration represents the configuration related to Duo API.
type DuoAPIConfiguration struct {
- Hostname string `koanf:"hostname"`
- IntegrationKey string `koanf:"integration_key"`
- SecretKey string `koanf:"secret_key"`
+ Hostname string `koanf:"hostname"`
+ EnableSelfEnrollment bool `koanf:"enable_self_enrollment"`
+ IntegrationKey string `koanf:"integration_key"`
+ SecretKey string `koanf:"secret_key"`
}
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index 67f6bef9b..2d3b88d5b 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -162,6 +162,7 @@ var ValidKeys = []string{
// DUO API Keys.
"duo_api.hostname",
+ "duo_api.enable_self_enrollment",
"duo_api.secret_key",
"duo_api.integration_key",
diff --git a/internal/duo/const.go b/internal/duo/const.go
new file mode 100644
index 000000000..89d5ce275
--- /dev/null
+++ b/internal/duo/const.go
@@ -0,0 +1,16 @@
+package duo
+
+// Duo Methods.
+const (
+ // Push Method - The device is activated for Duo Push.
+ Push = "push"
+ // OTP Method - The device is capable of generating passcodes with the Duo Mobile app.
+ OTP = "mobile_otp"
+ // Phone Method - The device can receive phone calls.
+ Phone = "phone"
+ // SMS Method - The device can receive batches of SMS passcodes.
+ SMS = "sms"
+)
+
+// PossibleMethods is the set of all possible Duo 2FA methods.
+var PossibleMethods = []string{Push} // OTP, Phone, SMS
diff --git a/internal/duo/duo.go b/internal/duo/duo.go
index 1d90e43c4..bcfaa15a5 100644
--- a/internal/duo/duo.go
+++ b/internal/duo/duo.go
@@ -18,20 +18,61 @@ func NewDuoAPI(duoAPI *duoapi.DuoApi) *APIImpl {
}
// Call call to the DuoAPI.
-func (d *APIImpl) Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error) {
+func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error) {
var response Response
- _, responseBytes, err := d.DuoApi.SignedCall("POST", "/auth/v2/auth", values)
+ _, responseBytes, err := d.DuoApi.SignedCall(method, path, values)
if err != nil {
return nil, err
}
- ctx.Logger.Tracef("Duo Push Auth Response Raw Data for %s from IP %s: %s", ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
+ ctx.Logger.Tracef("Duo endpoint: %s response raw data for %s from IP %s: %s", path, ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
err = json.Unmarshal(responseBytes, &response)
if err != nil {
return nil, err
}
+ if response.Stat == "FAIL" {
+ ctx.Logger.Warnf(
+ "Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
+ ctx.GetSession().Username, ctx.RemoteIP().String(),
+ response.Message, response.MessageDetail, response.Code)
+ }
+
return &response, nil
}
+
+// PreAuthCall call to the DuoAPI.
+func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error) {
+ var preAuthResponse PreAuthResponse
+
+ response, err := d.Call(ctx, values, "POST", "/auth/v2/preauth")
+ if err != nil {
+ return nil, err
+ }
+
+ err = json.Unmarshal(response.Response, &preAuthResponse)
+ if err != nil {
+ return nil, err
+ }
+
+ return &preAuthResponse, nil
+}
+
+// AuthCall call to the DuoAPI.
+func (d *APIImpl) AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error) {
+ var authResponse AuthResponse
+
+ response, err := d.Call(ctx, values, "POST", "/auth/v2/auth")
+ if err != nil {
+ return nil, err
+ }
+
+ err = json.Unmarshal(response.Response, &authResponse)
+ if err != nil {
+ return nil, err
+ }
+
+ return &authResponse, nil
+}
diff --git a/internal/duo/types.go b/internal/duo/types.go
index 4d7b45e71..54599cd02 100644
--- a/internal/duo/types.go
+++ b/internal/duo/types.go
@@ -1,6 +1,7 @@
package duo
import (
+ "encoding/json"
"net/url"
duoapi "github.com/duosecurity/duo_api_golang"
@@ -10,7 +11,9 @@ import (
// API interface wrapping duo api library for testing purpose.
type API interface {
- Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error)
+ Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error)
+ PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error)
+ AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error)
}
// APIImpl implementation of DuoAPI interface.
@@ -18,15 +21,38 @@ type APIImpl struct {
*duoapi.DuoApi
}
-// Response response coming from Duo API.
-type Response struct {
- Response struct {
- Result string `json:"result"`
- Status string `json:"status"`
- StatusMessage string `json:"status_msg"`
- } `json:"response"`
- Code int `json:"code"`
- Message string `json:"message"`
- MessageDetail string `json:"message_detail"`
- Stat string `json:"stat"`
+// Device holds all necessary info for frontend.
+type Device struct {
+ Capabilities []string `json:"capabilities"`
+ Device string `json:"device"`
+ DisplayName string `json:"display_name"`
+ Name string `json:"name"`
+ SmsNextcode string `json:"sms_nextcode"`
+ Number string `json:"number"`
+ Type string `json:"type"`
+}
+
+// Response coming from Duo API.
+type Response struct {
+ Response json.RawMessage `json:"response"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+ MessageDetail string `json:"message_detail"`
+ Stat string `json:"stat"`
+}
+
+// AuthResponse is a response for a authorization request.
+type AuthResponse struct {
+ Result string `json:"result"`
+ Status string `json:"status"`
+ StatusMessage string `json:"status_msg"`
+ TrustedDeviceToken string `json:"trusted_device_token"`
+}
+
+// PreAuthResponse is a response for a preauthorization request.
+type PreAuthResponse struct {
+ Result string `json:"result"`
+ StatusMessage string `json:"status_msg"`
+ Devices []Device `json:"devices"`
+ EnrollPortalURL string `json:"enroll_portal_url"`
}
diff --git a/internal/handlers/const.go b/internal/handlers/const.go
index b99751639..cec52f69a 100644
--- a/internal/handlers/const.go
+++ b/internal/handlers/const.go
@@ -59,7 +59,6 @@ const (
const (
testInactivity = "10"
testRedirectionURL = "http://redirection.local"
- testResultAllow = "allow"
testUsername = "john"
)
@@ -69,6 +68,14 @@ const (
loginDelayMaximumRandomDelayMilliseconds = int64(85)
)
+// Duo constants.
+const (
+ allow = "allow"
+ deny = "deny"
+ enroll = "enroll"
+ auth = "auth"
+)
+
// OIDC constants.
const (
pathOpenIDConnectWellKnown = "/.well-known/openid-configuration"
diff --git a/internal/handlers/duo.go b/internal/handlers/duo.go
new file mode 100644
index 000000000..098907bda
--- /dev/null
+++ b/internal/handlers/duo.go
@@ -0,0 +1,49 @@
+package handlers
+
+import (
+ "net/url"
+
+ "github.com/authelia/authelia/v4/internal/duo"
+ "github.com/authelia/authelia/v4/internal/middlewares"
+ "github.com/authelia/authelia/v4/internal/utils"
+)
+
+// DuoPreAuth helper function for retrieving supported devices and capabilities from duo api.
+func DuoPreAuth(ctx *middlewares.AutheliaCtx, duoAPI duo.API) (string, string, []DuoDevice, string, error) {
+ userSession := ctx.GetSession()
+ values := url.Values{}
+ values.Set("username", userSession.Username)
+
+ preAuthResponse, err := duoAPI.PreAuthCall(ctx, values)
+ if err != nil {
+ return "", "", nil, "", err
+ }
+
+ if preAuthResponse.Result == auth {
+ var supportedDevices []DuoDevice
+
+ for _, device := range preAuthResponse.Devices {
+ var supportedMethods []string
+
+ for _, method := range duo.PossibleMethods {
+ if utils.IsStringInSlice(method, device.Capabilities) {
+ supportedMethods = append(supportedMethods, method)
+ }
+ }
+
+ if len(supportedMethods) > 0 {
+ supportedDevices = append(supportedDevices, DuoDevice{
+ Device: device.Device,
+ DisplayName: device.DisplayName,
+ Capabilities: supportedMethods,
+ })
+ }
+ }
+
+ if len(supportedDevices) > 0 {
+ return preAuthResponse.Result, preAuthResponse.StatusMessage, supportedDevices, preAuthResponse.EnrollPortalURL, nil
+ }
+ }
+
+ return preAuthResponse.Result, preAuthResponse.StatusMessage, nil, preAuthResponse.EnrollPortalURL, nil
+}
diff --git a/internal/handlers/handler_register_duo_device.go b/internal/handlers/handler_register_duo_device.go
new file mode 100644
index 000000000..743df3ef8
--- /dev/null
+++ b/internal/handlers/handler_register_duo_device.go
@@ -0,0 +1,120 @@
+package handlers
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/authelia/authelia/v4/internal/duo"
+ "github.com/authelia/authelia/v4/internal/middlewares"
+ "github.com/authelia/authelia/v4/internal/models"
+ "github.com/authelia/authelia/v4/internal/utils"
+)
+
+// SecondFactorDuoDevicesGet handler for retrieving available devices and capabilities from duo api.
+func SecondFactorDuoDevicesGet(duoAPI duo.API) middlewares.RequestHandler {
+ return func(ctx *middlewares.AutheliaCtx) {
+ userSession := ctx.GetSession()
+ values := url.Values{}
+ values.Set("username", userSession.Username)
+
+ ctx.Logger.Debugf("Starting Duo PreAuth for %s", userSession.Username)
+
+ result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI)
+ if err != nil {
+ ctx.Error(fmt.Errorf("duo PreAuth API errored: %s", err), messageMFAValidationFailed)
+ return
+ }
+
+ if result == auth {
+ if devices == nil {
+ ctx.Logger.Debugf("No applicable device/method available for Duo user %s", userSession.Username)
+
+ if err := ctx.SetJSONBody(DuoDevicesResponse{Result: enroll}); err != nil {
+ ctx.Error(fmt.Errorf("unable to set JSON body in response"), messageMFAValidationFailed)
+ }
+
+ return
+ }
+
+ if err := ctx.SetJSONBody(DuoDevicesResponse{Result: auth, Devices: devices}); err != nil {
+ ctx.Error(fmt.Errorf("unable to set JSON body in response"), messageMFAValidationFailed)
+ }
+
+ return
+ }
+
+ if result == allow {
+ ctx.Logger.Debugf("Device selection not possible for user %s, because Duo authentication was bypassed - Defaults to Auto Push", userSession.Username)
+
+ if err := ctx.SetJSONBody(DuoDevicesResponse{Result: allow}); err != nil {
+ ctx.Error(fmt.Errorf("unable to set JSON body in response"), messageMFAValidationFailed)
+ }
+
+ return
+ }
+
+ if result == enroll {
+ ctx.Logger.Debugf("Duo user: %s not enrolled", userSession.Username)
+
+ if err := ctx.SetJSONBody(DuoDevicesResponse{Result: enroll, EnrollURL: enrollURL}); err != nil {
+ ctx.Error(fmt.Errorf("unable to set JSON body in response"), messageMFAValidationFailed)
+ }
+
+ return
+ }
+
+ if result == deny {
+ ctx.Logger.Debugf("Duo User not allowed to authenticate: %s", userSession.Username)
+
+ if err := ctx.SetJSONBody(DuoDevicesResponse{Result: deny}); err != nil {
+ ctx.Error(fmt.Errorf("unable to set JSON body in response"), messageMFAValidationFailed)
+ }
+
+ return
+ }
+
+ ctx.Error(fmt.Errorf("duo PreAuth API errored for %s: %s - %s", userSession.Username, result, message), messageMFAValidationFailed)
+ }
+}
+
+// SecondFactorDuoDevicePost update the user preferences regarding Duo device and method.
+func SecondFactorDuoDevicePost(ctx *middlewares.AutheliaCtx) {
+ device := DuoDeviceBody{}
+
+ err := ctx.ParseBody(&device)
+ if err != nil {
+ ctx.Error(err, messageMFAValidationFailed)
+ return
+ }
+
+ if !utils.IsStringInSlice(device.Method, duo.PossibleMethods) {
+ ctx.Error(fmt.Errorf("unknown method '%s', it should be one of %s", device.Method, strings.Join(duo.PossibleMethods, ", ")), messageMFAValidationFailed)
+ return
+ }
+
+ userSession := ctx.GetSession()
+ ctx.Logger.Debugf("Save new preferred Duo device and method of user %s to %s using %s", userSession.Username, device.Device, device.Method)
+ err = ctx.Providers.StorageProvider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: userSession.Username, Device: device.Device, Method: device.Method})
+
+ if err != nil {
+ ctx.Error(fmt.Errorf("unable to save new preferred Duo device and method: %s", err), messageMFAValidationFailed)
+ return
+ }
+
+ ctx.ReplyOK()
+}
+
+// SecondFactorDuoDeviceDelete deletes the useres preferred Duo device and method.
+func SecondFactorDuoDeviceDelete(ctx *middlewares.AutheliaCtx) {
+ userSession := ctx.GetSession()
+ ctx.Logger.Debugf("Deleting preferred Duo device and method of user %s", userSession.Username)
+ err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username)
+
+ if err != nil {
+ ctx.Error(fmt.Errorf("unable to delete preferred Duo device and method: %s", err), messageMFAValidationFailed)
+ return
+ }
+
+ ctx.ReplyOK()
+}
diff --git a/internal/handlers/handler_register_duo_device_test.go b/internal/handlers/handler_register_duo_device_test.go
new file mode 100644
index 000000000..2d224eead
--- /dev/null
+++ b/internal/handlers/handler_register_duo_device_test.go
@@ -0,0 +1,172 @@
+package handlers
+
+import (
+ "fmt"
+ "net/url"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/authelia/authelia/v4/internal/duo"
+ "github.com/authelia/authelia/v4/internal/mocks"
+ "github.com/authelia/authelia/v4/internal/models"
+)
+
+type RegisterDuoDeviceSuite struct {
+ suite.Suite
+ mock *mocks.MockAutheliaCtx
+}
+
+func (s *RegisterDuoDeviceSuite) SetupTest() {
+ s.mock = mocks.NewMockAutheliaCtx(s.T())
+ userSession := s.mock.Ctx.GetSession()
+ userSession.Username = testUsername
+ err := s.mock.Ctx.SaveSession(userSession)
+ s.Assert().NoError(err)
+}
+
+func (s *RegisterDuoDeviceSuite) TearDownTest() {
+ s.mock.Close()
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldCallDuoAPIAndFail() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(nil, fmt.Errorf("Connnection error"))
+
+ SecondFactorDuoDevicesGet(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200KO(s.T(), "Authentication failed, please retry later.")
+ assert.Equal(s.T(), "duo PreAuth API errored: Connnection error", s.mock.Hook.LastEntry().Message)
+ assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondWithSelection() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
+ {Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 3"},
+ }
+
+ var apiDevices = []DuoDevice{
+ {Capabilities: []string{"push"}, Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ {Capabilities: []string{"push"}, Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ response := duo.PreAuthResponse{}
+ response.Result = auth
+ response.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
+
+ SecondFactorDuoDevicesGet(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: auth, Devices: apiDevices})
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondWithAllowOnBypass() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ response := duo.PreAuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
+
+ SecondFactorDuoDevicesGet(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: allow})
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondWithEnroll() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ response := duo.PreAuthResponse{}
+ response.Result = enroll
+ response.EnrollPortalURL = enrollURL
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
+
+ SecondFactorDuoDevicesGet(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: enroll, EnrollURL: enrollURL})
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondWithDeny() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ response := duo.PreAuthResponse{}
+ response.Result = deny
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
+
+ SecondFactorDuoDevicesGet(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: deny})
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondOK() {
+ s.mock.Ctx.Request.SetBodyString("{\"device\":\"1234567890123456\", \"method\":\"push\"}")
+ s.mock.StorageProviderMock.EXPECT().
+ SavePreferredDuoDevice(gomock.Eq(s.mock.Ctx), gomock.Eq(models.DuoDevice{Username: "john", Device: "1234567890123456", Method: "push"})).
+ Return(nil)
+
+ SecondFactorDuoDevicePost(s.mock.Ctx)
+
+ assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondKOOnInvalidMethod() {
+ s.mock.Ctx.Request.SetBodyString("{\"device\":\"1234567890123456\", \"method\":\"testfailure\"}")
+
+ SecondFactorDuoDevicePost(s.mock.Ctx)
+
+ s.mock.Assert200KO(s.T(), "Authentication failed, please retry later.")
+ assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondKOOnEmptyMethod() {
+ s.mock.Ctx.Request.SetBodyString("{\"device\":\"1234567890123456\", \"method\":\"\"}")
+
+ SecondFactorDuoDevicePost(s.mock.Ctx)
+
+ s.mock.Assert200KO(s.T(), "Authentication failed, please retry later.")
+ assert.Equal(s.T(), "unable to validate body: method: non zero value required", s.mock.Hook.LastEntry().Message)
+ assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
+}
+
+func (s *RegisterDuoDeviceSuite) TestShouldRespondKOOnEmptyDevice() {
+ s.mock.Ctx.Request.SetBodyString("{\"device\":\"\", \"method\":\"push\"}")
+
+ SecondFactorDuoDevicePost(s.mock.Ctx)
+
+ s.mock.Assert200KO(s.T(), "Authentication failed, please retry later.")
+ assert.Equal(s.T(), "unable to validate body: device: non zero value required", s.mock.Hook.LastEntry().Message)
+ assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
+}
+
+func TestRunRegisterDuoDeviceSuite(t *testing.T) {
+ s := new(RegisterDuoDeviceSuite)
+ suite.Run(t, s)
+}
diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go
index 0531bec3d..de6d93d28 100644
--- a/internal/handlers/handler_sign_duo.go
+++ b/internal/handlers/handler_sign_duo.go
@@ -6,13 +6,19 @@ import (
"github.com/authelia/authelia/v4/internal/duo"
"github.com/authelia/authelia/v4/internal/middlewares"
+ "github.com/authelia/authelia/v4/internal/models"
"github.com/authelia/authelia/v4/internal/regulation"
+ "github.com/authelia/authelia/v4/internal/session"
+ "github.com/authelia/authelia/v4/internal/utils"
)
// SecondFactorDuoPost handler for sending a push notification via duo api.
func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return func(ctx *middlewares.AutheliaCtx) {
- var requestBody signDuoRequestBody
+ var (
+ requestBody signDuoRequestBody
+ device, method string
+ )
if err := ctx.ParseBody(&requestBody); err != nil {
ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err)
@@ -25,43 +31,49 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
userSession := ctx.GetSession()
remoteIP := ctx.RemoteIP().String()
- ctx.Logger.Debugf("Starting Duo Push Auth Attempt for user '%s' with IP '%s'", userSession.Username, remoteIP)
-
- values := url.Values{}
-
- values.Set("username", userSession.Username)
- values.Set("ipaddr", remoteIP)
- values.Set("factor", "push")
- values.Set("device", "auto")
-
- if requestBody.TargetURL != "" {
- values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", requestBody.TargetURL))
+ duoDevice, err := ctx.Providers.StorageProvider.LoadPreferredDuoDevice(ctx, userSession.Username)
+ if err != nil {
+ ctx.Logger.Debugf("Error identifying preferred device for user %s: %s", userSession.Username, err)
+ ctx.Logger.Debugf("Starting Duo PreAuth for initial device selection of user: %s", userSession.Username)
+ device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, requestBody.TargetURL)
+ } else {
+ ctx.Logger.Debugf("Starting Duo PreAuth to check preferred device of user: %s", userSession.Username)
+ device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, requestBody.TargetURL)
}
- duoResponse, err := duoAPI.Call(values, ctx)
if err != nil {
- ctx.Logger.Errorf("Failed to perform DUO call for user '%s': %+v", userSession.Username, err)
+ ctx.Error(err, messageMFAValidationFailed)
+ return
+ }
+
+ if device == "" || method == "" {
+ return
+ }
+
+ ctx.Logger.Debugf("Starting Duo Auth attempt for %s with device %s and method %s from IP %s", userSession.Username, device, method, remoteIP)
+
+ values, err := SetValues(userSession, device, method, remoteIP, requestBody.TargetURL, requestBody.Passcode)
+ if err != nil {
+ ctx.Logger.Errorf("Failed to set values for Duo Auth Call for user '%s': %+v", userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
- if duoResponse.Stat == "FAIL" {
- if duoResponse.Code == 40002 {
- ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d. "+
- "This error often occurs if you've not setup the username in the Admin Dashboard.",
- userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
- } else {
- ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
- userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
- }
+ authResponse, err := duoAPI.AuthCall(ctx, values)
+ if err != nil {
+ ctx.Logger.Errorf("Failed to perform Duo Auth Call for user '%s': %+v", userSession.Username, err)
+
+ respondUnauthorized(ctx, messageMFAValidationFailed)
+
+ return
}
- if duoResponse.Response.Result != testResultAllow {
+ if authResponse.Result != allow {
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO,
- fmt.Errorf("result: %s, code: %d, message: %s (%s)", duoResponse.Response.Result, duoResponse.Code,
- duoResponse.Message, duoResponse.MessageDetail))
+ fmt.Errorf("duo auth result: %s, status: %s, message: %s", authResponse.Result, authResponse.Status,
+ authResponse.StatusMessage))
respondUnauthorized(ctx, messageMFAValidationFailed)
@@ -73,29 +85,223 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return
}
- if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil {
- ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDUO, userSession.Username, err)
-
- respondUnauthorized(ctx, messageMFAValidationFailed)
-
- return
- }
-
- userSession.SetTwoFactor(ctx.Clock.Now())
-
- err = ctx.SaveSession(userSession)
- if err != nil {
- ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err)
-
- respondUnauthorized(ctx, messageMFAValidationFailed)
-
- return
- }
-
- if userSession.OIDCWorkflowSession != nil {
- handleOIDCWorkflowResponse(ctx)
- } else {
- Handle2FAResponse(ctx, requestBody.TargetURL)
- }
+ HandleAllow(ctx, requestBody.TargetURL)
}
}
+
+// HandleInitialDeviceSelection handler for retrieving all available devices.
+func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, targetURL string) (device string, method string, err error) {
+ result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI)
+ if err != nil {
+ ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err)
+
+ respondUnauthorized(ctx, messageMFAValidationFailed)
+
+ return "", "", err
+ }
+
+ switch result {
+ case enroll:
+ ctx.Logger.Debugf("Duo user: %s not enrolled", userSession.Username)
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll, EnrollURL: enrollURL}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ case deny:
+ ctx.Logger.Infof("Duo user: %s not allowed to authenticate: %s", userSession.Username, message)
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: deny}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ case allow:
+ ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username)
+ HandleAllow(ctx, targetURL)
+
+ return "", "", nil
+ case auth:
+ device, method, err = HandleAutoSelection(ctx, devices, userSession.Username)
+ if err != nil {
+ return "", "", err
+ }
+
+ return device, method, nil
+ }
+
+ return "", "", fmt.Errorf("unknown result: %s", result)
+}
+
+// HandlePreferredDeviceCheck handler to check if the saved device and method is still valid.
+func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, device string, method string, targetURL string) (string, string, error) {
+ result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI)
+ if err != nil {
+ ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err)
+
+ respondUnauthorized(ctx, messageMFAValidationFailed)
+
+ return "", "", nil
+ }
+
+ switch result {
+ case enroll:
+ ctx.Logger.Debugf("Duo user: %s no longer enrolled removing preferred device", userSession.Username)
+
+ if err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username); err != nil {
+ return "", "", fmt.Errorf("unable to delete preferred Duo device and method for user %s: %s", userSession.Username, err)
+ }
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll, EnrollURL: enrollURL}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ case deny:
+ ctx.Logger.Infof("Duo user: %s not allowed to authenticate: %s", userSession.Username, message)
+ ctx.ReplyUnauthorized()
+
+ return "", "", nil
+ case allow:
+ ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username)
+ HandleAllow(ctx, targetURL)
+
+ return "", "", nil
+ case auth:
+ if devices == nil {
+ ctx.Logger.Debugf("Duo user: %s has no compatible device/method available removing preferred device", userSession.Username)
+
+ if err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username); err != nil {
+ return "", "", fmt.Errorf("unable to delete preferred Duo device and method for user %s: %s", userSession.Username, err)
+ }
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ }
+
+ if len(devices) > 0 {
+ for i := range devices {
+ if devices[i].Device == device {
+ if utils.IsStringInSlice(method, devices[i].Capabilities) {
+ return device, method, nil
+ }
+ }
+ }
+ }
+
+ return HandleAutoSelection(ctx, devices, userSession.Username)
+ }
+
+ return "", "", fmt.Errorf("unknown result: %s", result)
+}
+
+// HandleAutoSelection handler automatically selects preferred device if there is only one suitable option.
+func HandleAutoSelection(ctx *middlewares.AutheliaCtx, devices []DuoDevice, username string) (string, string, error) {
+ if devices == nil {
+ ctx.Logger.Debugf("No compatible device/method available for Duo user: %s", username)
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ }
+
+ if len(devices) > 1 {
+ ctx.Logger.Debugf("Multiple devices available for Duo user: %s require manual selection", username)
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: auth, Devices: devices}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ }
+
+ if len(devices[0].Capabilities) > 1 {
+ ctx.Logger.Debugf("Multiple methods available for Duo user: %s require manual selection", username)
+
+ if err := ctx.SetJSONBody(DuoSignResponse{Result: auth, Devices: devices}); err != nil {
+ return "", "", fmt.Errorf("unable to set JSON body in response")
+ }
+
+ return "", "", nil
+ }
+
+ device := devices[0].Device
+ method := devices[0].Capabilities[0]
+ ctx.Logger.Debugf("Exactly one device: '%s' and method: '%s' found, saving as new preferred Duo device and method for user: %s", device, method, username)
+
+ if err := ctx.Providers.StorageProvider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: username, Method: method, Device: device}); err != nil {
+ return "", "", fmt.Errorf("unable to save new preferred Duo device and method for user %s: %s", username, err)
+ }
+
+ return device, method, nil
+}
+
+// HandleAllow handler for successful logins.
+func HandleAllow(ctx *middlewares.AutheliaCtx, targetURL string) {
+ userSession := ctx.GetSession()
+
+ err := ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx)
+ if err != nil {
+ ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDUO, userSession.Username, err)
+
+ respondUnauthorized(ctx, messageMFAValidationFailed)
+
+ return
+ }
+
+ userSession.SetTwoFactor(ctx.Clock.Now())
+
+ err = ctx.SaveSession(userSession)
+ if err != nil {
+ ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err)
+
+ respondUnauthorized(ctx, messageMFAValidationFailed)
+
+ return
+ }
+
+ if userSession.OIDCWorkflowSession != nil {
+ handleOIDCWorkflowResponse(ctx)
+ } else {
+ Handle2FAResponse(ctx, targetURL)
+ }
+}
+
+// SetValues sets all appropriate Values for the Auth Request.
+func SetValues(userSession session.UserSession, device string, method string, remoteIP string, targetURL string, passcode string) (url.Values, error) {
+ values := url.Values{}
+ values.Set("username", userSession.Username)
+ values.Set("ipaddr", remoteIP)
+ values.Set("factor", method)
+
+ switch method {
+ case duo.Push:
+ values.Set("device", device)
+
+ if userSession.DisplayName != "" {
+ values.Set("display_username", userSession.DisplayName)
+ }
+
+ if targetURL != "" {
+ values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", targetURL))
+ }
+ case duo.Phone:
+ values.Set("device", device)
+ case duo.SMS:
+ values.Set("device", device)
+ case duo.OTP:
+ if passcode != "" {
+ values.Set("passcode", passcode)
+ } else {
+ return nil, fmt.Errorf("no passcode received from user: %s", userSession.Username)
+ }
+ }
+
+ return values, nil
+}
diff --git a/internal/handlers/handler_sign_duo_test.go b/internal/handlers/handler_sign_duo_test.go
index 239b557d6..26f067c6f 100644
--- a/internal/handlers/handler_sign_duo_test.go
+++ b/internal/handlers/handler_sign_duo_test.go
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
+ "errors"
"fmt"
"net/url"
"regexp"
@@ -20,7 +21,6 @@ import (
type SecondFactorDuoPostSuite struct {
suite.Suite
-
mock *mocks.MockAutheliaCtx
}
@@ -36,18 +36,58 @@ func (s *SecondFactorDuoPostSuite) TearDownTest() {
s.mock.Close()
}
-func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
+func (s *SecondFactorDuoPostSuite) TestShouldEnroll() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(nil, errors.New("no Duo device and method saved"))
+
+ var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
+
values := url.Values{}
values.Set("username", "john")
- values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
- values.Set("factor", "push")
- values.Set("device", "auto")
- values.Set("pushinfo", "target%20url=https://target.example.com")
- response := duo.Response{}
- response.Response.Result = testResultAllow
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = enroll
+ preAuthResponse.EnrollPortalURL = enrollURL
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoSignResponse{
+ Result: enroll,
+ EnrollURL: enrollURL,
+ })
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldAutoSelect() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().LoadPreferredDuoDevice(s.mock.Ctx, "john").Return(nil, errors.New("no Duo device and method saved"))
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ {Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ s.mock.StorageProviderMock.EXPECT().
+ SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
+ Return(nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -58,29 +98,313 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
- duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
+ values = url.Values{}
+ values.Set("username", "john")
+ values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
+ values.Set("factor", "push")
+ values.Set("device", "12345ABCDEFGHIJ67890")
+ values.Set("pushinfo", "target%20url=https://target.example.com")
- s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
+ authResponse := duo.AuthResponse{}
+ authResponse.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+ assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldDenyAutoSelect() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(nil, errors.New("no Duo device and method saved"))
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = deny
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ values = url.Values{}
+ values.Set("username", "john")
+ values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
+ values.Set("factor", "push")
+ values.Set("device", "12345ABCDEFGHIJ67890")
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
- assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 200)
+ s.mock.Assert200OK(s.T(), DuoSignResponse{
+ Result: deny,
+ })
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldFailAutoSelect() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(nil, errors.New("no Duo device and method saved"))
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndEnroll() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
+
+ var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = enroll
+ preAuthResponse.EnrollPortalURL = enrollURL
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoSignResponse{
+ Result: enroll,
+ EnrollURL: enrollURL,
+ })
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndCallPreauthAPIWithInvalidDevicesAndEnroll() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"sms"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert200OK(s.T(), DuoSignResponse{
+ Result: enroll,
+ })
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldUseOldDeviceAndSelect() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
+ {Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 3"},
+ }
+
+ var apiDevices = []DuoDevice{
+ {Capabilities: []string{"push"}, Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ {Capabilities: []string{"push"}, Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+ s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: auth, Devices: apiDevices})
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldUseInvalidMethodAndAutoSelect() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "invalidmethod"}, nil)
+
+ s.mock.StorageProviderMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
+ Username: "john",
+ Successful: true,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthTypeDUO,
+ RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ s.mock.StorageProviderMock.EXPECT().
+ SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
+ Return(nil)
+
+ values = url.Values{}
+ values.Set("username", "john")
+ values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
+ values.Set("factor", "push")
+ values.Set("device", "12345ABCDEFGHIJ67890")
+ values.Set("pushinfo", "target%20url=https://target.example.com")
+
+ authResponse := duo.AuthResponse{}
+ authResponse.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+ assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndAllowAccess() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = allow
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndDenyAccess() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = deny
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ values = url.Values{}
+ values.Set("username", "john")
+ values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
+ values.Set("factor", "push")
+ values.Set("device", "12345ABCDEFGHIJ67890")
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
+}
+
+func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndFail() {
+ duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
+
+ SecondFactorDuoPost(duoMock)(s.mock.Ctx)
+
+ s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- values := url.Values{}
- values.Set("username", "john")
- values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
- values.Set("factor", "push")
- values.Set("device", "auto")
- values.Set("pushinfo", "target%20url=https://target.example.com")
-
- response := duo.Response{}
- response.Response.Result = "deny"
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -91,30 +415,67 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
- duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
- s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ values = url.Values{}
+ values.Set("username", "john")
+ values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
+ values.Set("factor", "push")
+ values.Set("device", "12345ABCDEFGHIJ67890")
+
+ response := duo.AuthResponse{}
+ response.Result = deny
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
- assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 401)
+ assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
values := url.Values{}
values.Set("username", "john")
- values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
- values.Set("factor", "push")
- values.Set("device", "auto")
- values.Set("pushinfo", "target%20url=https://target.example.com")
- duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(nil, fmt.Errorf("connnection error"))
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
- s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
+
+ bodyBytes, err := json.Marshal(signDuoRequestBody{})
+ s.Require().NoError(err)
+ s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
@@ -124,10 +485,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- response := duo.Response{}
- response.Response.Result = testResultAllow
-
- duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -138,7 +498,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ response := duo.AuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL
@@ -155,10 +534,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- response := duo.Response{}
- response.Response.Result = testResultAllow
-
- duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -169,7 +547,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ response := duo.AuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
@@ -182,10 +579,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- response := duo.Response{}
- response.Response.Result = testResultAllow
-
- duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -196,7 +592,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ response := duo.AuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "https://mydomain.local",
@@ -213,10 +628,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- response := duo.Response{}
- response.Response.Result = testResultAllow
-
- duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -227,7 +641,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ response := duo.AuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local",
@@ -242,10 +675,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
- response := duo.Response{}
- response.Response.Result = testResultAllow
-
- duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
+ s.mock.StorageProviderMock.EXPECT().
+ LoadPreferredDuoDevice(s.mock.Ctx, "john").
+ Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@@ -256,7 +688,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
- }))
+ })).
+ Return(nil)
+
+ var duoDevices = []duo.Device{
+ {Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
+ }
+
+ values := url.Values{}
+ values.Set("username", "john")
+
+ preAuthResponse := duo.PreAuthResponse{}
+ preAuthResponse.Result = auth
+ preAuthResponse.Devices = duoDevices
+
+ duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
+
+ response := duo.AuthResponse{}
+ response.Result = allow
+
+ duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local",
diff --git a/internal/handlers/types.go b/internal/handlers/types.go
index 4fa4886e9..2082b62c2 100644
--- a/internal/handlers/types.go
+++ b/internal/handlers/types.go
@@ -11,6 +11,24 @@ type MethodList = []string
type authorizationMatching int
+// UserInfo is the model of user info and second factor preferences.
+type UserInfo struct {
+ // The users display name.
+ DisplayName string `json:"display_name"`
+
+ // The preferred 2FA method.
+ Method string `json:"method" valid:"required"`
+
+ // True if a security key has been registered.
+ HasU2F bool `json:"has_u2f" valid:"required"`
+
+ // True if a TOTP device has been registered.
+ HasTOTP bool `json:"has_totp" valid:"required"`
+
+ // True if a Duo device and method has been enrolled.
+ HasDuo bool `json:"has_duo" valid:"required"`
+}
+
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
type signTOTPRequestBody struct {
Token string `json:"token" valid:"required"`
@@ -25,6 +43,7 @@ type signU2FRequestBody struct {
type signDuoRequestBody struct {
TargetURL string `json:"targetURL"`
+ Passcode string `json:"passcode"`
}
// firstFactorRequestBody represents the JSON body received by the endpoint.
@@ -60,6 +79,34 @@ type TOTPKeyResponse struct {
OTPAuthURL string `json:"otpauth_url"`
}
+// DuoDeviceBody the selected Duo device and method.
+type DuoDeviceBody struct {
+ Device string `json:"device" valid:"required"`
+ Method string `json:"method" valid:"required"`
+}
+
+// DuoDevice represents Duo devices and methods.
+type DuoDevice struct {
+ Device string `json:"device"`
+ DisplayName string `json:"display_name"`
+ Capabilities []string `json:"capabilities"`
+}
+
+// DuoDevicesResponse represents all available user devices and methods as well as an optional enrollment url.
+type DuoDevicesResponse struct {
+ Result string `json:"result" valid:"required"`
+ Devices []DuoDevice `json:"devices,omitempty"`
+ EnrollURL string `json:"enroll_url,omitempty"`
+}
+
+// DuoSignResponse represents a result of the preauth and or auth call with further optional info.
+type DuoSignResponse struct {
+ Result string `json:"result" valid:"required"`
+ Devices []DuoDevice `json:"devices,omitempty"`
+ Redirect string `json:"redirect,omitempty"`
+ EnrollURL string `json:"enroll_url,omitempty"`
+}
+
// StateResponse represents the response sent by the state endpoint.
type StateResponse struct {
Username string `json:"username"`
diff --git a/internal/mocks/mock_duo_api.go b/internal/mocks/mock_duo_api.go
index 40047a4f0..d5a753305 100644
--- a/internal/mocks/mock_duo_api.go
+++ b/internal/mocks/mock_duo_api.go
@@ -11,7 +11,7 @@ import (
gomock "github.com/golang/mock/gomock"
duo "github.com/authelia/authelia/v4/internal/duo"
- "github.com/authelia/authelia/v4/internal/middlewares"
+ middlewares "github.com/authelia/authelia/v4/internal/middlewares"
)
// MockAPI is a mock of API interface.
@@ -37,17 +37,47 @@ func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
return m.recorder
}
-// Call mocks base method.
-func (m *MockAPI) Call(arg0 url.Values, arg1 *middlewares.AutheliaCtx) (*duo.Response, error) {
+// AuthCall mocks base method.
+func (m *MockAPI) AuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.AuthResponse, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Call", arg0, arg1)
+ ret := m.ctrl.Call(m, "AuthCall", arg0, arg1)
+ ret0, _ := ret[0].(*duo.AuthResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// AuthCall indicates an expected call of AuthCall.
+func (mr *MockAPIMockRecorder) AuthCall(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCall", reflect.TypeOf((*MockAPI)(nil).AuthCall), arg0, arg1)
+}
+
+// Call mocks base method.
+func (m *MockAPI) Call(arg0 *middlewares.AutheliaCtx, arg1 url.Values, arg2, arg3 string) (*duo.Response, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Call", arg0, arg1, arg2, arg3)
ret0, _ := ret[0].(*duo.Response)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Call indicates an expected call of Call.
-func (mr *MockAPIMockRecorder) Call(arg0, arg1 interface{}) *gomock.Call {
+func (mr *MockAPIMockRecorder) Call(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1, arg2, arg3)
+}
+
+// PreAuthCall mocks base method.
+func (m *MockAPI) PreAuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.PreAuthResponse, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PreAuthCall", arg0, arg1)
+ ret0, _ := ret[0].(*duo.PreAuthResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PreAuthCall indicates an expected call of PreAuthCall.
+func (mr *MockAPIMockRecorder) PreAuthCall(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreAuthCall", reflect.TypeOf((*MockAPI)(nil).PreAuthCall), arg0, arg1)
}
diff --git a/internal/models/duo_device.go b/internal/models/duo_device.go
new file mode 100644
index 000000000..cf0ac83c1
--- /dev/null
+++ b/internal/models/duo_device.go
@@ -0,0 +1,9 @@
+package models
+
+// DuoDevice represents a DUO Device.
+type DuoDevice struct {
+ ID int `db:"id"`
+ Username string `db:"username"`
+ Device string `db:"device"`
+ Method string `db:"method"`
+}
diff --git a/internal/models/model_userinfo.go b/internal/models/model_userinfo.go
index da0b1f9fb..9daadc4e4 100644
--- a/internal/models/model_userinfo.go
+++ b/internal/models/model_userinfo.go
@@ -8,9 +8,12 @@ type UserInfo struct {
// The preferred 2FA method.
Method string `db:"second_factor_method" json:"method" valid:"required"`
+ // 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 TOTP device has been registered.
- HasTOTP bool `db:"has_totp" json:"has_totp" 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/server/const.go b/internal/server/const.go
index a01473383..ed3484db9 100644
--- a/internal/server/const.go
+++ b/internal/server/const.go
@@ -10,7 +10,11 @@ const (
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"}
-const dev = "dev"
+const (
+ dev = "dev"
+ f = "false"
+ t = "true"
+)
const healthCheckEnv = `# Written by Authelia Process
X_AUTHELIA_HEALTHCHECK=1
diff --git a/internal/server/server.go b/internal/server/server.go
index 1f99bfef2..2e826aeb1 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -30,14 +30,19 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0")
resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword)
+ duoSelfEnrollment := f
+ if configuration.DuoAPI != nil {
+ duoSelfEnrollment = strconv.FormatBool(configuration.DuoAPI.EnableSelfEnrollment)
+ }
+
embeddedPath, _ := fs.Sub(assets, "public_html")
embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath)))
https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != ""
- serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
- serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
- serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
+ serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
+ serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
+ serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
r := router.New()
r.GET("/", serveIndexHandler)
@@ -125,8 +130,14 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
configuration.DuoAPI.Hostname, ""))
}
+ r.GET("/api/secondfactor/duo_devices", autheliaMiddleware(
+ middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicesGet(duoAPI))))
+
r.POST("/api/secondfactor/duo", autheliaMiddleware(
middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI))))
+
+ r.POST("/api/secondfactor/duo_device", autheliaMiddleware(
+ middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicePost)))
}
if configuration.Server.EnablePprof {
diff --git a/internal/server/template.go b/internal/server/template.go
index 6b6a39163..907344c6f 100644
--- a/internal/server/template.go
+++ b/internal/server/template.go
@@ -16,15 +16,15 @@ import (
// ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui.
-func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
+func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
logger := logging.Logger()
- f, err := assets.Open(publicDir + file)
+ a, err := assets.Open(publicDir + file)
if err != nil {
logger.Fatalf("Unable to open %s: %s", file, err)
}
- b, err := ioutil.ReadAll(f)
+ b, err := ioutil.ReadAll(a)
if err != nil {
logger.Fatalf("Unable to read %s: %s", file, err)
}
@@ -40,11 +40,11 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
base = baseURL.(string)
}
- logoOverride := "false"
+ logoOverride := f
if assetPath != "" {
if _, err := os.Stat(assetPath + logoFile); err == nil {
- logoOverride = "true"
+ logoOverride = t
}
}
@@ -79,7 +79,7 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
}
- err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
+ err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, DuoSelfEnrollment, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, DuoSelfEnrollment: duoSelfEnrollment, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
if err != nil {
ctx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err)
diff --git a/internal/storage/const.go b/internal/storage/const.go
index 5472b1730..22455c0df 100644
--- a/internal/storage/const.go
+++ b/internal/storage/const.go
@@ -9,7 +9,7 @@ const (
tableIdentityVerification = "identity_verification"
tableTOTPConfigurations = "totp_configurations"
tableU2FDevices = "u2f_devices"
- tableDUODevices = "duo_devices"
+ tableDuoDevices = "duo_devices"
tableAuthenticationLogs = "authentication_logs"
tableMigrations = "migrations"
tableEncryption = "encryption"
diff --git a/internal/storage/errors.go b/internal/storage/errors.go
index 2b26f6c7c..4bae8cd8a 100644
--- a/internal/storage/errors.go
+++ b/internal/storage/errors.go
@@ -5,15 +5,18 @@ import (
)
var (
- // ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
- ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
-
// ErrNoAuthenticationLogs error thrown when no matching authentication logs hve been found in DB.
ErrNoAuthenticationLogs = errors.New("no matching authentication logs found")
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB.
ErrNoTOTPSecret = errors.New("no TOTP secret registered")
+ // ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
+ ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
+
+ // ErrNoDuoDevice error thrown when no Duo device and method has been found in DB.
+ ErrNoDuoDevice = errors.New("no Duo device and method saved")
+
// ErrNoAvailableMigrations is returned when no available migrations can be found.
ErrNoAvailableMigrations = errors.New("no available migrations")
diff --git a/internal/storage/migrations/V0001.Initial_Schema.all.down.sql b/internal/storage/migrations/V0001.Initial_Schema.all.down.sql
index 0209622f5..615ed34c2 100644
--- a/internal/storage/migrations/V0001.Initial_Schema.all.down.sql
+++ b/internal/storage/migrations/V0001.Initial_Schema.all.down.sql
@@ -2,6 +2,7 @@ DROP TABLE IF EXISTS authentication_logs;
DROP TABLE IF EXISTS identity_verification;
DROP TABLE IF EXISTS totp_configurations;
DROP TABLE IF EXISTS u2f_devices;
+DROP TABLE IF EXISTS duo_devices;
DROP TABLE IF EXISTS user_preferences;
DROP TABLE IF EXISTS migrations;
DROP TABLE IF EXISTS encryption;
\ No newline at end of file
diff --git a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql
index e36a12b10..8ab76a855 100644
--- a/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql
+++ b/internal/storage/migrations/V0001.Initial_Schema.mysql.up.sql
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE KEY (username, description)
);
+CREATE TABLE IF NOT EXISTS duo_devices (
+ id INTEGER AUTO_INCREMENT,
+ username VARCHAR(100) NOT NULL,
+ device VARCHAR(32) NOT NULL,
+ method VARCHAR(16) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY (username)
+);
+
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER AUTO_INCREMENT,
username VARCHAR(100) NOT NULL,
diff --git a/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql b/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql
index ec7f225d8..da58a18ad 100644
--- a/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql
+++ b/internal/storage/migrations/V0001.Initial_Schema.postgres.up.sql
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description)
);
+CREATE TABLE IF NOT EXISTS duo_devices (
+ id SERIAL,
+ username VARCHAR(100) NOT NULL,
+ device VARCHAR(32) NOT NULL,
+ method VARCHAR(16) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE (username)
+);
+
CREATE TABLE IF NOT EXISTS user_preferences (
id SERIAL,
username VARCHAR(100) NOT NULL,
diff --git a/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql b/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql
index 2e91d6f94..a93c45ef1 100644
--- a/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql
+++ b/internal/storage/migrations/V0001.Initial_Schema.sqlite.up.sql
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description)
);
+CREATE TABLE IF NOT EXISTS duo_devices (
+ id INTEGER,
+ username VARCHAR(100) NOT NULL,
+ device VARCHAR(32) NOT NULL,
+ method VARCHAR(16) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE (username)
+);
+
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER,
username VARCHAR(100) UNIQUE NOT NULL,
diff --git a/internal/storage/provider.go b/internal/storage/provider.go
index 1e9ff421a..b046dcbb2 100644
--- a/internal/storage/provider.go
+++ b/internal/storage/provider.go
@@ -30,18 +30,22 @@ type Provider interface {
SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error)
LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error)
+ SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error)
+ DeletePreferredDuoDevice(ctx context.Context, username string) (err error)
+ LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error)
+
SchemaTables(ctx context.Context) (tables []string, err error)
SchemaVersion(ctx context.Context) (version int, err error)
+ SchemaLatestVersion() (version int, err error)
+
SchemaMigrate(ctx context.Context, up bool, version int) (err error)
SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, err error)
+ SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
+ SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error)
SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error)
- SchemaLatestVersion() (version int, err error)
- SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
- SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
-
Close() (err error)
}
diff --git a/internal/storage/provider_mock.go b/internal/storage/provider_mock.go
index 4c3fb681f..a3aca1e2c 100644
--- a/internal/storage/provider_mock.go
+++ b/internal/storage/provider_mock.go
@@ -1,16 +1,17 @@
// Code generated by MockGen. DO NOT EDIT.
-// Source: ./internal/storage/provider.go
+// Source: github.com/authelia/authelia/v4/internal/storage (interfaces: Provider)
+// Package storage is a generated GoMock package.
package storage
import (
- "context"
- "reflect"
- "time"
+ context "context"
+ reflect "reflect"
+ time "time"
- "github.com/golang/mock/gomock"
+ gomock "github.com/golang/mock/gomock"
- "github.com/authelia/authelia/v4/internal/models"
+ models "github.com/authelia/authelia/v4/internal/models"
)
// MockProvider is a mock of Provider interface.
@@ -64,6 +65,20 @@ func (mr *MockProviderMockRecorder) Close() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProvider)(nil).Close))
}
+// DeletePreferredDuoDevice mocks base method.
+func (m *MockProvider) DeletePreferredDuoDevice(arg0 context.Context, arg1 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeletePreferredDuoDevice", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeletePreferredDuoDevice indicates an expected call of DeletePreferredDuoDevice.
+func (mr *MockProviderMockRecorder) DeletePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).DeletePreferredDuoDevice), arg0, arg1)
+}
+
// DeleteTOTPConfiguration mocks base method.
func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
@@ -123,6 +138,21 @@ func (mr *MockProviderMockRecorder) LoadPreferred2FAMethod(arg0, arg1 interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).LoadPreferred2FAMethod), arg0, arg1)
}
+// LoadPreferredDuoDevice mocks base method.
+func (m *MockProvider) LoadPreferredDuoDevice(arg0 context.Context, arg1 string) (*models.DuoDevice, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "LoadPreferredDuoDevice", arg0, arg1)
+ ret0, _ := ret[0].(*models.DuoDevice)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// LoadPreferredDuoDevice indicates an expected call of LoadPreferredDuoDevice.
+func (mr *MockProviderMockRecorder) LoadPreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).LoadPreferredDuoDevice), arg0, arg1)
+}
+
// LoadTOTPConfiguration mocks base method.
func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) {
m.ctrl.T.Helper()
@@ -225,6 +255,20 @@ func (mr *MockProviderMockRecorder) SavePreferred2FAMethod(arg0, arg1, arg2 inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).SavePreferred2FAMethod), arg0, arg1, arg2)
}
+// SavePreferredDuoDevice mocks base method.
+func (m *MockProvider) SavePreferredDuoDevice(arg0 context.Context, arg1 models.DuoDevice) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SavePreferredDuoDevice", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// SavePreferredDuoDevice indicates an expected call of SavePreferredDuoDevice.
+func (mr *MockProviderMockRecorder) SavePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).SavePreferredDuoDevice), arg0, arg1)
+}
+
// SaveTOTPConfiguration mocks base method.
func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error {
m.ctrl.T.Helper()
diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go
index dfa23e66b..9f448165f 100644
--- a/internal/storage/sql_provider.go
+++ b/internal/storage/sql_provider.go
@@ -46,9 +46,13 @@ func NewSQLProvider(name, driverName, dataSourceName, encryptionKey string) (pro
sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices),
sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices),
+ sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
+ sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
+ sqlSelectDuoDevice: fmt.Sprintf(queryFmtSelectDuoDevice, tableDuoDevices),
+
sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences),
sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, tableUserPreferences),
- sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableUserPreferences),
+ sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableDuoDevices, tableUserPreferences),
sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations),
sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations),
@@ -99,6 +103,11 @@ type SQLProvider struct {
sqlUpsertU2FDevice string
sqlSelectU2FDevice string
+ // Table: duo_devices
+ sqlUpsertDuoDevice string
+ sqlDeleteDuoDevice string
+ sqlSelectDuoDevice string
+
// Table: user_preferences.
sqlUpsertPreferred2FAMethod string
sqlSelectPreferred2FAMethod string
@@ -186,7 +195,7 @@ func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username strin
// LoadUserInfo loads the models.UserInfo from the database.
func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info models.UserInfo, err error) {
- err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username)
+ err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username)
switch {
case err == nil:
@@ -196,7 +205,7 @@ func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info m
return models.UserInfo{}, fmt.Errorf("error upserting preferred two factor method while selecting user info for user '%s': %w", username, err)
}
- if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username); err != nil {
+ if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username); err != nil {
return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
}
@@ -355,6 +364,33 @@ func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (devic
return device, nil
}
+// 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
+}
+
+// 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
+}
+
+// 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 == sql.ErrNoRows {
+ return nil, ErrNoDuoDevice
+ }
+
+ return nil, err
+ }
+
+ return device, nil
+}
+
// AppendAuthenticationLog append a mark to the authentication log.
func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt,
diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go
index 8f59c0c07..2ce60164f 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 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
FROM %s
WHERE username = ?;`
@@ -129,6 +129,23 @@ const (
DO UPDATE SET key_handle=$2, public_key=$3;`
)
+const (
+ queryFmtUpsertDuoDevice = `
+ REPLACE INTO %s (username, device, method)
+ VALUES (?, ?, ?);`
+
+ queryFmtDeleteDuoDevice = `
+ DELETE
+ FROM %s
+ WHERE username = ?;`
+
+ queryFmtSelectDuoDevice = `
+ SELECT id, username, device, method
+ FROM %s
+ WHERE username = ?
+ ORDER BY id;`
+)
+
const (
queryFmtInsertAuthenticationLogEntry = `
INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method)
diff --git a/internal/storage/sql_provider_schema.go b/internal/storage/sql_provider_schema.go
index fc97c531b..e00056661 100644
--- a/internal/storage/sql_provider_schema.go
+++ b/internal/storage/sql_provider_schema.go
@@ -118,13 +118,14 @@ func (p *SQLProvider) SchemaMigrate(ctx context.Context, up bool, version int) (
return p.schemaMigrate(ctx, currentVersion, version)
}
+//nolint: gocyclo
func (p *SQLProvider) schemaMigrate(ctx context.Context, prior, target int) (err error) {
migrations, err := loadMigrations(p.name, prior, target)
if err != nil {
return err
}
- if len(migrations) == 0 {
+ if len(migrations) == 0 && (prior != 1 || target != -1) {
return ErrNoMigrationsFound
}
@@ -277,7 +278,7 @@ func (p *SQLProvider) SchemaMigrationsDown(ctx context.Context, version int) (mi
return loadMigrations(p.name, current, version)
}
-// SchemaLatestVersion returns the latest version available for migration..
+// SchemaLatestVersion returns the latest version available for migration.
func (p *SQLProvider) SchemaLatestVersion() (version int, err error) {
return latestMigrationVersion(p.name)
}
diff --git a/internal/storage/sql_provider_schema_pre1.go b/internal/storage/sql_provider_schema_pre1.go
index a9d40e1a4..7cdddddee 100644
--- a/internal/storage/sql_provider_schema_pre1.go
+++ b/internal/storage/sql_provider_schema_pre1.go
@@ -291,7 +291,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) {
tableTOTPConfigurations,
tableIdentityVerification,
tableU2FDevices,
- tableDUODevices,
+ tableDuoDevices,
tableUserPreferences,
tableAuthenticationLogs,
tableEncryption,
diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml
index 56a7ec8c2..368dca94f 100644
--- a/internal/suites/DuoPush/configuration.yml
+++ b/internal/suites/DuoPush/configuration.yml
@@ -30,7 +30,7 @@ session:
storage:
encryption_key: a_not_so_secure_encryption_key
local:
- path: /config/db.sqlite
+ path: /tmp/db.sqlite3
# TOTP Issuer Name
#
@@ -44,6 +44,7 @@ duo_api:
hostname: duo.example.com
integration_key: ABCDEFGHIJKL
secret_key: abcdefghijklmnopqrstuvwxyz123456789
+ enable_self_enrollment: true
# Access Control
#
diff --git a/internal/suites/DuoPush/docker-compose.yml b/internal/suites/DuoPush/docker-compose.yml
index 3ba51bd2d..f5afb5812 100644
--- a/internal/suites/DuoPush/docker-compose.yml
+++ b/internal/suites/DuoPush/docker-compose.yml
@@ -6,4 +6,6 @@ services:
- './DuoPush/configuration.yml:/config/configuration.yml:ro'
- './DuoPush/users.yml:/config/users.yml'
- './common/ssl:/config/ssl:ro'
+ - '/tmp:/tmp'
+ user: ${USER_ID}:${GROUP_ID}
...
diff --git a/internal/suites/OneFactorOnly/configuration.yml b/internal/suites/OneFactorOnly/configuration.yml
index 32ead64e6..a2d44f649 100644
--- a/internal/suites/OneFactorOnly/configuration.yml
+++ b/internal/suites/OneFactorOnly/configuration.yml
@@ -45,5 +45,5 @@ access_control:
notifier:
filesystem:
- filename: /tmp/notifier.html
+ filename: /config/notifier.html
...
diff --git a/internal/suites/action_2fa_methods.go b/internal/suites/action_2fa_methods.go
index 96f35e7ca..89a1f2410 100644
--- a/internal/suites/action_2fa_methods.go
+++ b/internal/suites/action_2fa_methods.go
@@ -15,3 +15,20 @@ func (rs *RodSession) doChangeMethod(t *testing.T, page *rod.Page, method string
err = rs.WaitElementLocatedByCSSSelector(t, page, fmt.Sprintf("%s-option", method)).Click("left")
require.NoError(t, err)
}
+
+func (rs *RodSession) doChangeDevice(t *testing.T, page *rod.Page, deviceID string) {
+ err := rs.WaitElementLocatedByCSSSelector(t, page, "selection-link").Click("left")
+ require.NoError(t, err)
+ rs.doSelectDevice(t, page, deviceID)
+}
+
+func (rs *RodSession) doSelectDevice(t *testing.T, page *rod.Page, deviceID string) {
+ rs.WaitElementLocatedByCSSSelector(t, page, "device-selection")
+ err := rs.WaitElementLocatedByCSSSelector(t, page, fmt.Sprintf("device-%s", deviceID)).Click("left")
+ require.NoError(t, err)
+}
+
+func (rs *RodSession) doClickButton(t *testing.T, page *rod.Page, buttonID string) {
+ err := rs.WaitElementLocatedByCSSSelector(t, page, buttonID).Click("left")
+ require.NoError(t, err)
+}
diff --git a/internal/suites/const.go b/internal/suites/const.go
index 2a252cfd2..940168155 100644
--- a/internal/suites/const.go
+++ b/internal/suites/const.go
@@ -50,7 +50,8 @@ var DuoBaseURL = "https://duo.example.com"
// AutheliaBaseURL the base URL of Authelia service.
var AutheliaBaseURL = "https://authelia.example.com:9091"
-const stringTrue = "true"
-
-const testUsername = "john"
-const testPassword = "password"
+const (
+ t = "true"
+ testUsername = "john"
+ testPassword = "password"
+)
diff --git a/internal/suites/docker.go b/internal/suites/docker.go
index cb52aed6b..a3029c3e4 100644
--- a/internal/suites/docker.go
+++ b/internal/suites/docker.go
@@ -18,7 +18,7 @@ type DockerEnvironment struct {
// NewDockerEnvironment create a new docker environment.
func NewDockerEnvironment(files []string) *DockerEnvironment {
- if os.Getenv("CI") == stringTrue {
+ if os.Getenv("CI") == t {
for i := range files {
files[i] = strings.ReplaceAll(files[i], "{}", "dist")
}
diff --git a/internal/suites/duo.go b/internal/suites/duo.go
index 72ec13e61..544c5a095 100644
--- a/internal/suites/duo.go
+++ b/internal/suites/duo.go
@@ -1,11 +1,15 @@
package suites
import (
+ "bytes"
+ "encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
+
+ "github.com/authelia/authelia/v4/internal/duo"
)
// DuoPolicy a type of policy.
@@ -33,3 +37,20 @@ func ConfigureDuo(t *testing.T, allowDeny DuoPolicy) {
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
}
+
+// ConfigureDuoPreAuth configure duo api to respond with available devices or enrollment Url.
+func ConfigureDuoPreAuth(t *testing.T, response duo.PreAuthResponse) {
+ url := fmt.Sprintf("%s/preauth", DuoBaseURL)
+
+ body, err := json.Marshal(response)
+ require.NoError(t, err)
+
+ req, err := http.NewRequest("POST", url, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ require.NoError(t, err)
+
+ client := NewHTTPClient()
+ res, err := client.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, 200, res.StatusCode)
+}
diff --git a/internal/suites/environment.go b/internal/suites/environment.go
index a5088ce1f..da70eed8f 100644
--- a/internal/suites/environment.go
+++ b/internal/suites/environment.go
@@ -74,7 +74,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment, suite string
return err
}
- if os.Getenv("CI") != stringTrue && suite != "CLI" {
+ if os.Getenv("CI") != t && suite != "CLI" {
if err := waitUntilAutheliaFrontendIsReady(dockerEnvironment); err != nil {
return err
}
diff --git a/internal/suites/example/compose/duo-api/duo_api.js b/internal/suites/example/compose/duo-api/duo_api.js
index 23761cde2..4e077569a 100644
--- a/internal/suites/example/compose/duo-api/duo_api.js
+++ b/internal/suites/example/compose/duo-api/duo_api.js
@@ -1,54 +1,87 @@
/*
* This is a script to fake the Duo API for push notifications.
- *
- * Access is allowed by default but one can change the behavior at runtime
- * by POSTing to /allow or /deny. Then the /auth/v2/auth endpoint will act
- * accordingly.
+ *
+ * For Auth API access is allowed by default but one can change the
+ * behavior at runtime by POSTing to /allow or /deny. Then the /auth/v2/auth
+ * endpoint will act accordingly.
+ *
+ * For PreAuth API device selection is bypassed by default but one can
+ * change the behavior at runtime by POSTing to /preauth using the desired
+ * result parameters (and devices). Then the /auth/v2/preauth endpoint
+ * will act accordingly.
*/
const express = require("express");
const app = express();
const port = 3000;
-app.set('trust proxy', true);
+app.use(express.json());
+app.set("trust proxy", true);
-let permission = 'allow';
+// Auth API
+let permission = "allow";
-app.post('/allow', (req, res) => {
- permission = 'allow';
- console.log("set allowed!");
- res.send('ALLOWED');
+app.post("/allow", (req, res) => {
+ permission = "allow";
+ console.log("auth set allowed!");
+ res.send("ALLOWED");
});
-app.post('/deny', (req, res) => {
- permission = 'deny';
- console.log("set denied!");
- res.send('DENIED');
+app.post("/deny", (req, res) => {
+ permission = "deny";
+ console.log("auth set denied!");
+ res.send("DENIED");
});
-app.post('/auth/v2/auth', (req, res) => {
+app.post("/auth/v2/auth", (req, res) => {
setTimeout(() => {
let response;
- if (permission == 'allow') {
+ if (permission == "allow") {
response = {
response: {
- result: 'allow',
- status: 'allow',
- status_msg: 'The user allowed access.',
+ result: "allow",
+ status: "allow",
+ status_msg: "The user allowed access.",
},
- stat: 'OK',
+ stat: "OK",
};
} else {
response = {
response: {
- result: 'deny',
- status: 'deny',
- status_msg: 'The user denied access.',
+ result: "deny",
+ status: "deny",
+ status_msg: "The user denied access.",
},
- stat: 'OK',
+ stat: "OK",
};
}
res.json(response);
+ console.log("Auth API responded with %s", permission);
+ }, 2000);
+});
+
+// PreAuth API
+let preauth = {
+ result: "allow",
+ status_msg: "Allowing unknown user",
+};
+
+app.post("/preauth", (req, res) => {
+ preauth = req.body;
+ console.log("set result to: %s", preauth);
+ res.json(preauth);
+});
+
+app.post("/auth/v2/preauth", (req, res) => {
+ setTimeout(() => {
+ let response;
+ response = {
+ response: preauth,
+ stat: "OK",
+ };
+
+ res.json(response);
+ console.log("PreAuth API responded with %s", preauth);
}, 2000);
});
@@ -57,9 +90,9 @@ app.listen(port, () => console.log(`Duo API listening on port ${port}!`));
// The signals we want to handle
// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
var signals = {
- 'SIGHUP': 1,
- 'SIGINT': 2,
- 'SIGTERM': 15
+ SIGHUP: 1,
+ SIGINT: 2,
+ SIGTERM: 15,
};
// Create a listener for each of the signals that we want to handle
Object.keys(signals).forEach((signal) => {
@@ -67,4 +100,4 @@ Object.keys(signals).forEach((signal) => {
console.log(`process received a ${signal} signal`);
process.exit(128 + signals[signal]);
});
-});
\ No newline at end of file
+});
diff --git a/internal/suites/example/compose/duo-api/duo_client.js b/internal/suites/example/compose/duo-api/duo_client.js
index ee6b2a110..262dcb5b8 100644
--- a/internal/suites/example/compose/duo-api/duo_client.js
+++ b/internal/suites/example/compose/duo-api/duo_client.js
@@ -7,4 +7,17 @@ const DuoApi = require("@duosecurity/duo_api");
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
-client.jsonApiCall("POST", "/auth/v2/auth", { username: 'john', factor: "push", device: "auto" }, console.log);
\ No newline at end of file
+console.log("Testing Auth API first");
+client.jsonApiCall(
+ "POST",
+ "/auth/v2/auth",
+ { username: "john", factor: "push", device: "auto" },
+ console.log
+);
+console.log("Testing PreAuth API second");
+client.jsonApiCall(
+ "POST",
+ "/auth/v2/preauth",
+ { username: "john" },
+ console.log
+);
diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go
index 7cd7884fd..1b7a2a523 100644
--- a/internal/suites/suite_cli_test.go
+++ b/internal/suites/suite_cli_test.go
@@ -36,7 +36,7 @@ func (s *CLISuite) SetupTest() {
testArg := ""
coverageArg := ""
- if os.Getenv("CI") == stringTrue {
+ if os.Getenv("CI") == t {
testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt"
coverageArg = "COVERAGE"
}
@@ -261,7 +261,7 @@ 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, user_preferences, migrations, encryption\nSchema Encryption Key: valid`)
+ 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)
}
@@ -336,7 +336,7 @@ 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, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`)
+ 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)
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"})
diff --git a/internal/suites/suite_duo_push.go b/internal/suites/suite_duo_push.go
index 94383db87..586655844 100644
--- a/internal/suites/suite_duo_push.go
+++ b/internal/suites/suite_duo_push.go
@@ -2,6 +2,7 @@ package suites
import (
"fmt"
+ "os"
"time"
)
@@ -41,11 +42,21 @@ func init() {
fmt.Println(frontendLogs)
+ duoAPILogs, err := dockerEnvironment.Logs("duo-api", nil)
+ if err != nil {
+ return err
+ }
+
+ fmt.Println(duoAPILogs)
+
return nil
}
teardown := func(suitePath string) error {
- return dockerEnvironment.Down()
+ err := dockerEnvironment.Down()
+ _ = os.Remove("/tmp/db.sqlite3")
+
+ return err
}
GlobalRegistry.Register(duoPushSuiteName, Suite{
@@ -53,7 +64,7 @@ func init() {
SetUpTimeout: 5 * time.Minute,
OnSetupTimeout: displayAutheliaLogs,
OnError: displayAutheliaLogs,
- TestTimeout: 2 * time.Minute,
+ TestTimeout: 3 * time.Minute,
TearDown: teardown,
TearDownTimeout: 2 * time.Minute,
diff --git a/internal/suites/suite_duo_push_test.go b/internal/suites/suite_duo_push_test.go
index 05dcbf788..208296f08 100644
--- a/internal/suites/suite_duo_push_test.go
+++ b/internal/suites/suite_duo_push_test.go
@@ -6,7 +6,13 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
+
+ "github.com/authelia/authelia/v4/internal/duo"
+ "github.com/authelia/authelia/v4/internal/models"
+ "github.com/authelia/authelia/v4/internal/storage"
)
type DuoPushWebDriverSuite struct {
@@ -50,11 +56,275 @@ func (s *DuoPushWebDriverSuite) TearDownTest() {
s.MustClose()
}()
+ // Set default 2FA preference and clean up any Duo device already in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferred2FAMethod(ctx, "john", "totp"))
+ require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldBypassDeviceSelection() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "allow",
+ StatusMessage: "Allowing unknown user",
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.verifyIsHome(s.T(), s.Context(ctx))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldDenyDeviceSelection() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "deny",
+ StatusMessage: "We're sorry, access is not allowed.",
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldAskUserToRegister() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "enroll",
+ EnrollPortalURL: "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890",
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
+ enrollPage := s.Page.MustWaitOpen()
+ s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "register-link").MustClick()
+ s.Page = enrollPage()
+
+ assert.Contains(s.T(), s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "description").MustText(), "This enrollment code has expired. Contact your administrator to get a new enrollment code.")
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldAutoSelectDevice() {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }},
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
+ // Authenticate
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ // Switch Method where single Device should be selected automatically.
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.verifyIsHome(s.T(), s.Context(ctx))
+
+ // Re-Login the user
s.doLogout(s.T(), s.Context(ctx))
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
- s.verifyIsSecondFactorPage(s.T(), s.Context(ctx))
- s.doChangeMethod(s.T(), s.Context(ctx), "one-time-password")
- s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "one-time-password-method")
+ // And check the latest method and device is still used.
+ s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
+ // Meaning the authentication is successful
+ s.verifyIsHome(s.T(), s.Context(ctx))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldSelectDevice() {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Set default 2FA preference to enable Select Device link in frontend.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }, {
+ Device: "1234567890ABCDEFGHIJ",
+ DisplayName: "Test Device 2",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }},
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
+ // Authenticate
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ // Switch Method where Device Selection should open automatically.
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ // Check for available Device 1.
+ s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-12345ABCDEFGHIJ67890")
+ // Test Back button.
+ s.doClickButton(s.T(), s.Context(ctx), "device-selection-back")
+ // then select Device 2 for further use and be redirected.
+ s.doChangeDevice(s.T(), s.Context(ctx), "1234567890ABCDEFGHIJ")
+ s.verifyIsHome(s.T(), s.Context(ctx))
+
+ // Re-Login the user
+ s.doLogout(s.T(), s.Context(ctx))
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ // And check the latest method and device is still used.
+ s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
+ // Meaning the authentication is successful
+ s.verifyIsHome(s.T(), s.Context(ctx))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldFailInitialSelectionBecauseOfUnsupportedMethod() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"auto", "sms"},
+ }},
+ }
+
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldSelectNewDeviceAfterSavedDeviceMethodIsNoLongerSupported() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"push", "sms"},
+ }, {
+ Device: "1234567890ABCDEFGHIJ",
+ DisplayName: "Test Device 2",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }},
+ }
+
+ // Setup unsupported Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "sms"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-selection")
+ s.doSelectDevice(s.T(), s.Context(ctx), "12345ABCDEFGHIJ67890")
+ s.verifyIsHome(s.T(), s.Context(ctx))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldAutoSelectNewDeviceAfterSavedDeviceIsNoLongerAvailable() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"push", "sms"},
+ }},
+ }
+
+ // Setup unsupported Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.verifyIsHome(s.T(), s.Context(ctx))
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionBypassed() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "allow",
+ StatusMessage: "Allowing unknown user",
+ }
+
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Deny)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.doClickButton(s.T(), s.Context(ctx), "selection-link")
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was bypassed by Duo policy")
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionDenied() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "deny",
+ StatusMessage: "We're sorry, access is not allowed.",
+ }
+
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Deny)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ err := s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "selection-link").Click("left")
+ require.NoError(s.T(), err)
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
+}
+
+func (s *DuoPushWebDriverSuite) TestShouldFailAuthenticationBecausePreauthDenied() {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "deny",
+ StatusMessage: "We're sorry, access is not allowed.",
+ }
+
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+
+ s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
+ s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
+ s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "failure-icon")
+ s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "There was an issue completing sign in process")
}
func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
@@ -64,6 +334,19 @@ func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }},
+ }
+
+ // Setup Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@@ -78,6 +361,19 @@ func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "auth",
+ Devices: []duo.Device{{
+ Device: "12345ABCDEFGHIJ67890",
+ DisplayName: "Test Device 1",
+ Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
+ }},
+ }
+
+ // Setup Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Deny)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@@ -128,9 +424,23 @@ func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "allow",
+ StatusMessage: "Allowing unknown user",
+ }
+
+ // Setup Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Page)
+
+ // Clean up any Duo device already in DB.
+ require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
}
type DuoPushSuite struct {
@@ -157,7 +467,23 @@ func (s *DuoPushSuite) TestAvailableMethodsScenario() {
}
func (s *DuoPushSuite) TestUserPreferencesScenario() {
+ var PreAuthAPIResponse = duo.PreAuthResponse{
+ Result: "allow",
+ StatusMessage: "Allowing unknown user",
+ }
+
+ ctx := context.Background()
+
+ // Setup Duo device in DB.
+ provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
+ require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
+ ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
+ ConfigureDuo(s.T(), Allow)
+
suite.Run(s.T(), NewUserPreferencesScenario())
+
+ // Clean up any Duo device already in DB.
+ require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
}
func TestDuoPushSuite(t *testing.T) {
diff --git a/internal/suites/suite_kubernetes.go b/internal/suites/suite_kubernetes.go
index a59d5f081..ba99bf3f8 100644
--- a/internal/suites/suite_kubernetes.go
+++ b/internal/suites/suite_kubernetes.go
@@ -40,7 +40,7 @@ func init() {
log.Debug("Building authelia:dist image or use cache if already built...")
- if os.Getenv("CI") != stringTrue {
+ if os.Getenv("CI") != t {
if err := utils.Shell("authelia-scripts docker build").Run(); err != nil {
return err
}
diff --git a/internal/suites/utils.go b/internal/suites/utils.go
index d29e661db..0225a5b65 100644
--- a/internal/suites/utils.go
+++ b/internal/suites/utils.go
@@ -50,7 +50,7 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
}
func (rs *RodSession) collectScreenshot(err error, page *rod.Page) {
- if err == context.DeadlineExceeded && os.Getenv("CI") == stringTrue {
+ if err == context.DeadlineExceeded && os.Getenv("CI") == t {
base := "/buildkite/screenshots"
build := os.Getenv("BUILDKITE_BUILD_NUMBER")
suite := strings.ToLower(os.Getenv("SUITE"))
diff --git a/internal/utils/strings.go b/internal/utils/strings.go
index 39ff62480..eec7ba03b 100644
--- a/internal/utils/strings.go
+++ b/internal/utils/strings.go
@@ -80,6 +80,17 @@ func IsStringInSliceContains(needle string, haystack []string) (inSlice bool) {
return false
}
+// IsStringSliceContainsAll checks if the haystack contains all strings in the needles.
+func IsStringSliceContainsAll(needles []string, haystack []string) (inSlice bool) {
+ for _, n := range needles {
+ if !IsStringInSlice(n, haystack) {
+ return false
+ }
+ }
+
+ return true
+}
+
// SliceString splits a string s into an array with each item being a max of int d
// d = denominator, n = numerator, q = quotient, r = remainder.
func SliceString(s string, d int) (array []string) {
diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go
index 5e570b2ce..cc79c50d3 100644
--- a/internal/utils/strings_test.go
+++ b/internal/utils/strings_test.go
@@ -153,3 +153,12 @@ func TestIsStringInSliceSuffix(t *testing.T) {
assert.False(t, IsStringInSliceSuffix("an.orange", suffixes))
assert.False(t, IsStringInSliceSuffix("an.apple.orange", suffixes))
}
+
+func TestIsStringSliceContainsAll(t *testing.T) {
+ needles := []string{"abc", "123", "xyz"}
+ haystackOne := []string{"abc", "tvu", "123", "456", "xyz"}
+ haystackTwo := []string{"tvu", "123", "456", "xyz"}
+
+ assert.True(t, IsStringSliceContainsAll(needles, haystackOne))
+ assert.False(t, IsStringSliceContainsAll(needles, haystackTwo))
+}
diff --git a/web/.env.development b/web/.env.development
index 7e7d77ed4..6402411e7 100644
--- a/web/.env.development
+++ b/web/.env.development
@@ -1,6 +1,7 @@
VITE_HMR_PORT=8080
VITE_LOGO_OVERRIDE=false
VITE_PUBLIC_URL=""
+VITE_DUO_SELF_ENROLLMENT=true
VITE_REMEMBER_ME=true
VITE_RESET_PASSWORD=true
VITE_THEME=light
\ No newline at end of file
diff --git a/web/.env.production b/web/.env.production
index 9c94810ec..788e3f4f0 100644
--- a/web/.env.production
+++ b/web/.env.production
@@ -1,5 +1,6 @@
VITE_LOGO_OVERRIDE={{.LogoOverride}}
VITE_PUBLIC_URL={{.Base}}
+VITE_DUO_SELF_ENROLLMENT={{.DuoSelfEnrollment}}
VITE_REMEMBER_ME={{.RememberMe}}
VITE_RESET_PASSWORD={{.ResetPassword}}
VITE_THEME={{.Theme}}
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
index e6858e07d..31f7b90d9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,7 +13,7 @@
Login - Authelia
-
+
diff --git a/web/package.json b/web/package.json
index 1435c66d5..915658da9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,10 +26,11 @@
"prepare": "cd .. && husky install .github",
"start": "vite --host",
"build": "vite build",
- "lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"coverage": "VITE_COVERAGE=true vite build",
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"test": "jest --coverage --no-cache",
- "report": "nyc report -r clover -r json -r lcov -r text"
+ "report": "nyc report -r clover -r json -r lcov -r text",
+ "commit": "commitlint --edit $1"
},
"eslintConfig": {
"extends": "react-app"
diff --git a/web/src/App.tsx b/web/src/App.tsx
index b8c8a0524..4da293613 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -18,7 +18,7 @@ import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications";
import * as themes from "@themes/index";
import { getBasePath } from "@utils/BasePath";
-import { getRememberMe, getResetPassword, getTheme } from "@utils/Configuration";
+import { getDuoSelfEnrollment, getRememberMe, getResetPassword, getTheme } from "@utils/Configuration";
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey";
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
@@ -73,7 +73,13 @@ const App: React.FC = () => {
} />
}
+ element={
+
+ }
/>
diff --git a/web/src/models/UserInfo.ts b/web/src/models/UserInfo.ts
index 85059f540..c840f9915 100644
--- a/web/src/models/UserInfo.ts
+++ b/web/src/models/UserInfo.ts
@@ -5,4 +5,5 @@ export interface UserInfo {
method: SecondFactorMethod;
has_u2f: boolean;
has_totp: boolean;
+ has_duo: boolean;
}
diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts
index 652c85601..e315eb91f 100644
--- a/web/src/services/Api.ts
+++ b/web/src/services/Api.ts
@@ -18,6 +18,9 @@ export const CompleteU2FRegistrationStep2Path = basePath + "/api/secondfactor/u2
export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request";
export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign";
+export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
+export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
+
export const CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo";
export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp";
diff --git a/web/src/services/PushNotification.ts b/web/src/services/PushNotification.ts
index ec3db5b8a..d00681a90 100644
--- a/web/src/services/PushNotification.ts
+++ b/web/src/services/PushNotification.ts
@@ -1,6 +1,9 @@
-import { CompletePushNotificationSignInPath } from "@services/Api";
-import { PostWithOptionalResponse } from "@services/Client";
-import { SignInResponse } from "@services/SignIn";
+import {
+ CompletePushNotificationSignInPath,
+ InitiateDuoDeviceSelectionPath,
+ CompleteDuoDeviceSelectionPath,
+} from "@services/Api";
+import { Get, PostWithOptionalResponse } from "@services/Client";
interface CompleteU2FSigninBody {
targetURL?: string;
@@ -11,5 +14,35 @@ export function completePushNotificationSignIn(targetURL: string | undefined) {
if (targetURL) {
body.targetURL = targetURL;
}
- return PostWithOptionalResponse(CompletePushNotificationSignInPath, body);
+ return PostWithOptionalResponse(CompletePushNotificationSignInPath, body);
+}
+
+export interface DuoSignInResponse {
+ result: string;
+ devices: DuoDevice[];
+ redirect: string;
+ enroll_url: string;
+}
+
+export interface DuoDevicesGetResponse {
+ result: string;
+ devices: DuoDevice[];
+ enroll_url: string;
+}
+
+export interface DuoDevice {
+ device: string;
+ display_name: string;
+ capabilities: string[];
+}
+export async function initiateDuoDeviceSelectionProcess() {
+ return Get(InitiateDuoDeviceSelectionPath);
+}
+
+export interface DuoDevicePostRequest {
+ device: string;
+ method: string;
+}
+export async function completeDuoDeviceSelectionProcess(device: DuoDevicePostRequest) {
+ return PostWithOptionalResponse(CompleteDuoDeviceSelectionPath, { device: device.device, method: device.method });
}
diff --git a/web/src/services/UserPreferences.ts b/web/src/services/UserPreferences.ts
index 683dce717..0fe7a49c5 100644
--- a/web/src/services/UserPreferences.ts
+++ b/web/src/services/UserPreferences.ts
@@ -10,6 +10,7 @@ export interface UserInfoPayload {
method: Method2FA;
has_u2f: boolean;
has_totp: boolean;
+ has_duo: boolean;
}
export interface MethodPreferencePayload {
diff --git a/web/src/setupTests.js b/web/src/setupTests.js
index 5ba392930..0f20e1c5d 100644
--- a/web/src/setupTests.js
+++ b/web/src/setupTests.js
@@ -1,6 +1,7 @@
import "@testing-library/jest-dom";
document.body.setAttribute("data-basepath", "");
+document.body.setAttribute("data-duoselfenrollment", "true");
document.body.setAttribute("data-rememberme", "true");
document.body.setAttribute("data-resetpassword", "true");
document.body.setAttribute("data-theme", "light");
diff --git a/web/src/utils/Configuration.ts b/web/src/utils/Configuration.ts
index 73aab612e..4b6518b55 100644
--- a/web/src/utils/Configuration.ts
+++ b/web/src/utils/Configuration.ts
@@ -7,6 +7,10 @@ export function getEmbeddedVariable(variableName: string) {
return value;
}
+export function getDuoSelfEnrollment() {
+ return getEmbeddedVariable("duoselfenrollment") === "true";
+}
+
export function getLogoOverride() {
return getEmbeddedVariable("logooverride") === "true";
}
diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx
index 12e7b7035..296769653 100644
--- a/web/src/views/LoginPortal/LoginPortal.tsx
+++ b/web/src/views/LoginPortal/LoginPortal.tsx
@@ -26,6 +26,7 @@ import FirstFactorForm from "@views/LoginPortal/FirstFactor/FirstFactorForm";
import SecondFactorForm from "@views/LoginPortal/SecondFactor/SecondFactorForm";
export interface Props {
+ duoSelfEnrollment: boolean;
rememberMe: boolean;
resetPassword: boolean;
}
@@ -189,6 +190,7 @@ const LoginPortal = function (props: Props) {
authenticationLevel={state.authentication_level}
userInfo={userInfo}
configuration={configuration}
+ duoSelfEnrollment={props.duoSelfEnrollment}
onMethodChanged={() => fetchUserInfo()}
onAuthenticationSuccess={handleAuthSuccess}
/>
diff --git a/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx b/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx
new file mode 100644
index 000000000..eb7ec10fd
--- /dev/null
+++ b/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx
@@ -0,0 +1,183 @@
+import React, { ReactNode, useState } from "react";
+
+import { makeStyles, Typography, Grid, Button, Container } from "@material-ui/core";
+
+import PushNotificationIcon from "@components/PushNotificationIcon";
+
+export enum State {
+ DEVICE = 1,
+ METHOD = 2,
+}
+
+export interface SelectableDevice {
+ id: string;
+ name: string;
+ methods: string[];
+}
+
+export interface SelectedDevice {
+ id: string;
+ method: string;
+}
+
+export interface Props {
+ children?: ReactNode;
+ devices: SelectableDevice[];
+
+ onBack: () => void;
+ onSelect: (device: SelectedDevice) => void;
+}
+const DefaultDeviceSelectionContainer = function (props: Props) {
+ const [state, setState] = useState(State.DEVICE);
+ const [device, setDevice] = useState([] as unknown as SelectableDevice);
+
+ const handleDeviceSelected = (selecteddevice: SelectableDevice) => {
+ if (selecteddevice.methods.length === 1) handleMethodSelected(selecteddevice.methods[0], selecteddevice.id);
+ else {
+ setDevice(selecteddevice);
+ setState(State.METHOD);
+ }
+ };
+
+ const handleMethodSelected = (method: string, deviceid?: string) => {
+ if (deviceid) props.onSelect({ id: deviceid, method: method });
+ else props.onSelect({ id: device.id, method: method });
+ };
+
+ let container: ReactNode;
+ switch (state) {
+ case State.DEVICE:
+ container = (
+
+ {props.devices.map((value, index) => {
+ return (
+ handleDeviceSelected(value)}
+ />
+ );
+ })}
+
+ );
+ break;
+ case State.METHOD:
+ container = (
+
+ {device.methods.map((value, index) => {
+ return (
+ handleMethodSelected(value)}
+ />
+ );
+ })}
+
+ );
+ break;
+ }
+
+ return (
+
+ {container}
+
+
+ );
+};
+
+export default DefaultDeviceSelectionContainer;
+
+interface DeviceItemProps {
+ id: number;
+ device: SelectableDevice;
+
+ onSelect: () => void;
+}
+
+function DeviceItem(props: DeviceItemProps) {
+ const className = "device-option-" + props.id;
+ const idName = "device-" + props.device.id;
+ const style = makeStyles((theme) => ({
+ item: {
+ paddingTop: theme.spacing(4),
+ paddingBottom: theme.spacing(4),
+ width: "100%",
+ },
+ icon: {
+ display: "inline-block",
+ fill: "white",
+ },
+ buttonRoot: {
+ display: "block",
+ },
+ }))();
+
+ return (
+
+
+
+ );
+}
+
+interface MethodItemProps {
+ id: number;
+ method: string;
+
+ onSelect: () => void;
+}
+
+function MethodItem(props: MethodItemProps) {
+ const className = "method-option-" + props.id;
+ const idName = "method-" + props.method;
+ const style = makeStyles((theme) => ({
+ item: {
+ paddingTop: theme.spacing(4),
+ paddingBottom: theme.spacing(4),
+ width: "100%",
+ },
+ icon: {
+ display: "inline-block",
+ fill: "white",
+ },
+ buttonRoot: {
+ display: "block",
+ },
+ }))();
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
index 190fbf452..6874a50cf 100644
--- a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
@@ -15,17 +15,24 @@ export enum State {
export interface Props {
id: string;
title: string;
+ duoSelfEnrollment: boolean;
registered: boolean;
explanation: string;
state: State;
children: ReactNode;
onRegisterClick?: () => void;
+ onSelectClick?: () => void;
}
const DefaultMethodContainer = function (props: Props) {
const style = useStyles();
- const registerMessage = props.registered ? "Lost your device?" : "Register device";
+ const registerMessage = props.registered
+ ? props.title === "Push Notification"
+ ? ""
+ : "Lost your device?"
+ : "Register device";
+ const selectMessage = "Select a Device";
let container: ReactNode;
let stateClass: string = "";
@@ -35,7 +42,7 @@ const DefaultMethodContainer = function (props: Props) {
stateClass = "state-already-authenticated";
break;
case State.NOT_REGISTERED:
- container = ;
+ container = ;
stateClass = "state-not-registered";
break;
case State.METHOD:
@@ -50,7 +57,13 @@ const DefaultMethodContainer = function (props: Props) {
- {props.onRegisterClick ? (
+ {props.onSelectClick && props.registered ? (
+
+ {selectMessage}
+
+ ) : null}
+ {(props.onRegisterClick && props.title !== "Push Notification") ||
+ (props.onRegisterClick && props.title === "Push Notification" && props.duoSelfEnrollment) ? (
{registerMessage}
@@ -76,7 +89,12 @@ const useStyles = makeStyles(() => ({
},
}));
-function NotRegisteredContainer() {
+interface NotRegisteredContainerProps {
+ title: string;
+ duoSelfEnrollment: boolean;
+}
+
+function NotRegisteredContainer(props: NotRegisteredContainerProps) {
const theme = useTheme();
return (
@@ -87,7 +105,11 @@ function NotRegisteredContainer() {
The resource you're attempting to access requires two-factor authentication.
- Register your first device by clicking on the link below.
+ {props.title === "Push Notification"
+ ? props.duoSelfEnrollment
+ ? "Register your first device by clicking on the link below."
+ : "Contact your administrator to register a device."
+ : "Register your first device by clicking on the link below."}
);
diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
index 856541981..8640d6933 100644
--- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
@@ -89,6 +89,7 @@ const OneTimePasswordMethod = function (props: Props) {
id={props.id}
title="One-Time Password"
explanation="Enter one-time password"
+ duoSelfEnrollment={false}
registered={props.registered}
state={methodState}
onRegisterClick={props.onRegisterClick}
diff --git a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
index 75382c55f..f3ff72793 100644
--- a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState, ReactNode } from "react";
+import React, { useEffect, useCallback, useRef, useState, ReactNode } from "react";
import { Button, makeStyles } from "@material-ui/core";
@@ -7,21 +7,35 @@ import PushNotificationIcon from "@components/PushNotificationIcon";
import SuccessIcon from "@components/SuccessIcon";
import { useIsMountedRef } from "@hooks/Mounted";
import { useRedirectionURL } from "@hooks/RedirectionURL";
-import { completePushNotificationSignIn } from "@services/PushNotification";
+import {
+ completePushNotificationSignIn,
+ completeDuoDeviceSelectionProcess,
+ DuoDevicePostRequest,
+ initiateDuoDeviceSelectionProcess,
+} from "@services/PushNotification";
import { AuthenticationLevel } from "@services/State";
+import DeviceSelectionContainer, {
+ SelectedDevice,
+ SelectableDevice,
+} from "@views/LoginPortal/SecondFactor/DeviceSelectionContainer";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State {
SignInInProgress = 1,
Success = 2,
Failure = 3,
+ Selection = 4,
+ Enroll = 5,
}
export interface Props {
id: string;
authenticationLevel: AuthenticationLevel;
+ duoSelfEnrollment: boolean;
+ registered: boolean;
onSignInError: (err: Error) => void;
+ onSelectionClick: () => void;
onSignInSuccess: (redirectURL: string | undefined) => void;
}
@@ -30,11 +44,47 @@ const PushNotificationMethod = function (props: Props) {
const [state, setState] = useState(State.SignInInProgress);
const redirectionURL = useRedirectionURL();
const mounted = useIsMountedRef();
+ const [enroll_url, setEnrollUrl] = useState("");
+ const [devices, setDevices] = useState([] as SelectableDevice[]);
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current;
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
+ const fetchDuoDevicesFunc = useCallback(async () => {
+ try {
+ const res = await initiateDuoDeviceSelectionProcess();
+ if (!mounted.current) return;
+ switch (res.result) {
+ case "auth":
+ let selectableDevices = [] as SelectableDevice[];
+ res.devices.forEach((d: { device: any; display_name: any; capabilities: any }) =>
+ selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
+ );
+ setDevices(selectableDevices);
+ setState(State.Selection);
+ break;
+ case "allow":
+ onSignInErrorCallback(new Error("Device selection was bypassed by Duo policy"));
+ setState(State.Success);
+ break;
+ case "deny":
+ onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
+ setState(State.Failure);
+ break;
+ case "enroll":
+ onSignInErrorCallback(new Error("No compatible device found"));
+ if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
+ setState(State.Enroll);
+ break;
+ }
+ } catch (err) {
+ if (!mounted.current) return;
+ console.error(err);
+ onSignInErrorCallback(new Error("There was an issue fetching Duo device(s)"));
+ }
+ }, [props.duoSelfEnrollment, mounted, onSignInErrorCallback]);
+
const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
@@ -46,6 +96,26 @@ const PushNotificationMethod = function (props: Props) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
+ if (res && res.result === "auth") {
+ let selectableDevices = [] as SelectableDevice[];
+ res.devices.forEach((d) =>
+ selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
+ );
+ setDevices(selectableDevices);
+ setState(State.Selection);
+ return;
+ }
+ if (res && res.result === "enroll") {
+ onSignInErrorCallback(new Error("No compatible device found"));
+ if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
+ setState(State.Enroll);
+ return;
+ }
+ if (res && res.result === "deny") {
+ onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
+ setState(State.Failure);
+ return;
+ }
setState(State.Success);
setTimeout(() => {
@@ -55,17 +125,46 @@ const PushNotificationMethod = function (props: Props) {
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
- if (!mounted.current) return;
+ if (!mounted.current || state !== State.SignInInProgress) return;
console.error(err);
onSignInErrorCallback(new Error("There was an issue completing sign in process"));
setState(State.Failure);
}
- }, [onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, mounted, props.authenticationLevel]);
+ }, [
+ props.authenticationLevel,
+ props.duoSelfEnrollment,
+ redirectionURL,
+ mounted,
+ onSignInErrorCallback,
+ onSignInSuccessCallback,
+ state,
+ ]);
- useEffect(() => {
- signInFunc();
- }, [signInFunc]);
+ const updateDuoDevice = useCallback(
+ async function (device: DuoDevicePostRequest) {
+ try {
+ await completeDuoDeviceSelectionProcess(device);
+ if (!props.registered) {
+ setState(State.SignInInProgress);
+ props.onSelectionClick();
+ } else {
+ setState(State.SignInInProgress);
+ }
+ } catch (err) {
+ console.error(err);
+ onSignInErrorCallback(new Error("There was an issue updating preferred Duo device"));
+ }
+ },
+ [onSignInErrorCallback, props],
+ );
+
+ const handleDuoDeviceSelected = useCallback(
+ (device: SelectedDevice) => {
+ updateDuoDevice({ device: device.id, method: device.method });
+ },
+ [updateDuoDevice],
+ );
// Set successful state if user is already authenticated.
useEffect(() => {
@@ -74,6 +173,19 @@ const PushNotificationMethod = function (props: Props) {
}
}, [props.authenticationLevel, setState]);
+ useEffect(() => {
+ if (state === State.SignInInProgress) signInFunc();
+ }, [signInFunc, state]);
+
+ if (state === State.Selection)
+ return (
+ setState(State.SignInInProgress)}
+ onSelect={handleDuoDeviceSelected}
+ />
+ );
+
let icon: ReactNode;
switch (state) {
case State.SignInInProgress:
@@ -89,6 +201,8 @@ const PushNotificationMethod = function (props: Props) {
let methodState = MethodContainerState.METHOD;
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
+ } else if (state === State.Enroll) {
+ methodState = MethodContainerState.NOT_REGISTERED;
}
return (
@@ -96,8 +210,11 @@ const PushNotificationMethod = function (props: Props) {
id={props.id}
title="Push Notification"
explanation="A notification has been sent to your smartphone"
- registered={true}
+ duoSelfEnrollment={enroll_url ? props.duoSelfEnrollment : false}
+ registered={props.registered}
state={methodState}
+ onSelectClick={fetchDuoDevicesFunc}
+ onRegisterClick={() => window.open(enroll_url, "_blank")}
>
{icon}
diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
index dcd16aa3f..5d9d7e008 100644
--- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
@@ -27,11 +27,11 @@ const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to compl
export interface Props {
authenticationLevel: AuthenticationLevel;
-
userInfo: UserInfo;
configuration: Configuration;
+ duoSelfEnrollment: boolean;
- onMethodChanged: (method: SecondFactorMethod) => void;
+ onMethodChanged: () => void;
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
}
@@ -76,7 +76,7 @@ const SecondFactorForm = function (props: Props) {
try {
await setPreferred2FAMethod(method);
setMethodSelectionOpen(false);
- props.onMethodChanged(method);
+ props.onMethodChanged();
} catch (err) {
console.error(err);
createErrorNotification("There was an issue updating preferred second factor method");
@@ -143,6 +143,9 @@ const SecondFactorForm = function (props: Props) {
createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess}
/>
diff --git a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx b/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx
index a504b50f5..447102067 100644
--- a/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx
@@ -105,6 +105,7 @@ const SecurityKeyMethod = function (props: Props) {
id={props.id}
title="Security Key"
explanation="Touch the token of your security key"
+ duoSelfEnrollment={false}
registered={props.registered}
state={methodState}
onRegisterClick={props.onRegisterClick}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 6de3f312a..d6970c269 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
clientPort: env.VITE_HMR_PORT || 3000,
},
},
- plugins: [eslintPlugin(), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
+ plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
};
});