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.pull/2629/head
parent
08b6ecb7b1
commit
01b77384f9
|
@ -2,4 +2,4 @@
|
|||
. "$(dirname "$0")/_/husky.sh"
|
||||
. "$(dirname "$0")/required-apps"
|
||||
|
||||
cd web && ${PMGR} commitlint --edit $1
|
||||
cd web && ${PMGR} commit
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -291,7 +291,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) {
|
|||
tableTOTPConfigurations,
|
||||
tableIdentityVerification,
|
||||
tableU2FDevices,
|
||||
tableDUODevices,
|
||||
tableDuoDevices,
|
||||
tableUserPreferences,
|
||||
tableAuthenticationLogs,
|
||||
tableEncryption,
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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}
|
||||
...
|
||||
|
|
|
@ -45,5 +45,5 @@ access_control:
|
|||
|
||||
notifier:
|
||||
filesystem:
|
||||
filename: /tmp/notifier.html
|
||||
filename: /config/notifier.html
|
||||
...
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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"})
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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}}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -5,4 +5,5 @@ export interface UserInfo {
|
|||
method: SecondFactorMethod;
|
||||
has_u2f: boolean;
|
||||
has_totp: boolean;
|
||||
has_duo: boolean;
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ export interface UserInfoPayload {
|
|||
method: Method2FA;
|
||||
has_u2f: boolean;
|
||||
has_totp: boolean;
|
||||
has_duo: boolean;
|
||||
}
|
||||
|
||||
export interface MethodPreferencePayload {
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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" : ""}>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()],
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue