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")/_/husky.sh"
|
||||||
. "$(dirname "$0")/required-apps"
|
. "$(dirname "$0")/required-apps"
|
||||||
|
|
||||||
cd web && ${PMGR} commitlint --edit $1
|
cd web && ${PMGR} commit
|
||||||
|
|
|
@ -540,6 +540,46 @@ paths:
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
security:
|
security:
|
||||||
- authelia_auth: []
|
- authelia_auth: []
|
||||||
|
/api/secondfactor/duo_devices:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Second Factor
|
||||||
|
summary: Second Factor Authentication - Duo Mobile Push
|
||||||
|
description: This endpoint retreives a users available devices and capabilities from Duo.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Successful Operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/handlers.DuoDevicesResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
security:
|
||||||
|
- authelia_auth: []
|
||||||
|
/api/secondfactor/duo_device:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Second Factor
|
||||||
|
summary: Second Factor Authentication - Duo Mobile Push
|
||||||
|
description: This endpoint updates the users preferred Duo device and method.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/handlers.DuoDeviceBody'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Successful Operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/middlewares.OkResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
security:
|
||||||
|
- authelia_auth: []
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
originalURLParam:
|
originalURLParam:
|
||||||
|
@ -603,13 +643,19 @@ components:
|
||||||
totp_period:
|
totp_period:
|
||||||
type: integer
|
type: integer
|
||||||
example: 30
|
example: 30
|
||||||
handlers.logoutRequestBody:
|
handlers.DuoDeviceBody:
|
||||||
|
required:
|
||||||
|
- device
|
||||||
|
- method
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
targetURL:
|
device:
|
||||||
type: string
|
type: string
|
||||||
example: https://redirect.example.com
|
example: ABCDE123456789FGHIJK
|
||||||
handlers.logoutResponseBody:
|
method:
|
||||||
|
type: string
|
||||||
|
example: push
|
||||||
|
handlers.DuoDevicesResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
|
@ -618,9 +664,25 @@ components:
|
||||||
data:
|
data:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
safeTargetURL:
|
result:
|
||||||
type: boolean
|
type: string
|
||||||
example: true
|
example: auth
|
||||||
|
devices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
device:
|
||||||
|
type: string
|
||||||
|
example: ABCDE123456789FGHIJK
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
example: iOS (+XX XXX XXX 123)
|
||||||
|
capabilities:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: push
|
||||||
handlers.firstFactorRequestBody:
|
handlers.firstFactorRequestBody:
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
|
@ -642,6 +704,24 @@ components:
|
||||||
keepMeLoggedIn:
|
keepMeLoggedIn:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
|
handlers.logoutRequestBody:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
targetURL:
|
||||||
|
type: string
|
||||||
|
example: https://redirect.example.com
|
||||||
|
handlers.logoutResponseBody:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: OK
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
safeTargetURL:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
handlers.redirectResponse:
|
handlers.redirectResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -758,6 +838,9 @@ components:
|
||||||
has_totp:
|
has_totp:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
|
has_duo:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
handlers.UserInfo.MethodBody:
|
handlers.UserInfo.MethodBody:
|
||||||
required:
|
required:
|
||||||
- method
|
- method
|
||||||
|
|
|
@ -111,6 +111,7 @@ duo_api:
|
||||||
integration_key: ABCDEF
|
integration_key: ABCDEF
|
||||||
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||||
secret_key: 1234567890abcdefghifjkl
|
secret_key: 1234567890abcdefghifjkl
|
||||||
|
enable_self_enrollment: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## NTP Configuration
|
## NTP Configuration
|
||||||
|
|
|
@ -24,6 +24,7 @@ duo_api:
|
||||||
hostname: api-123456789.example.com
|
hostname: api-123456789.example.com
|
||||||
integration_key: ABCDEF
|
integration_key: ABCDEF
|
||||||
secret_key: 1234567890abcdefghifjkl
|
secret_key: 1234567890abcdefghifjkl
|
||||||
|
enable_self_enrollment: false
|
||||||
```
|
```
|
||||||
|
|
||||||
The secret key is shown as an example, you also have the option to set it using an environment
|
The secret key is shown as an example, you also have the option to set it using an environment
|
||||||
|
@ -67,4 +68,16 @@ required: yes
|
||||||
|
|
||||||
The secret [Duo] key used to verify your application is valid.
|
The secret [Duo] key used to verify your application is valid.
|
||||||
|
|
||||||
|
### enable_self_enrollment
|
||||||
|
<div markdown="1">
|
||||||
|
type: boolean
|
||||||
|
{: .label .label-config .label-purple }
|
||||||
|
default: false
|
||||||
|
{: .label .label-config .label-blue }
|
||||||
|
required: no
|
||||||
|
{: .label .label-config .label-green }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Enables [Duo] device self-enrollment from within the Authelia portal.
|
||||||
|
|
||||||
[Duo]: https://duo.com/
|
[Duo]: https://duo.com/
|
||||||
|
|
|
@ -41,6 +41,7 @@ option.
|
||||||
|
|
||||||
You should now receive a notification on your mobile phone with all the details
|
You should now receive a notification on your mobile phone with all the details
|
||||||
about the authentication request.
|
about the authentication request.
|
||||||
|
In case you have multiple devices available, you will be asked to select your preferred device.
|
||||||
|
|
||||||
|
|
||||||
## Limitation
|
## Limitation
|
||||||
|
|
|
@ -367,7 +367,12 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
|
||||||
return provider.SchemaMigrate(ctx, true, storage.SchemaLatest)
|
return provider.SchemaMigrate(ctx, true, storage.SchemaLatest)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if !cmd.Flags().Changed("target") {
|
pre1, err := cmd.Flags().GetBool("pre1")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cmd.Flags().Changed("target") && !pre1 {
|
||||||
return errors.New("must set target")
|
return errors.New("must set target")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -375,11 +380,6 @@ func newStorageMigrationRunE(up bool) func(cmd *cobra.Command, args []string) (e
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
pre1, err := cmd.Flags().GetBool("pre1")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case pre1:
|
case pre1:
|
||||||
return provider.SchemaMigrate(ctx, false, -1)
|
return provider.SchemaMigrate(ctx, false, -1)
|
||||||
|
|
|
@ -111,6 +111,7 @@ duo_api:
|
||||||
integration_key: ABCDEF
|
integration_key: ABCDEF
|
||||||
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||||
secret_key: 1234567890abcdefghifjkl
|
secret_key: 1234567890abcdefghifjkl
|
||||||
|
enable_self_enrollment: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## NTP Configuration
|
## NTP Configuration
|
||||||
|
|
|
@ -3,6 +3,7 @@ package schema
|
||||||
// DuoAPIConfiguration represents the configuration related to Duo API.
|
// DuoAPIConfiguration represents the configuration related to Duo API.
|
||||||
type DuoAPIConfiguration struct {
|
type DuoAPIConfiguration struct {
|
||||||
Hostname string `koanf:"hostname"`
|
Hostname string `koanf:"hostname"`
|
||||||
|
EnableSelfEnrollment bool `koanf:"enable_self_enrollment"`
|
||||||
IntegrationKey string `koanf:"integration_key"`
|
IntegrationKey string `koanf:"integration_key"`
|
||||||
SecretKey string `koanf:"secret_key"`
|
SecretKey string `koanf:"secret_key"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -162,6 +162,7 @@ var ValidKeys = []string{
|
||||||
|
|
||||||
// DUO API Keys.
|
// DUO API Keys.
|
||||||
"duo_api.hostname",
|
"duo_api.hostname",
|
||||||
|
"duo_api.enable_self_enrollment",
|
||||||
"duo_api.secret_key",
|
"duo_api.secret_key",
|
||||||
"duo_api.integration_key",
|
"duo_api.integration_key",
|
||||||
|
|
||||||
|
|
|
@ -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.
|
// Call call to the DuoAPI.
|
||||||
func (d *APIImpl) Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error) {
|
func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error) {
|
||||||
var response Response
|
var response Response
|
||||||
|
|
||||||
_, responseBytes, err := d.DuoApi.SignedCall("POST", "/auth/v2/auth", values)
|
_, responseBytes, err := d.DuoApi.SignedCall(method, path, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Logger.Tracef("Duo Push Auth Response Raw Data for %s from IP %s: %s", ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
|
ctx.Logger.Tracef("Duo endpoint: %s response raw data for %s from IP %s: %s", path, ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes))
|
||||||
|
|
||||||
err = json.Unmarshal(responseBytes, &response)
|
err = json.Unmarshal(responseBytes, &response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.Stat == "FAIL" {
|
||||||
|
ctx.Logger.Warnf(
|
||||||
|
"Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
|
||||||
|
ctx.GetSession().Username, ctx.RemoteIP().String(),
|
||||||
|
response.Message, response.MessageDetail, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
return &response, nil
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreAuthCall call to the DuoAPI.
|
||||||
|
func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error) {
|
||||||
|
var preAuthResponse PreAuthResponse
|
||||||
|
|
||||||
|
response, err := d.Call(ctx, values, "POST", "/auth/v2/preauth")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(response.Response, &preAuthResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &preAuthResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCall call to the DuoAPI.
|
||||||
|
func (d *APIImpl) AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error) {
|
||||||
|
var authResponse AuthResponse
|
||||||
|
|
||||||
|
response, err := d.Call(ctx, values, "POST", "/auth/v2/auth")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(response.Response, &authResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &authResponse, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package duo
|
package duo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
duoapi "github.com/duosecurity/duo_api_golang"
|
duoapi "github.com/duosecurity/duo_api_golang"
|
||||||
|
@ -10,7 +11,9 @@ import (
|
||||||
|
|
||||||
// API interface wrapping duo api library for testing purpose.
|
// API interface wrapping duo api library for testing purpose.
|
||||||
type API interface {
|
type API interface {
|
||||||
Call(values url.Values, ctx *middlewares.AutheliaCtx) (*Response, error)
|
Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error)
|
||||||
|
PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error)
|
||||||
|
AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIImpl implementation of DuoAPI interface.
|
// APIImpl implementation of DuoAPI interface.
|
||||||
|
@ -18,15 +21,38 @@ type APIImpl struct {
|
||||||
*duoapi.DuoApi
|
*duoapi.DuoApi
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response response coming from Duo API.
|
// Device holds all necessary info for frontend.
|
||||||
|
type 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 {
|
type Response struct {
|
||||||
Response struct {
|
Response json.RawMessage `json:"response"`
|
||||||
Result string `json:"result"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
StatusMessage string `json:"status_msg"`
|
|
||||||
} `json:"response"`
|
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
MessageDetail string `json:"message_detail"`
|
MessageDetail string `json:"message_detail"`
|
||||||
Stat string `json:"stat"`
|
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 (
|
const (
|
||||||
testInactivity = "10"
|
testInactivity = "10"
|
||||||
testRedirectionURL = "http://redirection.local"
|
testRedirectionURL = "http://redirection.local"
|
||||||
testResultAllow = "allow"
|
|
||||||
testUsername = "john"
|
testUsername = "john"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -69,6 +68,14 @@ const (
|
||||||
loginDelayMaximumRandomDelayMilliseconds = int64(85)
|
loginDelayMaximumRandomDelayMilliseconds = int64(85)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Duo constants.
|
||||||
|
const (
|
||||||
|
allow = "allow"
|
||||||
|
deny = "deny"
|
||||||
|
enroll = "enroll"
|
||||||
|
auth = "auth"
|
||||||
|
)
|
||||||
|
|
||||||
// OIDC constants.
|
// OIDC constants.
|
||||||
const (
|
const (
|
||||||
pathOpenIDConnectWellKnown = "/.well-known/openid-configuration"
|
pathOpenIDConnectWellKnown = "/.well-known/openid-configuration"
|
||||||
|
|
|
@ -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/duo"
|
||||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||||
|
"github.com/authelia/authelia/v4/internal/models"
|
||||||
"github.com/authelia/authelia/v4/internal/regulation"
|
"github.com/authelia/authelia/v4/internal/regulation"
|
||||||
|
"github.com/authelia/authelia/v4/internal/session"
|
||||||
|
"github.com/authelia/authelia/v4/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecondFactorDuoPost handler for sending a push notification via duo api.
|
// SecondFactorDuoPost handler for sending a push notification via duo api.
|
||||||
func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
||||||
return func(ctx *middlewares.AutheliaCtx) {
|
return func(ctx *middlewares.AutheliaCtx) {
|
||||||
var requestBody signDuoRequestBody
|
var (
|
||||||
|
requestBody signDuoRequestBody
|
||||||
|
device, method string
|
||||||
|
)
|
||||||
|
|
||||||
if err := ctx.ParseBody(&requestBody); err != nil {
|
if err := ctx.ParseBody(&requestBody); err != nil {
|
||||||
ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err)
|
ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDUO, err)
|
||||||
|
@ -25,43 +31,49 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
||||||
userSession := ctx.GetSession()
|
userSession := ctx.GetSession()
|
||||||
remoteIP := ctx.RemoteIP().String()
|
remoteIP := ctx.RemoteIP().String()
|
||||||
|
|
||||||
ctx.Logger.Debugf("Starting Duo Push Auth Attempt for user '%s' with IP '%s'", userSession.Username, remoteIP)
|
duoDevice, err := ctx.Providers.StorageProvider.LoadPreferredDuoDevice(ctx, userSession.Username)
|
||||||
|
if err != nil {
|
||||||
values := url.Values{}
|
ctx.Logger.Debugf("Error identifying preferred device for user %s: %s", userSession.Username, err)
|
||||||
|
ctx.Logger.Debugf("Starting Duo PreAuth for initial device selection of user: %s", userSession.Username)
|
||||||
values.Set("username", userSession.Username)
|
device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, requestBody.TargetURL)
|
||||||
values.Set("ipaddr", remoteIP)
|
} else {
|
||||||
values.Set("factor", "push")
|
ctx.Logger.Debugf("Starting Duo PreAuth to check preferred device of user: %s", userSession.Username)
|
||||||
values.Set("device", "auto")
|
device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, requestBody.TargetURL)
|
||||||
|
|
||||||
if requestBody.TargetURL != "" {
|
|
||||||
values.Set("pushinfo", fmt.Sprintf("target%%20url=%s", requestBody.TargetURL))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
duoResponse, err := duoAPI.Call(values, ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorf("Failed to perform DUO call for user '%s': %+v", userSession.Username, err)
|
ctx.Error(err, messageMFAValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if device == "" || method == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Logger.Debugf("Starting Duo Auth attempt for %s with device %s and method %s from IP %s", userSession.Username, device, method, remoteIP)
|
||||||
|
|
||||||
|
values, err := SetValues(userSession, device, method, remoteIP, requestBody.TargetURL, requestBody.Passcode)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Errorf("Failed to set values for Duo Auth Call for user '%s': %+v", userSession.Username, err)
|
||||||
|
|
||||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if duoResponse.Stat == "FAIL" {
|
authResponse, err := duoAPI.AuthCall(ctx, values)
|
||||||
if duoResponse.Code == 40002 {
|
if err != nil {
|
||||||
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d. "+
|
ctx.Logger.Errorf("Failed to perform Duo Auth Call for user '%s': %+v", userSession.Username, err)
|
||||||
"This error often occurs if you've not setup the username in the Admin Dashboard.",
|
|
||||||
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
|
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||||
} else {
|
|
||||||
ctx.Logger.Warnf("Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.",
|
return
|
||||||
userSession.Username, remoteIP, duoResponse.Message, duoResponse.MessageDetail, duoResponse.Code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if duoResponse.Response.Result != testResultAllow {
|
if authResponse.Result != allow {
|
||||||
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO,
|
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeDUO,
|
||||||
fmt.Errorf("result: %s, code: %d, message: %s (%s)", duoResponse.Response.Result, duoResponse.Code,
|
fmt.Errorf("duo auth result: %s, status: %s, message: %s", authResponse.Result, authResponse.Status,
|
||||||
duoResponse.Message, duoResponse.MessageDetail))
|
authResponse.StatusMessage))
|
||||||
|
|
||||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||||
|
|
||||||
|
@ -73,7 +85,169 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx); err != nil {
|
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)
|
ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDUO, userSession.Username, err)
|
||||||
|
|
||||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||||
|
@ -95,7 +269,39 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
||||||
if userSession.OIDCWorkflowSession != nil {
|
if userSession.OIDCWorkflowSession != nil {
|
||||||
handleOIDCWorkflowResponse(ctx)
|
handleOIDCWorkflowResponse(ctx)
|
||||||
} else {
|
} else {
|
||||||
Handle2FAResponse(ctx, requestBody.TargetURL)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
@ -20,7 +21,6 @@ import (
|
||||||
|
|
||||||
type SecondFactorDuoPostSuite struct {
|
type SecondFactorDuoPostSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
|
|
||||||
mock *mocks.MockAutheliaCtx
|
mock *mocks.MockAutheliaCtx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,18 +36,58 @@ func (s *SecondFactorDuoPostSuite) TearDownTest() {
|
||||||
s.mock.Close()
|
s.mock.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
|
func (s *SecondFactorDuoPostSuite) TestShouldEnroll() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(nil, errors.New("no Duo device and method saved"))
|
||||||
|
|
||||||
|
var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
|
||||||
|
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("username", "john")
|
values.Set("username", "john")
|
||||||
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
|
||||||
values.Set("factor", "push")
|
|
||||||
values.Set("device", "auto")
|
|
||||||
values.Set("pushinfo", "target%20url=https://target.example.com")
|
|
||||||
|
|
||||||
response := duo.Response{}
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
response.Response.Result = testResultAllow
|
preAuthResponse.Result = enroll
|
||||||
|
preAuthResponse.EnrollPortalURL = enrollURL
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200OK(s.T(), DuoSignResponse{
|
||||||
|
Result: enroll,
|
||||||
|
EnrollURL: enrollURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldAutoSelect() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().LoadPreferredDuoDevice(s.mock.Ctx, "john").Return(nil, errors.New("no Duo device and method saved"))
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
{Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -58,29 +98,313 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndAllowAccess() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
|
values = url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
||||||
|
values.Set("factor", "push")
|
||||||
|
values.Set("device", "12345ABCDEFGHIJ67890")
|
||||||
|
values.Set("pushinfo", "target%20url=https://target.example.com")
|
||||||
|
|
||||||
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
|
authResponse := duo.AuthResponse{}
|
||||||
|
authResponse.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldDenyAutoSelect() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(nil, errors.New("no Duo device and method saved"))
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = deny
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
values = url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
||||||
|
values.Set("factor", "push")
|
||||||
|
values.Set("device", "12345ABCDEFGHIJ67890")
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 200)
|
s.mock.Assert200OK(s.T(), DuoSignResponse{
|
||||||
|
Result: deny,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldFailAutoSelect() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(nil, errors.New("no Duo device and method saved"))
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndEnroll() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
|
||||||
|
|
||||||
|
var enrollURL = "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890"
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = enroll
|
||||||
|
preAuthResponse.EnrollPortalURL = enrollURL
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200OK(s.T(), DuoSignResponse{
|
||||||
|
Result: enroll,
|
||||||
|
EnrollURL: enrollURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndCallPreauthAPIWithInvalidDevicesAndEnroll() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"sms"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200OK(s.T(), DuoSignResponse{
|
||||||
|
Result: enroll,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldUseOldDeviceAndSelect() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "NOTEXISTENT", Method: "push"}, nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
|
||||||
|
{Capabilities: []string{"auto", "sms", "mobile_otp"}, Number: "+123456789****", Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiDevices = []DuoDevice{
|
||||||
|
{Capabilities: []string{"push"}, Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
{Capabilities: []string{"push"}, Device: "1234567890ABCDEFGHIJ", DisplayName: "Test Device 2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
s.mock.Assert200OK(s.T(), DuoDevicesResponse{Result: auth, Devices: apiDevices})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldUseInvalidMethodAndAutoSelect() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "invalidmethod"}, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
|
||||||
|
Username: "john",
|
||||||
|
Successful: true,
|
||||||
|
Banned: false,
|
||||||
|
Time: s.mock.Clock.Now(),
|
||||||
|
Type: regulation.AuthTypeDUO,
|
||||||
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
SavePreferredDuoDevice(s.mock.Ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
values = url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
||||||
|
values.Set("factor", "push")
|
||||||
|
values.Set("device", "12345ABCDEFGHIJ67890")
|
||||||
|
values.Set("pushinfo", "target%20url=https://target.example.com")
|
||||||
|
|
||||||
|
authResponse := duo.AuthResponse{}
|
||||||
|
authResponse.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndAllowAccess() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{TargetURL: "https://target.example.com"})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndDenyAccess() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = deny
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
values = url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
||||||
|
values.Set("factor", "push")
|
||||||
|
values.Set("device", "12345ABCDEFGHIJ67890")
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndFail() {
|
||||||
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert401KO(s.T(), "Authentication failed, please retry later.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
|
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
values := url.Values{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
values.Set("username", "john")
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
values.Set("factor", "push")
|
|
||||||
values.Set("device", "auto")
|
|
||||||
values.Set("pushinfo", "target%20url=https://target.example.com")
|
|
||||||
|
|
||||||
response := duo.Response{}
|
|
||||||
response.Response.Result = "deny"
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -91,30 +415,67 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(&response, nil)
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
values = url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
||||||
|
values.Set("factor", "push")
|
||||||
|
values.Set("device", "12345ABCDEFGHIJ67890")
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = deny
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil)
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
assert.Equal(s.T(), s.mock.Ctx.Response.StatusCode(), 401)
|
assert.Equal(s.T(), 401, s.mock.Ctx.Response.StatusCode())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
|
func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("username", "john")
|
values.Set("username", "john")
|
||||||
values.Set("ipaddr", s.mock.Ctx.RemoteIP().String())
|
|
||||||
values.Set("factor", "push")
|
|
||||||
values.Set("device", "auto")
|
|
||||||
values.Set("pushinfo", "target%20url=https://target.example.com")
|
|
||||||
|
|
||||||
duoMock.EXPECT().Call(gomock.Eq(values), s.mock.Ctx).Return(nil, fmt.Errorf("connnection error"))
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
s.mock.Ctx.Request.SetBodyString("{\"targetURL\": \"https://target.example.com\"}")
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error"))
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||||
|
|
||||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||||
|
|
||||||
|
@ -124,10 +485,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
|
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
response := duo.Response{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
response.Response.Result = testResultAllow
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -138,7 +498,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
|
||||||
|
|
||||||
s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL
|
s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL
|
||||||
|
|
||||||
|
@ -155,10 +534,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
|
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
response := duo.Response{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
response.Response.Result = testResultAllow
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -169,7 +547,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
|
@ -182,10 +579,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
|
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
response := duo.Response{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
response.Response.Result = testResultAllow
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -196,7 +592,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
||||||
TargetURL: "https://mydomain.local",
|
TargetURL: "https://mydomain.local",
|
||||||
|
@ -213,10 +628,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
|
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
response := duo.Response{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
response.Response.Result = testResultAllow
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -227,7 +641,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
||||||
TargetURL: "http://mydomain.local",
|
TargetURL: "http://mydomain.local",
|
||||||
|
@ -242,10 +675,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||||
func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() {
|
func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessionFixation() {
|
||||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||||
|
|
||||||
response := duo.Response{}
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
response.Response.Result = testResultAllow
|
LoadPreferredDuoDevice(s.mock.Ctx, "john").
|
||||||
|
Return(&models.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil)
|
||||||
duoMock.EXPECT().Call(gomock.Any(), s.mock.Ctx).Return(&response, nil)
|
|
||||||
|
|
||||||
s.mock.StorageProviderMock.
|
s.mock.StorageProviderMock.
|
||||||
EXPECT().
|
EXPECT().
|
||||||
|
@ -256,7 +688,26 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi
|
||||||
Time: s.mock.Clock.Now(),
|
Time: s.mock.Clock.Now(),
|
||||||
Type: regulation.AuthTypeDUO,
|
Type: regulation.AuthTypeDUO,
|
||||||
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
||||||
}))
|
})).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
var duoDevices = []duo.Device{
|
||||||
|
{Capabilities: []string{"auto", "push", "sms", "mobile_otp"}, Number: " ", Device: "12345ABCDEFGHIJ67890", DisplayName: "Test Device 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("username", "john")
|
||||||
|
|
||||||
|
preAuthResponse := duo.PreAuthResponse{}
|
||||||
|
preAuthResponse.Result = auth
|
||||||
|
preAuthResponse.Devices = duoDevices
|
||||||
|
|
||||||
|
duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil)
|
||||||
|
|
||||||
|
response := duo.AuthResponse{}
|
||||||
|
response.Result = allow
|
||||||
|
|
||||||
|
duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
||||||
TargetURL: "http://mydomain.local",
|
TargetURL: "http://mydomain.local",
|
||||||
|
|
|
@ -11,6 +11,24 @@ type MethodList = []string
|
||||||
|
|
||||||
type authorizationMatching int
|
type authorizationMatching int
|
||||||
|
|
||||||
|
// UserInfo is the model of user info and second factor preferences.
|
||||||
|
type UserInfo struct {
|
||||||
|
// The users display name.
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
|
||||||
|
// The preferred 2FA method.
|
||||||
|
Method string `json:"method" valid:"required"`
|
||||||
|
|
||||||
|
// True if a security key has been registered.
|
||||||
|
HasU2F bool `json:"has_u2f" valid:"required"`
|
||||||
|
|
||||||
|
// True if a TOTP device has been registered.
|
||||||
|
HasTOTP bool `json:"has_totp" valid:"required"`
|
||||||
|
|
||||||
|
// True if a Duo device and method has been enrolled.
|
||||||
|
HasDuo bool `json:"has_duo" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
|
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
|
||||||
type signTOTPRequestBody struct {
|
type signTOTPRequestBody struct {
|
||||||
Token string `json:"token" valid:"required"`
|
Token string `json:"token" valid:"required"`
|
||||||
|
@ -25,6 +43,7 @@ type signU2FRequestBody struct {
|
||||||
|
|
||||||
type signDuoRequestBody struct {
|
type signDuoRequestBody struct {
|
||||||
TargetURL string `json:"targetURL"`
|
TargetURL string `json:"targetURL"`
|
||||||
|
Passcode string `json:"passcode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstFactorRequestBody represents the JSON body received by the endpoint.
|
// firstFactorRequestBody represents the JSON body received by the endpoint.
|
||||||
|
@ -60,6 +79,34 @@ type TOTPKeyResponse struct {
|
||||||
OTPAuthURL string `json:"otpauth_url"`
|
OTPAuthURL string `json:"otpauth_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DuoDeviceBody the selected Duo device and method.
|
||||||
|
type DuoDeviceBody struct {
|
||||||
|
Device string `json:"device" valid:"required"`
|
||||||
|
Method string `json:"method" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuoDevice represents Duo devices and methods.
|
||||||
|
type DuoDevice struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuoDevicesResponse represents all available user devices and methods as well as an optional enrollment url.
|
||||||
|
type DuoDevicesResponse struct {
|
||||||
|
Result string `json:"result" valid:"required"`
|
||||||
|
Devices []DuoDevice `json:"devices,omitempty"`
|
||||||
|
EnrollURL string `json:"enroll_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuoSignResponse represents a result of the preauth and or auth call with further optional info.
|
||||||
|
type DuoSignResponse struct {
|
||||||
|
Result string `json:"result" valid:"required"`
|
||||||
|
Devices []DuoDevice `json:"devices,omitempty"`
|
||||||
|
Redirect string `json:"redirect,omitempty"`
|
||||||
|
EnrollURL string `json:"enroll_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// StateResponse represents the response sent by the state endpoint.
|
// StateResponse represents the response sent by the state endpoint.
|
||||||
type StateResponse struct {
|
type StateResponse struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
gomock "github.com/golang/mock/gomock"
|
gomock "github.com/golang/mock/gomock"
|
||||||
|
|
||||||
duo "github.com/authelia/authelia/v4/internal/duo"
|
duo "github.com/authelia/authelia/v4/internal/duo"
|
||||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
middlewares "github.com/authelia/authelia/v4/internal/middlewares"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockAPI is a mock of API interface.
|
// MockAPI is a mock of API interface.
|
||||||
|
@ -37,17 +37,47 @@ func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
|
||||||
return m.recorder
|
return m.recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call mocks base method.
|
// AuthCall mocks base method.
|
||||||
func (m *MockAPI) Call(arg0 url.Values, arg1 *middlewares.AutheliaCtx) (*duo.Response, error) {
|
func (m *MockAPI) AuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.AuthResponse, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Call", arg0, arg1)
|
ret := m.ctrl.Call(m, "AuthCall", arg0, arg1)
|
||||||
|
ret0, _ := ret[0].(*duo.AuthResponse)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCall indicates an expected call of AuthCall.
|
||||||
|
func (mr *MockAPIMockRecorder) AuthCall(arg0, arg1 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCall", reflect.TypeOf((*MockAPI)(nil).AuthCall), arg0, arg1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call mocks base method.
|
||||||
|
func (m *MockAPI) Call(arg0 *middlewares.AutheliaCtx, arg1 url.Values, arg2, arg3 string) (*duo.Response, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "Call", arg0, arg1, arg2, arg3)
|
||||||
ret0, _ := ret[0].(*duo.Response)
|
ret0, _ := ret[0].(*duo.Response)
|
||||||
ret1, _ := ret[1].(error)
|
ret1, _ := ret[1].(error)
|
||||||
return ret0, ret1
|
return ret0, ret1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call indicates an expected call of Call.
|
// Call indicates an expected call of Call.
|
||||||
func (mr *MockAPIMockRecorder) Call(arg0, arg1 interface{}) *gomock.Call {
|
func (mr *MockAPIMockRecorder) Call(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||||
mr.mock.ctrl.T.Helper()
|
mr.mock.ctrl.T.Helper()
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1, arg2, arg3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreAuthCall mocks base method.
|
||||||
|
func (m *MockAPI) PreAuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.PreAuthResponse, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "PreAuthCall", arg0, arg1)
|
||||||
|
ret0, _ := ret[0].(*duo.PreAuthResponse)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreAuthCall indicates an expected call of PreAuthCall.
|
||||||
|
func (mr *MockAPIMockRecorder) PreAuthCall(arg0, arg1 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreAuthCall", reflect.TypeOf((*MockAPI)(nil).PreAuthCall), arg0, arg1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
// The preferred 2FA method.
|
||||||
Method string `db:"second_factor_method" json:"method" valid:"required"`
|
Method string `db:"second_factor_method" json:"method" valid:"required"`
|
||||||
|
|
||||||
|
// True if a TOTP device has been registered.
|
||||||
|
HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"`
|
||||||
|
|
||||||
// True if a security key has been registered.
|
// True if a security key has been registered.
|
||||||
HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"`
|
HasU2F bool `db:"has_u2f" json:"has_u2f" valid:"required"`
|
||||||
|
|
||||||
// True if a TOTP device has been registered.
|
// True if a duo device has been configured as the preferred.
|
||||||
HasTOTP bool `db:"has_totp" json:"has_totp" valid:"required"`
|
HasDuo bool `db:"has_duo" json:"has_duo" valid:"required"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,11 @@ const (
|
||||||
|
|
||||||
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"}
|
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"}
|
||||||
|
|
||||||
const dev = "dev"
|
const (
|
||||||
|
dev = "dev"
|
||||||
|
f = "false"
|
||||||
|
t = "true"
|
||||||
|
)
|
||||||
|
|
||||||
const healthCheckEnv = `# Written by Authelia Process
|
const healthCheckEnv = `# Written by Authelia Process
|
||||||
X_AUTHELIA_HEALTHCHECK=1
|
X_AUTHELIA_HEALTHCHECK=1
|
||||||
|
|
|
@ -30,14 +30,19 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
|
||||||
rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0")
|
rememberMe := strconv.FormatBool(configuration.Session.RememberMeDuration != "0")
|
||||||
resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword)
|
resetPassword := strconv.FormatBool(!configuration.AuthenticationBackend.DisableResetPassword)
|
||||||
|
|
||||||
|
duoSelfEnrollment := f
|
||||||
|
if configuration.DuoAPI != nil {
|
||||||
|
duoSelfEnrollment = strconv.FormatBool(configuration.DuoAPI.EnableSelfEnrollment)
|
||||||
|
}
|
||||||
|
|
||||||
embeddedPath, _ := fs.Sub(assets, "public_html")
|
embeddedPath, _ := fs.Sub(assets, "public_html")
|
||||||
embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath)))
|
embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath)))
|
||||||
|
|
||||||
https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != ""
|
https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != ""
|
||||||
|
|
||||||
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
||||||
serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
||||||
serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
|
||||||
|
|
||||||
r := router.New()
|
r := router.New()
|
||||||
r.GET("/", serveIndexHandler)
|
r.GET("/", serveIndexHandler)
|
||||||
|
@ -125,8 +130,14 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
|
||||||
configuration.DuoAPI.Hostname, ""))
|
configuration.DuoAPI.Hostname, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.GET("/api/secondfactor/duo_devices", autheliaMiddleware(
|
||||||
|
middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicesGet(duoAPI))))
|
||||||
|
|
||||||
r.POST("/api/secondfactor/duo", autheliaMiddleware(
|
r.POST("/api/secondfactor/duo", autheliaMiddleware(
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI))))
|
middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI))))
|
||||||
|
|
||||||
|
r.POST("/api/secondfactor/duo_device", autheliaMiddleware(
|
||||||
|
middlewares.RequireFirstFactor(handlers.SecondFactorDuoDevicePost)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if configuration.Server.EnablePprof {
|
if configuration.Server.EnablePprof {
|
||||||
|
|
|
@ -16,15 +16,15 @@ import (
|
||||||
// ServeTemplatedFile serves a templated version of a specified file,
|
// ServeTemplatedFile serves a templated version of a specified file,
|
||||||
// this is utilised to pass information between the backend and frontend
|
// this is utilised to pass information between the backend and frontend
|
||||||
// and generate a nonce to support a restrictive CSP while using material-ui.
|
// and generate a nonce to support a restrictive CSP while using material-ui.
|
||||||
func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
|
func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
|
||||||
logger := logging.Logger()
|
logger := logging.Logger()
|
||||||
|
|
||||||
f, err := assets.Open(publicDir + file)
|
a, err := assets.Open(publicDir + file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Unable to open %s: %s", file, err)
|
logger.Fatalf("Unable to open %s: %s", file, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := ioutil.ReadAll(f)
|
b, err := ioutil.ReadAll(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Unable to read %s: %s", file, err)
|
logger.Fatalf("Unable to read %s: %s", file, err)
|
||||||
}
|
}
|
||||||
|
@ -40,11 +40,11 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
|
||||||
base = baseURL.(string)
|
base = baseURL.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
logoOverride := "false"
|
logoOverride := f
|
||||||
|
|
||||||
if assetPath != "" {
|
if assetPath != "" {
|
||||||
if _, err := os.Stat(assetPath + logoFile); err == nil {
|
if _, err := os.Stat(assetPath + logoFile); err == nil {
|
||||||
logoOverride = "true"
|
logoOverride = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, s
|
||||||
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
|
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
|
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, DuoSelfEnrollment, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, DuoSelfEnrollment: duoSelfEnrollment, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Error("an error occurred", 503)
|
ctx.Error("an error occurred", 503)
|
||||||
logger.Errorf("Unable to execute template: %v", err)
|
logger.Errorf("Unable to execute template: %v", err)
|
||||||
|
|
|
@ -9,7 +9,7 @@ const (
|
||||||
tableIdentityVerification = "identity_verification"
|
tableIdentityVerification = "identity_verification"
|
||||||
tableTOTPConfigurations = "totp_configurations"
|
tableTOTPConfigurations = "totp_configurations"
|
||||||
tableU2FDevices = "u2f_devices"
|
tableU2FDevices = "u2f_devices"
|
||||||
tableDUODevices = "duo_devices"
|
tableDuoDevices = "duo_devices"
|
||||||
tableAuthenticationLogs = "authentication_logs"
|
tableAuthenticationLogs = "authentication_logs"
|
||||||
tableMigrations = "migrations"
|
tableMigrations = "migrations"
|
||||||
tableEncryption = "encryption"
|
tableEncryption = "encryption"
|
||||||
|
|
|
@ -5,15 +5,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
|
|
||||||
ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
|
|
||||||
|
|
||||||
// ErrNoAuthenticationLogs error thrown when no matching authentication logs hve been found in DB.
|
// ErrNoAuthenticationLogs error thrown when no matching authentication logs hve been found in DB.
|
||||||
ErrNoAuthenticationLogs = errors.New("no matching authentication logs found")
|
ErrNoAuthenticationLogs = errors.New("no matching authentication logs found")
|
||||||
|
|
||||||
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB.
|
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB.
|
||||||
ErrNoTOTPSecret = errors.New("no TOTP secret registered")
|
ErrNoTOTPSecret = errors.New("no TOTP secret registered")
|
||||||
|
|
||||||
|
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
|
||||||
|
ErrNoU2FDeviceHandle = errors.New("no U2F device handle found")
|
||||||
|
|
||||||
|
// ErrNoDuoDevice error thrown when no Duo device and method has been found in DB.
|
||||||
|
ErrNoDuoDevice = errors.New("no Duo device and method saved")
|
||||||
|
|
||||||
// ErrNoAvailableMigrations is returned when no available migrations can be found.
|
// ErrNoAvailableMigrations is returned when no available migrations can be found.
|
||||||
ErrNoAvailableMigrations = errors.New("no available migrations")
|
ErrNoAvailableMigrations = errors.New("no available migrations")
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ DROP TABLE IF EXISTS authentication_logs;
|
||||||
DROP TABLE IF EXISTS identity_verification;
|
DROP TABLE IF EXISTS identity_verification;
|
||||||
DROP TABLE IF EXISTS totp_configurations;
|
DROP TABLE IF EXISTS totp_configurations;
|
||||||
DROP TABLE IF EXISTS u2f_devices;
|
DROP TABLE IF EXISTS u2f_devices;
|
||||||
|
DROP TABLE IF EXISTS duo_devices;
|
||||||
DROP TABLE IF EXISTS user_preferences;
|
DROP TABLE IF EXISTS user_preferences;
|
||||||
DROP TABLE IF EXISTS migrations;
|
DROP TABLE IF EXISTS migrations;
|
||||||
DROP TABLE IF EXISTS encryption;
|
DROP TABLE IF EXISTS encryption;
|
|
@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
|
||||||
UNIQUE KEY (username, description)
|
UNIQUE KEY (username, description)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS duo_devices (
|
||||||
|
id INTEGER AUTO_INCREMENT,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
device VARCHAR(32) NOT NULL,
|
||||||
|
method VARCHAR(16) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY (username)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
id INTEGER AUTO_INCREMENT,
|
id INTEGER AUTO_INCREMENT,
|
||||||
username VARCHAR(100) NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
|
|
|
@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
|
||||||
UNIQUE (username, description)
|
UNIQUE (username, description)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS duo_devices (
|
||||||
|
id SERIAL,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
device VARCHAR(32) NOT NULL,
|
||||||
|
method VARCHAR(16) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE (username)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
id SERIAL,
|
id SERIAL,
|
||||||
username VARCHAR(100) NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
|
|
|
@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS u2f_devices (
|
||||||
UNIQUE (username, description)
|
UNIQUE (username, description)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS duo_devices (
|
||||||
|
id INTEGER,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
device VARCHAR(32) NOT NULL,
|
||||||
|
method VARCHAR(16) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE (username)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
id INTEGER,
|
id INTEGER,
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
|
|
@ -30,18 +30,22 @@ type Provider interface {
|
||||||
SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error)
|
SaveU2FDevice(ctx context.Context, device models.U2FDevice) (err error)
|
||||||
LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error)
|
LoadU2FDevice(ctx context.Context, username string) (device *models.U2FDevice, err error)
|
||||||
|
|
||||||
|
SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error)
|
||||||
|
DeletePreferredDuoDevice(ctx context.Context, username string) (err error)
|
||||||
|
LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error)
|
||||||
|
|
||||||
SchemaTables(ctx context.Context) (tables []string, err error)
|
SchemaTables(ctx context.Context) (tables []string, err error)
|
||||||
SchemaVersion(ctx context.Context) (version int, err error)
|
SchemaVersion(ctx context.Context) (version int, err error)
|
||||||
|
SchemaLatestVersion() (version int, err error)
|
||||||
|
|
||||||
SchemaMigrate(ctx context.Context, up bool, version int) (err error)
|
SchemaMigrate(ctx context.Context, up bool, version int) (err error)
|
||||||
SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, err error)
|
SchemaMigrationHistory(ctx context.Context) (migrations []models.Migration, err error)
|
||||||
|
SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
|
||||||
|
SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
|
||||||
|
|
||||||
SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error)
|
SchemaEncryptionChangeKey(ctx context.Context, encryptionKey string) (err error)
|
||||||
SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error)
|
SchemaEncryptionCheckKey(ctx context.Context, verbose bool) (err error)
|
||||||
|
|
||||||
SchemaLatestVersion() (version int, err error)
|
|
||||||
SchemaMigrationsUp(ctx context.Context, version int) (migrations []SchemaMigration, err error)
|
|
||||||
SchemaMigrationsDown(ctx context.Context, version int) (migrations []SchemaMigration, err error)
|
|
||||||
|
|
||||||
Close() (err error)
|
Close() (err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
// Code generated by MockGen. DO NOT EDIT.
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
// Source: ./internal/storage/provider.go
|
// Source: github.com/authelia/authelia/v4/internal/storage (interfaces: Provider)
|
||||||
|
|
||||||
|
// Package storage is a generated GoMock package.
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
context "context"
|
||||||
"reflect"
|
reflect "reflect"
|
||||||
"time"
|
time "time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
gomock "github.com/golang/mock/gomock"
|
||||||
|
|
||||||
"github.com/authelia/authelia/v4/internal/models"
|
models "github.com/authelia/authelia/v4/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockProvider is a mock of Provider interface.
|
// MockProvider is a mock of Provider interface.
|
||||||
|
@ -64,6 +65,20 @@ func (mr *MockProviderMockRecorder) Close() *gomock.Call {
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProvider)(nil).Close))
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockProvider)(nil).Close))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePreferredDuoDevice mocks base method.
|
||||||
|
func (m *MockProvider) DeletePreferredDuoDevice(arg0 context.Context, arg1 string) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "DeletePreferredDuoDevice", arg0, arg1)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePreferredDuoDevice indicates an expected call of DeletePreferredDuoDevice.
|
||||||
|
func (mr *MockProviderMockRecorder) DeletePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).DeletePreferredDuoDevice), arg0, arg1)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteTOTPConfiguration mocks base method.
|
// DeleteTOTPConfiguration mocks base method.
|
||||||
func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error {
|
func (m *MockProvider) DeleteTOTPConfiguration(arg0 context.Context, arg1 string) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
@ -123,6 +138,21 @@ func (mr *MockProviderMockRecorder) LoadPreferred2FAMethod(arg0, arg1 interface{
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).LoadPreferred2FAMethod), arg0, arg1)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).LoadPreferred2FAMethod), arg0, arg1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadPreferredDuoDevice mocks base method.
|
||||||
|
func (m *MockProvider) LoadPreferredDuoDevice(arg0 context.Context, arg1 string) (*models.DuoDevice, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "LoadPreferredDuoDevice", arg0, arg1)
|
||||||
|
ret0, _ := ret[0].(*models.DuoDevice)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPreferredDuoDevice indicates an expected call of LoadPreferredDuoDevice.
|
||||||
|
func (mr *MockProviderMockRecorder) LoadPreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).LoadPreferredDuoDevice), arg0, arg1)
|
||||||
|
}
|
||||||
|
|
||||||
// LoadTOTPConfiguration mocks base method.
|
// LoadTOTPConfiguration mocks base method.
|
||||||
func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) {
|
func (m *MockProvider) LoadTOTPConfiguration(arg0 context.Context, arg1 string) (*models.TOTPConfiguration, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
@ -225,6 +255,20 @@ func (mr *MockProviderMockRecorder) SavePreferred2FAMethod(arg0, arg1, arg2 inte
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).SavePreferred2FAMethod), arg0, arg1, arg2)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferred2FAMethod", reflect.TypeOf((*MockProvider)(nil).SavePreferred2FAMethod), arg0, arg1, arg2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SavePreferredDuoDevice mocks base method.
|
||||||
|
func (m *MockProvider) SavePreferredDuoDevice(arg0 context.Context, arg1 models.DuoDevice) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "SavePreferredDuoDevice", arg0, arg1)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePreferredDuoDevice indicates an expected call of SavePreferredDuoDevice.
|
||||||
|
func (mr *MockProviderMockRecorder) SavePreferredDuoDevice(arg0, arg1 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePreferredDuoDevice", reflect.TypeOf((*MockProvider)(nil).SavePreferredDuoDevice), arg0, arg1)
|
||||||
|
}
|
||||||
|
|
||||||
// SaveTOTPConfiguration mocks base method.
|
// SaveTOTPConfiguration mocks base method.
|
||||||
func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error {
|
func (m *MockProvider) SaveTOTPConfiguration(arg0 context.Context, arg1 models.TOTPConfiguration) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|
|
@ -46,9 +46,13 @@ func NewSQLProvider(name, driverName, dataSourceName, encryptionKey string) (pro
|
||||||
sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices),
|
sqlUpsertU2FDevice: fmt.Sprintf(queryFmtUpsertU2FDevice, tableU2FDevices),
|
||||||
sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices),
|
sqlSelectU2FDevice: fmt.Sprintf(queryFmtSelectU2FDevice, tableU2FDevices),
|
||||||
|
|
||||||
|
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
|
||||||
|
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
|
||||||
|
sqlSelectDuoDevice: fmt.Sprintf(queryFmtSelectDuoDevice, tableDuoDevices),
|
||||||
|
|
||||||
sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences),
|
sqlUpsertPreferred2FAMethod: fmt.Sprintf(queryFmtUpsertPreferred2FAMethod, tableUserPreferences),
|
||||||
sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, tableUserPreferences),
|
sqlSelectPreferred2FAMethod: fmt.Sprintf(queryFmtSelectPreferred2FAMethod, tableUserPreferences),
|
||||||
sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableUserPreferences),
|
sqlSelectUserInfo: fmt.Sprintf(queryFmtSelectUserInfo, tableTOTPConfigurations, tableU2FDevices, tableDuoDevices, tableUserPreferences),
|
||||||
|
|
||||||
sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations),
|
sqlInsertMigration: fmt.Sprintf(queryFmtInsertMigration, tableMigrations),
|
||||||
sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations),
|
sqlSelectMigrations: fmt.Sprintf(queryFmtSelectMigrations, tableMigrations),
|
||||||
|
@ -99,6 +103,11 @@ type SQLProvider struct {
|
||||||
sqlUpsertU2FDevice string
|
sqlUpsertU2FDevice string
|
||||||
sqlSelectU2FDevice string
|
sqlSelectU2FDevice string
|
||||||
|
|
||||||
|
// Table: duo_devices
|
||||||
|
sqlUpsertDuoDevice string
|
||||||
|
sqlDeleteDuoDevice string
|
||||||
|
sqlSelectDuoDevice string
|
||||||
|
|
||||||
// Table: user_preferences.
|
// Table: user_preferences.
|
||||||
sqlUpsertPreferred2FAMethod string
|
sqlUpsertPreferred2FAMethod string
|
||||||
sqlSelectPreferred2FAMethod string
|
sqlSelectPreferred2FAMethod string
|
||||||
|
@ -186,7 +195,7 @@ func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username strin
|
||||||
|
|
||||||
// LoadUserInfo loads the models.UserInfo from the database.
|
// LoadUserInfo loads the models.UserInfo from the database.
|
||||||
func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info models.UserInfo, err error) {
|
func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info models.UserInfo, err error) {
|
||||||
err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username)
|
err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
|
@ -196,7 +205,7 @@ func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info m
|
||||||
return models.UserInfo{}, fmt.Errorf("error upserting preferred two factor method while selecting user info for user '%s': %w", username, err)
|
return models.UserInfo{}, fmt.Errorf("error upserting preferred two factor method while selecting user info for user '%s': %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username); err != nil {
|
if err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username); err != nil {
|
||||||
return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
|
return models.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -355,6 +364,33 @@ func (p *SQLProvider) LoadU2FDevice(ctx context.Context, username string) (devic
|
||||||
return device, nil
|
return device, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SavePreferredDuoDevice saves a Duo device.
|
||||||
|
func (p *SQLProvider) SavePreferredDuoDevice(ctx context.Context, device models.DuoDevice) (err error) {
|
||||||
|
_, err = p.db.ExecContext(ctx, p.sqlUpsertDuoDevice, device.Username, device.Device, device.Method)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePreferredDuoDevice deletes a Duo device of a given user.
|
||||||
|
func (p *SQLProvider) DeletePreferredDuoDevice(ctx context.Context, username string) (err error) {
|
||||||
|
_, err = p.db.ExecContext(ctx, p.sqlDeleteDuoDevice, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPreferredDuoDevice loads a Duo device of a given user.
|
||||||
|
func (p *SQLProvider) LoadPreferredDuoDevice(ctx context.Context, username string) (device *models.DuoDevice, err error) {
|
||||||
|
device = &models.DuoDevice{}
|
||||||
|
|
||||||
|
if err := p.db.QueryRowxContext(ctx, p.sqlSelectDuoDevice, username).StructScan(device); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNoDuoDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return device, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AppendAuthenticationLog append a mark to the authentication log.
|
// AppendAuthenticationLog append a mark to the authentication log.
|
||||||
func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) {
|
func (p *SQLProvider) AppendAuthenticationLog(ctx context.Context, attempt models.AuthenticationAttempt) (err error) {
|
||||||
if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt,
|
if _, err = p.db.ExecContext(ctx, p.sqlInsertAuthenticationAttempt,
|
||||||
|
|
|
@ -35,7 +35,7 @@ const (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
queryFmtSelectUserInfo = `
|
queryFmtSelectUserInfo = `
|
||||||
SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_u2f
|
SELECT second_factor_method, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_totp, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_u2f, (SELECT EXISTS (SELECT id FROM %s WHERE username = ?)) AS has_duo
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE username = ?;`
|
WHERE username = ?;`
|
||||||
|
|
||||||
|
@ -129,6 +129,23 @@ const (
|
||||||
DO UPDATE SET key_handle=$2, public_key=$3;`
|
DO UPDATE SET key_handle=$2, public_key=$3;`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
queryFmtUpsertDuoDevice = `
|
||||||
|
REPLACE INTO %s (username, device, method)
|
||||||
|
VALUES (?, ?, ?);`
|
||||||
|
|
||||||
|
queryFmtDeleteDuoDevice = `
|
||||||
|
DELETE
|
||||||
|
FROM %s
|
||||||
|
WHERE username = ?;`
|
||||||
|
|
||||||
|
queryFmtSelectDuoDevice = `
|
||||||
|
SELECT id, username, device, method
|
||||||
|
FROM %s
|
||||||
|
WHERE username = ?
|
||||||
|
ORDER BY id;`
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
queryFmtInsertAuthenticationLogEntry = `
|
queryFmtInsertAuthenticationLogEntry = `
|
||||||
INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method)
|
INSERT INTO %s (time, successful, banned, username, auth_type, remote_ip, request_uri, request_method)
|
||||||
|
|
|
@ -118,13 +118,14 @@ func (p *SQLProvider) SchemaMigrate(ctx context.Context, up bool, version int) (
|
||||||
return p.schemaMigrate(ctx, currentVersion, version)
|
return p.schemaMigrate(ctx, currentVersion, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint: gocyclo
|
||||||
func (p *SQLProvider) schemaMigrate(ctx context.Context, prior, target int) (err error) {
|
func (p *SQLProvider) schemaMigrate(ctx context.Context, prior, target int) (err error) {
|
||||||
migrations, err := loadMigrations(p.name, prior, target)
|
migrations, err := loadMigrations(p.name, prior, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(migrations) == 0 {
|
if len(migrations) == 0 && (prior != 1 || target != -1) {
|
||||||
return ErrNoMigrationsFound
|
return ErrNoMigrationsFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -277,7 +278,7 @@ func (p *SQLProvider) SchemaMigrationsDown(ctx context.Context, version int) (mi
|
||||||
return loadMigrations(p.name, current, version)
|
return loadMigrations(p.name, current, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchemaLatestVersion returns the latest version available for migration..
|
// SchemaLatestVersion returns the latest version available for migration.
|
||||||
func (p *SQLProvider) SchemaLatestVersion() (version int, err error) {
|
func (p *SQLProvider) SchemaLatestVersion() (version int, err error) {
|
||||||
return latestMigrationVersion(p.name)
|
return latestMigrationVersion(p.name)
|
||||||
}
|
}
|
||||||
|
|
|
@ -291,7 +291,7 @@ func (p *SQLProvider) schemaMigrate1ToPre1(ctx context.Context) (err error) {
|
||||||
tableTOTPConfigurations,
|
tableTOTPConfigurations,
|
||||||
tableIdentityVerification,
|
tableIdentityVerification,
|
||||||
tableU2FDevices,
|
tableU2FDevices,
|
||||||
tableDUODevices,
|
tableDuoDevices,
|
||||||
tableUserPreferences,
|
tableUserPreferences,
|
||||||
tableAuthenticationLogs,
|
tableAuthenticationLogs,
|
||||||
tableEncryption,
|
tableEncryption,
|
||||||
|
|
|
@ -30,7 +30,7 @@ session:
|
||||||
storage:
|
storage:
|
||||||
encryption_key: a_not_so_secure_encryption_key
|
encryption_key: a_not_so_secure_encryption_key
|
||||||
local:
|
local:
|
||||||
path: /config/db.sqlite
|
path: /tmp/db.sqlite3
|
||||||
|
|
||||||
# TOTP Issuer Name
|
# TOTP Issuer Name
|
||||||
#
|
#
|
||||||
|
@ -44,6 +44,7 @@ duo_api:
|
||||||
hostname: duo.example.com
|
hostname: duo.example.com
|
||||||
integration_key: ABCDEFGHIJKL
|
integration_key: ABCDEFGHIJKL
|
||||||
secret_key: abcdefghijklmnopqrstuvwxyz123456789
|
secret_key: abcdefghijklmnopqrstuvwxyz123456789
|
||||||
|
enable_self_enrollment: true
|
||||||
|
|
||||||
# Access Control
|
# Access Control
|
||||||
#
|
#
|
||||||
|
|
|
@ -6,4 +6,6 @@ services:
|
||||||
- './DuoPush/configuration.yml:/config/configuration.yml:ro'
|
- './DuoPush/configuration.yml:/config/configuration.yml:ro'
|
||||||
- './DuoPush/users.yml:/config/users.yml'
|
- './DuoPush/users.yml:/config/users.yml'
|
||||||
- './common/ssl:/config/ssl:ro'
|
- './common/ssl:/config/ssl:ro'
|
||||||
|
- '/tmp:/tmp'
|
||||||
|
user: ${USER_ID}:${GROUP_ID}
|
||||||
...
|
...
|
||||||
|
|
|
@ -45,5 +45,5 @@ access_control:
|
||||||
|
|
||||||
notifier:
|
notifier:
|
||||||
filesystem:
|
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")
|
err = rs.WaitElementLocatedByCSSSelector(t, page, fmt.Sprintf("%s-option", method)).Click("left")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rs *RodSession) doChangeDevice(t *testing.T, page *rod.Page, deviceID string) {
|
||||||
|
err := rs.WaitElementLocatedByCSSSelector(t, page, "selection-link").Click("left")
|
||||||
|
require.NoError(t, err)
|
||||||
|
rs.doSelectDevice(t, page, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RodSession) doSelectDevice(t *testing.T, page *rod.Page, deviceID string) {
|
||||||
|
rs.WaitElementLocatedByCSSSelector(t, page, "device-selection")
|
||||||
|
err := rs.WaitElementLocatedByCSSSelector(t, page, fmt.Sprintf("device-%s", deviceID)).Click("left")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RodSession) doClickButton(t *testing.T, page *rod.Page, buttonID string) {
|
||||||
|
err := rs.WaitElementLocatedByCSSSelector(t, page, buttonID).Click("left")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
|
@ -50,7 +50,8 @@ var DuoBaseURL = "https://duo.example.com"
|
||||||
// AutheliaBaseURL the base URL of Authelia service.
|
// AutheliaBaseURL the base URL of Authelia service.
|
||||||
var AutheliaBaseURL = "https://authelia.example.com:9091"
|
var AutheliaBaseURL = "https://authelia.example.com:9091"
|
||||||
|
|
||||||
const stringTrue = "true"
|
const (
|
||||||
|
t = "true"
|
||||||
const testUsername = "john"
|
testUsername = "john"
|
||||||
const testPassword = "password"
|
testPassword = "password"
|
||||||
|
)
|
||||||
|
|
|
@ -18,7 +18,7 @@ type DockerEnvironment struct {
|
||||||
|
|
||||||
// NewDockerEnvironment create a new docker environment.
|
// NewDockerEnvironment create a new docker environment.
|
||||||
func NewDockerEnvironment(files []string) *DockerEnvironment {
|
func NewDockerEnvironment(files []string) *DockerEnvironment {
|
||||||
if os.Getenv("CI") == stringTrue {
|
if os.Getenv("CI") == t {
|
||||||
for i := range files {
|
for i := range files {
|
||||||
files[i] = strings.ReplaceAll(files[i], "{}", "dist")
|
files[i] = strings.ReplaceAll(files[i], "{}", "dist")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
package suites
|
package suites
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/authelia/authelia/v4/internal/duo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DuoPolicy a type of policy.
|
// DuoPolicy a type of policy.
|
||||||
|
@ -33,3 +37,20 @@ func ConfigureDuo(t *testing.T, allowDeny DuoPolicy) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 200, res.StatusCode)
|
require.Equal(t, 200, res.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigureDuoPreAuth configure duo api to respond with available devices or enrollment Url.
|
||||||
|
func ConfigureDuoPreAuth(t *testing.T, response duo.PreAuthResponse) {
|
||||||
|
url := fmt.Sprintf("%s/preauth", DuoBaseURL)
|
||||||
|
|
||||||
|
body, err := json.Marshal(response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := NewHTTPClient()
|
||||||
|
res, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 200, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment, suite string
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("CI") != stringTrue && suite != "CLI" {
|
if os.Getenv("CI") != t && suite != "CLI" {
|
||||||
if err := waitUntilAutheliaFrontendIsReady(dockerEnvironment); err != nil {
|
if err := waitUntilAutheliaFrontendIsReady(dockerEnvironment); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,54 +1,87 @@
|
||||||
/*
|
/*
|
||||||
* This is a script to fake the Duo API for push notifications.
|
* This is a script to fake the Duo API for push notifications.
|
||||||
*
|
*
|
||||||
* Access is allowed by default but one can change the behavior at runtime
|
* For Auth API access is allowed by default but one can change the
|
||||||
* by POSTing to /allow or /deny. Then the /auth/v2/auth endpoint will act
|
* behavior at runtime by POSTing to /allow or /deny. Then the /auth/v2/auth
|
||||||
* accordingly.
|
* endpoint will act accordingly.
|
||||||
|
*
|
||||||
|
* For PreAuth API device selection is bypassed by default but one can
|
||||||
|
* change the behavior at runtime by POSTing to /preauth using the desired
|
||||||
|
* result parameters (and devices). Then the /auth/v2/preauth endpoint
|
||||||
|
* will act accordingly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
||||||
app.set('trust proxy', true);
|
app.use(express.json());
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
let permission = 'allow';
|
// Auth API
|
||||||
|
let permission = "allow";
|
||||||
|
|
||||||
app.post('/allow', (req, res) => {
|
app.post("/allow", (req, res) => {
|
||||||
permission = 'allow';
|
permission = "allow";
|
||||||
console.log("set allowed!");
|
console.log("auth set allowed!");
|
||||||
res.send('ALLOWED');
|
res.send("ALLOWED");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/deny', (req, res) => {
|
app.post("/deny", (req, res) => {
|
||||||
permission = 'deny';
|
permission = "deny";
|
||||||
console.log("set denied!");
|
console.log("auth set denied!");
|
||||||
res.send('DENIED');
|
res.send("DENIED");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/v2/auth', (req, res) => {
|
app.post("/auth/v2/auth", (req, res) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
let response;
|
let response;
|
||||||
if (permission == 'allow') {
|
if (permission == "allow") {
|
||||||
response = {
|
response = {
|
||||||
response: {
|
response: {
|
||||||
result: 'allow',
|
result: "allow",
|
||||||
status: 'allow',
|
status: "allow",
|
||||||
status_msg: 'The user allowed access.',
|
status_msg: "The user allowed access.",
|
||||||
},
|
},
|
||||||
stat: 'OK',
|
stat: "OK",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
response = {
|
response = {
|
||||||
response: {
|
response: {
|
||||||
result: 'deny',
|
result: "deny",
|
||||||
status: 'deny',
|
status: "deny",
|
||||||
status_msg: 'The user denied access.',
|
status_msg: "The user denied access.",
|
||||||
},
|
},
|
||||||
stat: 'OK',
|
stat: "OK",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
res.json(response);
|
res.json(response);
|
||||||
|
console.log("Auth API responded with %s", permission);
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PreAuth API
|
||||||
|
let preauth = {
|
||||||
|
result: "allow",
|
||||||
|
status_msg: "Allowing unknown user",
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post("/preauth", (req, res) => {
|
||||||
|
preauth = req.body;
|
||||||
|
console.log("set result to: %s", preauth);
|
||||||
|
res.json(preauth);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/v2/preauth", (req, res) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
let response;
|
||||||
|
response = {
|
||||||
|
response: preauth,
|
||||||
|
stat: "OK",
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
console.log("PreAuth API responded with %s", preauth);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -57,9 +90,9 @@ app.listen(port, () => console.log(`Duo API listening on port ${port}!`));
|
||||||
// The signals we want to handle
|
// The signals we want to handle
|
||||||
// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
|
// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
|
||||||
var signals = {
|
var signals = {
|
||||||
'SIGHUP': 1,
|
SIGHUP: 1,
|
||||||
'SIGINT': 2,
|
SIGINT: 2,
|
||||||
'SIGTERM': 15
|
SIGTERM: 15,
|
||||||
};
|
};
|
||||||
// Create a listener for each of the signals that we want to handle
|
// Create a listener for each of the signals that we want to handle
|
||||||
Object.keys(signals).forEach((signal) => {
|
Object.keys(signals).forEach((signal) => {
|
||||||
|
|
|
@ -7,4 +7,17 @@ const DuoApi = require("@duosecurity/duo_api");
|
||||||
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
|
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
|
||||||
|
|
||||||
const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
|
const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
|
||||||
client.jsonApiCall("POST", "/auth/v2/auth", { username: 'john', factor: "push", device: "auto" }, console.log);
|
console.log("Testing Auth API first");
|
||||||
|
client.jsonApiCall(
|
||||||
|
"POST",
|
||||||
|
"/auth/v2/auth",
|
||||||
|
{ username: "john", factor: "push", device: "auto" },
|
||||||
|
console.log
|
||||||
|
);
|
||||||
|
console.log("Testing PreAuth API second");
|
||||||
|
client.jsonApiCall(
|
||||||
|
"POST",
|
||||||
|
"/auth/v2/preauth",
|
||||||
|
{ username: "john" },
|
||||||
|
console.log
|
||||||
|
);
|
||||||
|
|
|
@ -36,7 +36,7 @@ func (s *CLISuite) SetupTest() {
|
||||||
testArg := ""
|
testArg := ""
|
||||||
coverageArg := ""
|
coverageArg := ""
|
||||||
|
|
||||||
if os.Getenv("CI") == stringTrue {
|
if os.Getenv("CI") == t {
|
||||||
testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt"
|
testArg = "-test.coverprofile=/authelia/coverage-$(date +%s).txt"
|
||||||
coverageArg = "COVERAGE"
|
coverageArg = "COVERAGE"
|
||||||
}
|
}
|
||||||
|
@ -261,7 +261,7 @@ func (s *CLISuite) TestStorage02ShouldShowSchemaInfo() {
|
||||||
output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
|
output, err := s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
|
||||||
s.Assert().NoError(err)
|
s.Assert().NoError(err)
|
||||||
|
|
||||||
pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`)
|
pattern := regexp.MustCompile(`^Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: valid`)
|
||||||
|
|
||||||
s.Assert().Regexp(pattern, output)
|
s.Assert().Regexp(pattern, output)
|
||||||
}
|
}
|
||||||
|
@ -336,7 +336,7 @@ func (s *CLISuite) TestStorage04ShouldChangeEncryptionKey() {
|
||||||
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
|
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "schema-info", "--config", "/config/configuration.storage.yml"})
|
||||||
s.Assert().NoError(err)
|
s.Assert().NoError(err)
|
||||||
|
|
||||||
pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`)
|
pattern := regexp.MustCompile(`Schema Version: \d+\nSchema Upgrade Available: no\nSchema Tables: authentication_logs, identity_verification, totp_configurations, u2f_devices, duo_devices, user_preferences, migrations, encryption\nSchema Encryption Key: invalid`)
|
||||||
s.Assert().Regexp(pattern, output)
|
s.Assert().Regexp(pattern, output)
|
||||||
|
|
||||||
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"})
|
output, err = s.Exec("authelia-backend", []string{"authelia", s.testArg, s.coverageArg, "storage", "encryption", "check", "--config", "/config/configuration.storage.yml"})
|
||||||
|
|
|
@ -2,6 +2,7 @@ package suites
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -41,11 +42,21 @@ func init() {
|
||||||
|
|
||||||
fmt.Println(frontendLogs)
|
fmt.Println(frontendLogs)
|
||||||
|
|
||||||
|
duoAPILogs, err := dockerEnvironment.Logs("duo-api", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(duoAPILogs)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
teardown := func(suitePath string) error {
|
teardown := func(suitePath string) error {
|
||||||
return dockerEnvironment.Down()
|
err := dockerEnvironment.Down()
|
||||||
|
_ = os.Remove("/tmp/db.sqlite3")
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalRegistry.Register(duoPushSuiteName, Suite{
|
GlobalRegistry.Register(duoPushSuiteName, Suite{
|
||||||
|
@ -53,7 +64,7 @@ func init() {
|
||||||
SetUpTimeout: 5 * time.Minute,
|
SetUpTimeout: 5 * time.Minute,
|
||||||
OnSetupTimeout: displayAutheliaLogs,
|
OnSetupTimeout: displayAutheliaLogs,
|
||||||
OnError: displayAutheliaLogs,
|
OnError: displayAutheliaLogs,
|
||||||
TestTimeout: 2 * time.Minute,
|
TestTimeout: 3 * time.Minute,
|
||||||
TearDown: teardown,
|
TearDown: teardown,
|
||||||
TearDownTimeout: 2 * time.Minute,
|
TearDownTimeout: 2 * time.Minute,
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,13 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
"github.com/authelia/authelia/v4/internal/duo"
|
||||||
|
"github.com/authelia/authelia/v4/internal/models"
|
||||||
|
"github.com/authelia/authelia/v4/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DuoPushWebDriverSuite struct {
|
type DuoPushWebDriverSuite struct {
|
||||||
|
@ -50,11 +56,275 @@ func (s *DuoPushWebDriverSuite) TearDownTest() {
|
||||||
s.MustClose()
|
s.MustClose()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Set default 2FA preference and clean up any Duo device already in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferred2FAMethod(ctx, "john", "totp"))
|
||||||
|
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldBypassDeviceSelection() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "allow",
|
||||||
|
StatusMessage: "Allowing unknown user",
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldDenyDeviceSelection() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "deny",
|
||||||
|
StatusMessage: "We're sorry, access is not allowed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldAskUserToRegister() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "enroll",
|
||||||
|
EnrollPortalURL: "https://api-example.duosecurity.com/portal?code=1234567890ABCDEF&akey=12345ABCDEFGHIJ67890",
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
|
||||||
|
enrollPage := s.Page.MustWaitOpen()
|
||||||
|
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "register-link").MustClick()
|
||||||
|
s.Page = enrollPage()
|
||||||
|
|
||||||
|
assert.Contains(s.T(), s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "description").MustText(), "This enrollment code has expired. Contact your administrator to get a new enrollment code.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldAutoSelectDevice() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
// Switch Method where single Device should be selected automatically.
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
|
||||||
|
// Re-Login the user
|
||||||
s.doLogout(s.T(), s.Context(ctx))
|
s.doLogout(s.T(), s.Context(ctx))
|
||||||
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
s.verifyIsSecondFactorPage(s.T(), s.Context(ctx))
|
// And check the latest method and device is still used.
|
||||||
s.doChangeMethod(s.T(), s.Context(ctx), "one-time-password")
|
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
|
||||||
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "one-time-password-method")
|
// Meaning the authentication is successful
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldSelectDevice() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Set default 2FA preference to enable Select Device link in frontend.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}, {
|
||||||
|
Device: "1234567890ABCDEFGHIJ",
|
||||||
|
DisplayName: "Test Device 2",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
// Switch Method where Device Selection should open automatically.
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
// Check for available Device 1.
|
||||||
|
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-12345ABCDEFGHIJ67890")
|
||||||
|
// Test Back button.
|
||||||
|
s.doClickButton(s.T(), s.Context(ctx), "device-selection-back")
|
||||||
|
// then select Device 2 for further use and be redirected.
|
||||||
|
s.doChangeDevice(s.T(), s.Context(ctx), "1234567890ABCDEFGHIJ")
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
|
||||||
|
// Re-Login the user
|
||||||
|
s.doLogout(s.T(), s.Context(ctx))
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
// And check the latest method and device is still used.
|
||||||
|
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "push-notification-method")
|
||||||
|
// Meaning the authentication is successful
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldFailInitialSelectionBecauseOfUnsupportedMethod() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"auto", "sms"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "state-not-registered")
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "No compatible device found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldSelectNewDeviceAfterSavedDeviceMethodIsNoLongerSupported() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"push", "sms"},
|
||||||
|
}, {
|
||||||
|
Device: "1234567890ABCDEFGHIJ",
|
||||||
|
DisplayName: "Test Device 2",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup unsupported Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "sms"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "device-selection")
|
||||||
|
s.doSelectDevice(s.T(), s.Context(ctx), "12345ABCDEFGHIJ67890")
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldAutoSelectNewDeviceAfterSavedDeviceIsNoLongerAvailable() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"push", "sms"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup unsupported Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "ABCDEFGHIJ1234567890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.verifyIsHome(s.T(), s.Context(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionBypassed() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "allow",
|
||||||
|
StatusMessage: "Allowing unknown user",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Deny)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.doClickButton(s.T(), s.Context(ctx), "selection-link")
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was bypassed by Duo policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldFailSelectionBecauseOfSelectionDenied() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "deny",
|
||||||
|
StatusMessage: "We're sorry, access is not allowed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Deny)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
err := s.WaitElementLocatedByCSSSelector(s.T(), s.Context(ctx), "selection-link").Click("left")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Device selection was denied by Duo policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DuoPushWebDriverSuite) TestShouldFailAuthenticationBecausePreauthDenied() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "deny",
|
||||||
|
StatusMessage: "We're sorry, access is not allowed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
|
||||||
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
|
s.WaitElementLocatedByClassName(s.T(), s.Context(ctx), "failure-icon")
|
||||||
|
s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "There was an issue completing sign in process")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
|
func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
|
||||||
|
@ -64,6 +334,19 @@ func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
|
||||||
s.collectScreenshot(ctx.Err(), s.Page)
|
s.collectScreenshot(ctx.Err(), s.Page)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
ConfigureDuo(s.T(), Allow)
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
@ -78,6 +361,19 @@ func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {
|
||||||
s.collectScreenshot(ctx.Err(), s.Page)
|
s.collectScreenshot(ctx.Err(), s.Page)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "auth",
|
||||||
|
Devices: []duo.Device{{
|
||||||
|
Device: "12345ABCDEFGHIJ67890",
|
||||||
|
DisplayName: "Test Device 1",
|
||||||
|
Capabilities: []string{"auto", "push", "sms", "mobile_otp"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
ConfigureDuo(s.T(), Deny)
|
ConfigureDuo(s.T(), Deny)
|
||||||
|
|
||||||
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
|
@ -128,9 +424,23 @@ func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() {
|
||||||
s.collectScreenshot(ctx.Err(), s.Page)
|
s.collectScreenshot(ctx.Err(), s.Page)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "allow",
|
||||||
|
StatusMessage: "Allowing unknown user",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", false, "")
|
||||||
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
s.doChangeMethod(s.T(), s.Context(ctx), "push-notification")
|
||||||
s.verifyIsHome(s.T(), s.Page)
|
s.verifyIsHome(s.T(), s.Page)
|
||||||
|
|
||||||
|
// Clean up any Duo device already in DB.
|
||||||
|
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuoPushSuite struct {
|
type DuoPushSuite struct {
|
||||||
|
@ -157,7 +467,23 @@ func (s *DuoPushSuite) TestAvailableMethodsScenario() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DuoPushSuite) TestUserPreferencesScenario() {
|
func (s *DuoPushSuite) TestUserPreferencesScenario() {
|
||||||
|
var PreAuthAPIResponse = duo.PreAuthResponse{
|
||||||
|
Result: "allow",
|
||||||
|
StatusMessage: "Allowing unknown user",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Setup Duo device in DB.
|
||||||
|
provider := storage.NewSQLiteProvider("/tmp/db.sqlite3", "a_not_so_secure_encryption_key")
|
||||||
|
require.NoError(s.T(), provider.SavePreferredDuoDevice(ctx, models.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}))
|
||||||
|
ConfigureDuoPreAuth(s.T(), PreAuthAPIResponse)
|
||||||
|
ConfigureDuo(s.T(), Allow)
|
||||||
|
|
||||||
suite.Run(s.T(), NewUserPreferencesScenario())
|
suite.Run(s.T(), NewUserPreferencesScenario())
|
||||||
|
|
||||||
|
// Clean up any Duo device already in DB.
|
||||||
|
require.NoError(s.T(), provider.DeletePreferredDuoDevice(ctx, "john"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDuoPushSuite(t *testing.T) {
|
func TestDuoPushSuite(t *testing.T) {
|
||||||
|
|
|
@ -40,7 +40,7 @@ func init() {
|
||||||
|
|
||||||
log.Debug("Building authelia:dist image or use cache if already built...")
|
log.Debug("Building authelia:dist image or use cache if already built...")
|
||||||
|
|
||||||
if os.Getenv("CI") != stringTrue {
|
if os.Getenv("CI") != t {
|
||||||
if err := utils.Shell("authelia-scripts docker build").Run(); err != nil {
|
if err := utils.Shell("authelia-scripts docker build").Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs *RodSession) collectScreenshot(err error, page *rod.Page) {
|
func (rs *RodSession) collectScreenshot(err error, page *rod.Page) {
|
||||||
if err == context.DeadlineExceeded && os.Getenv("CI") == stringTrue {
|
if err == context.DeadlineExceeded && os.Getenv("CI") == t {
|
||||||
base := "/buildkite/screenshots"
|
base := "/buildkite/screenshots"
|
||||||
build := os.Getenv("BUILDKITE_BUILD_NUMBER")
|
build := os.Getenv("BUILDKITE_BUILD_NUMBER")
|
||||||
suite := strings.ToLower(os.Getenv("SUITE"))
|
suite := strings.ToLower(os.Getenv("SUITE"))
|
||||||
|
|
|
@ -80,6 +80,17 @@ func IsStringInSliceContains(needle string, haystack []string) (inSlice bool) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsStringSliceContainsAll checks if the haystack contains all strings in the needles.
|
||||||
|
func IsStringSliceContainsAll(needles []string, haystack []string) (inSlice bool) {
|
||||||
|
for _, n := range needles {
|
||||||
|
if !IsStringInSlice(n, haystack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// SliceString splits a string s into an array with each item being a max of int d
|
// SliceString splits a string s into an array with each item being a max of int d
|
||||||
// d = denominator, n = numerator, q = quotient, r = remainder.
|
// d = denominator, n = numerator, q = quotient, r = remainder.
|
||||||
func SliceString(s string, d int) (array []string) {
|
func SliceString(s string, d int) (array []string) {
|
||||||
|
|
|
@ -153,3 +153,12 @@ func TestIsStringInSliceSuffix(t *testing.T) {
|
||||||
assert.False(t, IsStringInSliceSuffix("an.orange", suffixes))
|
assert.False(t, IsStringInSliceSuffix("an.orange", suffixes))
|
||||||
assert.False(t, IsStringInSliceSuffix("an.apple.orange", suffixes))
|
assert.False(t, IsStringInSliceSuffix("an.apple.orange", suffixes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsStringSliceContainsAll(t *testing.T) {
|
||||||
|
needles := []string{"abc", "123", "xyz"}
|
||||||
|
haystackOne := []string{"abc", "tvu", "123", "456", "xyz"}
|
||||||
|
haystackTwo := []string{"tvu", "123", "456", "xyz"}
|
||||||
|
|
||||||
|
assert.True(t, IsStringSliceContainsAll(needles, haystackOne))
|
||||||
|
assert.False(t, IsStringSliceContainsAll(needles, haystackTwo))
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
VITE_HMR_PORT=8080
|
VITE_HMR_PORT=8080
|
||||||
VITE_LOGO_OVERRIDE=false
|
VITE_LOGO_OVERRIDE=false
|
||||||
VITE_PUBLIC_URL=""
|
VITE_PUBLIC_URL=""
|
||||||
|
VITE_DUO_SELF_ENROLLMENT=true
|
||||||
VITE_REMEMBER_ME=true
|
VITE_REMEMBER_ME=true
|
||||||
VITE_RESET_PASSWORD=true
|
VITE_RESET_PASSWORD=true
|
||||||
VITE_THEME=light
|
VITE_THEME=light
|
|
@ -1,5 +1,6 @@
|
||||||
VITE_LOGO_OVERRIDE={{.LogoOverride}}
|
VITE_LOGO_OVERRIDE={{.LogoOverride}}
|
||||||
VITE_PUBLIC_URL={{.Base}}
|
VITE_PUBLIC_URL={{.Base}}
|
||||||
|
VITE_DUO_SELF_ENROLLMENT={{.DuoSelfEnrollment}}
|
||||||
VITE_REMEMBER_ME={{.RememberMe}}
|
VITE_REMEMBER_ME={{.RememberMe}}
|
||||||
VITE_RESET_PASSWORD={{.ResetPassword}}
|
VITE_RESET_PASSWORD={{.ResetPassword}}
|
||||||
VITE_THEME={{.Theme}}
|
VITE_THEME={{.Theme}}
|
|
@ -13,7 +13,7 @@
|
||||||
<title>Login - Authelia</title>
|
<title>Login - Authelia</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body data-basepath="%VITE_PUBLIC_URL%" data-logooverride="%VITE_LOGO_OVERRIDE%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%">
|
<body data-basepath="%VITE_PUBLIC_URL%" data-duoselfenrollment="%VITE_DUO_SELF_ENROLLMENT%" data-logooverride="%VITE_LOGO_OVERRIDE%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
|
|
@ -26,10 +26,11 @@
|
||||||
"prepare": "cd .. && husky install .github",
|
"prepare": "cd .. && husky install .github",
|
||||||
"start": "vite --host",
|
"start": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
|
||||||
"coverage": "VITE_COVERAGE=true vite build",
|
"coverage": "VITE_COVERAGE=true vite build",
|
||||||
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||||
"test": "jest --coverage --no-cache",
|
"test": "jest --coverage --no-cache",
|
||||||
"report": "nyc report -r clover -r json -r lcov -r text"
|
"report": "nyc report -r clover -r json -r lcov -r text",
|
||||||
|
"commit": "commitlint --edit $1"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "react-app"
|
"extends": "react-app"
|
||||||
|
|
|
@ -18,7 +18,7 @@ import NotificationsContext from "@hooks/NotificationsContext";
|
||||||
import { Notification } from "@models/Notifications";
|
import { Notification } from "@models/Notifications";
|
||||||
import * as themes from "@themes/index";
|
import * as themes from "@themes/index";
|
||||||
import { getBasePath } from "@utils/BasePath";
|
import { getBasePath } from "@utils/BasePath";
|
||||||
import { getRememberMe, getResetPassword, getTheme } from "@utils/Configuration";
|
import { getDuoSelfEnrollment, getRememberMe, getResetPassword, getTheme } from "@utils/Configuration";
|
||||||
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
|
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
|
||||||
import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey";
|
import RegisterSecurityKey from "@views/DeviceRegistration/RegisterSecurityKey";
|
||||||
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
|
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
|
||||||
|
@ -73,7 +73,13 @@ const App: React.FC = () => {
|
||||||
<Route path={ConsentRoute} element={<ConsentView />} />
|
<Route path={ConsentRoute} element={<ConsentView />} />
|
||||||
<Route
|
<Route
|
||||||
path={`${FirstFactorRoute}*`}
|
path={`${FirstFactorRoute}*`}
|
||||||
element={<LoginPortal rememberMe={getRememberMe()} resetPassword={getResetPassword()} />}
|
element={
|
||||||
|
<LoginPortal
|
||||||
|
duoSelfEnrollment={getDuoSelfEnrollment()}
|
||||||
|
rememberMe={getRememberMe()}
|
||||||
|
resetPassword={getResetPassword()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
@ -5,4 +5,5 @@ export interface UserInfo {
|
||||||
method: SecondFactorMethod;
|
method: SecondFactorMethod;
|
||||||
has_u2f: boolean;
|
has_u2f: boolean;
|
||||||
has_totp: boolean;
|
has_totp: boolean;
|
||||||
|
has_duo: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,9 @@ export const CompleteU2FRegistrationStep2Path = basePath + "/api/secondfactor/u2
|
||||||
export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request";
|
export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request";
|
||||||
export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign";
|
export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign";
|
||||||
|
|
||||||
|
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
|
||||||
|
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
|
||||||
|
|
||||||
export const CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo";
|
export const CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo";
|
||||||
export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp";
|
export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { CompletePushNotificationSignInPath } from "@services/Api";
|
import {
|
||||||
import { PostWithOptionalResponse } from "@services/Client";
|
CompletePushNotificationSignInPath,
|
||||||
import { SignInResponse } from "@services/SignIn";
|
InitiateDuoDeviceSelectionPath,
|
||||||
|
CompleteDuoDeviceSelectionPath,
|
||||||
|
} from "@services/Api";
|
||||||
|
import { Get, PostWithOptionalResponse } from "@services/Client";
|
||||||
|
|
||||||
interface CompleteU2FSigninBody {
|
interface CompleteU2FSigninBody {
|
||||||
targetURL?: string;
|
targetURL?: string;
|
||||||
|
@ -11,5 +14,35 @@ export function completePushNotificationSignIn(targetURL: string | undefined) {
|
||||||
if (targetURL) {
|
if (targetURL) {
|
||||||
body.targetURL = targetURL;
|
body.targetURL = targetURL;
|
||||||
}
|
}
|
||||||
return PostWithOptionalResponse<SignInResponse>(CompletePushNotificationSignInPath, body);
|
return PostWithOptionalResponse<DuoSignInResponse>(CompletePushNotificationSignInPath, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoSignInResponse {
|
||||||
|
result: string;
|
||||||
|
devices: DuoDevice[];
|
||||||
|
redirect: string;
|
||||||
|
enroll_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoDevicesGetResponse {
|
||||||
|
result: string;
|
||||||
|
devices: DuoDevice[];
|
||||||
|
enroll_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoDevice {
|
||||||
|
device: string;
|
||||||
|
display_name: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
export async function initiateDuoDeviceSelectionProcess() {
|
||||||
|
return Get<DuoDevicesGetResponse>(InitiateDuoDeviceSelectionPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoDevicePostRequest {
|
||||||
|
device: string;
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
export async function completeDuoDeviceSelectionProcess(device: DuoDevicePostRequest) {
|
||||||
|
return PostWithOptionalResponse(CompleteDuoDeviceSelectionPath, { device: device.device, method: device.method });
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ export interface UserInfoPayload {
|
||||||
method: Method2FA;
|
method: Method2FA;
|
||||||
has_u2f: boolean;
|
has_u2f: boolean;
|
||||||
has_totp: boolean;
|
has_totp: boolean;
|
||||||
|
has_duo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MethodPreferencePayload {
|
export interface MethodPreferencePayload {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
document.body.setAttribute("data-basepath", "");
|
document.body.setAttribute("data-basepath", "");
|
||||||
|
document.body.setAttribute("data-duoselfenrollment", "true");
|
||||||
document.body.setAttribute("data-rememberme", "true");
|
document.body.setAttribute("data-rememberme", "true");
|
||||||
document.body.setAttribute("data-resetpassword", "true");
|
document.body.setAttribute("data-resetpassword", "true");
|
||||||
document.body.setAttribute("data-theme", "light");
|
document.body.setAttribute("data-theme", "light");
|
||||||
|
|
|
@ -7,6 +7,10 @@ export function getEmbeddedVariable(variableName: string) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDuoSelfEnrollment() {
|
||||||
|
return getEmbeddedVariable("duoselfenrollment") === "true";
|
||||||
|
}
|
||||||
|
|
||||||
export function getLogoOverride() {
|
export function getLogoOverride() {
|
||||||
return getEmbeddedVariable("logooverride") === "true";
|
return getEmbeddedVariable("logooverride") === "true";
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import FirstFactorForm from "@views/LoginPortal/FirstFactor/FirstFactorForm";
|
||||||
import SecondFactorForm from "@views/LoginPortal/SecondFactor/SecondFactorForm";
|
import SecondFactorForm from "@views/LoginPortal/SecondFactor/SecondFactorForm";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
duoSelfEnrollment: boolean;
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
resetPassword: boolean;
|
resetPassword: boolean;
|
||||||
}
|
}
|
||||||
|
@ -189,6 +190,7 @@ const LoginPortal = function (props: Props) {
|
||||||
authenticationLevel={state.authentication_level}
|
authenticationLevel={state.authentication_level}
|
||||||
userInfo={userInfo}
|
userInfo={userInfo}
|
||||||
configuration={configuration}
|
configuration={configuration}
|
||||||
|
duoSelfEnrollment={props.duoSelfEnrollment}
|
||||||
onMethodChanged={() => fetchUserInfo()}
|
onMethodChanged={() => fetchUserInfo()}
|
||||||
onAuthenticationSuccess={handleAuthSuccess}
|
onAuthenticationSuccess={handleAuthSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 {
|
export interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
duoSelfEnrollment: boolean;
|
||||||
registered: boolean;
|
registered: boolean;
|
||||||
explanation: string;
|
explanation: string;
|
||||||
state: State;
|
state: State;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
||||||
onRegisterClick?: () => void;
|
onRegisterClick?: () => void;
|
||||||
|
onSelectClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMethodContainer = function (props: Props) {
|
const DefaultMethodContainer = function (props: Props) {
|
||||||
const style = useStyles();
|
const style = useStyles();
|
||||||
const registerMessage = props.registered ? "Lost your device?" : "Register device";
|
const registerMessage = props.registered
|
||||||
|
? props.title === "Push Notification"
|
||||||
|
? ""
|
||||||
|
: "Lost your device?"
|
||||||
|
: "Register device";
|
||||||
|
const selectMessage = "Select a Device";
|
||||||
|
|
||||||
let container: ReactNode;
|
let container: ReactNode;
|
||||||
let stateClass: string = "";
|
let stateClass: string = "";
|
||||||
|
@ -35,7 +42,7 @@ const DefaultMethodContainer = function (props: Props) {
|
||||||
stateClass = "state-already-authenticated";
|
stateClass = "state-already-authenticated";
|
||||||
break;
|
break;
|
||||||
case State.NOT_REGISTERED:
|
case State.NOT_REGISTERED:
|
||||||
container = <NotRegisteredContainer />;
|
container = <NotRegisteredContainer title={props.title} duoSelfEnrollment={props.duoSelfEnrollment} />;
|
||||||
stateClass = "state-not-registered";
|
stateClass = "state-not-registered";
|
||||||
break;
|
break;
|
||||||
case State.METHOD:
|
case State.METHOD:
|
||||||
|
@ -50,7 +57,13 @@ const DefaultMethodContainer = function (props: Props) {
|
||||||
<div className={classnames(style.container, stateClass)} id="2fa-container">
|
<div className={classnames(style.container, stateClass)} id="2fa-container">
|
||||||
<div className={style.containerFlex}>{container}</div>
|
<div className={style.containerFlex}>{container}</div>
|
||||||
</div>
|
</div>
|
||||||
{props.onRegisterClick ? (
|
{props.onSelectClick && props.registered ? (
|
||||||
|
<Link component="button" id="selection-link" onClick={props.onSelectClick}>
|
||||||
|
{selectMessage}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{(props.onRegisterClick && props.title !== "Push Notification") ||
|
||||||
|
(props.onRegisterClick && props.title === "Push Notification" && props.duoSelfEnrollment) ? (
|
||||||
<Link component="button" id="register-link" onClick={props.onRegisterClick}>
|
<Link component="button" id="register-link" onClick={props.onRegisterClick}>
|
||||||
{registerMessage}
|
{registerMessage}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -76,7 +89,12 @@ const useStyles = makeStyles(() => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function NotRegisteredContainer() {
|
interface NotRegisteredContainerProps {
|
||||||
|
title: string;
|
||||||
|
duoSelfEnrollment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotRegisteredContainer(props: NotRegisteredContainerProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
@ -87,7 +105,11 @@ function NotRegisteredContainer() {
|
||||||
The resource you're attempting to access requires two-factor authentication.
|
The resource you're attempting to access requires two-factor authentication.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography style={{ color: "#5858ff" }}>
|
<Typography style={{ color: "#5858ff" }}>
|
||||||
Register your first device by clicking on the link below.
|
{props.title === "Push Notification"
|
||||||
|
? props.duoSelfEnrollment
|
||||||
|
? "Register your first device by clicking on the link below."
|
||||||
|
: "Contact your administrator to register a device."
|
||||||
|
: "Register your first device by clicking on the link below."}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
@ -89,6 +89,7 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||||
id={props.id}
|
id={props.id}
|
||||||
title="One-Time Password"
|
title="One-Time Password"
|
||||||
explanation="Enter one-time password"
|
explanation="Enter one-time password"
|
||||||
|
duoSelfEnrollment={false}
|
||||||
registered={props.registered}
|
registered={props.registered}
|
||||||
state={methodState}
|
state={methodState}
|
||||||
onRegisterClick={props.onRegisterClick}
|
onRegisterClick={props.onRegisterClick}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState, ReactNode } from "react";
|
import React, { useEffect, useCallback, useRef, useState, ReactNode } from "react";
|
||||||
|
|
||||||
import { Button, makeStyles } from "@material-ui/core";
|
import { Button, makeStyles } from "@material-ui/core";
|
||||||
|
|
||||||
|
@ -7,21 +7,35 @@ import PushNotificationIcon from "@components/PushNotificationIcon";
|
||||||
import SuccessIcon from "@components/SuccessIcon";
|
import SuccessIcon from "@components/SuccessIcon";
|
||||||
import { useIsMountedRef } from "@hooks/Mounted";
|
import { useIsMountedRef } from "@hooks/Mounted";
|
||||||
import { useRedirectionURL } from "@hooks/RedirectionURL";
|
import { useRedirectionURL } from "@hooks/RedirectionURL";
|
||||||
import { completePushNotificationSignIn } from "@services/PushNotification";
|
import {
|
||||||
|
completePushNotificationSignIn,
|
||||||
|
completeDuoDeviceSelectionProcess,
|
||||||
|
DuoDevicePostRequest,
|
||||||
|
initiateDuoDeviceSelectionProcess,
|
||||||
|
} from "@services/PushNotification";
|
||||||
import { AuthenticationLevel } from "@services/State";
|
import { AuthenticationLevel } from "@services/State";
|
||||||
|
import DeviceSelectionContainer, {
|
||||||
|
SelectedDevice,
|
||||||
|
SelectableDevice,
|
||||||
|
} from "@views/LoginPortal/SecondFactor/DeviceSelectionContainer";
|
||||||
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
|
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
|
||||||
|
|
||||||
export enum State {
|
export enum State {
|
||||||
SignInInProgress = 1,
|
SignInInProgress = 1,
|
||||||
Success = 2,
|
Success = 2,
|
||||||
Failure = 3,
|
Failure = 3,
|
||||||
|
Selection = 4,
|
||||||
|
Enroll = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
authenticationLevel: AuthenticationLevel;
|
authenticationLevel: AuthenticationLevel;
|
||||||
|
duoSelfEnrollment: boolean;
|
||||||
|
registered: boolean;
|
||||||
|
|
||||||
onSignInError: (err: Error) => void;
|
onSignInError: (err: Error) => void;
|
||||||
|
onSelectionClick: () => void;
|
||||||
onSignInSuccess: (redirectURL: string | undefined) => void;
|
onSignInSuccess: (redirectURL: string | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,11 +44,47 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
const [state, setState] = useState(State.SignInInProgress);
|
const [state, setState] = useState(State.SignInInProgress);
|
||||||
const redirectionURL = useRedirectionURL();
|
const redirectionURL = useRedirectionURL();
|
||||||
const mounted = useIsMountedRef();
|
const mounted = useIsMountedRef();
|
||||||
|
const [enroll_url, setEnrollUrl] = useState("");
|
||||||
|
const [devices, setDevices] = useState([] as SelectableDevice[]);
|
||||||
|
|
||||||
const { onSignInSuccess, onSignInError } = props;
|
const { onSignInSuccess, onSignInError } = props;
|
||||||
const onSignInErrorCallback = useRef(onSignInError).current;
|
const onSignInErrorCallback = useRef(onSignInError).current;
|
||||||
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
|
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
|
||||||
|
|
||||||
|
const fetchDuoDevicesFunc = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await initiateDuoDeviceSelectionProcess();
|
||||||
|
if (!mounted.current) return;
|
||||||
|
switch (res.result) {
|
||||||
|
case "auth":
|
||||||
|
let selectableDevices = [] as SelectableDevice[];
|
||||||
|
res.devices.forEach((d: { device: any; display_name: any; capabilities: any }) =>
|
||||||
|
selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
|
||||||
|
);
|
||||||
|
setDevices(selectableDevices);
|
||||||
|
setState(State.Selection);
|
||||||
|
break;
|
||||||
|
case "allow":
|
||||||
|
onSignInErrorCallback(new Error("Device selection was bypassed by Duo policy"));
|
||||||
|
setState(State.Success);
|
||||||
|
break;
|
||||||
|
case "deny":
|
||||||
|
onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
|
||||||
|
setState(State.Failure);
|
||||||
|
break;
|
||||||
|
case "enroll":
|
||||||
|
onSignInErrorCallback(new Error("No compatible device found"));
|
||||||
|
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
|
||||||
|
setState(State.Enroll);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted.current) return;
|
||||||
|
console.error(err);
|
||||||
|
onSignInErrorCallback(new Error("There was an issue fetching Duo device(s)"));
|
||||||
|
}
|
||||||
|
}, [props.duoSelfEnrollment, mounted, onSignInErrorCallback]);
|
||||||
|
|
||||||
const signInFunc = useCallback(async () => {
|
const signInFunc = useCallback(async () => {
|
||||||
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
|
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
|
||||||
return;
|
return;
|
||||||
|
@ -46,6 +96,26 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
// If the request was initiated and the user changed 2FA method in the meantime,
|
// If the request was initiated and the user changed 2FA method in the meantime,
|
||||||
// the process is interrupted to avoid updating state of unmounted component.
|
// the process is interrupted to avoid updating state of unmounted component.
|
||||||
if (!mounted.current) return;
|
if (!mounted.current) return;
|
||||||
|
if (res && res.result === "auth") {
|
||||||
|
let selectableDevices = [] as SelectableDevice[];
|
||||||
|
res.devices.forEach((d) =>
|
||||||
|
selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
|
||||||
|
);
|
||||||
|
setDevices(selectableDevices);
|
||||||
|
setState(State.Selection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res && res.result === "enroll") {
|
||||||
|
onSignInErrorCallback(new Error("No compatible device found"));
|
||||||
|
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
|
||||||
|
setState(State.Enroll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res && res.result === "deny") {
|
||||||
|
onSignInErrorCallback(new Error("Device selection was denied by Duo policy"));
|
||||||
|
setState(State.Failure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(State.Success);
|
setState(State.Success);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -55,17 +125,46 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If the request was initiated and the user changed 2FA method in the meantime,
|
// If the request was initiated and the user changed 2FA method in the meantime,
|
||||||
// the process is interrupted to avoid updating state of unmounted component.
|
// the process is interrupted to avoid updating state of unmounted component.
|
||||||
if (!mounted.current) return;
|
if (!mounted.current || state !== State.SignInInProgress) return;
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
onSignInErrorCallback(new Error("There was an issue completing sign in process"));
|
onSignInErrorCallback(new Error("There was an issue completing sign in process"));
|
||||||
setState(State.Failure);
|
setState(State.Failure);
|
||||||
}
|
}
|
||||||
}, [onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, mounted, props.authenticationLevel]);
|
}, [
|
||||||
|
props.authenticationLevel,
|
||||||
|
props.duoSelfEnrollment,
|
||||||
|
redirectionURL,
|
||||||
|
mounted,
|
||||||
|
onSignInErrorCallback,
|
||||||
|
onSignInSuccessCallback,
|
||||||
|
state,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateDuoDevice = useCallback(
|
||||||
signInFunc();
|
async function (device: DuoDevicePostRequest) {
|
||||||
}, [signInFunc]);
|
try {
|
||||||
|
await completeDuoDeviceSelectionProcess(device);
|
||||||
|
if (!props.registered) {
|
||||||
|
setState(State.SignInInProgress);
|
||||||
|
props.onSelectionClick();
|
||||||
|
} else {
|
||||||
|
setState(State.SignInInProgress);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
onSignInErrorCallback(new Error("There was an issue updating preferred Duo device"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSignInErrorCallback, props],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDuoDeviceSelected = useCallback(
|
||||||
|
(device: SelectedDevice) => {
|
||||||
|
updateDuoDevice({ device: device.id, method: device.method });
|
||||||
|
},
|
||||||
|
[updateDuoDevice],
|
||||||
|
);
|
||||||
|
|
||||||
// Set successful state if user is already authenticated.
|
// Set successful state if user is already authenticated.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -74,6 +173,19 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
}
|
}
|
||||||
}, [props.authenticationLevel, setState]);
|
}, [props.authenticationLevel, setState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === State.SignInInProgress) signInFunc();
|
||||||
|
}, [signInFunc, state]);
|
||||||
|
|
||||||
|
if (state === State.Selection)
|
||||||
|
return (
|
||||||
|
<DeviceSelectionContainer
|
||||||
|
devices={devices}
|
||||||
|
onBack={() => setState(State.SignInInProgress)}
|
||||||
|
onSelect={handleDuoDeviceSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
let icon: ReactNode;
|
let icon: ReactNode;
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case State.SignInInProgress:
|
case State.SignInInProgress:
|
||||||
|
@ -89,6 +201,8 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
let methodState = MethodContainerState.METHOD;
|
let methodState = MethodContainerState.METHOD;
|
||||||
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
|
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
|
||||||
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
|
methodState = MethodContainerState.ALREADY_AUTHENTICATED;
|
||||||
|
} else if (state === State.Enroll) {
|
||||||
|
methodState = MethodContainerState.NOT_REGISTERED;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -96,8 +210,11 @@ const PushNotificationMethod = function (props: Props) {
|
||||||
id={props.id}
|
id={props.id}
|
||||||
title="Push Notification"
|
title="Push Notification"
|
||||||
explanation="A notification has been sent to your smartphone"
|
explanation="A notification has been sent to your smartphone"
|
||||||
registered={true}
|
duoSelfEnrollment={enroll_url ? props.duoSelfEnrollment : false}
|
||||||
|
registered={props.registered}
|
||||||
state={methodState}
|
state={methodState}
|
||||||
|
onSelectClick={fetchDuoDevicesFunc}
|
||||||
|
onRegisterClick={() => window.open(enroll_url, "_blank")}
|
||||||
>
|
>
|
||||||
<div className={style.icon}>{icon}</div>
|
<div className={style.icon}>{icon}</div>
|
||||||
<div className={state !== State.Failure ? "hidden" : ""}>
|
<div className={state !== State.Failure ? "hidden" : ""}>
|
||||||
|
|
|
@ -27,11 +27,11 @@ const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to compl
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
authenticationLevel: AuthenticationLevel;
|
authenticationLevel: AuthenticationLevel;
|
||||||
|
|
||||||
userInfo: UserInfo;
|
userInfo: UserInfo;
|
||||||
configuration: Configuration;
|
configuration: Configuration;
|
||||||
|
duoSelfEnrollment: boolean;
|
||||||
|
|
||||||
onMethodChanged: (method: SecondFactorMethod) => void;
|
onMethodChanged: () => void;
|
||||||
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
|
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +76,7 @@ const SecondFactorForm = function (props: Props) {
|
||||||
try {
|
try {
|
||||||
await setPreferred2FAMethod(method);
|
await setPreferred2FAMethod(method);
|
||||||
setMethodSelectionOpen(false);
|
setMethodSelectionOpen(false);
|
||||||
props.onMethodChanged(method);
|
props.onMethodChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
createErrorNotification("There was an issue updating preferred second factor method");
|
createErrorNotification("There was an issue updating preferred second factor method");
|
||||||
|
@ -143,6 +143,9 @@ const SecondFactorForm = function (props: Props) {
|
||||||
<PushNotificationMethod
|
<PushNotificationMethod
|
||||||
id="push-notification-method"
|
id="push-notification-method"
|
||||||
authenticationLevel={props.authenticationLevel}
|
authenticationLevel={props.authenticationLevel}
|
||||||
|
duoSelfEnrollment={props.duoSelfEnrollment}
|
||||||
|
registered={props.userInfo.has_duo}
|
||||||
|
onSelectionClick={props.onMethodChanged}
|
||||||
onSignInError={(err) => createErrorNotification(err.message)}
|
onSignInError={(err) => createErrorNotification(err.message)}
|
||||||
onSignInSuccess={props.onAuthenticationSuccess}
|
onSignInSuccess={props.onAuthenticationSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -105,6 +105,7 @@ const SecurityKeyMethod = function (props: Props) {
|
||||||
id={props.id}
|
id={props.id}
|
||||||
title="Security Key"
|
title="Security Key"
|
||||||
explanation="Touch the token of your security key"
|
explanation="Touch the token of your security key"
|
||||||
|
duoSelfEnrollment={false}
|
||||||
registered={props.registered}
|
registered={props.registered}
|
||||||
state={methodState}
|
state={methodState}
|
||||||
onRegisterClick={props.onRegisterClick}
|
onRegisterClick={props.onRegisterClick}
|
||||||
|
|
|
@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
|
||||||
clientPort: env.VITE_HMR_PORT || 3000,
|
clientPort: env.VITE_HMR_PORT || 3000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [eslintPlugin(), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue