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")/required-apps"
cd web && ${PMGR} commitlint --edit $1
cd web && ${PMGR} commit

View File

@ -540,6 +540,46 @@ paths:
description: Unauthorized
security:
- authelia_auth: []
/api/secondfactor/duo_devices:
get:
tags:
- Second Factor
summary: Second Factor Authentication - Duo Mobile Push
description: This endpoint retreives a users available devices and capabilities from Duo.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.DuoDevicesResponse'
"401":
description: Unauthorized
security:
- authelia_auth: []
/api/secondfactor/duo_device:
post:
tags:
- Second Factor
summary: Second Factor Authentication - Duo Mobile Push
description: This endpoint updates the users preferred Duo device and method.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.DuoDeviceBody'
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/middlewares.OkResponse'
"401":
description: Unauthorized
security:
- authelia_auth: []
components:
parameters:
originalURLParam:
@ -603,13 +643,19 @@ components:
totp_period:
type: integer
example: 30
handlers.logoutRequestBody:
handlers.DuoDeviceBody:
required:
- device
- method
type: object
properties:
targetURL:
device:
type: string
example: https://redirect.example.com
handlers.logoutResponseBody:
example: ABCDE123456789FGHIJK
method:
type: string
example: push
handlers.DuoDevicesResponse:
type: object
properties:
status:
@ -618,9 +664,25 @@ components:
data:
type: object
properties:
safeTargetURL:
type: boolean
example: true
result:
type: string
example: auth
devices:
type: array
items:
type: object
properties:
device:
type: string
example: ABCDE123456789FGHIJK
display_name:
type: string
example: iOS (+XX XXX XXX 123)
capabilities:
type: array
items:
type: string
example: push
handlers.firstFactorRequestBody:
required:
- username
@ -642,6 +704,24 @@ components:
keepMeLoggedIn:
type: boolean
example: true
handlers.logoutRequestBody:
type: object
properties:
targetURL:
type: string
example: https://redirect.example.com
handlers.logoutResponseBody:
type: object
properties:
status:
type: string
example: OK
data:
type: object
properties:
safeTargetURL:
type: boolean
example: true
handlers.redirectResponse:
type: object
properties:
@ -758,6 +838,9 @@ components:
has_totp:
type: boolean
example: true
has_duo:
type: boolean
example: true
handlers.UserInfo.MethodBody:
required:
- method

View File

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

View File

@ -24,6 +24,7 @@ duo_api:
hostname: api-123456789.example.com
integration_key: ABCDEF
secret_key: 1234567890abcdefghifjkl
enable_self_enrollment: false
```
The secret key is shown as an example, you also have the option to set it using an environment
@ -67,4 +68,16 @@ required: yes
The secret [Duo] key used to verify your application is valid.
### enable_self_enrollment
<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/

View File

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

View File

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

View File

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

View File

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

View File

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

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.
func (d *APIImpl) Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error) {
func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error) {
var response Response
_, responseBytes, err := d.DuoApi.SignedCall("POST", "/auth/v2/auth", values)
_, responseBytes, err := d.DuoApi.SignedCall(method, path, values)
if err != nil {
return nil, err
}
ctx.Logger.Tracef("Duo Push Auth Response Raw Data for %s from IP %s: %s", ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
ctx.Logger.Tracef("Duo endpoint: %s response raw data for %s from IP %s: %s", path, ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
err = json.Unmarshal(responseBytes, &response)
if err != nil {
return nil, err
}
if response.Stat == "FAIL" {
ctx.Logger.Warnf(
"Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
ctx.GetSession().Username, ctx.RemoteIP().String(),
response.Message, response.MessageDetail, response.Code)
}
return &response, nil
}
// PreAuthCall call to the DuoAPI.
func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error) {
var preAuthResponse PreAuthResponse
response, err := d.Call(ctx, values, "POST", "/auth/v2/preauth")
if err != nil {
return nil, err
}
err = json.Unmarshal(response.Response, &preAuthResponse)
if err != nil {
return nil, err
}
return &preAuthResponse, nil
}
// AuthCall call to the DuoAPI.
func (d *APIImpl) AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error) {
var authResponse AuthResponse
response, err := d.Call(ctx, values, "POST", "/auth/v2/auth")
if err != nil {
return nil, err
}
err = json.Unmarshal(response.Response, &authResponse)
if err != nil {
return nil, err
}
return &authResponse, nil
}

View File

@ -1,6 +1,7 @@
package duo
import (
"encoding/json"
"net/url"
duoapi "github.com/duosecurity/duo_api_golang"
@ -10,7 +11,9 @@ import (
// API interface wrapping duo api library for testing purpose.
type API interface {
Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error)
Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error)
PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error)
AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error)
}
// APIImpl implementation of DuoAPI interface.
@ -18,15 +21,38 @@ type APIImpl struct {
*duoapi.DuoApi
}
// Response response coming from Duo API.
type Response struct {
Response struct {
Result string `json:"result"`
Status string `json:"status"`
StatusMessage string `json:"status_msg"`
} `json:"response"`
Code int `json:"code"`
Message string `json:"message"`
MessageDetail string `json:"message_detail"`
Stat string `json:"stat"`
// Device holds all necessary info for frontend.
type Device struct {
Capabilities []string `json:"capabilities"`
Device string `json:"device"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
SmsNextcode string `json:"sms_nextcode"`
Number string `json:"number"`
Type string `json:"type"`
}
// Response coming from Duo API.
type Response struct {
Response json.RawMessage `json:"response"`
Code int `json:"code"`
Message string `json:"message"`
MessageDetail string `json:"message_detail"`
Stat string `json:"stat"`
}
// AuthResponse is a response for a authorization request.
type AuthResponse struct {
Result string `json:"result"`
Status string `json:"status"`
StatusMessage string `json:"status_msg"`
TrustedDeviceToken string `json:"trusted_device_token"`
}
// PreAuthResponse is a response for a preauthorization request.
type PreAuthResponse struct {
Result string `json:"result"`
StatusMessage string `json:"status_msg"`
Devices []Device `json:"devices"`
EnrollPortalURL string `json:"enroll_portal_url"`
}

View File

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

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/middlewares"
"github.com/authelia/authelia/v4/internal/models"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/utils"
)
// SecondFactorDuoPost handler for sending a push notification via duo api.
func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return func(ctx *middlewares.AutheliaCtx) {
var requestBody signDuoRequestBody
var (
requestBody signDuoRequestBody
device, method string
)
if err := ctx.ParseBody(&requestBody); err != nil {
ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err)
@ -25,43 +31,49 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
userSession := ctx.GetSession()
remoteIP := ctx.RemoteIP().String()
ctx.Logger.Debugf("Starting Duo Push Auth Attempt for user '%s' with IP '%s'", userSession.Username, remoteIP)
values := url.Values{}
values.Set("username", userSession.Username)
values.Set("ipaddr", remoteIP)
values.Set("factor", "push")
values.Set("device", "auto")
if requestBody.TargetURL != "" {
values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", requestBody.TargetURL))
duoDevice, err := ctx.Providers.StorageProvider.LoadPreferredDuoDevice(ctx, userSession.Username)
if err != nil {
ctx.Logger.Debugf("Error identifying preferred device for user %s: %s", userSession.Username, err)
ctx.Logger.Debugf("Starting Duo PreAuth for initial device selection of user: %s", userSession.Username)
device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, requestBody.TargetURL)
} else {
ctx.Logger.Debugf("Starting Duo PreAuth to check preferred device of user: %s", userSession.Username)
device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, requestBody.TargetURL)
}
duoResponse, err := duoAPI.Call(values, ctx)
if err != nil {
ctx.Logger.Errorf("Failed to perform DUO call for user '%s': %+v", userSession.Username, err)
ctx.Error(err, messageMFAValidationFailed)
return
}
if device == "" || method == "" {
return
}
ctx.Logger.Debugf("Starting Duo Auth attempt for %s with device %s and method %s from IP %s", userSession.Username, device, method, remoteIP)
values, err := SetValues(userSession, device, method, remoteIP, requestBody.TargetURL, requestBody.Passcode)
if err != nil {
ctx.Logger.Errorf("Failed to set values for Duo Auth Call for user '%s': %+v", userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
if duoResponse.Stat == "FAIL" {
if duoResponse.Code == 40002 {
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d. "+
"This error often occurs if you've not setup the username in the Admin Dashboard.",
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
} else {
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
}
authResponse, err := duoAPI.AuthCall(ctx, values)
if err != nil {
ctx.Logger.Errorf("Failed to perform Duo Auth Call for user '%s': %+v", userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
if duoResponse.Response.Result != testResultAllow {
if authResponse.Result != allow {
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO,
fmt.Errorf("result: %s, code: %d, message: %s (%s)", duoResponse.Response.Result, duoResponse.Code,
duoResponse.Message, duoResponse.MessageDetail))
fmt.Errorf("duo auth result: %s, status: %s, message: %s", authResponse.Result, authResponse.Status,
authResponse.StatusMessage))
respondUnauthorized(ctx, messageMFAValidationFailed)
@ -73,29 +85,223 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return
}
if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil {
ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDUO, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
userSession.SetTwoFactor(ctx.Clock.Now())
err = ctx.SaveSession(userSession)
if err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
if userSession.OIDCWorkflowSession != nil {
handleOIDCWorkflowResponse(ctx)
} else {
Handle2FAResponse(ctx, requestBody.TargetURL)
}
HandleAllow(ctx, requestBody.TargetURL)
}
}
// HandleInitialDeviceSelection handler for retrieving all available devices.
func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, targetURL string) (device string, method string, err error) {
result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI)
if err != nil {
ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return "", "", err
}
switch result {
case enroll:
ctx.Logger.Debugf("Duo user: %s not enrolled", userSession.Username)
if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll, EnrollURL: enrollURL}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
case deny:
ctx.Logger.Infof("Duo user: %s not allowed to authenticate: %s", userSession.Username, message)
if err := ctx.SetJSONBody(DuoSignResponse{Result: deny}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
case allow:
ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username)
HandleAllow(ctx, targetURL)
return "", "", nil
case auth:
device, method, err = HandleAutoSelection(ctx, devices, userSession.Username)
if err != nil {
return "", "", err
}
return device, method, nil
}
return "", "", fmt.Errorf("unknown result: %s", result)
}
// HandlePreferredDeviceCheck handler to check if the saved device and method is still valid.
func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, device string, method string, targetURL string) (string, string, error) {
result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI)
if err != nil {
ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return "", "", nil
}
switch result {
case enroll:
ctx.Logger.Debugf("Duo user: %s no longer enrolled removing preferred device", userSession.Username)
if err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username); err != nil {
return "", "", fmt.Errorf("unable to delete preferred Duo device and method for user %s: %s", userSession.Username, err)
}
if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll, EnrollURL: enrollURL}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
case deny:
ctx.Logger.Infof("Duo user: %s not allowed to authenticate: %s", userSession.Username, message)
ctx.ReplyUnauthorized()
return "", "", nil
case allow:
ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username)
HandleAllow(ctx, targetURL)
return "", "", nil
case auth:
if devices == nil {
ctx.Logger.Debugf("Duo user: %s has no compatible device/method available removing preferred device", userSession.Username)
if err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username); err != nil {
return "", "", fmt.Errorf("unable to delete preferred Duo device and method for user %s: %s", userSession.Username, err)
}
if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
}
if len(devices) > 0 {
for i := range devices {
if devices[i].Device == device {
if utils.IsStringInSlice(method, devices[i].Capabilities) {
return device, method, nil
}
}
}
}
return HandleAutoSelection(ctx, devices, userSession.Username)
}
return "", "", fmt.Errorf("unknown result: %s", result)
}
// HandleAutoSelection handler automatically selects preferred device if there is only one suitable option.
func HandleAutoSelection(ctx *middlewares.AutheliaCtx, devices []DuoDevice, username string) (string, string, error) {
if devices == nil {
ctx.Logger.Debugf("No compatible device/method available for Duo user: %s", username)
if err := ctx.SetJSONBody(DuoSignResponse{Result: enroll}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
}
if len(devices) > 1 {
ctx.Logger.Debugf("Multiple devices available for Duo user: %s require manual selection", username)
if err := ctx.SetJSONBody(DuoSignResponse{Result: auth, Devices: devices}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
}
if len(devices[0].Capabilities) > 1 {
ctx.Logger.Debugf("Multiple methods available for Duo user: %s require manual selection", username)
if err := ctx.SetJSONBody(DuoSignResponse{Result: auth, Devices: devices}); err != nil {
return "", "", fmt.Errorf("unable to set JSON body in response")
}
return "", "", nil
}
device := devices[0].Device
method := devices[0].Capabilities[0]
ctx.Logger.Debugf("Exactly one device: '%s' and method: '%s' found, saving as new preferred Duo device and method for user: %s", device, method, username)
if err := ctx.Providers.StorageProvider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: username, Method: method, Device: device}); err != nil {
return "", "", fmt.Errorf("unable to save new preferred Duo device and method for user %s: %s", username, err)
}
return device, method, nil
}
// HandleAllow handler for successful logins.
func HandleAllow(ctx *middlewares.AutheliaCtx, targetURL string) {
userSession := ctx.GetSession()
err := ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx)
if err != nil {
ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDUO, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
userSession.SetTwoFactor(ctx.Clock.Now())
err = ctx.SaveSession(userSession)
if err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
if userSession.OIDCWorkflowSession != nil {
handleOIDCWorkflowResponse(ctx)
} else {
Handle2FAResponse(ctx, targetURL)
}
}
// SetValues sets all appropriate Values for the Auth Request.
func SetValues(userSession session.UserSession, device string, method string, remoteIP string, targetURL string, passcode string) (url.Values, error) {
values := url.Values{}
values.Set("username", userSession.Username)
values.Set("ipaddr", remoteIP)
values.Set("factor", method)
switch method {
case duo.Push:
values.Set("device", device)
if userSession.DisplayName != "" {
values.Set("display_username", userSession.DisplayName)
}
if targetURL != "" {
values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", targetURL))
}
case duo.Phone:
values.Set("device", device)
case duo.SMS:
values.Set("device", device)
case duo.OTP:
if passcode != "" {
values.Set("passcode", passcode)
} else {
return nil, fmt.Errorf("no passcode received from user: %s", userSession.Username)
}
}
return values, nil
}

View File

@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
@ -20,7 +21,6 @@ import (
type SecondFactorDuoPostSuite struct {
suite.Suite
mock *mocks.MockAutheliaCtx
}
@ -36,18 +36,58 @@ func (s *SecondFactorDuoPostSuite) TearDownTest() {
s.mock.Close()
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
func (s *SecondFactorDuoPostSuite) TestShouldEnroll() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(nil, errors.New("no Duo device and method saved"))
var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
values := url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "auto")
values.Set("pushinfo", "target%20url=https://target.example.com")
response := duo.Response{}
response.Response.Result = testResultAllow
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = enroll
preAuthResponse.EnrollPortalURL = enrollURL
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert200OK(s.T(), DuoSignResponse{
Result: enroll,
EnrollURL: enrollURL,
})
}
func (s *SecondFactorDuoPostSuite) TestShouldAutoSelect() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().LoadPreferredDuoDevice(s.mock.Ctx, "john").Return(nil, errors.New("no Duo device and method saved"))
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
{Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
s.mock.StorageProviderMock.EXPECT().
SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
Return(nil)
s.mock.StorageProviderMock.
EXPECT().
@ -58,29 +98,313 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
values = url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "12345ABCDEFGHIJ67890")
values.Set("pushinfo", "target%20url=https://target.example.com")
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
authResponse := duo.AuthResponse{}
authResponse.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldDenyAutoSelect() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(nil, errors.New("no Duo device and method saved"))
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = deny
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
values = url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "12345ABCDEFGHIJ67890")
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 200)
s.mock.Assert200OK(s.T(), DuoSignResponse{
Result: deny,
})
}
func (s *SecondFactorDuoPostSuite) TestShouldFailAutoSelect() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(nil, errors.New("no Duo device and method saved"))
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
}
func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndEnroll() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = enroll
preAuthResponse.EnrollPortalURL = enrollURL
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert200OK(s.T(), DuoSignResponse{
Result: enroll,
EnrollURL: enrollURL,
})
}
func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndCallPreauthAPIWithInvalidDevicesAndEnroll() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"sms"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert200OK(s.T(), DuoSignResponse{
Result: enroll,
})
}
func (s *SecondFactorDuoPostSuite) TestShouldUseOldDeviceAndSelect() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
{Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 3"},
}
var apiDevices = []DuoDevice{
{Capabilities: []string{"push"}, Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
{Capabilities: []string{"push"}, Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: auth, Devices: apiDevices})
}
func (s *SecondFactorDuoPostSuite) TestShouldUseInvalidMethodAndAutoSelect() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "invalidmethod"}, nil)
s.mock.StorageProviderMock.
EXPECT().
AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
Username: "john",
Successful: true,
Banned: false,
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
s.mock.StorageProviderMock.EXPECT().
SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
Return(nil)
values = url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "12345ABCDEFGHIJ67890")
values.Set("pushinfo", "target%20url=https://target.example.com")
authResponse := duo.AuthResponse{}
authResponse.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndAllowAccess() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = allow
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndDenyAccess() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = deny
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
values = url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "12345ABCDEFGHIJ67890")
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndFail() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
values := url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "auto")
values.Set("pushinfo", "target%20url=https://target.example.com")
response := duo.Response{}
response.Response.Result = "deny"
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -91,30 +415,67 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
values = url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "12345ABCDEFGHIJ67890")
response := duo.AuthResponse{}
response.Result = deny
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 401)
assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
}
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
values.Set("factor", "push")
values.Set("device", "auto")
values.Set("pushinfo", "target%20url=https://target.example.com")
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(nil, fmt.Errorf("connnection error"))
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
s.mock.Ctx.Request.SetBody(bodyBytes)
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
@ -124,10 +485,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{}
response.Response.Result = testResultAllow
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -138,7 +498,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
response := duo.AuthResponse{}
response.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL
@ -155,10 +534,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{}
response.Response.Result = testResultAllow
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -169,7 +547,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
response := duo.AuthResponse{}
response.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{})
s.Require().NoError(err)
@ -182,10 +579,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{}
response.Response.Result = testResultAllow
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -196,7 +592,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
response := duo.AuthResponse{}
response.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "https://mydomain.local",
@ -213,10 +628,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{}
response.Response.Result = testResultAllow
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -227,7 +641,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
response := duo.AuthResponse{}
response.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local",
@ -242,10 +675,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() {
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
response := duo.Response{}
response.Response.Result = testResultAllow
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
s.mock.StorageProviderMock.EXPECT().
LoadPreferredDuoDevice(s.mock.Ctx, "john").
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
s.mock.StorageProviderMock.
EXPECT().
@ -256,7 +688,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi
Time: s.mock.Clock.Now(),
Type: regulation.AuthTypeDUO,
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
}))
})).
Return(nil)
var duoDevices = []duo.Device{
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
}
values := url.Values{}
values.Set("username", "john")
preAuthResponse := duo.PreAuthResponse{}
preAuthResponse.Result = auth
preAuthResponse.Devices = duoDevices
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
response := duo.AuthResponse{}
response.Result = allow
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
bodyBytes, err := json.Marshal(signDuoRequestBody{
TargetURL: "http://mydomain.local",

View File

@ -11,6 +11,24 @@ type MethodList = []string
type authorizationMatching int
// UserInfo is the model of user info and second factor preferences.
type UserInfo struct {
// The users display name.
DisplayName string `json:"display_name"`
// The preferred 2FA method.
Method string `json:"method" valid:"required"`
// True if a security key has been registered.
HasU2F bool `json:"has_u2f" valid:"required"`
// True if a TOTP device has been registered.
HasTOTP bool `json:"has_totp" valid:"required"`
// True if a Duo device and method has been enrolled.
HasDuo bool `json:"has_duo" valid:"required"`
}
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
type signTOTPRequestBody struct {
Token string `json:"token" valid:"required"`
@ -25,6 +43,7 @@ type signU2FRequestBody struct {
type signDuoRequestBody struct {
TargetURL string `json:"targetURL"`
Passcode string `json:"passcode"`
}
// firstFactorRequestBody represents the JSON body received by the endpoint.
@ -60,6 +79,34 @@ type TOTPKeyResponse struct {
OTPAuthURL string `json:"otpauth_url"`
}
// DuoDeviceBody the selected Duo device and method.
type DuoDeviceBody struct {
Device string `json:"device" valid:"required"`
Method string `json:"method" valid:"required"`
}
// DuoDevice represents Duo devices and methods.
type DuoDevice struct {
Device string `json:"device"`
DisplayName string `json:"display_name"`
Capabilities []string `json:"capabilities"`
}
// DuoDevicesResponse represents all available user devices and methods as well as an optional enrollment url.
type DuoDevicesResponse struct {
Result string `json:"result" valid:"required"`
Devices []DuoDevice `json:"devices,omitempty"`
EnrollURL string `json:"enroll_url,omitempty"`
}
// DuoSignResponse represents a result of the preauth and or auth call with further optional info.
type DuoSignResponse struct {
Result string `json:"result" valid:"required"`
Devices []DuoDevice `json:"devices,omitempty"`
Redirect string `json:"redirect,omitempty"`
EnrollURL string `json:"enroll_url,omitempty"`
}
// StateResponse represents the response sent by the state endpoint.
type StateResponse struct {
Username string `json:"username"`

View File

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

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.
Method string `db:"second_factor_method" json:"method" valid:"required"`
// True if a TOTP device has been registered.
HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"`
// True if a security key has been registered.
HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"`
// True if a TOTP device has been registered.
HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"`
// True if a duo device has been configured as the preferred.
HasDuo bool `db:"has_duo" json:"has_duo" valid:"required"`
}

View File

@ -10,7 +10,11 @@ const (
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"}
const dev = "dev"
const (
dev = "dev"
f = "false"
t = "true"
)
const healthCheckEnv = `# Written by Authelia Process
X_AUTHELIA_HEALTHCHECK=1

View File

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

View File

@ -16,15 +16,15 @@ import (
// ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui.
func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
logger := logging.Logger()
f, err := assets.Open(publicDir + file)
a, err := assets.Open(publicDir + file)
if err != nil {
logger.Fatalf("Unable to open %s: %s", file, err)
}
b, err := ioutil.ReadAll(f)
b, err := ioutil.ReadAll(a)
if err != nil {
logger.Fatalf("Unable to read %s: %s", file, err)
}
@ -40,11 +40,11 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
base = baseURL.(string)
}
logoOverride := "false"
logoOverride := f
if assetPath != "" {
if _, err := os.Stat(assetPath + logoFile); err == nil {
logoOverride = "true"
logoOverride = t
}
}
@ -79,7 +79,7 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
}
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, DuoSelfEnrollment, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, DuoSelfEnrollment: duoSelfEnrollment, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
if err != nil {
ctx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err)

View File

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

View File

@ -5,15 +5,18 @@ import (
)
var (
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
// ErrNoAuthenticationLogs error thrown when no matching authentication logs hve been found in DB.
ErrNoAuthenticationLogs = errors.New("no matching authentication logs found")
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB.
ErrNoTOTPSecret = errors.New("no TOTP secret registered")
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
// ErrNoDuoDevice error thrown when no Duo device and method has been found in DB.
ErrNoDuoDevice = errors.New("no Duo device and method saved")
// ErrNoAvailableMigrations is returned when no available migrations can be found.
ErrNoAvailableMigrations = errors.New("no available migrations")

View File

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

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE KEY (username, description)
);
CREATE TABLE IF NOT EXISTS duo_devices (
id INTEGER AUTO_INCREMENT,
username VARCHAR(100) NOT NULL,
device VARCHAR(32) NOT NULL,
method VARCHAR(16) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY (username)
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER AUTO_INCREMENT,
username VARCHAR(100) NOT NULL,

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description)
);
CREATE TABLE IF NOT EXISTS duo_devices (
id SERIAL,
username VARCHAR(100) NOT NULL,
device VARCHAR(32) NOT NULL,
method VARCHAR(16) NOT NULL,
PRIMARY KEY (id),
UNIQUE (username)
);
CREATE TABLE IF NOT EXISTS user_preferences (
id SERIAL,
username VARCHAR(100) NOT NULL,

View File

@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
UNIQUE (username, description)
);
CREATE TABLE IF NOT EXISTS duo_devices (
id INTEGER,
username VARCHAR(100) NOT NULL,
device VARCHAR(32) NOT NULL,
method VARCHAR(16) NOT NULL,
PRIMARY KEY (id),
UNIQUE (username)
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER,
username VARCHAR(100) UNIQUE NOT NULL,

View File

@ -30,18 +30,22 @@ type Provider interface {
SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error)
LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error)
SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error)
DeletePreferredDuoDevice(ctx context.Context, username string) (err error)
LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error)
SchemaTables(ctx context.Context) (tables []string, err error)
SchemaVersion(ctx context.Context) (version int, err error)
SchemaLatestVersion() (version int, err error)
SchemaMigrate(ctx context.Context, up bool, version int) (err error)
SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, err error)
SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error)
SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error)
SchemaLatestVersion() (version int, err error)
SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
Close() (err error)
}

View File

@ -1,16 +1,17 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./internal/storage/provider.go
// Source: github.com/authelia/authelia/v4/internal/storage (interfaces: Provider)
// Package storage is a generated GoMock package.
package storage
import (
"context"
"reflect"
"time"
context "context"
reflect "reflect"
time "time"
"github.com/golang/mock/gomock"
gomock "github.com/golang/mock/gomock"
"github.com/authelia/authelia/v4/internal/models"
models "github.com/authelia/authelia/v4/internal/models"
)
// MockProvider is a mock of Provider interface.
@ -64,6 +65,20 @@ func (mr *MockProviderMockRecorder) Close() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProvider)(nil).Close))
}
// DeletePreferredDuoDevice mocks base method.
func (m *MockProvider) DeletePreferredDuoDevice(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeletePreferredDuoDevice", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeletePreferredDuoDevice indicates an expected call of DeletePreferredDuoDevice.
func (mr *MockProviderMockRecorder) DeletePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).DeletePreferredDuoDevice), arg0, arg1)
}
// DeleteTOTPConfiguration mocks base method.
func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
@ -123,6 +138,21 @@ func (mr *MockProviderMockRecorder) LoadPreferred2FAMethod(arg0, arg1 interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).LoadPreferred2FAMethod), arg0, arg1)
}
// LoadPreferredDuoDevice mocks base method.
func (m *MockProvider) LoadPreferredDuoDevice(arg0 context.Context, arg1 string) (*models.DuoDevice, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadPreferredDuoDevice", arg0, arg1)
ret0, _ := ret[0].(*models.DuoDevice)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadPreferredDuoDevice indicates an expected call of LoadPreferredDuoDevice.
func (mr *MockProviderMockRecorder) LoadPreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).LoadPreferredDuoDevice), arg0, arg1)
}
// LoadTOTPConfiguration mocks base method.
func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) {
m.ctrl.T.Helper()
@ -225,6 +255,20 @@ func (mr *MockProviderMockRecorder) SavePreferred2FAMethod(arg0, arg1, arg2 inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).SavePreferred2FAMethod), arg0, arg1, arg2)
}
// SavePreferredDuoDevice mocks base method.
func (m *MockProvider) SavePreferredDuoDevice(arg0 context.Context, arg1 models.DuoDevice) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SavePreferredDuoDevice", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SavePreferredDuoDevice indicates an expected call of SavePreferredDuoDevice.
func (mr *MockProviderMockRecorder) SavePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).SavePreferredDuoDevice), arg0, arg1)
}
// SaveTOTPConfiguration mocks base method.
func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error {
m.ctrl.T.Helper()

View File

@ -46,9 +46,13 @@ func NewSQLProvider(name, driverName, dataSourceName, encryptionKey string) (pro
sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices),
sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
sqlSelectDuoDevice: fmt.Sprintf(queryFmtSelectDuoDevice, tableDuoDevices),
sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences),
sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, tableUserPreferences),
sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableUserPreferences),
sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableDuoDevices, tableUserPreferences),
sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations),
sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations),
@ -99,6 +103,11 @@ type SQLProvider struct {
sqlUpsertU2FDevice string
sqlSelectU2FDevice string
// Table: duo_devices
sqlUpsertDuoDevice string
sqlDeleteDuoDevice string
sqlSelectDuoDevice string
// Table: user_preferences.
sqlUpsertPreferred2FAMethod string
sqlSelectPreferred2FAMethod string
@ -186,7 +195,7 @@ func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username strin
// LoadUserInfo loads the models.UserInfo from the database.
func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info models.UserInfo, err error) {
err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username)
err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username)
switch {
case err == nil:
@ -196,7 +205,7 @@ func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info m
return models.UserInfo{}, fmt.Errorf("error upserting preferred two factor method while selecting user info for user '%s': %w", username, err)
}
if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username); err != nil {
if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username); err != nil {
return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
}
@ -355,6 +364,33 @@ func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (devic
return device, nil
}
// SavePreferredDuoDevice saves a Duo device.
func (p *SQLProvider) SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error) {
_, err = p.db.ExecContext(ctx, p.sqlUpsertDuoDevice, device.Username, device.Device, device.Method)
return err
}
// DeletePreferredDuoDevice deletes a Duo device of a given user.
func (p *SQLProvider) DeletePreferredDuoDevice(ctx context.Context, username string) (err error) {
_, err = p.db.ExecContext(ctx, p.sqlDeleteDuoDevice, username)
return err
}
// LoadPreferredDuoDevice loads a Duo device of a given user.
func (p *SQLProvider) LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error) {
device = &models.DuoDevice{}
if err := p.db.QueryRowxContext(ctx, p.sqlSelectDuoDevice, username).StructScan(device); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNoDuoDevice
}
return nil, err
}
return device, nil
}
// AppendAuthenticationLog append a mark to the authentication log.
func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt,

View File

@ -35,7 +35,7 @@ const (
const (
queryFmtSelectUserInfo = `
SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_u2f
SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_u2f, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_duo
FROM %s
WHERE username = ?;`
@ -129,6 +129,23 @@ const (
DO UPDATE SET key_handle=$2, public_key=$3;`
)
const (
queryFmtUpsertDuoDevice = `
REPLACE INTO %s (username, device, method)
VALUES (?, ?, ?);`
queryFmtDeleteDuoDevice = `
DELETE
FROM %s
WHERE username = ?;`
queryFmtSelectDuoDevice = `
SELECT id, username, device, method
FROM %s
WHERE username = ?
ORDER BY id;`
)
const (
queryFmtInsertAuthenticationLogEntry = `
INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method)

View File

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

View File

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

View File

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

View File

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

View File

@ -45,5 +45,5 @@ access_control:
notifier:
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")
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.
var AutheliaBaseURL = "https://authelia.example.com:9091"
const stringTrue = "true"
const testUsername = "john"
const testPassword = "password"
const (
t = "true"
testUsername = "john"
testPassword = "password"
)

View File

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

View File

@ -1,11 +1,15 @@
package suites
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/v4/internal/duo"
)
// DuoPolicy a type of policy.
@ -33,3 +37,20 @@ func ConfigureDuo(t *testing.T, allowDeny DuoPolicy) {
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
}
// ConfigureDuoPreAuth configure duo api to respond with available devices or enrollment Url.
func ConfigureDuoPreAuth(t *testing.T, response duo.PreAuthResponse) {
url := fmt.Sprintf("%s/preauth", DuoBaseURL)
body, err := json.Marshal(response)
require.NoError(t, err)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
require.NoError(t, err)
client := NewHTTPClient()
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
}

View File

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

View File

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

View File

@ -7,4 +7,17 @@ const DuoApi = require("@duosecurity/duo_api");
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
client.jsonApiCall("POST", "/auth/v2/auth", { username: 'john', factor: "push", device: "auto" }, console.log);
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 := ""
coverageArg := ""
if os.Getenv("CI") == stringTrue {
if os.Getenv("CI") == t {
testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt"
coverageArg = "COVERAGE"
}
@ -261,7 +261,7 @@ func (s *CLISuite) TestStorage02ShouldShowSchemaInfo() {
output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
s.Assert().NoError(err)
pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`)
pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`)
s.Assert().Regexp(pattern, output)
}
@ -336,7 +336,7 @@ func (s *CLISuite) TestStorage04ShouldChangeEncryptionKey() {
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
s.Assert().NoError(err)
pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`)
pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`)
s.Assert().Regexp(pattern, output)
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"})

View File

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

View File

@ -6,7 +6,13 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/authelia/authelia/v4/internal/duo"
"github.com/authelia/authelia/v4/internal/models"
"github.com/authelia/authelia/v4/internal/storage"
)
type DuoPushWebDriverSuite struct {
@ -50,11 +56,275 @@ func (s *DuoPushWebDriverSuite) TearDownTest() {
s.MustClose()
}()
// Set default 2FA preference and clean up any Duo device already in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferred2FAMethod(ctx, "john", "totp"))
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
}
func (s *DuoPushWebDriverSuite) TestShouldBypassDeviceSelection() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "allow",
StatusMessage: "Allowing unknown user",
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Context(ctx))
}
func (s *DuoPushWebDriverSuite) TestShouldDenyDeviceSelection() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "deny",
StatusMessage: "We're sorry, access is not allowed.",
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
}
func (s *DuoPushWebDriverSuite) TestShouldAskUserToRegister() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "enroll",
EnrollPortalURL: "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890",
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
enrollPage := s.Page.MustWaitOpen()
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "register-link").MustClick()
s.Page = enrollPage()
assert.Contains(s.T(), s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "description").MustText(), "This enrollment code has expired. Contact your administrator to get a new enrollment code.")
}
func (s *DuoPushWebDriverSuite) TestShouldAutoSelectDevice() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}},
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
// Authenticate
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
// Switch Method where single Device should be selected automatically.
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Context(ctx))
// Re-Login the user
s.doLogout(s.T(), s.Context(ctx))
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.verifyIsSecondFactorPage(s.T(), s.Context(ctx))
s.doChangeMethod(s.T(), s.Context(ctx), "one-time-password")
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "one-time-password-method")
// And check the latest method and device is still used.
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
// Meaning the authentication is successful
s.verifyIsHome(s.T(), s.Context(ctx))
}
func (s *DuoPushWebDriverSuite) TestShouldSelectDevice() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Set default 2FA preference to enable Select Device link in frontend.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}, {
Device: "1234567890ABCDEFGHIJ",
DisplayName: "Test Device 2",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}},
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
// Authenticate
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
// Switch Method where Device Selection should open automatically.
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
// Check for available Device 1.
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-12345ABCDEFGHIJ67890")
// Test Back button.
s.doClickButton(s.T(), s.Context(ctx), "device-selection-back")
// then select Device 2 for further use and be redirected.
s.doChangeDevice(s.T(), s.Context(ctx), "1234567890ABCDEFGHIJ")
s.verifyIsHome(s.T(), s.Context(ctx))
// Re-Login the user
s.doLogout(s.T(), s.Context(ctx))
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
// And check the latest method and device is still used.
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
// Meaning the authentication is successful
s.verifyIsHome(s.T(), s.Context(ctx))
}
func (s *DuoPushWebDriverSuite) TestShouldFailInitialSelectionBecauseOfUnsupportedMethod() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"auto", "sms"},
}},
}
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
}
func (s *DuoPushWebDriverSuite) TestShouldSelectNewDeviceAfterSavedDeviceMethodIsNoLongerSupported() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"push", "sms"},
}, {
Device: "1234567890ABCDEFGHIJ",
DisplayName: "Test Device 2",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}},
}
// Setup unsupported Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "sms"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-selection")
s.doSelectDevice(s.T(), s.Context(ctx), "12345ABCDEFGHIJ67890")
s.verifyIsHome(s.T(), s.Context(ctx))
}
func (s *DuoPushWebDriverSuite) TestShouldAutoSelectNewDeviceAfterSavedDeviceIsNoLongerAvailable() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"push", "sms"},
}},
}
// Setup unsupported Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Context(ctx))
}
func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionBypassed() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "allow",
StatusMessage: "Allowing unknown user",
}
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Deny)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.doClickButton(s.T(), s.Context(ctx), "selection-link")
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was bypassed by Duo policy")
}
func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionDenied() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "deny",
StatusMessage: "We're sorry, access is not allowed.",
}
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Deny)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
err := s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "selection-link").Click("left")
require.NoError(s.T(), err)
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
}
func (s *DuoPushWebDriverSuite) TestShouldFailAuthenticationBecausePreauthDenied() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "deny",
StatusMessage: "We're sorry, access is not allowed.",
}
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "failure-icon")
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "There was an issue completing sign in process")
}
func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
@ -64,6 +334,19 @@ func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}},
}
// Setup Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@ -78,6 +361,19 @@ func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "auth",
Devices: []duo.Device{{
Device: "12345ABCDEFGHIJ67890",
DisplayName: "Test Device 1",
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
}},
}
// Setup Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Deny)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
@ -128,9 +424,23 @@ func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() {
s.collectScreenshot(ctx.Err(), s.Page)
}()
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "allow",
StatusMessage: "Allowing unknown user",
}
// Setup Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
s.verifyIsHome(s.T(), s.Page)
// Clean up any Duo device already in DB.
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
}
type DuoPushSuite struct {
@ -157,7 +467,23 @@ func (s *DuoPushSuite) TestAvailableMethodsScenario() {
}
func (s *DuoPushSuite) TestUserPreferencesScenario() {
var PreAuthAPIResponse = duo.PreAuthResponse{
Result: "allow",
StatusMessage: "Allowing unknown user",
}
ctx := context.Background()
// Setup Duo device in DB.
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
ConfigureDuo(s.T(), Allow)
suite.Run(s.T(), NewUserPreferencesScenario())
// Clean up any Duo device already in DB.
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
}
func TestDuoPushSuite(t *testing.T) {

View File

@ -40,7 +40,7 @@ func init() {
log.Debug("Building authelia:dist image or use cache if already built...")
if os.Getenv("CI") != stringTrue {
if os.Getenv("CI") != t {
if err := utils.Shell("authelia-scripts docker build").Run(); err != nil {
return err
}

View File

@ -50,7 +50,7 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
}
func (rs *RodSession) collectScreenshot(err error, page *rod.Page) {
if err == context.DeadlineExceeded && os.Getenv("CI") == stringTrue {
if err == context.DeadlineExceeded && os.Getenv("CI") == t {
base := "/buildkite/screenshots"
build := os.Getenv("BUILDKITE_BUILD_NUMBER")
suite := strings.ToLower(os.Getenv("SUITE"))

View File

@ -80,6 +80,17 @@ func IsStringInSliceContains(needle string, haystack []string) (inSlice bool) {
return false
}
// IsStringSliceContainsAll checks if the haystack contains all strings in the needles.
func IsStringSliceContainsAll(needles []string, haystack []string) (inSlice bool) {
for _, n := range needles {
if !IsStringInSlice(n, haystack) {
return false
}
}
return true
}
// SliceString splits a string s into an array with each item being a max of int d
// d = denominator, n = numerator, q = quotient, r = remainder.
func SliceString(s string, d int) (array []string) {

View File

@ -153,3 +153,12 @@ func TestIsStringInSliceSuffix(t *testing.T) {
assert.False(t, IsStringInSliceSuffix("an.orange", suffixes))
assert.False(t, IsStringInSliceSuffix("an.apple.orange", suffixes))
}
func TestIsStringSliceContainsAll(t *testing.T) {
needles := []string{"abc", "123", "xyz"}
haystackOne := []string{"abc", "tvu", "123", "456", "xyz"}
haystackTwo := []string{"tvu", "123", "456", "xyz"}
assert.True(t, IsStringSliceContainsAll(needles, haystackOne))
assert.False(t, IsStringSliceContainsAll(needles, haystackTwo))
}

View File

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

View File

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

View File

@ -13,7 +13,7 @@
<title>Login - Authelia</title>
</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>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>

View File

@ -26,10 +26,11 @@
"prepare": "cd .. && husky install .github",
"start": "vite --host",
"build": "vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"coverage": "VITE_COVERAGE=true vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"test": "jest --coverage --no-cache",
"report": "nyc report -r clover -r json -r lcov -r text"
"report": "nyc report -r clover -r json -r lcov -r text",
"commit": "commitlint --edit $1"
},
"eslintConfig": {
"extends": "react-app"

View File

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

View File

@ -5,4 +5,5 @@ export interface UserInfo {
method: SecondFactorMethod;
has_u2f: 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 CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign";
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
export const CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo";
export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp";

View File

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

View File

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

View File

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

View File

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

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 {
id: string;
title: string;
duoSelfEnrollment: boolean;
registered: boolean;
explanation: string;
state: State;
children: ReactNode;
onRegisterClick?: () => void;
onSelectClick?: () => void;
}
const DefaultMethodContainer = function (props: Props) {
const style = useStyles();
const registerMessage = props.registered ? "Lost your device?" : "Register device";
const registerMessage = props.registered
? props.title === "Push Notification"
? ""
: "Lost your device?"
: "Register device";
const selectMessage = "Select a Device";
let container: ReactNode;
let stateClass: string = "";
@ -35,7 +42,7 @@ const DefaultMethodContainer = function (props: Props) {
stateClass = "state-already-authenticated";
break;
case State.NOT_REGISTERED:
container = <NotRegisteredContainer />;
container = <NotRegisteredContainer title={props.title} duoSelfEnrollment={props.duoSelfEnrollment} />;
stateClass = "state-not-registered";
break;
case State.METHOD:
@ -50,7 +57,13 @@ const DefaultMethodContainer = function (props: Props) {
<div className={classnames(style.container, stateClass)} id="2fa-container">
<div className={style.containerFlex}>{container}</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}>
{registerMessage}
</Link>
@ -76,7 +89,12 @@ const useStyles = makeStyles(() => ({
},
}));
function NotRegisteredContainer() {
interface NotRegisteredContainerProps {
title: string;
duoSelfEnrollment: boolean;
}
function NotRegisteredContainer(props: NotRegisteredContainerProps) {
const theme = useTheme();
return (
<Fragment>
@ -87,7 +105,11 @@ function NotRegisteredContainer() {
The resource you're attempting to access requires two-factor authentication.
</Typography>
<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>
</Fragment>
);

View File

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

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";
@ -7,21 +7,35 @@ import PushNotificationIcon from "@components/PushNotificationIcon";
import SuccessIcon from "@components/SuccessIcon";
import { useIsMountedRef } from "@hooks/Mounted";
import { useRedirectionURL } from "@hooks/RedirectionURL";
import { completePushNotificationSignIn } from "@services/PushNotification";
import {
completePushNotificationSignIn,
completeDuoDeviceSelectionProcess,
DuoDevicePostRequest,
initiateDuoDeviceSelectionProcess,
} from "@services/PushNotification";
import { AuthenticationLevel } from "@services/State";
import DeviceSelectionContainer, {
SelectedDevice,
SelectableDevice,
} from "@views/LoginPortal/SecondFactor/DeviceSelectionContainer";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State {
SignInInProgress = 1,
Success = 2,
Failure = 3,
Selection = 4,
Enroll = 5,
}
export interface Props {
id: string;
authenticationLevel: AuthenticationLevel;
duoSelfEnrollment: boolean;
registered: boolean;
onSignInError: (err: Error) => void;
onSelectionClick: () => void;
onSignInSuccess: (redirectURL: string | undefined) => void;
}
@ -30,11 +44,47 @@ const PushNotificationMethod = function (props: Props) {
const [state, setState] = useState(State.SignInInProgress);
const redirectionURL = useRedirectionURL();
const mounted = useIsMountedRef();
const [enroll_url, setEnrollUrl] = useState("");
const [devices, setDevices] = useState([] as SelectableDevice[]);
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current;
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
const fetchDuoDevicesFunc = useCallback(async () => {
try {
const res = await initiateDuoDeviceSelectionProcess();
if (!mounted.current) return;
switch (res.result) {
case "auth":
let selectableDevices = [] as SelectableDevice[];
res.devices.forEach((d: { device: any; display_name: any; capabilities: any }) =>
selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
);
setDevices(selectableDevices);
setState(State.Selection);
break;
case "allow":
onSignInErrorCallback(new Error("Device selection was bypassed by Duo policy"));
setState(State.Success);
break;
case "deny":
onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
setState(State.Failure);
break;
case "enroll":
onSignInErrorCallback(new Error("No compatible device found"));
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
setState(State.Enroll);
break;
}
} catch (err) {
if (!mounted.current) return;
console.error(err);
onSignInErrorCallback(new Error("There was an issue fetching Duo device(s)"));
}
}, [props.duoSelfEnrollment, mounted, onSignInErrorCallback]);
const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
@ -46,6 +96,26 @@ const PushNotificationMethod = function (props: Props) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
if (res && res.result === "auth") {
let selectableDevices = [] as SelectableDevice[];
res.devices.forEach((d) =>
selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
);
setDevices(selectableDevices);
setState(State.Selection);
return;
}
if (res && res.result === "enroll") {
onSignInErrorCallback(new Error("No compatible device found"));
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
setState(State.Enroll);
return;
}
if (res && res.result === "deny") {
onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
setState(State.Failure);
return;
}
setState(State.Success);
setTimeout(() => {
@ -55,17 +125,46 @@ const PushNotificationMethod = function (props: Props) {
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
if (!mounted.current || state !== State.SignInInProgress) return;
console.error(err);
onSignInErrorCallback(new Error("There was an issue completing sign in process"));
setState(State.Failure);
}
}, [onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, mounted, props.authenticationLevel]);
}, [
props.authenticationLevel,
props.duoSelfEnrollment,
redirectionURL,
mounted,
onSignInErrorCallback,
onSignInSuccessCallback,
state,
]);
useEffect(() => {
signInFunc();
}, [signInFunc]);
const updateDuoDevice = useCallback(
async function (device: DuoDevicePostRequest) {
try {
await completeDuoDeviceSelectionProcess(device);
if (!props.registered) {
setState(State.SignInInProgress);
props.onSelectionClick();
} else {
setState(State.SignInInProgress);
}
} catch (err) {
console.error(err);
onSignInErrorCallback(new Error("There was an issue updating preferred Duo device"));
}
},
[onSignInErrorCallback, props],
);
const handleDuoDeviceSelected = useCallback(
(device: SelectedDevice) => {
updateDuoDevice({ device: device.id, method: device.method });
},
[updateDuoDevice],
);
// Set successful state if user is already authenticated.
useEffect(() => {
@ -74,6 +173,19 @@ const PushNotificationMethod = function (props: Props) {
}
}, [props.authenticationLevel, setState]);
useEffect(() => {
if (state === State.SignInInProgress) signInFunc();
}, [signInFunc, state]);
if (state === State.Selection)
return (
<DeviceSelectionContainer
devices={devices}
onBack={() => setState(State.SignInInProgress)}
onSelect={handleDuoDeviceSelected}
/>
);
let icon: ReactNode;
switch (state) {
case State.SignInInProgress:
@ -89,6 +201,8 @@ const PushNotificationMethod = function (props: Props) {
let methodState = MethodContainerState.METHOD;
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
} else if (state === State.Enroll) {
methodState = MethodContainerState.NOT_REGISTERED;
}
return (
@ -96,8 +210,11 @@ const PushNotificationMethod = function (props: Props) {
id={props.id}
title="Push Notification"
explanation="A notification has been sent to your smartphone"
registered={true}
duoSelfEnrollment={enroll_url ? props.duoSelfEnrollment : false}
registered={props.registered}
state={methodState}
onSelectClick={fetchDuoDevicesFunc}
onRegisterClick={() => window.open(enroll_url, "_blank")}
>
<div className={style.icon}>{icon}</div>
<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 {
authenticationLevel: AuthenticationLevel;
userInfo: UserInfo;
configuration: Configuration;
duoSelfEnrollment: boolean;
onMethodChanged: (method: SecondFactorMethod) => void;
onMethodChanged: () => void;
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
}
@ -76,7 +76,7 @@ const SecondFactorForm = function (props: Props) {
try {
await setPreferred2FAMethod(method);
setMethodSelectionOpen(false);
props.onMethodChanged(method);
props.onMethodChanged();
} catch (err) {
console.error(err);
createErrorNotification("There was an issue updating preferred second factor method");
@ -143,6 +143,9 @@ const SecondFactorForm = function (props: Props) {
<PushNotificationMethod
id="push-notification-method"
authenticationLevel={props.authenticationLevel}
duoSelfEnrollment={props.duoSelfEnrollment}
registered={props.userInfo.has_duo}
onSelectionClick={props.onMethodChanged}
onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess}
/>

View File

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

View File

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