Introduce hasU2F and hasTOTP in user info.
parent
778f069013
commit
5942e00412
|
@ -17,9 +17,9 @@ const (
|
||||||
TOTP = "totp"
|
TOTP = "totp"
|
||||||
// U2F Method using U2F devices like Yubikeys
|
// U2F Method using U2F devices like Yubikeys
|
||||||
U2F = "u2f"
|
U2F = "u2f"
|
||||||
// DuoPush Method using Duo application to receive push notifications.
|
// Push Method using Duo application to receive push notifications.
|
||||||
DuoPush = "duo_push"
|
Push = "mobile_push"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PossibleMethods is the set of all possible 2FA methods.
|
// PossibleMethods is the set of all possible 2FA methods.
|
||||||
var PossibleMethods = []string{TOTP, U2F, DuoPush}
|
var PossibleMethods = []string{TOTP, U2F, Push}
|
||||||
|
|
|
@ -11,7 +11,7 @@ func SecondFactorAvailableMethodsGet(ctx *middlewares.AutheliaCtx) {
|
||||||
availableMethods := MethodList{authentication.TOTP, authentication.U2F}
|
availableMethods := MethodList{authentication.TOTP, authentication.U2F}
|
||||||
|
|
||||||
if ctx.Configuration.DuoAPI != nil {
|
if ctx.Configuration.DuoAPI != nil {
|
||||||
availableMethods = append(availableMethods, authentication.DuoPush)
|
availableMethods = append(availableMethods, authentication.Push)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Logger.Debugf("Available methods are %s", availableMethods)
|
ctx.Logger.Debugf("Available methods are %s", availableMethods)
|
||||||
|
|
|
@ -1,68 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/clems4ever/authelia/internal/authentication"
|
|
||||||
"github.com/clems4ever/authelia/internal/middlewares"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SecondFactorPreferencesGet get the user preferences regarding 2FA.
|
|
||||||
func SecondFactorPreferencesGet(ctx *middlewares.AutheliaCtx) {
|
|
||||||
preferences := preferences{
|
|
||||||
Method: "totp",
|
|
||||||
}
|
|
||||||
|
|
||||||
userSession := ctx.GetSession()
|
|
||||||
method, err := ctx.Providers.StorageProvider.LoadPrefered2FAMethod(userSession.Username)
|
|
||||||
ctx.Logger.Debugf("Loaded prefered 2FA method of user %s is %s", userSession.Username, method)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(fmt.Errorf("Unable to load prefered 2FA method: %s", err), operationFailedMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if method != "" {
|
|
||||||
// Set the retrieved method.
|
|
||||||
preferences.Method = method
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetJSONBody(preferences)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringInSlice(a string, list []string) bool {
|
|
||||||
for _, b := range list {
|
|
||||||
if b == a {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecondFactorPreferencesPost update the user preferences regarding 2FA.
|
|
||||||
func SecondFactorPreferencesPost(ctx *middlewares.AutheliaCtx) {
|
|
||||||
bodyJSON := preferences{}
|
|
||||||
|
|
||||||
err := ctx.ParseBody(&bodyJSON)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(err, operationFailedMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !stringInSlice(bodyJSON.Method, authentication.PossibleMethods) {
|
|
||||||
ctx.Error(fmt.Errorf("Unknown method %s, it should be either u2f, totp or duo_push", bodyJSON.Method), operationFailedMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userSession := ctx.GetSession()
|
|
||||||
|
|
||||||
ctx.Logger.Debugf("Save new prefered 2FA method of user %s to %s", userSession.Username, bodyJSON.Method)
|
|
||||||
err = ctx.Providers.StorageProvider.SavePrefered2FAMethod(userSession.Username, bodyJSON.Method)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(fmt.Errorf("Unable to save new prefered 2FA method: %s", err), operationFailedMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.ReplyOK()
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/clems4ever/authelia/internal/mocks"
|
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SecondFactorPreferencesSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
|
|
||||||
mock *mocks.MockAutheliaCtx
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) SetupTest() {
|
|
||||||
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
|
||||||
// Set the intial user session.
|
|
||||||
userSession := s.mock.Ctx.GetSession()
|
|
||||||
userSession.Username = "john"
|
|
||||||
userSession.AuthenticationLevel = 1
|
|
||||||
s.mock.Ctx.SaveSession(userSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TearDownTest() {
|
|
||||||
s.mock.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldGetPreferenceRetrievedFromStorage() {
|
|
||||||
s.mock.StorageProviderMock.EXPECT().
|
|
||||||
LoadPrefered2FAMethod(gomock.Eq("john")).
|
|
||||||
Return("u2f", nil)
|
|
||||||
SecondFactorPreferencesGet(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200OK(s.T(), preferences{Method: "u2f"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldGetDefaultPreferenceIfNotInDB() {
|
|
||||||
s.mock.StorageProviderMock.EXPECT().
|
|
||||||
LoadPrefered2FAMethod(gomock.Eq("john")).
|
|
||||||
Return("", nil)
|
|
||||||
SecondFactorPreferencesGet(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200OK(s.T(), preferences{Method: "totp"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturnError500WhenStorageFailsToLoad() {
|
|
||||||
s.mock.StorageProviderMock.EXPECT().
|
|
||||||
LoadPrefered2FAMethod(gomock.Eq("john")).
|
|
||||||
Return("", fmt.Errorf("Failure"))
|
|
||||||
SecondFactorPreferencesGet(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
assert.Equal(s.T(), "Unable to load prefered 2FA method: Failure", s.mock.Hook.LastEntry().Message)
|
|
||||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturnError500WhenNoBodyProvided() {
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
|
||||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturnError500WhenMalformedBodyProvided() {
|
|
||||||
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"abc\""))
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
|
||||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturnError500WhenBadBodyProvided() {
|
|
||||||
s.mock.Ctx.Request.SetBody([]byte("{\"weird_key\":\"abc\"}"))
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
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 *SecondFactorPreferencesSuite) TestShouldReturnError500WhenBadMethodProvided() {
|
|
||||||
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"abc\"}"))
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
assert.Equal(s.T(), "Unknown method abc, it should be either u2f, totp or duo_push", s.mock.Hook.LastEntry().Message)
|
|
||||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturnError500WhenDatabaseFailsToSave() {
|
|
||||||
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}"))
|
|
||||||
s.mock.StorageProviderMock.EXPECT().
|
|
||||||
SavePrefered2FAMethod(gomock.Eq("john"), gomock.Eq("u2f")).
|
|
||||||
Return(fmt.Errorf("Failure"))
|
|
||||||
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
s.mock.Assert200KO(s.T(), "Operation failed.")
|
|
||||||
assert.Equal(s.T(), "Unable to save new prefered 2FA method: Failure", s.mock.Hook.LastEntry().Message)
|
|
||||||
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SecondFactorPreferencesSuite) TestShouldReturn200WhenMethodIsSuccessfullySaved() {
|
|
||||||
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}"))
|
|
||||||
s.mock.StorageProviderMock.EXPECT().
|
|
||||||
SavePrefered2FAMethod(gomock.Eq("john"), gomock.Eq("u2f")).
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
SecondFactorPreferencesPost(s.mock.Ctx)
|
|
||||||
|
|
||||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunPreferencesSuite(t *testing.T) {
|
|
||||||
s := new(SecondFactorPreferencesSuite)
|
|
||||||
suite.Run(t, s)
|
|
||||||
}
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/clems4ever/authelia/internal/authentication"
|
||||||
|
"github.com/clems4ever/authelia/internal/middlewares"
|
||||||
|
"github.com/clems4ever/authelia/internal/storage"
|
||||||
|
"github.com/clems4ever/authelia/internal/utils"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadInfo(username string, storageProvier storage.Provider, preferences *UserPreferences, logger *logrus.Entry) []error {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(3)
|
||||||
|
|
||||||
|
errors := make([]error, 0)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
method, err := storageProvier.LoadPrefered2FAMethod(username)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, err)
|
||||||
|
logger.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if method == "" {
|
||||||
|
preferences.Method = authentication.PossibleMethods[0]
|
||||||
|
} else {
|
||||||
|
preferences.Method = method
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, err := storageProvier.LoadU2FDeviceHandle(username)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrNoU2FDeviceHandle {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors = append(errors, err)
|
||||||
|
logger.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preferences.HasU2F = true
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := storageProvier.LoadTOTPSecret(username)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrNoTOTPSecret {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errors = append(errors, err)
|
||||||
|
logger.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preferences.HasTOTP = true
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfoGet get the info related to the user identitified by the session.
|
||||||
|
func UserInfoGet(ctx *middlewares.AutheliaCtx) {
|
||||||
|
userSession := ctx.GetSession()
|
||||||
|
|
||||||
|
preferences := UserPreferences{}
|
||||||
|
errors := loadInfo(userSession.Username, ctx.Providers.StorageProvider, &preferences, ctx.Logger)
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
ctx.Error(fmt.Errorf("Unable to load user preferences"), operationFailedMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetJSONBody(preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MethodBody struct {
|
||||||
|
Method string `json:"method" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodPreferencePost update the user preferences regarding 2FA method.
|
||||||
|
func MethodPreferencePost(ctx *middlewares.AutheliaCtx) {
|
||||||
|
bodyJSON := MethodBody{}
|
||||||
|
err := ctx.ParseBody(&bodyJSON)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(err, operationFailedMessage)
|
||||||
|
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, ", ")), operationFailedMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userSession := ctx.GetSession()
|
||||||
|
ctx.Logger.Debugf("Save new prefered 2FA method of user %s to %s", userSession.Username, bodyJSON.Method)
|
||||||
|
err = ctx.Providers.StorageProvider.SavePrefered2FAMethod(userSession.Username, bodyJSON.Method)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(fmt.Errorf("Unable to save new prefered 2FA method: %s", err), operationFailedMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ReplyOK()
|
||||||
|
}
|
|
@ -0,0 +1,213 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/clems4ever/authelia/internal/mocks"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FetchSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
mock *mocks.MockAutheliaCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) SetupTest() {
|
||||||
|
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
||||||
|
// Set the intial user session.
|
||||||
|
userSession := s.mock.Ctx.GetSession()
|
||||||
|
userSession.Username = "john"
|
||||||
|
userSession.AuthenticationLevel = 1
|
||||||
|
s.mock.Ctx.SaveSession(userSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) TearDownTest() {
|
||||||
|
s.mock.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) setPreferencesExpectations(preferences UserPreferences) {
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadPrefered2FAMethod(gomock.Eq("john")).
|
||||||
|
Return(preferences.Method, nil)
|
||||||
|
|
||||||
|
var u2fData []byte
|
||||||
|
if preferences.HasU2F {
|
||||||
|
u2fData = []byte("abc")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadU2FDeviceHandle(gomock.Eq("john")).
|
||||||
|
Return(u2fData, u2fData, nil)
|
||||||
|
|
||||||
|
var totpSecret string
|
||||||
|
if preferences.HasTOTP {
|
||||||
|
totpSecret = "secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadTOTPSecret(gomock.Eq("john")).
|
||||||
|
Return(totpSecret, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) TestMethodSetToU2F() {
|
||||||
|
table := []UserPreferences{
|
||||||
|
UserPreferences{
|
||||||
|
Method: "totp",
|
||||||
|
},
|
||||||
|
UserPreferences{
|
||||||
|
Method: "u2f",
|
||||||
|
HasU2F: true,
|
||||||
|
HasTOTP: true,
|
||||||
|
},
|
||||||
|
UserPreferences{
|
||||||
|
Method: "u2f",
|
||||||
|
HasU2F: true,
|
||||||
|
HasTOTP: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expectedPreferences := range table {
|
||||||
|
s.setPreferencesExpectations(expectedPreferences)
|
||||||
|
UserInfoGet(s.mock.Ctx)
|
||||||
|
|
||||||
|
actualPreferences := UserPreferences{}
|
||||||
|
s.mock.GetResponseData(s.T(), &actualPreferences)
|
||||||
|
|
||||||
|
s.Run("expected method", func() {
|
||||||
|
s.Assert().Equal(expectedPreferences.Method, actualPreferences.Method)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("registered u2f", func() {
|
||||||
|
s.Assert().Equal(expectedPreferences.HasU2F, actualPreferences.HasU2F)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("registered totp", func() {
|
||||||
|
s.Assert().Equal(expectedPreferences.HasTOTP, actualPreferences.HasTOTP)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) TestShouldGetDefaultPreferenceIfNotInDB() {
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadPrefered2FAMethod(gomock.Eq("john")).
|
||||||
|
Return("", nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadU2FDeviceHandle(gomock.Eq("john")).
|
||||||
|
Return(nil, nil, nil)
|
||||||
|
|
||||||
|
s.mock.StorageProviderMock.
|
||||||
|
EXPECT().
|
||||||
|
LoadTOTPSecret(gomock.Eq("john")).
|
||||||
|
Return("", nil)
|
||||||
|
|
||||||
|
UserInfoGet(s.mock.Ctx)
|
||||||
|
s.mock.Assert200OK(s.T(), UserPreferences{Method: "totp"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FetchSuite) TestShouldReturnError500WhenStorageFailsToLoad() {
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
LoadPrefered2FAMethod(gomock.Eq("john")).
|
||||||
|
Return("", fmt.Errorf("Failure"))
|
||||||
|
UserInfoGet(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
assert.Equal(s.T(), "Unable to load prefered 2FA method: Failure", s.mock.Hook.LastEntry().Message)
|
||||||
|
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSuite(t *testing.T) {
|
||||||
|
suite.Run(t, &FetchSuite{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
mock *mocks.MockAutheliaCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) SetupTest() {
|
||||||
|
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
||||||
|
// Set the intial user session.
|
||||||
|
userSession := s.mock.Ctx.GetSession()
|
||||||
|
userSession.Username = "john"
|
||||||
|
userSession.AuthenticationLevel = 1
|
||||||
|
s.mock.Ctx.SaveSession(userSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TearDownTest() {
|
||||||
|
s.mock.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TestShouldReturnError500WhenNoBodyProvided() {
|
||||||
|
s.mock.Ctx.Request.SetBody(nil)
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
||||||
|
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TestShouldReturnError500WhenMalformedBodyProvided() {
|
||||||
|
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"abc\""))
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
||||||
|
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TestShouldReturnError500WhenBadBodyProvided() {
|
||||||
|
s.mock.Ctx.Request.SetBody([]byte("{\"weird_key\":\"abc\"}"))
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
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 *SaveSuite) TestShouldReturnError500WhenBadMethodProvided() {
|
||||||
|
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"abc\"}"))
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
assert.Equal(s.T(), "Unknown method 'abc', it should be one of totp, u2f, mobile_push", s.mock.Hook.LastEntry().Message)
|
||||||
|
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TestShouldReturnError500WhenDatabaseFailsToSave() {
|
||||||
|
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}"))
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
SavePrefered2FAMethod(gomock.Eq("john"), gomock.Eq("u2f")).
|
||||||
|
Return(fmt.Errorf("Failure"))
|
||||||
|
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
s.mock.Assert200KO(s.T(), "Operation failed.")
|
||||||
|
assert.Equal(s.T(), "Unable to save new prefered 2FA method: Failure", s.mock.Hook.LastEntry().Message)
|
||||||
|
assert.Equal(s.T(), logrus.ErrorLevel, s.mock.Hook.LastEntry().Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SaveSuite) TestShouldReturn200WhenMethodIsSuccessfullySaved() {
|
||||||
|
s.mock.Ctx.Request.SetBody([]byte("{\"method\":\"u2f\"}"))
|
||||||
|
s.mock.StorageProviderMock.EXPECT().
|
||||||
|
SavePrefered2FAMethod(gomock.Eq("john"), gomock.Eq("u2f")).
|
||||||
|
Return(nil)
|
||||||
|
|
||||||
|
MethodPreferencePost(s.mock.Ctx)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveSuite(t *testing.T) {
|
||||||
|
suite.Run(t, &SaveSuite{})
|
||||||
|
}
|
|
@ -10,10 +10,16 @@ type MethodList = []string
|
||||||
|
|
||||||
type authorizationMatching int
|
type authorizationMatching int
|
||||||
|
|
||||||
// preferences is the model of user second factor preferences
|
// UserInfo is the model of user second factor preferences
|
||||||
type preferences struct {
|
type UserPreferences struct {
|
||||||
// The prefered 2FA method.
|
// The prefered 2FA method.
|
||||||
Method string `json:"method" valid:"required"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
|
// signTOTPRequestBody model of the request body received by TOTP authentication endpoint.
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/clems4ever/authelia/internal/regulation"
|
"github.com/clems4ever/authelia/internal/regulation"
|
||||||
"github.com/clems4ever/authelia/internal/storage"
|
"github.com/clems4ever/authelia/internal/storage"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/clems4ever/authelia/internal/authorization"
|
"github.com/clems4ever/authelia/internal/authorization"
|
||||||
"github.com/clems4ever/authelia/internal/configuration/schema"
|
"github.com/clems4ever/authelia/internal/configuration/schema"
|
||||||
|
@ -143,3 +144,10 @@ func (m *MockAutheliaCtx) Assert200OK(t *testing.T, data interface{}) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, string(b), string(m.Ctx.Response.Body()))
|
assert.Equal(t, string(b), string(m.Ctx.Response.Body()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockAutheliaCtx) GetResponseData(t *testing.T, data interface{}) {
|
||||||
|
okResponse := middlewares.OKResponse{}
|
||||||
|
okResponse.Data = data
|
||||||
|
err := json.Unmarshal(m.Ctx.Response.Body(), &okResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
|
@ -49,10 +49,11 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
||||||
router.GET("/api/secondfactor/available", autheliaMiddleware(
|
router.GET("/api/secondfactor/available", autheliaMiddleware(
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorAvailableMethodsGet)))
|
middlewares.RequireFirstFactor(handlers.SecondFactorAvailableMethodsGet)))
|
||||||
|
|
||||||
router.GET("/api/secondfactor/preferences", autheliaMiddleware(
|
// Information about the user
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorPreferencesGet)))
|
router.GET("/api/user/info", autheliaMiddleware(
|
||||||
router.POST("/api/secondfactor/preferences", autheliaMiddleware(
|
middlewares.RequireFirstFactor(handlers.UserInfoGet)))
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorPreferencesPost)))
|
router.POST("/api/user/info/2fa_method", autheliaMiddleware(
|
||||||
|
middlewares.RequireFirstFactor(handlers.MethodPreferencePost)))
|
||||||
|
|
||||||
// TOTP related endpoints
|
// TOTP related endpoints
|
||||||
router.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware(
|
router.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware(
|
||||||
|
|
|
@ -5,4 +5,7 @@ import "errors"
|
||||||
var (
|
var (
|
||||||
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
|
// ErrNoU2FDeviceHandle error thrown when no U2F device handle has been found in DB.
|
||||||
ErrNoU2FDeviceHandle = errors.New("No U2F device handle found")
|
ErrNoU2FDeviceHandle = errors.New("No U2F device handle found")
|
||||||
|
|
||||||
|
// ErrNoTOTPSecret error thrown when no TOTP secret has been found in DB
|
||||||
|
ErrNoTOTPSecret = errors.New("No TOTP secret registered")
|
||||||
)
|
)
|
||||||
|
|
|
@ -20,7 +20,7 @@ type Provider interface {
|
||||||
LoadTOTPSecret(username string) (string, error)
|
LoadTOTPSecret(username string) (string, error)
|
||||||
|
|
||||||
SaveU2FDeviceHandle(username string, keyHandle []byte, publicKey []byte) error
|
SaveU2FDeviceHandle(username string, keyHandle []byte, publicKey []byte) error
|
||||||
LoadU2FDeviceHandle(username string) ([]byte, []byte, error)
|
LoadU2FDeviceHandle(username string) (keyHandle []byte, publicKey []byte, err error)
|
||||||
|
|
||||||
AppendAuthenticationLog(attempt models.AuthenticationAttempt) error
|
AppendAuthenticationLog(attempt models.AuthenticationAttempt) error
|
||||||
LoadLatestAuthenticationLogs(username string, fromDate time.Time) ([]models.AuthenticationAttempt, error)
|
LoadLatestAuthenticationLogs(username string, fromDate time.Time) ([]models.AuthenticationAttempt, error)
|
||||||
|
|
|
@ -128,7 +128,7 @@ func (p *SQLProvider) LoadTOTPSecret(username string) (string, error) {
|
||||||
var secret string
|
var secret string
|
||||||
if err := p.db.QueryRow(p.sqlGetTOTPSecretByUsername, username).Scan(&secret); err != nil {
|
if err := p.db.QueryRow(p.sqlGetTOTPSecretByUsername, username).Scan(&secret); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return "", nil
|
return "", ErrNoTOTPSecret
|
||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,8 +42,8 @@ func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() {
|
||||||
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign", AutheliaBaseURL), 403)
|
||||||
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/register", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/register", AutheliaBaseURL), 403)
|
||||||
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign_request", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/sign_request", AutheliaBaseURL), 403)
|
||||||
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/preferences", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/user/info/2fa_method", AutheliaBaseURL), 403)
|
||||||
s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/secondfactor/preferences", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/secondfactor/user/info", AutheliaBaseURL), 403)
|
||||||
s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/secondfactor/available", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/secondfactor/available", AutheliaBaseURL), 403)
|
||||||
|
|
||||||
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/start", AutheliaBaseURL), 403)
|
s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/start", AutheliaBaseURL), 403)
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
func IsStringInSlice(a string, list []string) bool {
|
||||||
|
for _, b := range list {
|
||||||
|
if b == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -2,5 +2,5 @@
|
||||||
export enum SecondFactorMethod {
|
export enum SecondFactorMethod {
|
||||||
TOTP = 1,
|
TOTP = 1,
|
||||||
U2F = 2,
|
U2F = 2,
|
||||||
Duo = 3
|
MobilePush = 3
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,8 @@ export const ResetPasswordPath = "/api/reset-password"
|
||||||
|
|
||||||
export const LogoutPath = "/api/logout";
|
export const LogoutPath = "/api/logout";
|
||||||
export const StatePath = "/api/state";
|
export const StatePath = "/api/state";
|
||||||
export const UserPreferencesPath = "/api/secondfactor/preferences";
|
export const UserInfoPath = "/api/user/info";
|
||||||
|
export const UserInfo2FAMethodPath = "/api/user/info/2fa_method";
|
||||||
export const Available2FAMethodsPath = "/api/secondfactor/available";
|
export const Available2FAMethodsPath = "/api/secondfactor/available";
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Get, PostWithOptionalResponse } from "./Client";
|
import { Get, PostWithOptionalResponse } from "./Client";
|
||||||
import { UserPreferencesPath } from "./Api";
|
import { UserInfoPath, UserInfo2FAMethodPath } from "./Api";
|
||||||
import { SecondFactorMethod } from "../models/Methods";
|
import { SecondFactorMethod } from "../models/Methods";
|
||||||
import { UserPreferences } from "../models/UserPreferences";
|
import { UserPreferences } from "../models/UserPreferences";
|
||||||
|
|
||||||
export type Method2FA = "u2f" | "totp" | "duo_push";
|
export type Method2FA = "u2f" | "totp" | "mobile_push";
|
||||||
|
|
||||||
export interface UserPreferencesPayload {
|
export interface UserPreferencesPayload {
|
||||||
method: Method2FA;
|
method: Method2FA;
|
||||||
|
@ -15,8 +15,8 @@ export function toEnum(method: Method2FA): SecondFactorMethod {
|
||||||
return SecondFactorMethod.U2F;
|
return SecondFactorMethod.U2F;
|
||||||
case "totp":
|
case "totp":
|
||||||
return SecondFactorMethod.TOTP;
|
return SecondFactorMethod.TOTP;
|
||||||
case "duo_push":
|
case "mobile_push":
|
||||||
return SecondFactorMethod.Duo;
|
return SecondFactorMethod.MobilePush;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,17 +26,17 @@ export function toString(method: SecondFactorMethod): Method2FA {
|
||||||
return "u2f";
|
return "u2f";
|
||||||
case SecondFactorMethod.TOTP:
|
case SecondFactorMethod.TOTP:
|
||||||
return "totp";
|
return "totp";
|
||||||
case SecondFactorMethod.Duo:
|
case SecondFactorMethod.MobilePush:
|
||||||
return "duo_push";
|
return "mobile_push";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
const res = await Get<UserPreferencesPayload>(UserPreferencesPath);
|
const res = await Get<UserPreferencesPayload>(UserInfoPath);
|
||||||
return { method: toEnum(res.method) };
|
return { method: toEnum(res.method) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPrefered2FAMethod(method: SecondFactorMethod) {
|
export function setPrefered2FAMethod(method: SecondFactorMethod) {
|
||||||
return PostWithOptionalResponse(UserPreferencesPath,
|
return PostWithOptionalResponse(UserInfo2FAMethodPath,
|
||||||
{ method: toString(method) } as UserPreferencesPayload);
|
{ method: toString(method) } as UserPreferencesPayload);
|
||||||
}
|
}
|
|
@ -81,7 +81,7 @@ export default function () {
|
||||||
console.log("redirect");
|
console.log("redirect");
|
||||||
if (preferences.method === SecondFactorMethod.U2F) {
|
if (preferences.method === SecondFactorMethod.U2F) {
|
||||||
redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`);
|
redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`);
|
||||||
} else if (preferences.method === SecondFactorMethod.Duo) {
|
} else if (preferences.method === SecondFactorMethod.MobilePush) {
|
||||||
redirect(`${SecondFactorPushRoute}${redirectionSuffix}`);
|
redirect(`${SecondFactorPushRoute}${redirectionSuffix}`);
|
||||||
} else {
|
} else {
|
||||||
redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
|
redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
|
||||||
|
|
|
@ -42,12 +42,12 @@ export default function (props: Props) {
|
||||||
icon={<FingerTouchIcon size={32} />}
|
icon={<FingerTouchIcon size={32} />}
|
||||||
onClick={() => props.onClick(SecondFactorMethod.U2F)} />
|
onClick={() => props.onClick(SecondFactorMethod.U2F)} />
|
||||||
: null}
|
: null}
|
||||||
{props.methods.has(SecondFactorMethod.Duo)
|
{props.methods.has(SecondFactorMethod.MobilePush)
|
||||||
? <MethodItem
|
? <MethodItem
|
||||||
id="push-notification-option"
|
id="push-notification-option"
|
||||||
method="Push Notification"
|
method="Push Notification"
|
||||||
icon={<PushNotificationIcon width={32} height={32} />}
|
icon={<PushNotificationIcon width={32} height={32} />}
|
||||||
onClick={() => props.onClick(SecondFactorMethod.Duo)} />
|
onClick={() => props.onClick(SecondFactorMethod.MobilePush)} />
|
||||||
: null}
|
: null}
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
Loading…
Reference in New Issue