diff --git a/.github/commit-msg b/.github/commit-msg index 3c0661ad..6437e582 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 c27dece2..1a525d38 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 732dfdb5..ac468213 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 a0604b0a..b69c9396 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 7b53439d..5443fb91 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 0af2e790..be7915e2 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 732dfdb5..ac468213 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 520c724e..55e81d21 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 67f6bef9..2d3b88d5 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 00000000..89d5ce27 --- /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 1d90e43c..bcfaa15a 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 4d7b45e7..54599cd0 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 b9975163..cec52f69 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 00000000..098907bd --- /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 00000000..743df3ef --- /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 00000000..2d224eea --- /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 0531bec3..de6d93d2 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 239b557d..26f067c6 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 4fa4886e..2082b62c 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 40047a4f..d5a75330 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 00000000..cf0ac83c --- /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 da0b1f9f..9daadc4e 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 a0147338..ed3484db 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 1f99bfef..2e826aeb 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 6b6a3916..907344c6 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 5472b173..22455c0d 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 2b26f6c7..4bae8cd8 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 0209622f..615ed34c 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 e36a12b1..8ab76a85 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 ec7f225d..da58a18a 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 2e91d6f9..a93c45ef 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 1e9ff421..b046dcbb 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 4c3fb681..a3aca1e2 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 dfa23e66..9f448165 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 8f59c0c0..2ce60164 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 fc97c531..e0005666 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 a9d40e1a..7cddddde 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 56a7ec8c..368dca94 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 3ba51bd2..f5afb581 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 32ead64e..a2d44f64 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 96f35e7c..89a1f241 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 2a252cfd..94016815 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 cb52aed6..a3029c3e 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 72ec13e6..544c5a09 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 a5088ce1..da70eed8 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 23761cde..4e077569 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 ee6b2a11..262dcb5b 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 7cd7884f..1b7a2a52 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 94383db8..58665584 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 05dcbf78..208296f0 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 a59d5f08..ba99bf3f 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 d29e661d..0225a5b6 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 39ff6248..eec7ba03 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 5e570b2c..cc79c50d 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 7e7d77ed..6402411e 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 9c94810e..788e3f4f 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 e6858e07..31f7b90d 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 1435c66d..915658da 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 b8c8a052..4da29361 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 85059f54..c840f991 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 652c8560..e315eb91 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 ec3db5b8..d00681a9 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 683dce71..0fe7a49c 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 5ba39293..0f20e1c5 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 73aab612..4b6518b5 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 12e7b703..29676965 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 00000000..eb7ec10f --- /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 190fbf45..6874a50c 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) {
{container}
- {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 85654198..8640d693 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 75382c55..f3ff7279 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 dcd16aa3..5d9d7e00 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 a504b50f..44710206 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 6de3f312..d6970c26 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()], }; });