mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
ad8e844af6
Allow users to configure the TOTP Algorithm and Digits. This should be used with caution as many TOTP applications do not support it. Some will also fail to notify the user that there is an issue. i.e. if the algorithm in the QR code is sha512, they continue to generate one time passwords with sha1. In addition this drastically refactors TOTP in general to be more user friendly by not forcing them to register a new device if the administrator changes the period (or algorithm). Fixes #1226.
503 lines
15 KiB
Go
503 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
|
|
"github.com/authelia/authelia/v4/internal/authentication"
|
|
"github.com/authelia/authelia/v4/internal/authorization"
|
|
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
|
"github.com/authelia/authelia/v4/internal/mocks"
|
|
"github.com/authelia/authelia/v4/internal/models"
|
|
"github.com/authelia/authelia/v4/internal/regulation"
|
|
)
|
|
|
|
type FirstFactorSuite struct {
|
|
suite.Suite
|
|
|
|
mock *mocks.MockAutheliaCtx
|
|
}
|
|
|
|
func (s *FirstFactorSuite) SetupTest() {
|
|
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TearDownTest() {
|
|
s.mock.Close()
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldFailIfBodyIsNil() {
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// No body
|
|
assert.Equal(s.T(), "Failed to parse 1FA request body: unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldFailIfBodyIsInBadFormat() {
|
|
// Missing password
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test"
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
assert.Equal(s.T(), "Failed to parse 1FA request body: unable to validate body: password: non zero value required", s.mock.Hook.LastEntry().Message)
|
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(false, fmt.Errorf("failed"))
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
|
|
Username: "test",
|
|
Successful: false,
|
|
Banned: false,
|
|
Time: s.mock.Clock.Now(),
|
|
Type: regulation.AuthType1FA,
|
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
|
}))
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
assert.Equal(s.T(), "Unsuccessful 1FA authentication attempt by user 'test': failed", s.mock.Hook.LastEntry().Message)
|
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderCheckPasswordError() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(false, fmt.Errorf("invalid credentials"))
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
|
|
Username: "test",
|
|
Successful: false,
|
|
Banned: false,
|
|
Time: s.mock.Clock.Now(),
|
|
Type: regulation.AuthType1FA,
|
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
|
}))
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCredentials() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(false, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(models.AuthenticationAttempt{
|
|
Username: "test",
|
|
Successful: false,
|
|
Banned: false,
|
|
Time: s.mock.Clock.Now(),
|
|
Type: regulation.AuthType1FA,
|
|
RemoteIP: models.NewIPAddressFromString("0.0.0.0"),
|
|
}))
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(nil)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
GetDetails(gomock.Eq("test")).
|
|
Return(nil, fmt.Errorf("failed"))
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
assert.Equal(s.T(), "Could not obtain profile details during 1FA authentication for user 'test': failed", s.mock.Hook.LastEntry().Message)
|
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(fmt.Errorf("failed"))
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
assert.Equal(s.T(), "Unable to mark 1FA authentication attempt by user 'test': failed", s.mock.Hook.LastEntry().Message)
|
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
GetDetails(gomock.Eq("test")).
|
|
Return(&authentication.UserDetails{
|
|
Username: "test",
|
|
Emails: []string{"test@example.com"},
|
|
Groups: []string{"dev", "admins"},
|
|
}, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(nil)
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
|
assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
|
|
|
|
// And store authentication in session.
|
|
session := s.mock.Ctx.GetSession()
|
|
assert.Equal(s.T(), "test", session.Username)
|
|
assert.Equal(s.T(), true, session.KeepMeLoggedIn)
|
|
assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel)
|
|
assert.Equal(s.T(), []string{"test@example.com"}, session.Emails)
|
|
assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups)
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
GetDetails(gomock.Eq("test")).
|
|
Return(&authentication.UserDetails{
|
|
Username: "test",
|
|
Emails: []string{"test@example.com"},
|
|
Groups: []string{"dev", "admins"},
|
|
}, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(nil)
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": false
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
|
assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
|
|
|
|
// And store authentication in session.
|
|
session := s.mock.Ctx.GetSession()
|
|
assert.Equal(s.T(), "test", session.Username)
|
|
assert.Equal(s.T(), false, session.KeepMeLoggedIn)
|
|
assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel)
|
|
assert.Equal(s.T(), []string{"test@example.com"}, session.Emails)
|
|
assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups)
|
|
}
|
|
|
|
func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSession() {
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
GetDetails(gomock.Eq("test")).
|
|
Return(&authentication.UserDetails{
|
|
// This is the name in authentication backend, in some setups the binding is
|
|
// case insensitive but the user ID in session must match the user in LDAP
|
|
// for the other modules of Authelia to be coherent.
|
|
Username: "Test",
|
|
Emails: []string{"test@example.com"},
|
|
Groups: []string{"dev", "admins"},
|
|
}, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(nil)
|
|
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": true
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
|
assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
|
|
|
|
// And store authentication in session.
|
|
session := s.mock.Ctx.GetSession()
|
|
assert.Equal(s.T(), "Test", session.Username)
|
|
assert.Equal(s.T(), true, session.KeepMeLoggedIn)
|
|
assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel)
|
|
assert.Equal(s.T(), []string{"test@example.com"}, session.Emails)
|
|
assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups)
|
|
}
|
|
|
|
type FirstFactorRedirectionSuite struct {
|
|
suite.Suite
|
|
|
|
mock *mocks.MockAutheliaCtx
|
|
}
|
|
|
|
func (s *FirstFactorRedirectionSuite) SetupTest() {
|
|
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
|
s.mock.Ctx.Configuration.DefaultRedirectionURL = "https://default.local"
|
|
s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = "bypass"
|
|
s.mock.Ctx.Configuration.AccessControl.Rules = []schema.ACLRule{
|
|
{
|
|
Domains: []string{"default.local"},
|
|
Policy: "one_factor",
|
|
},
|
|
}
|
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&s.mock.Ctx.Configuration)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
|
|
Return(true, nil)
|
|
|
|
s.mock.UserProviderMock.
|
|
EXPECT().
|
|
GetDetails(gomock.Eq("test")).
|
|
Return(&authentication.UserDetails{
|
|
Username: "test",
|
|
Emails: []string{"test@example.com"},
|
|
Groups: []string{"dev", "admins"},
|
|
}, nil)
|
|
|
|
s.mock.StorageMock.
|
|
EXPECT().
|
|
AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
|
|
Return(nil)
|
|
}
|
|
|
|
func (s *FirstFactorRedirectionSuite) TearDownTest() {
|
|
s.mock.Close()
|
|
}
|
|
|
|
// When:
|
|
// 1/ the target url is unknown
|
|
// 2/ two_factor is disabled (no policy is set to two_factor)
|
|
// 3/ default_redirect_url is provided
|
|
// Then:
|
|
// the user should be redirected to the default url.
|
|
func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTargetURLProvidedAndTwoFactorDisabled() {
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": false
|
|
}`)
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
|
}
|
|
|
|
// When:
|
|
// 1/ the target url is unsafe
|
|
// 2/ two_factor is disabled (no policy is set to two_factor)
|
|
// 3/ default_redirect_url is provided
|
|
// Then:
|
|
// the user should be redirected to the default url.
|
|
func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUnsafeAndTwoFactorDisabled() {
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": false,
|
|
"targetURL": "http://notsafe.local"
|
|
}`)
|
|
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
|
}
|
|
|
|
// When:
|
|
// 1/ two_factor is enabled (default policy)
|
|
// Then:
|
|
// the user should receive 200 without redirection URL.
|
|
func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedAndTwoFactorEnabled() {
|
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
|
|
AccessControl: schema.AccessControlConfiguration{
|
|
DefaultPolicy: "two_factor",
|
|
},
|
|
})
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": false
|
|
}`)
|
|
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
s.mock.Assert200OK(s.T(), nil)
|
|
}
|
|
|
|
// When:
|
|
// 1/ two_factor is enabled (some rule)
|
|
// Then:
|
|
// the user should receive 200 without redirection URL.
|
|
func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvidedAndTwoFactorEnabled() {
|
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
|
|
AccessControl: schema.AccessControlConfiguration{
|
|
DefaultPolicy: "one_factor",
|
|
Rules: []schema.ACLRule{
|
|
{
|
|
Domains: []string{"test.example.com"},
|
|
Policy: "one_factor",
|
|
},
|
|
{
|
|
Domains: []string{"example.com"},
|
|
Policy: "two_factor",
|
|
},
|
|
},
|
|
}})
|
|
s.mock.Ctx.Request.SetBodyString(`{
|
|
"username": "test",
|
|
"password": "hello",
|
|
"requestMethod": "GET",
|
|
"keepMeLoggedIn": false
|
|
}`)
|
|
|
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
|
|
|
// Respond with 200.
|
|
s.mock.Assert200OK(s.T(), nil)
|
|
}
|
|
|
|
func TestFirstFactorSuite(t *testing.T) {
|
|
suite.Run(t, new(FirstFactorSuite))
|
|
suite.Run(t, new(FirstFactorRedirectionSuite))
|
|
}
|
|
|
|
func TestFirstFactorDelayAverages(t *testing.T) {
|
|
execDuration := time.Millisecond * 500
|
|
oneSecond := time.Millisecond * 1000
|
|
durations := []time.Duration{oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond}
|
|
cursor := 0
|
|
mutex := &sync.Mutex{}
|
|
avgExecDuration := movingAverageIteration(execDuration, false, &cursor, &durations, mutex)
|
|
assert.Equal(t, avgExecDuration, float64(1000))
|
|
|
|
execDurations := []time.Duration{
|
|
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
|
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
|
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
|
}
|
|
|
|
current := float64(1000)
|
|
|
|
// Execute at 500ms for 12 requests.
|
|
for _, execDuration = range execDurations {
|
|
// Should not dip below 500, and should decrease in value by 50 each iteration.
|
|
if current > 500 {
|
|
current -= 50
|
|
}
|
|
|
|
avgExecDuration := movingAverageIteration(execDuration, true, &cursor, &durations, mutex)
|
|
assert.Equal(t, avgExecDuration, current)
|
|
}
|
|
}
|
|
|
|
func TestFirstFactorDelayCalculations(t *testing.T) {
|
|
mock := mocks.NewMockAutheliaCtx(t)
|
|
successful := false
|
|
|
|
execDuration := 500 * time.Millisecond
|
|
avgExecDurationMs := 1000.0
|
|
expectedMinimumDelayMs := avgExecDurationMs - float64(execDuration.Milliseconds())
|
|
|
|
for i := 0; i < 100; i++ {
|
|
delay := calculateActualDelay(mock.Ctx, execDuration, avgExecDurationMs, &successful)
|
|
assert.True(t, delay >= expectedMinimumDelayMs)
|
|
assert.True(t, delay <= expectedMinimumDelayMs+float64(loginDelayMaximumRandomDelayMilliseconds))
|
|
}
|
|
|
|
execDuration = 5 * time.Millisecond
|
|
avgExecDurationMs = 5.0
|
|
expectedMinimumDelayMs = loginDelayMinimumDelayMilliseconds - float64(execDuration.Milliseconds())
|
|
|
|
for i := 0; i < 100; i++ {
|
|
delay := calculateActualDelay(mock.Ctx, execDuration, avgExecDurationMs, &successful)
|
|
assert.True(t, delay >= expectedMinimumDelayMs)
|
|
assert.True(t, delay <= expectedMinimumDelayMs+float64(loginDelayMaximumRandomDelayMilliseconds))
|
|
}
|
|
}
|