fix(web): show appropriate default and available methods (#2999)
This ensures that; the method set when a user does not have a preference is a method that is available, that if a user has a preferred method that is not available it is changed to an enabled method with preference put on methods the user has configured, that the frontend does not show the method selection option when only one method is available.pull/3066/head^2
parent
2d8978c15a
commit
70ab8aab15
|
@ -317,6 +317,24 @@ paths:
|
|||
description: Forbidden
|
||||
security:
|
||||
- authelia_auth: []
|
||||
post:
|
||||
tags:
|
||||
- User Information
|
||||
summary: User Configuration
|
||||
description: >
|
||||
The user info endpoint provides detailed information including a users display name, preferred and registered
|
||||
second factor method(s). The POST method also ensures the preferred method is configured correctly.
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/handlers.UserInfo'
|
||||
"403":
|
||||
description: Forbidden
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/user/info/totp:
|
||||
get:
|
||||
tags:
|
||||
|
|
|
@ -16,15 +16,6 @@ const (
|
|||
TwoFactor Level = iota
|
||||
)
|
||||
|
||||
const (
|
||||
// TOTP Method using Time-Based One-Time Password applications like Google Authenticator.
|
||||
TOTP = "totp"
|
||||
// Webauthn Method using Webauthn devices like YubiKeys.
|
||||
Webauthn = "webauthn"
|
||||
// Push Method using Duo application to receive push notifications.
|
||||
Push = "mobile_push"
|
||||
)
|
||||
|
||||
const (
|
||||
ldapSupportedExtensionAttribute = "supportedExtension"
|
||||
ldapOIDPasswdModifyExtension = "1.3.6.1.4.1.4203.1.11.1" // http://oidref.com/1.3.6.1.4.1.4203.1.11.1
|
||||
|
@ -36,9 +27,6 @@ const (
|
|||
ldapPlaceholderUsername = "{username}"
|
||||
)
|
||||
|
||||
// PossibleMethods is the set of all possible 2FA methods.
|
||||
var PossibleMethods = []string{TOTP, Webauthn, Push}
|
||||
|
||||
// CryptAlgo the crypt representation of an algorithm used in the prefix of the hash.
|
||||
type CryptAlgo string
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||
)
|
||||
|
||||
|
@ -12,17 +11,7 @@ func ConfigurationGet(ctx *middlewares.AutheliaCtx) {
|
|||
}
|
||||
|
||||
if ctx.Providers.Authorizer.IsSecondFactorEnabled() {
|
||||
if !ctx.Configuration.TOTP.Disable {
|
||||
body.AvailableMethods = append(body.AvailableMethods, authentication.TOTP)
|
||||
}
|
||||
|
||||
if !ctx.Configuration.Webauthn.Disable {
|
||||
body.AvailableMethods = append(body.AvailableMethods, authentication.Webauthn)
|
||||
}
|
||||
|
||||
if ctx.Configuration.DuoAPI != nil {
|
||||
body.AvailableMethods = append(body.AvailableMethods, authentication.Push)
|
||||
}
|
||||
body.AvailableMethods = ctx.AvailableSecondFactorMethods()
|
||||
}
|
||||
|
||||
ctx.Logger.Tracef("Available methods are %s", body.AvailableMethods)
|
||||
|
|
|
@ -1,16 +1,61 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
// UserInfoGet get the info related to the user identified by the session.
|
||||
func UserInfoGet(ctx *middlewares.AutheliaCtx) {
|
||||
// UserInfoPOST handles setting up info for users if necessary when they login.
|
||||
func UserInfoPOST(ctx *middlewares.AutheliaCtx) {
|
||||
userSession := ctx.GetSession()
|
||||
|
||||
var (
|
||||
userInfo model.UserInfo
|
||||
err error
|
||||
)
|
||||
|
||||
if _, err = ctx.Providers.StorageProvider.LoadPreferred2FAMethod(ctx, userSession.Username); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, ""); err != nil {
|
||||
ctx.Error(fmt.Errorf("unable to load user information: %v", err), messageOperationFailed)
|
||||
}
|
||||
} else {
|
||||
ctx.Error(fmt.Errorf("unable to load user information: %v", err), messageOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
if userInfo, err = ctx.Providers.StorageProvider.LoadUserInfo(ctx, userSession.Username); err != nil {
|
||||
ctx.Error(fmt.Errorf("unable to load user information: %v", err), messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
changed bool
|
||||
)
|
||||
|
||||
if changed = userInfo.SetDefaultPreferred2FAMethod(ctx.AvailableSecondFactorMethods()); changed {
|
||||
if err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, userInfo.Method); err != nil {
|
||||
ctx.Error(fmt.Errorf("unable to save user two factor method: %v", err), messageOperationFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userInfo.DisplayName = userSession.DisplayName
|
||||
|
||||
err = ctx.SetJSONBody(userInfo)
|
||||
if err != nil {
|
||||
ctx.Logger.Errorf("Unable to set user info response in body: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfoGET get the info related to the user identified by the session.
|
||||
func UserInfoGET(ctx *middlewares.AutheliaCtx) {
|
||||
userSession := ctx.GetSession()
|
||||
|
||||
userInfo, err := ctx.Providers.StorageProvider.LoadUserInfo(ctx, userSession.Username)
|
||||
|
@ -37,8 +82,8 @@ func MethodPreferencePost(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
if !utils.IsStringInSlice(bodyJSON.Method, authentication.PossibleMethods) {
|
||||
ctx.Error(fmt.Errorf("unknown method '%s', it should be one of %s", bodyJSON.Method, strings.Join(authentication.PossibleMethods, ", ")), messageOperationFailed)
|
||||
if !utils.IsStringInSlice(bodyJSON.Method, ctx.AvailableSecondFactorMethods()) {
|
||||
ctx.Error(fmt.Errorf("unknown or unavailable method '%s', it should be one of %s", bodyJSON.Method, strings.Join(ctx.AvailableSecondFactorMethods(), ", ")), messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/mocks"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
)
|
||||
|
@ -41,7 +42,17 @@ type expectedResponse struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func TestMethodSetToU2F(t *testing.T) {
|
||||
type expectedResponseAlt struct {
|
||||
description string
|
||||
|
||||
db model.UserInfo
|
||||
api *model.UserInfo
|
||||
loadErr error
|
||||
saveErr error
|
||||
config *schema.Configuration
|
||||
}
|
||||
|
||||
func TestUserInfoEndpoint_SetCorrectMethod(t *testing.T) {
|
||||
expectedResponses := []expectedResponse{
|
||||
{
|
||||
db: model.UserInfo{
|
||||
|
@ -89,6 +100,9 @@ func TestMethodSetToU2F(t *testing.T) {
|
|||
}
|
||||
|
||||
mock := mocks.NewMockAutheliaCtx(t)
|
||||
|
||||
mock.Ctx.Configuration.DuoAPI = &schema.DuoAPIConfiguration{}
|
||||
|
||||
// Set the initial user session.
|
||||
userSession := mock.Ctx.GetSession()
|
||||
userSession.Username = testUsername
|
||||
|
@ -101,7 +115,7 @@ func TestMethodSetToU2F(t *testing.T) {
|
|||
LoadUserInfo(mock.Ctx, gomock.Eq("john")).
|
||||
Return(resp.db, resp.err)
|
||||
|
||||
UserInfoGet(mock.Ctx)
|
||||
UserInfoGET(mock.Ctx)
|
||||
|
||||
if resp.err == nil {
|
||||
t.Run("expected status code", func(t *testing.T) {
|
||||
|
@ -123,6 +137,207 @@ func TestMethodSetToU2F(t *testing.T) {
|
|||
t.Run("registered totp", func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.HasTOTP, actualPreferences.HasTOTP)
|
||||
})
|
||||
|
||||
t.Run("registered duo", func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.HasDuo, actualPreferences.HasDuo)
|
||||
})
|
||||
} else {
|
||||
t.Run("expected status code", func(t *testing.T) {
|
||||
assert.Equal(t, 200, mock.Ctx.Response.StatusCode())
|
||||
})
|
||||
|
||||
errResponse := mock.GetResponseError(t)
|
||||
|
||||
assert.Equal(t, "KO", errResponse.Status)
|
||||
assert.Equal(t, "Operation failed.", errResponse.Message)
|
||||
}
|
||||
|
||||
mock.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInfoEndpoint_SetDefaultMethod(t *testing.T) {
|
||||
expectedResponses := []expectedResponseAlt{
|
||||
{
|
||||
description: "should set method to totp by default even when user doesn't have totp configured and no preferred method",
|
||||
db: model.UserInfo{
|
||||
Method: "",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: false,
|
||||
},
|
||||
api: &model.UserInfo{
|
||||
Method: "totp",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: false,
|
||||
},
|
||||
config: &schema.Configuration{
|
||||
DuoAPI: &schema.DuoAPIConfiguration{},
|
||||
},
|
||||
loadErr: nil,
|
||||
saveErr: nil,
|
||||
},
|
||||
{
|
||||
description: "should set method to duo by default when user has duo configured and no preferred method",
|
||||
db: model.UserInfo{
|
||||
Method: "",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: true,
|
||||
},
|
||||
api: &model.UserInfo{
|
||||
Method: "mobile_push",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: true,
|
||||
},
|
||||
config: &schema.Configuration{
|
||||
DuoAPI: &schema.DuoAPIConfiguration{},
|
||||
},
|
||||
loadErr: nil,
|
||||
saveErr: nil,
|
||||
},
|
||||
{
|
||||
description: "should set method to totp by default when user has duo configured and no preferred method but duo is not enabled",
|
||||
db: model.UserInfo{
|
||||
Method: "",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: true,
|
||||
},
|
||||
api: &model.UserInfo{
|
||||
Method: "totp",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: true,
|
||||
},
|
||||
loadErr: nil,
|
||||
saveErr: nil,
|
||||
},
|
||||
{
|
||||
description: "should set method to duo by default when user has duo configured and no preferred method",
|
||||
db: model.UserInfo{
|
||||
Method: "",
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
HasDuo: true,
|
||||
},
|
||||
api: &model.UserInfo{
|
||||
Method: "webauthn",
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
HasDuo: true,
|
||||
},
|
||||
config: &schema.Configuration{
|
||||
TOTP: schema.TOTPConfiguration{
|
||||
Disable: true,
|
||||
},
|
||||
DuoAPI: &schema.DuoAPIConfiguration{},
|
||||
},
|
||||
loadErr: nil,
|
||||
saveErr: nil,
|
||||
},
|
||||
{
|
||||
description: "should default new users to totp if all enabled",
|
||||
db: model.UserInfo{
|
||||
Method: "",
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
HasDuo: false,
|
||||
},
|
||||
api: &model.UserInfo{
|
||||
Method: "totp",
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
HasDuo: true,
|
||||
},
|
||||
config: &schema.Configuration{
|
||||
DuoAPI: &schema.DuoAPIConfiguration{},
|
||||
},
|
||||
loadErr: nil,
|
||||
saveErr: errors.New("could not save"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, resp := range expectedResponses {
|
||||
if resp.api == nil {
|
||||
resp.api = &resp.db
|
||||
}
|
||||
|
||||
mock := mocks.NewMockAutheliaCtx(t)
|
||||
|
||||
if resp.config != nil {
|
||||
mock.Ctx.Configuration = *resp.config
|
||||
}
|
||||
|
||||
// Set the initial user session.
|
||||
userSession := mock.Ctx.GetSession()
|
||||
userSession.Username = testUsername
|
||||
userSession.AuthenticationLevel = 1
|
||||
err := mock.Ctx.SaveSession(userSession)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.db.Method == "" {
|
||||
gomock.InOrder(
|
||||
mock.StorageMock.
|
||||
EXPECT().
|
||||
LoadPreferred2FAMethod(mock.Ctx, gomock.Eq("john")).
|
||||
Return("", sql.ErrNoRows),
|
||||
mock.StorageMock.
|
||||
EXPECT().
|
||||
SavePreferred2FAMethod(mock.Ctx, gomock.Eq("john"), gomock.Eq("")).
|
||||
Return(resp.saveErr),
|
||||
mock.StorageMock.
|
||||
EXPECT().
|
||||
LoadUserInfo(mock.Ctx, gomock.Eq("john")).
|
||||
Return(resp.db, nil),
|
||||
mock.StorageMock.EXPECT().
|
||||
SavePreferred2FAMethod(mock.Ctx, gomock.Eq("john"), gomock.Eq(resp.api.Method)).
|
||||
Return(resp.saveErr),
|
||||
)
|
||||
} else {
|
||||
gomock.InOrder(
|
||||
mock.StorageMock.
|
||||
EXPECT().
|
||||
LoadPreferred2FAMethod(mock.Ctx, gomock.Eq("john")).
|
||||
Return(resp.db.Method, nil),
|
||||
mock.StorageMock.
|
||||
EXPECT().
|
||||
LoadUserInfo(mock.Ctx, gomock.Eq("john")).
|
||||
Return(resp.db, nil),
|
||||
mock.StorageMock.EXPECT().
|
||||
SavePreferred2FAMethod(mock.Ctx, gomock.Eq("john"), gomock.Eq(resp.api.Method)).
|
||||
Return(resp.saveErr),
|
||||
)
|
||||
}
|
||||
|
||||
UserInfoPOST(mock.Ctx)
|
||||
|
||||
if resp.loadErr == nil && resp.saveErr == nil {
|
||||
t.Run(fmt.Sprintf("%s/%s", resp.description, "expected status code"), func(t *testing.T) {
|
||||
assert.Equal(t, 200, mock.Ctx.Response.StatusCode())
|
||||
})
|
||||
|
||||
actualPreferences := model.UserInfo{}
|
||||
|
||||
mock.GetResponseData(t, &actualPreferences)
|
||||
|
||||
t.Run(fmt.Sprintf("%s/%s", resp.description, "expected method"), func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.Method, actualPreferences.Method)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("%s/%s", resp.description, "registered webauthn"), func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.HasWebauthn, actualPreferences.HasWebauthn)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("%s/%s", resp.description, "registered totp"), func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.HasTOTP, actualPreferences.HasTOTP)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("%s/%s", resp.description, "registered duo"), func(t *testing.T) {
|
||||
assert.Equal(t, resp.api.HasDuo, actualPreferences.HasDuo)
|
||||
})
|
||||
} else {
|
||||
t.Run("expected status code", func(t *testing.T) {
|
||||
assert.Equal(t, 200, mock.Ctx.Response.StatusCode())
|
||||
|
@ -143,7 +358,7 @@ func (s *FetchSuite) TestShouldReturnError500WhenStorageFailsToLoad() {
|
|||
LoadUserInfo(s.mock.Ctx, gomock.Eq("john")).
|
||||
Return(model.UserInfo{}, fmt.Errorf("failure"))
|
||||
|
||||
UserInfoGet(s.mock.Ctx)
|
||||
UserInfoGET(s.mock.Ctx)
|
||||
|
||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||
assert.Equal(s.T(), "unable to load user information: failure", s.mock.Hook.LastEntry().Message)
|
||||
|
@ -205,7 +420,7 @@ func (s *SaveSuite) TestShouldReturnError500WhenBadMethodProvided() {
|
|||
MethodPreferencePost(s.mock.Ctx)
|
||||
|
||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||
assert.Equal(s.T(), "unknown method 'abc', it should be one of totp, webauthn, mobile_push", s.mock.Hook.LastEntry().Message)
|
||||
assert.Equal(s.T(), "unknown or unavailable method 'abc', it should be one of totp, webauthn", s.mock.Hook.LastEntry().Message)
|
||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/logging"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
@ -54,6 +55,25 @@ func AutheliaMiddleware(configuration schema.Configuration, providers Providers)
|
|||
}
|
||||
}
|
||||
|
||||
// AvailableSecondFactorMethods returns the available 2FA methods.
|
||||
func (ctx *AutheliaCtx) AvailableSecondFactorMethods() (methods []string) {
|
||||
methods = make([]string, 0, 3)
|
||||
|
||||
if !ctx.Configuration.TOTP.Disable {
|
||||
methods = append(methods, model.SecondFactorMethodTOTP)
|
||||
}
|
||||
|
||||
if !ctx.Configuration.Webauthn.Disable {
|
||||
methods = append(methods, model.SecondFactorMethodWebauthn)
|
||||
}
|
||||
|
||||
if ctx.Configuration.DuoAPI != nil {
|
||||
methods = append(methods, model.SecondFactorMethodDuo)
|
||||
}
|
||||
|
||||
return methods
|
||||
}
|
||||
|
||||
// Error reply with an error and display the stack trace in the logs.
|
||||
func (ctx *AutheliaCtx) Error(err error, message string) {
|
||||
ctx.SetJSONError(message)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||
"github.com/authelia/authelia/v4/internal/mocks"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
)
|
||||
|
||||
|
@ -115,3 +116,26 @@ func TestShouldDetectNonXHR(t *testing.T) {
|
|||
|
||||
assert.False(t, mock.Ctx.IsXHR())
|
||||
}
|
||||
|
||||
func TestShouldReturnCorrectSecondFactorMethods(t *testing.T) {
|
||||
mock := mocks.NewMockAutheliaCtx(t)
|
||||
defer mock.Close()
|
||||
|
||||
assert.Equal(t, []string{model.SecondFactorMethodTOTP, model.SecondFactorMethodWebauthn}, mock.Ctx.AvailableSecondFactorMethods())
|
||||
|
||||
mock.Ctx.Configuration.DuoAPI = &schema.DuoAPIConfiguration{}
|
||||
|
||||
assert.Equal(t, []string{model.SecondFactorMethodTOTP, model.SecondFactorMethodWebauthn, model.SecondFactorMethodDuo}, mock.Ctx.AvailableSecondFactorMethods())
|
||||
|
||||
mock.Ctx.Configuration.TOTP.Disable = true
|
||||
|
||||
assert.Equal(t, []string{model.SecondFactorMethodWebauthn, model.SecondFactorMethodDuo}, mock.Ctx.AvailableSecondFactorMethods())
|
||||
|
||||
mock.Ctx.Configuration.Webauthn.Disable = true
|
||||
|
||||
assert.Equal(t, []string{model.SecondFactorMethodDuo}, mock.Ctx.AvailableSecondFactorMethods())
|
||||
|
||||
mock.Ctx.Configuration.DuoAPI = nil
|
||||
|
||||
assert.Equal(t, []string{}, mock.Ctx.AvailableSecondFactorMethods())
|
||||
}
|
||||
|
|
|
@ -6,3 +6,14 @@ const (
|
|||
errFmtScanInvalidType = "cannot scan model type '%T' from type '%T' with value '%v'"
|
||||
errFmtScanInvalidTypeErr = "cannot scan model type '%T' from type '%T' with value '%v': %w"
|
||||
)
|
||||
|
||||
const (
|
||||
// SecondFactorMethodTOTP method using Time-Based One-Time Password applications like Google Authenticator.
|
||||
SecondFactorMethodTOTP = "totp"
|
||||
|
||||
// SecondFactorMethodWebauthn method using Webauthn devices like YubiKey's.
|
||||
SecondFactorMethodWebauthn = "webauthn"
|
||||
|
||||
// SecondFactorMethodDuo method using Duo application to receive push notifications.
|
||||
SecondFactorMethodDuo = "mobile_push"
|
||||
)
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
// UserInfo represents the user information required by the web UI.
|
||||
type UserInfo struct {
|
||||
// The users display name.
|
||||
|
@ -17,3 +21,38 @@ type UserInfo struct {
|
|||
// True if a duo device has been configured as the preferred.
|
||||
HasDuo bool `db:"has_duo" json:"has_duo" valid:"required"`
|
||||
}
|
||||
|
||||
// SetDefaultPreferred2FAMethod configures the default method based on what is configured as available and the users available methods.
|
||||
func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool) {
|
||||
if len(methods) == 0 {
|
||||
// No point attempting to change the method if no methods are available.
|
||||
return false
|
||||
}
|
||||
|
||||
before := i.Method
|
||||
|
||||
totp, webauthn, duo := utils.IsStringInSlice(SecondFactorMethodTOTP, methods), utils.IsStringInSlice(SecondFactorMethodWebauthn, methods), utils.IsStringInSlice(SecondFactorMethodDuo, methods)
|
||||
|
||||
if i.Method != "" && !utils.IsStringInSlice(i.Method, methods) {
|
||||
i.Method = ""
|
||||
}
|
||||
|
||||
if i.Method == "" {
|
||||
switch {
|
||||
case i.HasTOTP && totp:
|
||||
i.Method = SecondFactorMethodTOTP
|
||||
case i.HasWebauthn && webauthn:
|
||||
i.Method = SecondFactorMethodWebauthn
|
||||
case i.HasDuo && duo:
|
||||
i.Method = SecondFactorMethodDuo
|
||||
case totp:
|
||||
i.Method = SecondFactorMethodTOTP
|
||||
case webauthn:
|
||||
i.Method = SecondFactorMethodWebauthn
|
||||
case duo:
|
||||
i.Method = SecondFactorMethodDuo
|
||||
}
|
||||
}
|
||||
|
||||
return before != i.Method
|
||||
}
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
|
||||
none := "none"
|
||||
|
||||
testName := func(i int, have UserInfo, availableMethods []string) string {
|
||||
method := have.Method
|
||||
|
||||
if method == "" {
|
||||
method = none
|
||||
}
|
||||
|
||||
has := ""
|
||||
|
||||
if have.HasTOTP || have.HasDuo || have.HasWebauthn {
|
||||
has += " has"
|
||||
|
||||
if have.HasTOTP {
|
||||
has += " " + SecondFactorMethodTOTP
|
||||
}
|
||||
|
||||
if have.HasDuo {
|
||||
has += " " + SecondFactorMethodDuo
|
||||
}
|
||||
|
||||
if have.HasWebauthn {
|
||||
has += " " + SecondFactorMethodWebauthn
|
||||
}
|
||||
}
|
||||
|
||||
available := none
|
||||
if len(availableMethods) != 0 {
|
||||
available = strings.Join(availableMethods, " ")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d/method %s%s/available methods %s", i+1, method, has, available)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
have UserInfo
|
||||
availableMethods []string
|
||||
changed bool
|
||||
want UserInfo
|
||||
}{
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: true,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: true,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
HasDuo: true,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: true,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: true,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodTOTP},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: true,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodTOTP},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodWebauthn},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodTOTP,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodDuo},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodDuo,
|
||||
HasDuo: false,
|
||||
HasTOTP: false,
|
||||
HasWebauthn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
|
||||
changed: false,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: "",
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodWebauthn,
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: "",
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: []string{SecondFactorMethodDuo},
|
||||
changed: true,
|
||||
want: UserInfo{
|
||||
Method: SecondFactorMethodDuo,
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
have: UserInfo{
|
||||
Method: "",
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
availableMethods: nil,
|
||||
changed: false,
|
||||
want: UserInfo{
|
||||
Method: "",
|
||||
HasDuo: false,
|
||||
HasTOTP: true,
|
||||
HasWebauthn: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range testCases {
|
||||
t.Run(testName(i, tc.have, tc.availableMethods), func(t *testing.T) {
|
||||
changed := tc.have.SetDefaultPreferred2FAMethod(tc.availableMethods)
|
||||
|
||||
assert.Equal(t, tc.changed, changed)
|
||||
assert.Equal(t, tc.want, tc.have)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -86,7 +86,9 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
|
|||
|
||||
// Information about the user.
|
||||
r.GET("/api/user/info", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.UserInfoGet)))
|
||||
middlewares.RequireFirstFactor(handlers.UserInfoGET)))
|
||||
r.POST("/api/user/info", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.UserInfoPOST)))
|
||||
r.POST("/api/user/info/2fa_method", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.MethodPreferencePost)))
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/logging"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
|
@ -205,7 +204,7 @@ func (p *SQLProvider) LoadPreferred2FAMethod(ctx context.Context, username strin
|
|||
case err == nil:
|
||||
return method, nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", nil
|
||||
return "", sql.ErrNoRows
|
||||
default:
|
||||
return "", fmt.Errorf("error selecting preferred two factor method for user '%s': %w", username, err)
|
||||
}
|
||||
|
@ -216,17 +215,7 @@ func (p *SQLProvider) LoadUserInfo(ctx context.Context, username string) (info m
|
|||
err = p.db.GetContext(ctx, &info, p.sqlSelectUserInfo, username, username, username, username)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
return info, nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, authentication.PossibleMethods[0]); err != nil {
|
||||
return model.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, username); err != nil {
|
||||
return model.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
|
||||
}
|
||||
|
||||
case err == nil, errors.Is(err, sql.ErrNoRows):
|
||||
return info, nil
|
||||
default:
|
||||
return model.UserInfo{}, fmt.Errorf("error selecting user info for user '%s': %w", username, err)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useRemoteCall } from "@hooks/RemoteCall";
|
||||
import { getUserInfo } from "@services/UserInfo";
|
||||
import { postUserInfo } from "@services/UserInfo";
|
||||
|
||||
export function useUserInfo() {
|
||||
return useRemoteCall(getUserInfo, []);
|
||||
export function useUserInfoPOST() {
|
||||
return useRemoteCall(postUserInfo, []);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { SecondFactorMethod } from "@models/Methods";
|
||||
import { UserInfo } from "@models/UserInfo";
|
||||
import { UserInfo2FAMethodPath, UserInfoPath } from "@services/Api";
|
||||
import { Get, PostWithOptionalResponse } from "@services/Client";
|
||||
import { Post, PostWithOptionalResponse } from "@services/Client";
|
||||
|
||||
export type Method2FA = "webauthn" | "totp" | "mobile_push";
|
||||
|
||||
|
@ -39,8 +39,8 @@ export function toString(method: SecondFactorMethod): Method2FA {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getUserInfo(): Promise<UserInfo> {
|
||||
const res = await Get<UserInfoPayload>(UserInfoPath);
|
||||
export async function postUserInfo(): Promise<UserInfo> {
|
||||
const res = await Post<UserInfoPayload>(UserInfoPath);
|
||||
return { ...res, method: toEnum(res.method) };
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import { useRedirectionURL } from "@hooks/RedirectionURL";
|
|||
import { useRedirector } from "@hooks/Redirector";
|
||||
import { useRequestMethod } from "@hooks/RequestMethod";
|
||||
import { useAutheliaState } from "@hooks/State";
|
||||
import { useUserInfo } from "@hooks/UserInfo";
|
||||
import { useUserInfoPOST } from "@hooks/UserInfo";
|
||||
import { SecondFactorMethod } from "@models/Methods";
|
||||
import { checkSafeRedirection } from "@services/SafeRedirection";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
|
@ -44,7 +44,7 @@ const LoginPortal = function (props: Props) {
|
|||
const redirector = useRedirector();
|
||||
|
||||
const [state, fetchState, , fetchStateError] = useAutheliaState();
|
||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfo();
|
||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
|
||||
const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration();
|
||||
|
||||
const redirect = useCallback((url: string) => navigate(url), [navigate]);
|
||||
|
|
|
@ -85,6 +85,7 @@ const SecondFactorForm = function (props: Props) {
|
|||
|
||||
return (
|
||||
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand>
|
||||
{props.configuration.available_methods.size > 1 ? (
|
||||
<MethodSelectionDialog
|
||||
open={methodSelectionOpen}
|
||||
methods={props.configuration.available_methods}
|
||||
|
@ -92,15 +93,18 @@ const SecondFactorForm = function (props: Props) {
|
|||
onClose={() => setMethodSelectionOpen(false)}
|
||||
onClick={handleMethodSelected}
|
||||
/>
|
||||
) : null}
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Button color="secondary" onClick={handleLogoutClick} id="logout-button">
|
||||
{translate("Logout")}
|
||||
</Button>
|
||||
{" | "}
|
||||
{props.configuration.available_methods.size > 1 ? " | " : null}
|
||||
{props.configuration.available_methods.size > 1 ? (
|
||||
<Button color="secondary" onClick={handleMethodSelectionClick} id="methods-button">
|
||||
{translate("Methods")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Grid>
|
||||
<Grid item xs={12} className={style.methodContainer}>
|
||||
<Routes>
|
||||
|
|
Loading…
Reference in New Issue