feat(duo): multi device selection (#2137)

Allow users to select and save the preferred duo device and method, depending on availability in the duo account. A default enrollment URL is provided and adjusted if returned by the duo API. This allows auto-enrollment if enabled by the administrator.

Closes #594. Closes #1039.
This commit is contained in:
Philipp Staiger 2021-12-01 04:32:58 +01:00 committed by GitHub
parent 08b6ecb7b1
commit 01b77384f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 2503 additions and 251 deletions

2
.github/commit-msg vendored
View File

@ -2,4 +2,4 @@
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/required-apps" . "$(dirname "$0")/required-apps"
cd web && ${PMGR} commitlint --edit $1 cd web && ${PMGR} commit

View File

@ -540,6 +540,46 @@ paths:
description: Unauthorized description: Unauthorized
security: security:
- authelia_auth: [] - 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: components:
parameters: parameters:
originalURLParam: originalURLParam:
@ -603,13 +643,19 @@ components:
totp_period: totp_period:
type: integer type: integer
example: 30 example: 30
handlers.logoutRequestBody: handlers.DuoDeviceBody:
required:
- device
- method
type: object type: object
properties: properties:
targetURL: device:
type: string type: string
example: https://redirect.example.com example: ABCDE123456789FGHIJK
handlers.logoutResponseBody: method:
type: string
example: push
handlers.DuoDevicesResponse:
type: object type: object
properties: properties:
status: status:
@ -618,9 +664,25 @@ components:
data: data:
type: object type: object
properties: properties:
safeTargetURL: result:
type: boolean type: string
example: true 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: handlers.firstFactorRequestBody:
required: required:
- username - username
@ -642,6 +704,24 @@ components:
keepMeLoggedIn: keepMeLoggedIn:
type: boolean type: boolean
example: true 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: handlers.redirectResponse:
type: object type: object
properties: properties:
@ -758,6 +838,9 @@ components:
has_totp: has_totp:
type: boolean type: boolean
example: true example: true
has_duo:
type: boolean
example: true
handlers.UserInfo.MethodBody: handlers.UserInfo.MethodBody:
required: required:
- method - method

View File

@ -111,6 +111,7 @@ duo_api:
integration_key: ABCDEF integration_key: ABCDEF
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html ## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl secret_key: 1234567890abcdefghifjkl
enable_self_enrollment: false
## ##
## NTP Configuration ## NTP Configuration

View File

@ -24,6 +24,7 @@ duo_api:
hostname: api-123456789.example.com hostname: api-123456789.example.com
integration_key: ABCDEF integration_key: ABCDEF
secret_key: 1234567890abcdefghifjkl 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 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. The secret [Duo] key used to verify your application is valid.
### enable_self_enrollment
<div markdown="1">
type: boolean
{: .label .label-config .label-purple }
default: false
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
Enables [Duo] device self-enrollment from within the Authelia portal.
[Duo]: https://duo.com/ [Duo]: https://duo.com/

View File

@ -41,6 +41,7 @@ option.
You should now receive a notification on your mobile phone with all the details You should now receive a notification on your mobile phone with all the details
about the authentication request. about the authentication request.
In case you have multiple devices available, you will be asked to select your preferred device.
## Limitation ## Limitation

View File

@ -367,7 +367,12 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
return provider.SchemaMigrate(ctx, true, storage.SchemaLatest) return provider.SchemaMigrate(ctx, true, storage.SchemaLatest)
} }
default: 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") return errors.New("must set target")
} }
@ -375,11 +380,6 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
return err return err
} }
pre1, err := cmd.Flags().GetBool("pre1")
if err != nil {
return err
}
switch { switch {
case pre1: case pre1:
return provider.SchemaMigrate(ctx, false, -1) return provider.SchemaMigrate(ctx, false, -1)

View File

@ -111,6 +111,7 @@ duo_api:
integration_key: ABCDEF integration_key: ABCDEF
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html ## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl secret_key: 1234567890abcdefghifjkl
enable_self_enrollment: false
## ##
## NTP Configuration ## NTP Configuration

View File

@ -2,7 +2,8 @@ package schema
// DuoAPIConfiguration represents the configuration related to Duo API. // DuoAPIConfiguration represents the configuration related to Duo API.
type DuoAPIConfiguration struct { type DuoAPIConfiguration struct {
Hostname string `koanf:"hostname"` Hostname string `koanf:"hostname"`
IntegrationKey string `koanf:"integration_key"` EnableSelfEnrollment bool `koanf:"enable_self_enrollment"`
SecretKey string `koanf:"secret_key"` IntegrationKey string `koanf:"integration_key"`
SecretKey string `koanf:"secret_key"`
} }

View File

@ -162,6 +162,7 @@ var ValidKeys = []string{
// DUO API Keys. // DUO API Keys.
"duo_api.hostname", "duo_api.hostname",
"duo_api.enable_self_enrollment",
"duo_api.secret_key", "duo_api.secret_key",
"duo_api.integration_key", "duo_api.integration_key",

16
internal/duo/const.go Normal file
View File

@ -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

View File

@ -18,20 +18,61 @@ func NewDuoAPI(duoAPI *duoapi.DuoApi) *APIImpl {
} }
// Call call to the DuoAPI. // 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 var response Response
_, responseBytes, err := d.DuoApi.SignedCall("POST", "/auth/v2/auth", values) _, responseBytes, err := d.DuoApi.SignedCall(method, path, values)
if err != nil { if err != nil {
return nil, err 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) err = json.Unmarshal(responseBytes, &response)
if err != nil { if err != nil {
return nil, err 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 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
}

View File

@ -1,6 +1,7 @@
package duo package duo
import ( import (
"encoding/json"
"net/url" "net/url"
duoapi "github.com/duosecurity/duo_api_golang" duoapi "github.com/duosecurity/duo_api_golang"
@ -10,7 +11,9 @@ import (
// API interface wrapping duo api library for testing purpose. // API interface wrapping duo api library for testing purpose.
type API interface { 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. // APIImpl implementation of DuoAPI interface.
@ -18,15 +21,38 @@ type APIImpl struct {
*duoapi.DuoApi *duoapi.DuoApi
} }
// Response response coming from Duo API. // Device holds all necessary info for frontend.
type Response struct { type Device struct {
Response struct { Capabilities []string `json:"capabilities"`
Result string `json:"result"` Device string `json:"device"`
Status string `json:"status"` DisplayName string `json:"display_name"`
StatusMessage string `json:"status_msg"` Name string `json:"name"`
} `json:"response"` SmsNextcode string `json:"sms_nextcode"`
Code int `json:"code"` Number string `json:"number"`
Message string `json:"message"` Type string `json:"type"`
MessageDetail string `json:"message_detail"` }
Stat string `json:"stat"`
// 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"`
} }

View File

@ -59,7 +59,6 @@ const (
const ( const (
testInactivity = "10" testInactivity = "10"
testRedirectionURL = "http://redirection.local" testRedirectionURL = "http://redirection.local"
testResultAllow = "allow"
testUsername = "john" testUsername = "john"
) )
@ -69,6 +68,14 @@ const (
loginDelayMaximumRandomDelayMilliseconds = int64(85) loginDelayMaximumRandomDelayMilliseconds = int64(85)
) )
// Duo constants.
const (
allow = "allow"
deny = "deny"
enroll = "enroll"
auth = "auth"
)
// OIDC constants. // OIDC constants.
const ( const (
pathOpenIDConnectWellKnown = "/.well-known/openid-configuration" pathOpenIDConnectWellKnown = "/.well-known/openid-configuration"

49
internal/handlers/duo.go Normal file
View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -6,13 +6,19 @@ import (
"github.com/authelia/authelia/v4/internal/duo" "github.com/authelia/authelia/v4/internal/duo"
"github.com/authelia/authelia/v4/internal/middlewares" "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/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. // SecondFactorDuoPost handler for sending a push notification via duo api.
func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler { func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return func(ctx *middlewares.AutheliaCtx) { return func(ctx *middlewares.AutheliaCtx) {
var requestBody signDuoRequestBody var (
requestBody signDuoRequestBody
device, method string
)
if err := ctx.ParseBody(&requestBody); err != nil { if err := ctx.ParseBody(&requestBody); err != nil {
ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err) ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err)
@ -25,43 +31,49 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
userSession := ctx.GetSession() userSession := ctx.GetSession()
remoteIP := ctx.RemoteIP().String() remoteIP := ctx.RemoteIP().String()
ctx.Logger.Debugf("Starting Duo Push Auth Attempt for user '%s' with IP '%s'", userSession.Username, remoteIP) duoDevice, err := ctx.Providers.StorageProvider.LoadPreferredDuoDevice(ctx, userSession.Username)
if err != nil {
values := url.Values{} 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)
values.Set("username", userSession.Username) device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, requestBody.TargetURL)
values.Set("ipaddr", remoteIP) } else {
values.Set("factor", "push") ctx.Logger.Debugf("Starting Duo PreAuth to check preferred device of user: %s", userSession.Username)
values.Set("device", "auto") device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, requestBody.TargetURL)
if requestBody.TargetURL != "" {
values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", requestBody.TargetURL))
} }
duoResponse, err := duoAPI.Call(values, ctx)
if err != nil { 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) respondUnauthorized(ctx, messageMFAValidationFailed)
return return
} }
if duoResponse.Stat == "FAIL" { authResponse, err := duoAPI.AuthCall(ctx, values)
if duoResponse.Code == 40002 { if err != nil {
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d. "+ ctx.Logger.Errorf("Failed to perform Duo Auth Call for user '%s': %+v", userSession.Username, err)
"This error often occurs if you've not setup the username in the Admin Dashboard.",
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code) respondUnauthorized(ctx, messageMFAValidationFailed)
} else {
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.", return
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
}
} }
if duoResponse.Response.Result != testResultAllow { if authResponse.Result != allow {
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO, _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO,
fmt.Errorf("result: %s, code: %d, message: %s (%s)", duoResponse.Response.Result, duoResponse.Code, fmt.Errorf("duo auth result: %s, status: %s, message: %s", authResponse.Result, authResponse.Status,
duoResponse.Message, duoResponse.MessageDetail)) authResponse.StatusMessage))
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -73,29 +85,223 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return return
} }
if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil { HandleAllow(ctx, requestBody.TargetURL)
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)
}
} }
} }
// 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
}

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
@ -20,7 +21,6 @@ import (
type SecondFactorDuoPostSuite struct { type SecondFactorDuoPostSuite struct {
suite.Suite suite.Suite
mock *mocks.MockAutheliaCtx mock *mocks.MockAutheliaCtx
} }
@ -36,18 +36,58 @@ func (s *SecondFactorDuoPostSuite) TearDownTest() {
s.mock.Close() s.mock.Close()
} }
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() { func (s *SecondFactorDuoPostSuite) TestShouldEnroll() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) 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 := url.Values{}
values.Set("username", "john") 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{} preAuthResponse := duo.PreAuthResponse{}
response.Response.Result = testResultAllow 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. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -58,29 +98,313 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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) 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() { func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
values := url.Values{} s.mock.StorageProviderMock.EXPECT().
values.Set("username", "john") LoadPreferredDuoDevice(s.mock.Ctx, "john").
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String()) Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
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. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -91,30 +415,67 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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) 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() { func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) 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 := url.Values{}
values.Set("username", "john") 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) SecondFactorDuoPost(duoMock)(s.mock.Ctx)
@ -124,10 +485,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() { func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{} s.mock.StorageProviderMock.EXPECT().
response.Response.Result = testResultAllow LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -138,7 +498,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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 s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL
@ -155,10 +534,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() { func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{} s.mock.StorageProviderMock.EXPECT().
response.Response.Result = testResultAllow LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -169,7 +547,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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{}) bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err) s.Require().NoError(err)
@ -182,10 +579,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() { func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{} s.mock.StorageProviderMock.EXPECT().
response.Response.Result = testResultAllow LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -196,7 +592,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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{ bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "https://mydomain.local", TargetURL: "https://mydomain.local",
@ -213,10 +628,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() { func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{} s.mock.StorageProviderMock.EXPECT().
response.Response.Result = testResultAllow LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -227,7 +641,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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{ bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local", TargetURL: "http://mydomain.local",
@ -242,10 +675,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() { func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl) duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{} s.mock.StorageProviderMock.EXPECT().
response.Response.Result = testResultAllow LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock. s.mock.StorageProviderMock.
EXPECT(). EXPECT().
@ -256,7 +688,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi
Time: s.mock.Clock.Now(), Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO, Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"), 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{ bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local", TargetURL: "http://mydomain.local",

View File

@ -11,6 +11,24 @@ type MethodList = []string
type authorizationMatching int 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. // signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
type signTOTPRequestBody struct { type signTOTPRequestBody struct {
Token string `json:"token" valid:"required"` Token string `json:"token" valid:"required"`
@ -25,6 +43,7 @@ type signU2FRequestBody struct {
type signDuoRequestBody struct { type signDuoRequestBody struct {
TargetURL string `json:"targetURL"` TargetURL string `json:"targetURL"`
Passcode string `json:"passcode"`
} }
// firstFactorRequestBody represents the JSON body received by the endpoint. // firstFactorRequestBody represents the JSON body received by the endpoint.
@ -60,6 +79,34 @@ type TOTPKeyResponse struct {
OTPAuthURL string `json:"otpauth_url"` 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. // StateResponse represents the response sent by the state endpoint.
type StateResponse struct { type StateResponse struct {
Username string `json:"username"` Username string `json:"username"`

View File

@ -11,7 +11,7 @@ import (
gomock "github.com/golang/mock/gomock" gomock "github.com/golang/mock/gomock"
duo "github.com/authelia/authelia/v4/internal/duo" 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. // MockAPI is a mock of API interface.
@ -37,17 +37,47 @@ func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
return m.recorder return m.recorder
} }
// Call mocks base method. // AuthCall mocks base method.
func (m *MockAPI) Call(arg0 url.Values, arg1 *middlewares.AutheliaCtx) (*duo.Response, error) { func (m *MockAPI) AuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.AuthResponse, error) {
m.ctrl.T.Helper() 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) ret0, _ := ret[0].(*duo.Response)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// Call indicates an expected call of Call. // 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() 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)
} }

View File

@ -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"`
}

View File

@ -8,9 +8,12 @@ type UserInfo struct {
// The preferred 2FA method. // The preferred 2FA method.
Method string `db:"second_factor_method" json:"method" valid:"required"` 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. // True if a security key has been registered.
HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"` HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"`
// True if a TOTP device has been registered. // True if a duo device has been configured as the preferred.
HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"` HasDuo bool `db:"has_duo" json:"has_duo" valid:"required"`
} }

View File

@ -10,7 +10,11 @@ const (
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"} 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 const healthCheckEnv = `# Written by Authelia Process
X_AUTHELIA_HEALTHCHECK=1 X_AUTHELIA_HEALTHCHECK=1

View File

@ -30,14 +30,19 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0") rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0")
resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword) resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword)
duoSelfEnrollment := f
if configuration.DuoAPI != nil {
duoSelfEnrollment = strconv.FormatBool(configuration.DuoAPI.EnableSelfEnrollment)
}
embeddedPath, _ := fs.Sub(assets, "public_html") embeddedPath, _ := fs.Sub(assets, "public_html")
embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath))) embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath)))
https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != "" https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != ""
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, 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, 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, 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 := router.New()
r.GET("/", serveIndexHandler) r.GET("/", serveIndexHandler)
@ -125,8 +130,14 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
configuration.DuoAPI.Hostname, "")) configuration.DuoAPI.Hostname, ""))
} }
r.GET("/api/secondfactor/duo_devices", autheliaMiddleware(
middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicesGet(duoAPI))))
r.POST("/api/secondfactor/duo", autheliaMiddleware( r.POST("/api/secondfactor/duo", autheliaMiddleware(
middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI)))) middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI))))
r.POST("/api/secondfactor/duo_device", autheliaMiddleware(
middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicePost)))
} }
if configuration.Server.EnablePprof { if configuration.Server.EnablePprof {

View File

@ -16,15 +16,15 @@ import (
// ServeTemplatedFile serves a templated version of a specified file, // ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend // this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui. // 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() logger := logging.Logger()
f, err := assets.Open(publicDir + file) a, err := assets.Open(publicDir + file)
if err != nil { if err != nil {
logger.Fatalf("Unable to open %s: %s", file, err) logger.Fatalf("Unable to open %s: %s", file, err)
} }
b, err := ioutil.ReadAll(f) b, err := ioutil.ReadAll(a)
if err != nil { if err != nil {
logger.Fatalf("Unable to read %s: %s", file, err) logger.Fatalf("Unable to read %s: %s", file, err)
} }
@ -40,11 +40,11 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
base = baseURL.(string) base = baseURL.(string)
} }
logoOverride := "false" logoOverride := f
if assetPath != "" { if assetPath != "" {
if _, err := os.Stat(assetPath + logoFile); err == nil { 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)) 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 { if err != nil {
ctx.Error("an error occurred", 503) ctx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err) logger.Errorf("Unable to execute template: %v", err)

View File

@ -9,7 +9,7 @@ const (
tableIdentityVerification = "identity_verification" tableIdentityVerification = "identity_verification"
tableTOTPConfigurations = "totp_configurations" tableTOTPConfigurations = "totp_configurations"
tableU2FDevices = "u2f_devices" tableU2FDevices = "u2f_devices"
tableDUODevices = "duo_devices" tableDuoDevices = "duo_devices"
tableAuthenticationLogs = "authentication_logs" tableAuthenticationLogs = "authentication_logs"
tableMigrations = "migrations" tableMigrations = "migrations"
tableEncryption = "encryption" tableEncryption = "encryption"

View File

@ -5,15 +5,18 @@ import (
) )
var ( 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 error thrown when no matching authentication logs hve been found in DB.
ErrNoAuthenticationLogs = errors.New("no matching authentication logs found") ErrNoAuthenticationLogs = errors.New("no matching authentication logs found")
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB. // ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB.
ErrNoTOTPSecret = errors.New("no TOTP secret registered") 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 is returned when no available migrations can be found.
ErrNoAvailableMigrations = errors.New("no available migrations") ErrNoAvailableMigrations = errors.New("no available migrations")

View File

@ -2,6 +2,7 @@ DROP TABLE IF EXISTS authentication_logs;
DROP TABLE IF EXISTS identity_verification; DROP TABLE IF EXISTS identity_verification;
DROP TABLE IF EXISTS totp_configurations; DROP TABLE IF EXISTS totp_configurations;
DROP TABLE IF EXISTS u2f_devices; DROP TABLE IF EXISTS u2f_devices;
DROP TABLE IF EXISTS duo_devices;
DROP TABLE IF EXISTS user_preferences; DROP TABLE IF EXISTS user_preferences;
DROP TABLE IF EXISTS migrations; DROP TABLE IF EXISTS migrations;
DROP TABLE IF EXISTS encryption; DROP TABLE IF EXISTS encryption;

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE KEY (username, description) 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 ( CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER AUTO_INCREMENT, id INTEGER AUTO_INCREMENT,
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description) 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 ( CREATE TABLE IF NOT EXISTS user_preferences (
id SERIAL, id SERIAL,
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description) 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 ( CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER, id INTEGER,
username VARCHAR(100) UNIQUE NOT NULL, username VARCHAR(100) UNIQUE NOT NULL,

View File

@ -30,18 +30,22 @@ type Provider interface {
SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error) SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error)
LoadU2FDevice(ctx context.Context, username string) (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) SchemaTables(ctx context.Context) (tables []string, err error)
SchemaVersion(ctx context.Context) (version int, 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) SchemaMigrate(ctx context.Context, up bool, version int) (err error)
SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, 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) SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error)
SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (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) Close() (err error)
} }

View File

@ -1,16 +1,17 @@
// Code generated by MockGen. DO NOT EDIT. // 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 package storage
import ( import (
"context" context "context"
"reflect" reflect "reflect"
"time" 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. // 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)) 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. // DeleteTOTPConfiguration mocks base method.
func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error { func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper() 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) 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. // LoadTOTPConfiguration mocks base method.
func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) { func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) {
m.ctrl.T.Helper() 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) 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. // SaveTOTPConfiguration mocks base method.
func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error { func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -46,9 +46,13 @@ func NewSQLProvider(name, driverName, dataSourceName, encryptionKey string) (pro
sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices), sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices),
sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, 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), sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences),
sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, 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), sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations),
sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations), sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations),
@ -99,6 +103,11 @@ type SQLProvider struct {
sqlUpsertU2FDevice string sqlUpsertU2FDevice string
sqlSelectU2FDevice string sqlSelectU2FDevice string
// Table: duo_devices
sqlUpsertDuoDevice string
sqlDeleteDuoDevice string
sqlSelectDuoDevice string
// Table: user_preferences. // Table: user_preferences.
sqlUpsertPreferred2FAMethod string sqlUpsertPreferred2FAMethod string
sqlSelectPreferred2FAMethod string sqlSelectPreferred2FAMethod string
@ -186,7 +195,7 @@ func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username strin
// LoadUserInfo loads the models.UserInfo from the database. // LoadUserInfo loads the models.UserInfo from the database.
func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info models.UserInfo, err error) { 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 { switch {
case err == nil: 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) 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) 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 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. // AppendAuthenticationLog append a mark to the authentication log.
func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) { func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt, if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt,

View File

@ -35,7 +35,7 @@ const (
const ( const (
queryFmtSelectUserInfo = ` 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 FROM %s
WHERE username = ?;` WHERE username = ?;`
@ -129,6 +129,23 @@ const (
DO UPDATE SET key_handle=$2, public_key=$3;` 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 ( const (
queryFmtInsertAuthenticationLogEntry = ` queryFmtInsertAuthenticationLogEntry = `
INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method) INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method)

View File

@ -118,13 +118,14 @@ func (p *SQLProvider) SchemaMigrate(ctx context.Context, up bool, version int) (
return p.schemaMigrate(ctx, currentVersion, version) return p.schemaMigrate(ctx, currentVersion, version)
} }
//nolint: gocyclo
func (p *SQLProvider) schemaMigrate(ctx context.Context, prior, target int) (err error) { func (p *SQLProvider) schemaMigrate(ctx context.Context, prior, target int) (err error) {
migrations, err := loadMigrations(p.name, prior, target) migrations, err := loadMigrations(p.name, prior, target)
if err != nil { if err != nil {
return err return err
} }
if len(migrations) == 0 { if len(migrations) == 0 && (prior != 1 || target != -1) {
return ErrNoMigrationsFound return ErrNoMigrationsFound
} }
@ -277,7 +278,7 @@ func (p *SQLProvider) SchemaMigrationsDown(ctx context.Context, version int) (mi
return loadMigrations(p.name, current, version) 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) { func (p *SQLProvider) SchemaLatestVersion() (version int, err error) {
return latestMigrationVersion(p.name) return latestMigrationVersion(p.name)
} }

View File

@ -291,7 +291,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) {
tableTOTPConfigurations, tableTOTPConfigurations,
tableIdentityVerification, tableIdentityVerification,
tableU2FDevices, tableU2FDevices,
tableDUODevices, tableDuoDevices,
tableUserPreferences, tableUserPreferences,
tableAuthenticationLogs, tableAuthenticationLogs,
tableEncryption, tableEncryption,

View File

@ -30,7 +30,7 @@ session:
storage: storage:
encryption_key: a_not_so_secure_encryption_key encryption_key: a_not_so_secure_encryption_key
local: local:
path: /config/db.sqlite path: /tmp/db.sqlite3
# TOTP Issuer Name # TOTP Issuer Name
# #
@ -44,6 +44,7 @@ duo_api:
hostname: duo.example.com hostname: duo.example.com
integration_key: ABCDEFGHIJKL integration_key: ABCDEFGHIJKL
secret_key: abcdefghijklmnopqrstuvwxyz123456789 secret_key: abcdefghijklmnopqrstuvwxyz123456789
enable_self_enrollment: true
# Access Control # Access Control
# #

View File

@ -6,4 +6,6 @@ services:
- './DuoPush/configuration.yml:/config/configuration.yml:ro' - './DuoPush/configuration.yml:/config/configuration.yml:ro'
- './DuoPush/users.yml:/config/users.yml' - './DuoPush/users.yml:/config/users.yml'
- './common/ssl:/config/ssl:ro' - './common/ssl:/config/ssl:ro'
- '/tmp:/tmp'
user: ${USER_ID}:${GROUP_ID}
... ...

View File

@ -45,5 +45,5 @@ access_control:
notifier: notifier:
filesystem: filesystem:
filename: /tmp/notifier.html filename: /config/notifier.html
... ...

View File

@ -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") err = rs.WaitElementLocatedByCSSSelector(t, page, fmt.Sprintf("%s-option", method)).Click("left")
require.NoError(t, err) 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)
}

View File

@ -50,7 +50,8 @@ var DuoBaseURL = "https://duo.example.com"
// AutheliaBaseURL the base URL of Authelia service. // AutheliaBaseURL the base URL of Authelia service.
var AutheliaBaseURL = "https://authelia.example.com:9091" var AutheliaBaseURL = "https://authelia.example.com:9091"
const stringTrue = "true" const (
t = "true"
const testUsername = "john" testUsername = "john"
const testPassword = "password" testPassword = "password"
)

View File

@ -18,7 +18,7 @@ type DockerEnvironment struct {
// NewDockerEnvironment create a new docker environment. // NewDockerEnvironment create a new docker environment.
func NewDockerEnvironment(files []string) *DockerEnvironment { func NewDockerEnvironment(files []string) *DockerEnvironment {
if os.Getenv("CI") == stringTrue { if os.Getenv("CI") == t {
for i := range files { for i := range files {
files[i] = strings.ReplaceAll(files[i], "{}", "dist") files[i] = strings.ReplaceAll(files[i], "{}", "dist")
} }

View File

@ -1,11 +1,15 @@
package suites package suites
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/authelia/authelia/v4/internal/duo"
) )
// DuoPolicy a type of policy. // DuoPolicy a type of policy.
@ -33,3 +37,20 @@ func ConfigureDuo(t *testing.T, allowDeny DuoPolicy) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 200, res.StatusCode) 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)
}

View File

@ -74,7 +74,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment, suite string
return err return err
} }
if os.Getenv("CI") != stringTrue && suite != "CLI" { if os.Getenv("CI") != t && suite != "CLI" {
if err := waitUntilAutheliaFrontendIsReady(dockerEnvironment); err != nil { if err := waitUntilAutheliaFrontendIsReady(dockerEnvironment); err != nil {
return err return err
} }

View File

@ -1,54 +1,87 @@
/* /*
* This is a script to fake the Duo API for push notifications. * 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 * For Auth API access is allowed by default but one can change the
* by POSTing to /allow or /deny. Then the /auth/v2/auth endpoint will act * behavior at runtime by POSTing to /allow or /deny. Then the /auth/v2/auth
* accordingly. * 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 express = require("express");
const app = express(); const app = express();
const port = 3000; 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) => { app.post("/allow", (req, res) => {
permission = 'allow'; permission = "allow";
console.log("set allowed!"); console.log("auth set allowed!");
res.send('ALLOWED'); res.send("ALLOWED");
}); });
app.post('/deny', (req, res) => { app.post("/deny", (req, res) => {
permission = 'deny'; permission = "deny";
console.log("set denied!"); console.log("auth set denied!");
res.send('DENIED'); res.send("DENIED");
}); });
app.post('/auth/v2/auth', (req, res) => { app.post("/auth/v2/auth", (req, res) => {
setTimeout(() => { setTimeout(() => {
let response; let response;
if (permission == 'allow') { if (permission == "allow") {
response = { response = {
response: { response: {
result: 'allow', result: "allow",
status: 'allow', status: "allow",
status_msg: 'The user allowed access.', status_msg: "The user allowed access.",
}, },
stat: 'OK', stat: "OK",
}; };
} else { } else {
response = { response = {
response: { response: {
result: 'deny', result: "deny",
status: 'deny', status: "deny",
status_msg: 'The user denied access.', status_msg: "The user denied access.",
}, },
stat: 'OK', stat: "OK",
}; };
} }
res.json(response); 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); }, 2000);
}); });
@ -57,9 +90,9 @@ app.listen(port, () => console.log(`Duo API listening on port ${port}!`));
// The signals we want to handle // The signals we want to handle
// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled // NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
var signals = { var signals = {
'SIGHUP': 1, SIGHUP: 1,
'SIGINT': 2, SIGINT: 2,
'SIGTERM': 15 SIGTERM: 15,
}; };
// Create a listener for each of the signals that we want to handle // Create a listener for each of the signals that we want to handle
Object.keys(signals).forEach((signal) => { Object.keys(signals).forEach((signal) => {

View File

@ -7,4 +7,17 @@ const DuoApi = require("@duosecurity/duo_api");
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com"); const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
client.jsonApiCall("POST", "/auth/v2/auth", { username: 'john', factor: "push", device: "auto" }, console.log); 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
);

View File

@ -36,7 +36,7 @@ func (s *CLISuite) SetupTest() {
testArg := "" testArg := ""
coverageArg := "" coverageArg := ""
if os.Getenv("CI") == stringTrue { if os.Getenv("CI") == t {
testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt" testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt"
coverageArg = "COVERAGE" 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"}) output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
s.Assert().NoError(err) 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) 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"}) output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
s.Assert().NoError(err) 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) 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"}) output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"})

View File

@ -2,6 +2,7 @@ package suites
import ( import (
"fmt" "fmt"
"os"
"time" "time"
) )
@ -41,11 +42,21 @@ func init() {
fmt.Println(frontendLogs) fmt.Println(frontendLogs)
duoAPILogs, err := dockerEnvironment.Logs("duo-api", nil)
if err != nil {
return err
}
fmt.Println(duoAPILogs)
return nil return nil
} }
teardown := func(suitePath string) error { teardown := func(suitePath string) error {
return dockerEnvironment.Down() err := dockerEnvironment.Down()
_ = os.Remove("/tmp/db.sqlite3")
return err
} }
GlobalRegistry.Register(duoPushSuiteName, Suite{ GlobalRegistry.Register(duoPushSuiteName, Suite{
@ -53,7 +64,7 @@ func init() {
SetUpTimeout: 5 * time.Minute, SetUpTimeout: 5 * time.Minute,
OnSetupTimeout: displayAutheliaLogs, OnSetupTimeout: displayAutheliaLogs,
OnError: displayAutheliaLogs, OnError: displayAutheliaLogs,
TestTimeout: 2 * time.Minute, TestTimeout: 3 * time.Minute,
TearDown: teardown, TearDown: teardown,
TearDownTimeout: 2 * time.Minute, TearDownTimeout: 2 * time.Minute,

View File

@ -6,7 +6,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite" "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 { type DuoPushWebDriverSuite struct {
@ -50,11 +56,275 @@ func (s *DuoPushWebDriverSuite) TearDownTest() {
s.MustClose() 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.doLogout(s.T(), s.Context(ctx))
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "") s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.verifyIsSecondFactorPage(s.T(), s.Context(ctx)) // And check the latest method and device is still used.
s.doChangeMethod(s.T(), s.Context(ctx), "one-time-password") s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "one-time-password-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() { func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
@ -64,6 +334,19 @@ func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page) 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) ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "") s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@ -78,6 +361,19 @@ func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page) 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) ConfigureDuo(s.T(), Deny)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "") s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@ -128,9 +424,23 @@ func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() {
s.collectScreenshot(ctx.Err(), s.Page) 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.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification") s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Page) 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 { type DuoPushSuite struct {
@ -157,7 +467,23 @@ func (s *DuoPushSuite) TestAvailableMethodsScenario() {
} }
func (s *DuoPushSuite) TestUserPreferencesScenario() { 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()) 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) { func TestDuoPushSuite(t *testing.T) {

View File

@ -40,7 +40,7 @@ func init() {
log.Debug("Building authelia:dist image or use cache if already built...") 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 { if err := utils.Shell("authelia-scripts docker build").Run(); err != nil {
return err return err
} }

View File

@ -50,7 +50,7 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
} }
func (rs *RodSession) collectScreenshot(err error, 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" base := "/buildkite/screenshots"
build := os.Getenv("BUILDKITE_BUILD_NUMBER") build := os.Getenv("BUILDKITE_BUILD_NUMBER")
suite := strings.ToLower(os.Getenv("SUITE")) suite := strings.ToLower(os.Getenv("SUITE"))

View File

@ -80,6 +80,17 @@ func IsStringInSliceContains(needle string, haystack []string) (inSlice bool) {
return false 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 // 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. // d = denominator, n = numerator, q = quotient, r = remainder.
func SliceString(s string, d int) (array []string) { func SliceString(s string, d int) (array []string) {

View File

@ -153,3 +153,12 @@ func TestIsStringInSliceSuffix(t *testing.T) {
assert.False(t, IsStringInSliceSuffix("an.orange", suffixes)) assert.False(t, IsStringInSliceSuffix("an.orange", suffixes))
assert.False(t, IsStringInSliceSuffix("an.apple.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))
}

View File

@ -1,6 +1,7 @@
VITE_HMR_PORT=8080 VITE_HMR_PORT=8080
VITE_LOGO_OVERRIDE=false VITE_LOGO_OVERRIDE=false
VITE_PUBLIC_URL="" VITE_PUBLIC_URL=""
VITE_DUO_SELF_ENROLLMENT=true
VITE_REMEMBER_ME=true VITE_REMEMBER_ME=true
VITE_RESET_PASSWORD=true VITE_RESET_PASSWORD=true
VITE_THEME=light VITE_THEME=light

View File

@ -1,5 +1,6 @@
VITE_LOGO_OVERRIDE={{.LogoOverride}} VITE_LOGO_OVERRIDE={{.LogoOverride}}
VITE_PUBLIC_URL={{.Base}} VITE_PUBLIC_URL={{.Base}}
VITE_DUO_SELF_ENROLLMENT={{.DuoSelfEnrollment}}
VITE_REMEMBER_ME={{.RememberMe}} VITE_REMEMBER_ME={{.RememberMe}}
VITE_RESET_PASSWORD={{.ResetPassword}} VITE_RESET_PASSWORD={{.ResetPassword}}
VITE_THEME={{.Theme}} VITE_THEME={{.Theme}}

View File

@ -13,7 +13,7 @@
<title>Login - Authelia</title> <title>Login - Authelia</title>
</head> </head>
<body data-basepath="%VITE_PUBLIC_URL%" data-logooverride="%VITE_LOGO_OVERRIDE%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%"> <body data-basepath="%VITE_PUBLIC_URL%" data-duoselfenrollment="%VITE_DUO_SELF_ENROLLMENT%" data-logooverride="%VITE_LOGO_OVERRIDE%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>

View File

@ -26,10 +26,11 @@
"prepare": "cd .. && husky install .github", "prepare": "cd .. && husky install .github",
"start": "vite --host", "start": "vite --host",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"coverage": "VITE_COVERAGE=true vite build", "coverage": "VITE_COVERAGE=true vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"test": "jest --coverage --no-cache", "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": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"

View File

@ -18,7 +18,7 @@ import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications"; import { Notification } from "@models/Notifications";
import * as themes from "@themes/index"; import * as themes from "@themes/index";
import { getBasePath } from "@utils/BasePath"; 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 RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey"; import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey";
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView"; import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
@ -73,7 +73,13 @@ const App: React.FC = () => {
<Route path={ConsentRoute} element={<ConsentView />} /> <Route path={ConsentRoute} element={<ConsentView />} />
<Route <Route
path={`${FirstFactorRoute}*`} path={`${FirstFactorRoute}*`}
element={<LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} />} element={
<LoginPortal
duoSelfEnrollment={getDuoSelfEnrollment()}
rememberMe={getRememberMe()}
resetPassword={getResetPassword()}
/>
}
/> />
</Routes> </Routes>
</Router> </Router>

View File

@ -5,4 +5,5 @@ export interface UserInfo {
method: SecondFactorMethod; method: SecondFactorMethod;
has_u2f: boolean; has_u2f: boolean;
has_totp: boolean; has_totp: boolean;
has_duo: boolean;
} }

View File

@ -18,6 +18,9 @@ export const CompleteU2FRegistrationStep2Path = basePath + "/api/secondfactor/u2
export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request"; export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request";
export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign"; 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 CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo";
export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp"; export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp";

View File

@ -1,6 +1,9 @@
import { CompletePushNotificationSignInPath } from "@services/Api"; import {
import { PostWithOptionalResponse } from "@services/Client"; CompletePushNotificationSignInPath,
import { SignInResponse } from "@services/SignIn"; InitiateDuoDeviceSelectionPath,
CompleteDuoDeviceSelectionPath,
} from "@services/Api";
import { Get, PostWithOptionalResponse } from "@services/Client";
interface CompleteU2FSigninBody { interface CompleteU2FSigninBody {
targetURL?: string; targetURL?: string;
@ -11,5 +14,35 @@ export function completePushNotificationSignIn(targetURL: string | undefined) {
if (targetURL) { if (targetURL) {
body.targetURL = targetURL; body.targetURL = targetURL;
} }
return PostWithOptionalResponse<SignInResponse>(CompletePushNotificationSignInPath, body); return PostWithOptionalResponse<DuoSignInResponse>(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<DuoDevicesGetResponse>(InitiateDuoDeviceSelectionPath);
}
export interface DuoDevicePostRequest {
device: string;
method: string;
}
export async function completeDuoDeviceSelectionProcess(device: DuoDevicePostRequest) {
return PostWithOptionalResponse(CompleteDuoDeviceSelectionPath, { device: device.device, method: device.method });
} }

View File

@ -10,6 +10,7 @@ export interface UserInfoPayload {
method: Method2FA; method: Method2FA;
has_u2f: boolean; has_u2f: boolean;
has_totp: boolean; has_totp: boolean;
has_duo: boolean;
} }
export interface MethodPreferencePayload { export interface MethodPreferencePayload {

View File

@ -1,6 +1,7 @@
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
document.body.setAttribute("data-basepath", ""); document.body.setAttribute("data-basepath", "");
document.body.setAttribute("data-duoselfenrollment", "true");
document.body.setAttribute("data-rememberme", "true"); document.body.setAttribute("data-rememberme", "true");
document.body.setAttribute("data-resetpassword", "true"); document.body.setAttribute("data-resetpassword", "true");
document.body.setAttribute("data-theme", "light"); document.body.setAttribute("data-theme", "light");

View File

@ -7,6 +7,10 @@ export function getEmbeddedVariable(variableName: string) {
return value; return value;
} }
export function getDuoSelfEnrollment() {
return getEmbeddedVariable("duoselfenrollment") === "true";
}
export function getLogoOverride() { export function getLogoOverride() {
return getEmbeddedVariable("logooverride") === "true"; return getEmbeddedVariable("logooverride") === "true";
} }

View File

@ -26,6 +26,7 @@ import FirstFactorForm from "@views/LoginPortal/FirstFactor/FirstFactorForm";
import SecondFactorForm from "@views/LoginPortal/SecondFactor/SecondFactorForm"; import SecondFactorForm from "@views/LoginPortal/SecondFactor/SecondFactorForm";
export interface Props { export interface Props {
duoSelfEnrollment: boolean;
rememberMe: boolean; rememberMe: boolean;
resetPassword: boolean; resetPassword: boolean;
} }
@ -189,6 +190,7 @@ const LoginPortal = function (props: Props) {
authenticationLevel={state.authentication_level} authenticationLevel={state.authentication_level}
userInfo={userInfo} userInfo={userInfo}
configuration={configuration} configuration={configuration}
duoSelfEnrollment={props.duoSelfEnrollment}
onMethodChanged={() => fetchUserInfo()} onMethodChanged={() => fetchUserInfo()}
onAuthenticationSuccess={handleAuthSuccess} onAuthenticationSuccess={handleAuthSuccess}
/> />

View File

@ -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 = (
<Grid container justifyContent="center" spacing={1} id="device-selection">
{props.devices.map((value, index) => {
return (
<DeviceItem
id={index}
key={index}
device={value}
onSelect={() => handleDeviceSelected(value)}
/>
);
})}
</Grid>
);
break;
case State.METHOD:
container = (
<Grid container justifyContent="center" spacing={1} id="method-selection">
{device.methods.map((value, index) => {
return (
<MethodItem
id={index}
key={index}
method={value}
onSelect={() => handleMethodSelected(value)}
/>
);
})}
</Grid>
);
break;
}
return (
<Container>
{container}
<Button color="primary" onClick={props.onBack} id="device-selection-back">
back
</Button>
</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 (
<Grid item xs={12} className={className} id={idName}>
<Button
className={style.item}
color="primary"
classes={{ root: style.buttonRoot }}
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
<Typography>{props.device.name}</Typography>
</div>
</Button>
</Grid>
);
}
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 (
<Grid item xs={12} className={className} id={idName}>
<Button
className={style.item}
color="primary"
classes={{ root: style.buttonRoot }}
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
<Typography>{props.method}</Typography>
</div>
</Button>
</Grid>
);
}

View File

@ -15,17 +15,24 @@ export enum State {
export interface Props { export interface Props {
id: string; id: string;
title: string; title: string;
duoSelfEnrollment: boolean;
registered: boolean; registered: boolean;
explanation: string; explanation: string;
state: State; state: State;
children: ReactNode; children: ReactNode;
onRegisterClick?: () => void; onRegisterClick?: () => void;
onSelectClick?: () => void;
} }
const DefaultMethodContainer = function (props: Props) { const DefaultMethodContainer = function (props: Props) {
const style = useStyles(); 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 container: ReactNode;
let stateClass: string = ""; let stateClass: string = "";
@ -35,7 +42,7 @@ const DefaultMethodContainer = function (props: Props) {
stateClass = "state-already-authenticated"; stateClass = "state-already-authenticated";
break; break;
case State.NOT_REGISTERED: case State.NOT_REGISTERED:
container = <NotRegisteredContainer />; container = <NotRegisteredContainer title={props.title} duoSelfEnrollment={props.duoSelfEnrollment} />;
stateClass = "state-not-registered"; stateClass = "state-not-registered";
break; break;
case State.METHOD: case State.METHOD:
@ -50,7 +57,13 @@ const DefaultMethodContainer = function (props: Props) {
<div className={classnames(style.container, stateClass)} id="2fa-container"> <div className={classnames(style.container, stateClass)} id="2fa-container">
<div className={style.containerFlex}>{container}</div> <div className={style.containerFlex}>{container}</div>
</div> </div>
{props.onRegisterClick ? ( {props.onSelectClick && props.registered ? (
<Link component="button" id="selection-link" onClick={props.onSelectClick}>
{selectMessage}
</Link>
) : null}
{(props.onRegisterClick && props.title !== "Push Notification") ||
(props.onRegisterClick && props.title === "Push Notification" && props.duoSelfEnrollment) ? (
<Link component="button" id="register-link" onClick={props.onRegisterClick}> <Link component="button" id="register-link" onClick={props.onRegisterClick}>
{registerMessage} {registerMessage}
</Link> </Link>
@ -76,7 +89,12 @@ const useStyles = makeStyles(() => ({
}, },
})); }));
function NotRegisteredContainer() { interface NotRegisteredContainerProps {
title: string;
duoSelfEnrollment: boolean;
}
function NotRegisteredContainer(props: NotRegisteredContainerProps) {
const theme = useTheme(); const theme = useTheme();
return ( return (
<Fragment> <Fragment>
@ -87,7 +105,11 @@ function NotRegisteredContainer() {
The resource you're attempting to access requires two-factor authentication. The resource you're attempting to access requires two-factor authentication.
</Typography> </Typography>
<Typography style={{ color: "#5858ff" }}> <Typography style={{ color: "#5858ff" }}>
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."}
</Typography> </Typography>
</Fragment> </Fragment>
); );

View File

@ -89,6 +89,7 @@ const OneTimePasswordMethod = function (props: Props) {
id={props.id} id={props.id}
title="One-Time Password" title="One-Time Password"
explanation="Enter one-time password" explanation="Enter one-time password"
duoSelfEnrollment={false}
registered={props.registered} registered={props.registered}
state={methodState} state={methodState}
onRegisterClick={props.onRegisterClick} onRegisterClick={props.onRegisterClick}

View File

@ -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"; import { Button, makeStyles } from "@material-ui/core";
@ -7,21 +7,35 @@ import PushNotificationIcon from "@components/PushNotificationIcon";
import SuccessIcon from "@components/SuccessIcon"; import SuccessIcon from "@components/SuccessIcon";
import { useIsMountedRef } from "@hooks/Mounted"; import { useIsMountedRef } from "@hooks/Mounted";
import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useRedirectionURL } from "@hooks/RedirectionURL";
import { completePushNotificationSignIn } from "@services/PushNotification"; import {
completePushNotificationSignIn,
completeDuoDeviceSelectionProcess,
DuoDevicePostRequest,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import DeviceSelectionContainer, {
SelectedDevice,
SelectableDevice,
} from "@views/LoginPortal/SecondFactor/DeviceSelectionContainer";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State { export enum State {
SignInInProgress = 1, SignInInProgress = 1,
Success = 2, Success = 2,
Failure = 3, Failure = 3,
Selection = 4,
Enroll = 5,
} }
export interface Props { export interface Props {
id: string; id: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
duoSelfEnrollment: boolean;
registered: boolean;
onSignInError: (err: Error) => void; onSignInError: (err: Error) => void;
onSelectionClick: () => void;
onSignInSuccess: (redirectURL: string | undefined) => void; onSignInSuccess: (redirectURL: string | undefined) => void;
} }
@ -30,11 +44,47 @@ const PushNotificationMethod = function (props: Props) {
const [state, setState] = useState(State.SignInInProgress); const [state, setState] = useState(State.SignInInProgress);
const redirectionURL = useRedirectionURL(); const redirectionURL = useRedirectionURL();
const mounted = useIsMountedRef(); const mounted = useIsMountedRef();
const [enroll_url, setEnrollUrl] = useState("");
const [devices, setDevices] = useState([] as SelectableDevice[]);
const { onSignInSuccess, onSignInError } = props; const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current; const onSignInErrorCallback = useRef(onSignInError).current;
const onSignInSuccessCallback = useRef(onSignInSuccess).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 () => { const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) { if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return; return;
@ -46,6 +96,26 @@ const PushNotificationMethod = function (props: Props) {
// If the request was initiated and the user changed 2FA method in the meantime, // 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. // the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return; 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); setState(State.Success);
setTimeout(() => { setTimeout(() => {
@ -55,17 +125,46 @@ const PushNotificationMethod = function (props: Props) {
} catch (err) { } catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime, // 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. // 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); console.error(err);
onSignInErrorCallback(new Error("There was an issue completing sign in process")); onSignInErrorCallback(new Error("There was an issue completing sign in process"));
setState(State.Failure); setState(State.Failure);
} }
}, [onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, mounted, props.authenticationLevel]); }, [
props.authenticationLevel,
props.duoSelfEnrollment,
redirectionURL,
mounted,
onSignInErrorCallback,
onSignInSuccessCallback,
state,
]);
useEffect(() => { const updateDuoDevice = useCallback(
signInFunc(); async function (device: DuoDevicePostRequest) {
}, [signInFunc]); 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. // Set successful state if user is already authenticated.
useEffect(() => { useEffect(() => {
@ -74,6 +173,19 @@ const PushNotificationMethod = function (props: Props) {
} }
}, [props.authenticationLevel, setState]); }, [props.authenticationLevel, setState]);
useEffect(() => {
if (state === State.SignInInProgress) signInFunc();
}, [signInFunc, state]);
if (state === State.Selection)
return (
<DeviceSelectionContainer
devices={devices}
onBack={() => setState(State.SignInInProgress)}
onSelect={handleDuoDeviceSelected}
/>
);
let icon: ReactNode; let icon: ReactNode;
switch (state) { switch (state) {
case State.SignInInProgress: case State.SignInInProgress:
@ -89,6 +201,8 @@ const PushNotificationMethod = function (props: Props) {
let methodState = MethodContainerState.METHOD; let methodState = MethodContainerState.METHOD;
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) { if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
methodState = MethodContainerState.ALREADY_AUTHENTICATED; methodState = MethodContainerState.ALREADY_AUTHENTICATED;
} else if (state === State.Enroll) {
methodState = MethodContainerState.NOT_REGISTERED;
} }
return ( return (
@ -96,8 +210,11 @@ const PushNotificationMethod = function (props: Props) {
id={props.id} id={props.id}
title="Push Notification" title="Push Notification"
explanation="A notification has been sent to your smartphone" explanation="A notification has been sent to your smartphone"
registered={true} duoSelfEnrollment={enroll_url ? props.duoSelfEnrollment : false}
registered={props.registered}
state={methodState} state={methodState}
onSelectClick={fetchDuoDevicesFunc}
onRegisterClick={() => window.open(enroll_url, "_blank")}
> >
<div className={style.icon}>{icon}</div> <div className={style.icon}>{icon}</div>
<div className={state !== State.Failure ? "hidden" : ""}> <div className={state !== State.Failure ? "hidden" : ""}>

View File

@ -27,11 +27,11 @@ const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to compl
export interface Props { export interface Props {
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
userInfo: UserInfo; userInfo: UserInfo;
configuration: Configuration; configuration: Configuration;
duoSelfEnrollment: boolean;
onMethodChanged: (method: SecondFactorMethod) => void; onMethodChanged: () => void;
onAuthenticationSuccess: (redirectURL: string | undefined) => void; onAuthenticationSuccess: (redirectURL: string | undefined) => void;
} }
@ -76,7 +76,7 @@ const SecondFactorForm = function (props: Props) {
try { try {
await setPreferred2FAMethod(method); await setPreferred2FAMethod(method);
setMethodSelectionOpen(false); setMethodSelectionOpen(false);
props.onMethodChanged(method); props.onMethodChanged();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createErrorNotification("There was an issue updating preferred second factor method"); createErrorNotification("There was an issue updating preferred second factor method");
@ -143,6 +143,9 @@ const SecondFactorForm = function (props: Props) {
<PushNotificationMethod <PushNotificationMethod
id="push-notification-method" id="push-notification-method"
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
duoSelfEnrollment={props.duoSelfEnrollment}
registered={props.userInfo.has_duo}
onSelectionClick={props.onMethodChanged}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} onSignInSuccess={props.onAuthenticationSuccess}
/> />

View File

@ -105,6 +105,7 @@ const SecurityKeyMethod = function (props: Props) {
id={props.id} id={props.id}
title="Security Key" title="Security Key"
explanation="Touch the token of your security key" explanation="Touch the token of your security key"
duoSelfEnrollment={false}
registered={props.registered} registered={props.registered}
state={methodState} state={methodState}
onRegisterClick={props.onRegisterClick} onRegisterClick={props.onRegisterClick}

View File

@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
clientPort: env.VITE_HMR_PORT || 3000, clientPort: env.VITE_HMR_PORT || 3000,
}, },
}, },
plugins: [eslintPlugin(), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()], plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
}; };
}); });