summaryrefslogtreecommitdiff
path: root/internal/handlers/handler_firstfactor_password_test.go
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2025-02-23 16:08:49 +1100
committerGitHub <noreply@github.com>2025-02-23 16:08:49 +1100
commit197b45521f5e3799d0b9ef1ec0000d4f83abdee9 (patch)
tree065752a305c6edccaca3d72dfe45868029df8c54 /internal/handlers/handler_firstfactor_password_test.go
parent8a5e96a342d28c00b7dcaa72d16f39ddfcdaec74 (diff)
feat(webauthn): passkeys (#7942)
Add support for passkeys, granular attachment modality, granular authenticator selection, and authenticator filtering which is commonly used in an enterprise environment. This also adds metadata verification elements utilizing the MDS3 to the project, including saving attestation statements, verification of attestation statements, etc. This also makes a significant change to the authentication level logic to purely use RFC8176 authentication method references to ensure the future-proof nature of the implementation. This change paves the way for the future of Authelia ensuring we can add custom policies in the future to allow administrators to very deliberately decide what authentication methods are sufficient for a given resource as well as the ability to clearly communicate these authentication methods to third parties via OpenID Connect 1.0 and SAML 2.0. It should be noted that at the time of this commit Passkey authentication is considered a single factor and we will at a later stage add the customizable policies described here to handle other use cases, though we've included a flag that considers properly implemented passkeys as if they were MFA. Closes #2827, Closes #2761 Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Diffstat (limited to 'internal/handlers/handler_firstfactor_password_test.go')
-rw-r--r--internal/handlers/handler_firstfactor_password_test.go1698
1 files changed, 1698 insertions, 0 deletions
diff --git a/internal/handlers/handler_firstfactor_password_test.go b/internal/handlers/handler_firstfactor_password_test.go
new file mode 100644
index 000000000..742ac801e
--- /dev/null
+++ b/internal/handlers/handler_firstfactor_password_test.go
@@ -0,0 +1,1698 @@
+package handlers
+
+import (
+ "database/sql"
+ "fmt"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/valyala/fasthttp"
+ "go.uber.org/mock/gomock"
+
+ "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/model"
+ "github.com/authelia/authelia/v4/internal/oidc"
+ "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() {
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // No body.
+ s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to parse body: unexpected end of JSON input")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldFailIfBodyIsInBadFormat() {
+ // Missing password.
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test"
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to validate body: password: non zero value required")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq("test")).
+ Return(&authentication.UserDetails{Username: "test"}, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("failed"))
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test'", "failed")
+
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderCheckPasswordError() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq("test")).
+ Return(&authentication.UserDetails{Username: "test"}, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("invalid credentials"))
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorSuite) TestShouldCheckUserNotBanned() {
+ s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2}, s.mock.StorageMock, &s.mock.Clock)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ gomock.InOrder(
+ s.mock.UserProviderMock.EXPECT().GetDetails(testValue).Return(&authentication.UserDetails{Username: testValue}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(nil, nil),
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("invalid credentials")),
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ })))
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorSuite) TestShouldCheckBannedUser() {
+ s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2, FindTime: time.Hour, BanTime: time.Hour}, s.mock.StorageMock, &s.mock.Clock)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ gomock.InOrder(
+ s.mock.UserProviderMock.EXPECT().GetDetails(testValue).Return(&authentication.UserDetails{Username: testValue}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()).
+ Return([]model.AuthenticationAttempt{
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ }, nil),
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: true,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ })))
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test' and they are banned until 2013-02-03 00:59:59 +0000 UTC", "")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCredentials() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq("test")).
+ Return(&authentication.UserDetails{Username: "test"}, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, nil)
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ Return(nil, fmt.Errorf("failed"))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ AssertLogEntryMessageAndError(s.T(), s.mock.Hook.LastEntry(), "Error occurred getting details for user with username input 'test' which usually indicates they do not exist", "failed")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq("test")).
+ Return(&authentication.UserDetails{Username: "test"}, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), 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
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unable to mark 1FA authentication attempt by user 'test'", "failed")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(true, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ Return(&authentication.UserDetails{
+ Username: testValue,
+ 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
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ assert.Equal(s.T(), fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode())
+ assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
+
+ userSession, err := s.mock.Ctx.GetSession()
+ s.Assert().NoError(err)
+
+ assert.Equal(s.T(), testValue, userSession.Username)
+ assert.Equal(s.T(), true, userSession.KeepMeLoggedIn)
+ assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel(false))
+ assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails)
+ assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups)
+}
+
+func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(true, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ Return(&authentication.UserDetails{
+ Username: testValue,
+ 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
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ assert.Equal(s.T(), fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode())
+ assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
+
+ userSession, err := s.mock.Ctx.GetSession()
+ s.Assert().NoError(err)
+
+ assert.Equal(s.T(), testValue, userSession.Username)
+ assert.Equal(s.T(), false, userSession.KeepMeLoggedIn)
+ assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel(false))
+ assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails)
+ assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups)
+}
+
+func (s *FirstFactorSuite) TestShouldAuthenticateUserWithEmailAsUsernameInput() {
+ gomock.InOrder(
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq("test@example.com")).
+ Return(&authentication.UserDetails{
+ Username: "test",
+ Emails: []string{"test@example.com"},
+ Groups: []string{"dev", "admins"},
+ }, nil),
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
+ Return(true, nil),
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{Time: s.mock.Clock.Now(), Successful: true, Username: "test", Type: regulation.AuthType1FA, RemoteIP: model.NewNullIP(s.mock.Ctx.RemoteIP())})).
+ Return(nil),
+ )
+
+ s.mock.Ctx.Request.SetBodyString(`{"username":"test@example.com","password":"hello","requestMethod":"GET","keepMeLoggedIn":false}`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.Equal(fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode())
+ s.Equal([]byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
+
+ userSession, err := s.mock.Ctx.GetSession()
+ s.Assert().NoError(err)
+
+ s.Equal(testValue, userSession.Username)
+ s.Equal(false, userSession.KeepMeLoggedIn)
+ s.Equal(authentication.OneFactor, userSession.AuthenticationLevel(false))
+ s.Equal([]string{"test@example.com"}, userSession.Emails)
+ s.Equal([]string{"dev", "admins"}, userSession.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(testValue)).
+ 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
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ assert.Equal(s.T(), fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode())
+ assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
+
+ userSession, err := s.mock.Ctx.GetSession()
+ s.Assert().NoError(err)
+
+ assert.Equal(s.T(), "Test", userSession.Username)
+ assert.Equal(s.T(), true, userSession.KeepMeLoggedIn)
+ assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel(false))
+ assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails)
+ assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups)
+}
+
+type FirstFactorRedirectionSuite struct {
+ suite.Suite
+
+ mock *mocks.MockAutheliaCtx
+}
+
+func (s *FirstFactorRedirectionSuite) SetupTest() {
+ s.mock = mocks.NewMockAutheliaCtx(s.T())
+ s.mock.Ctx.Configuration.Session.Cookies[0].DefaultRedirectionURL = &url.URL{Scheme: "https", Host: "default.local"}
+ s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass
+ s.mock.Ctx.Configuration.AccessControl.Rules = []schema.AccessControlRule{
+ {
+ 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(testValue), gomock.Eq("hello")).
+ Return(true, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ Return(&authentication.UserDetails{
+ Username: testValue,
+ 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
+ }`)
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"})
+}
+
+// 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"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"})
+}
+
+// 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.AccessControl{
+ DefaultPolicy: "two_factor",
+ },
+ })
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false
+ }`)
+
+ FirstFactorPasswordPOST(nil)(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.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ 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
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), nil)
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyWhenBadTargetURL() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ 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,
+ "targetURL": "#https://23kjnm412jk3"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoFactorOK() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "targetURL": "https://two-factor.example.com"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), nil)
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoTwoFactorUnsafe() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "targetURL": "https://unsafe-domain.com"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), nil)
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyTwoTwoFactorSafe() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "targetURL": "https://test.example.com"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://test.example.com"})
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantParseUUID() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "aaaaaaaaaaaaaaaaaaaaaaaaaaa-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to parse consent session challenge id 'aaaaaaaaaaaaaaaaaaaaaaaaaaa-9107-4067-8d31-407ca59eb69c': invalid UUID length: 55", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantGetConsentSession() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(nil, fmt.Errorf("failed to obtain")),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to load consent session by challenge id 'd1ba0ad8-9107-4067-8d31-407ca59eb69c': failed to obtain", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectConsentSessionAlreadyResponded() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{RespondedAt: sql.NullTime{Valid: true, Time: time.Now().Add(-time.Minute)}}, nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "consent has already been responded to 'd1ba0ad8-9107-4067-8d31-407ca59eb69c'", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectCantGetClient() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(&schema.Configuration{IdentityProviders: schema.IdentityProviders{OIDC: &schema.IdentityProvidersOpenIDConnect{}}}, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to get client for client with id 'd1ba0ad8-9107-4067-8d31-407ca59eb69c' with consent challenge id '00000000-0000-0000-0000-000000000000': invalid_client", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueID() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, fmt.Errorf("bad identifier")),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to determine consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': bad identifier", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueIDCreateError() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(fmt.Errorf("oops")),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to determine consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': oops", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNoOpaqueIDCreateSaveConsentError() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc"}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ s.mock.StorageMock.EXPECT().
+ SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(fmt.Errorf("bad id")),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to update consent subject for client with id 'abc' with consent challenge id '00000000-0000-0000-0000-000000000000': bad id", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectFormRequiresLogin() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ form := url.Values{
+ "max_age": []string{"0"},
+ }
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: form.Encode()}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ s.mock.StorageMock.EXPECT().
+ SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "http://example.com/consent/openid/login?workflow=openid_connect&workflow_id=d1ba0ad8-9107-4067-8d31-407ca59eb69c"})
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectFormRequiresLoginBadForm() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: "1238y12978y189gb128g1287g12807g128702g38172%1"}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ s.mock.StorageMock.EXPECT().
+ SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200KO(s.T(), "Authentication failed. Check your credentials.")
+ s.mock.AssertLastLogMessage(s.T(), "unable to get authorization form values from consent session with challenge id '00000000-0000-0000-0000-000000000000': invalid URL escape \"%1\"", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNeeds2FA() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ AuthorizationPolicy: "two_factor",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: ""}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ s.mock.StorageMock.EXPECT().
+ SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), nil)
+ s.mock.AssertLastLogMessage(s.T(), "OpenID Connect client 'abc' requires 2FA, cannot be redirected yet", "")
+}
+
+func (s *FirstFactorRedirectionSuite) TestShouldReplyOpenIDConnectNeeds1FA() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"two-factor.example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+
+ config := &schema.Configuration{
+ IdentityProviders: schema.IdentityProviders{
+ OIDC: &schema.IdentityProvidersOpenIDConnect{
+ Clients: []schema.IdentityProvidersOpenIDConnectClient{
+ {
+ ID: "abc",
+ AuthorizationPolicy: "one_factor",
+ },
+ },
+ },
+ },
+ }
+
+ s.mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(config, s.mock.StorageMock, s.mock.Ctx.Providers.Templates)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "requestMethod": "GET",
+ "keepMeLoggedIn": false,
+ "workflow": "openid_connect",
+ "workflowID": "d1ba0ad8-9107-4067-8d31-407ca59eb69c"
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(s.mock.Ctx), gomock.Eq(uuid.Must(uuid.Parse("d1ba0ad8-9107-4067-8d31-407ca59eb69c")))).
+ Return(&model.OAuth2ConsentSession{ClientID: "abc", Form: "grant_type=authorization_code"}, nil),
+ s.mock.StorageMock.EXPECT().
+ LoadUserOpaqueIdentifierBySignature(gomock.Eq(s.mock.Ctx), gomock.Eq("openid"), gomock.Eq(""), gomock.Eq("test")).
+ Return(nil, nil),
+ s.mock.StorageMock.EXPECT().
+ SaveUserOpaqueIdentifier(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ s.mock.StorageMock.EXPECT().
+ SaveOAuth2ConsentSessionSubject(gomock.Eq(s.mock.Ctx), gomock.Any()).
+ Return(nil),
+ )
+
+ FirstFactorPasswordPOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "http://example.com/api/oidc/authorization?consent_id=d1ba0ad8-9107-4067-8d31-407ca59eb69c&grant_type=authorization_code"})
+}
+
+type FirstFactorReauthenticateSuite struct {
+ suite.Suite
+
+ mock *mocks.MockAutheliaCtx
+}
+
+func (s *FirstFactorReauthenticateSuite) SetupTest() {
+ s.mock = mocks.NewMockAutheliaCtx(s.T())
+
+ session, err := s.mock.Ctx.GetSession()
+
+ s.Require().NoError(err)
+
+ session.SetOneFactorPassword(s.mock.Ctx.Clock.Now(), &authentication.UserDetails{Username: testValue}, false)
+
+ s.Require().NoError(s.mock.Ctx.SaveSession(session))
+}
+
+func (s *FirstFactorReauthenticateSuite) TearDownTest() {
+ s.mock.Close()
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldFailIfBodyIsNil() {
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ // No body.
+ s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to parse body: unexpected end of JSON input")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldFailIfBodyIsInBadFormat() {
+ // Missing password.
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test"
+ }`)
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Failed to parse 1FA request body", "unable to validate body: password: non zero value required")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldFailIfUserProviderCheckPasswordFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("failed"))
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test'", "failed")
+
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldCheckAuthenticationIsNotMarkedWhenProviderCheckPasswordError() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("invalid credentials"))
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldCheckUserNotBanned() {
+ s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2}, s.mock.StorageMock, &s.mock.Clock)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(nil, nil),
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, fmt.Errorf("invalid credentials")),
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ })))
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldCheckBannedUser() {
+ s.mock.Ctx.Providers.Regulator = regulation.NewRegulator(schema.Regulation{MaxRetries: 2, FindTime: time.Hour, BanTime: time.Hour}, s.mock.StorageMock, &s.mock.Clock)
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "username": "test",
+ "password": "hello",
+ "keepMeLoggedIn": true
+ }`)
+
+ gomock.InOrder(
+ s.mock.StorageMock.EXPECT().
+ LoadAuthenticationLogs(gomock.Eq(s.mock.Ctx), testValue, gomock.Any(), gomock.Any(), gomock.Any()).
+ Return([]model.AuthenticationAttempt{
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ {Successful: false, Time: s.mock.Clock.Now().Add(-time.Second)},
+ }, nil),
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(gomock.Eq(s.mock.Ctx), gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: true,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ })))
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unsuccessful 1FA authentication attempt by user 'test' and they are banned until 2013-02-03 00:59:59 +0000 UTC", "")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCredentials() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(false, nil)
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Eq(model.AuthenticationAttempt{
+ Username: testValue,
+ Successful: false,
+ Banned: false,
+ Time: s.mock.Clock.Now(),
+ Type: regulation.AuthType1FA,
+ RemoteIP: model.NewNullIPFromString("0.0.0.0"),
+ }))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldFailIfUserProviderGetDetailsFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), 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(testValue)).
+ Return(nil, fmt.Errorf("failed"))
+
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Could not obtain profile details during 1FA authentication for user 'test'", "failed")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldFailIfAuthenticationMarkFail() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), 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(`{
+ "password": "hello"
+ }`)
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ s.mock.AssertLastLogMessage(s.T(), "Unable to mark 1FA authentication attempt by user 'test'", "failed")
+ s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
+}
+
+func (s *FirstFactorReauthenticateSuite) TestShouldSaveUsernameFromAuthenticationBackendInSession() {
+ s.mock.UserProviderMock.
+ EXPECT().
+ CheckUserPassword(gomock.Eq(testValue), gomock.Eq("hello")).
+ Return(true, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ 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(`{
+ "password": "hello"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ assert.Equal(s.T(), fasthttp.StatusOK, s.mock.Ctx.Response.StatusCode())
+ assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body())
+
+ userSession, err := s.mock.Ctx.GetSession()
+ s.Assert().NoError(err)
+
+ assert.Equal(s.T(), testValue, userSession.Username)
+ assert.Equal(s.T(), false, userSession.KeepMeLoggedIn)
+ assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel(false))
+ assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails)
+ assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups)
+}
+
+type FirstFactorReauthenticateRedirectionSuite struct {
+ suite.Suite
+
+ mock *mocks.MockAutheliaCtx
+}
+
+func (s *FirstFactorReauthenticateRedirectionSuite) SetupTest() {
+ s.mock = mocks.NewMockAutheliaCtx(s.T())
+
+ session, err := s.mock.Ctx.GetSession()
+
+ s.Require().NoError(err)
+
+ session.SetOneFactorPassword(s.mock.Ctx.Clock.Now(), &authentication.UserDetails{Username: testValue}, false)
+
+ s.Require().NoError(s.mock.Ctx.SaveSession(session))
+
+ s.mock.Ctx.Configuration.Session.Cookies[0].DefaultRedirectionURL = &url.URL{Scheme: "https", Host: "default.local"}
+ s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass
+ s.mock.Ctx.Configuration.AccessControl.Rules = []schema.AccessControlRule{
+ {
+ 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(testValue), gomock.Eq("hello")).
+ Return(true, nil)
+
+ s.mock.UserProviderMock.
+ EXPECT().
+ GetDetails(gomock.Eq(testValue)).
+ Return(&authentication.UserDetails{
+ Username: testValue,
+ Emails: []string{"test@example.com"},
+ Groups: []string{"dev", "admins"},
+ }, nil)
+
+ s.mock.StorageMock.
+ EXPECT().
+ AppendAuthenticationLog(s.mock.Ctx, gomock.Any()).
+ Return(nil)
+}
+
+func (s *FirstFactorReauthenticateRedirectionSuite) 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 *FirstFactorReauthenticateRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTargetURLProvidedAndTwoFactorDisabled() {
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"})
+}
+
+// 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 *FirstFactorReauthenticateRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUnsafeAndTwoFactorDisabled() {
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello",
+ "targetURL": "http://notsafe.local"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(s.mock.Ctx)
+
+ // Respond with 200.
+ s.mock.Assert200OK(s.T(), &redirectResponse{Redirect: "https://www.example.com"})
+}
+
+// When:
+//
+// 1/ two_factor is enabled (default policy)
+//
+// Then:
+//
+// the user should receive 200 without redirection URL.
+func (s *FirstFactorReauthenticateRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedAndTwoFactorEnabled() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "two_factor",
+ },
+ })
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(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 *FirstFactorReauthenticateRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvidedAndTwoFactorEnabled() {
+ s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{
+ AccessControl: schema.AccessControl{
+ DefaultPolicy: "one_factor",
+ Rules: []schema.AccessControlRule{
+ {
+ Domains: []string{"test.example.com"},
+ Policy: "one_factor",
+ },
+ {
+ Domains: []string{"example.com"},
+ Policy: "two_factor",
+ },
+ },
+ }})
+ s.mock.Ctx.Request.SetBodyString(`{
+ "password": "hello"
+ }`)
+
+ FirstFactorReauthenticatePOST(nil)(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 TestFirstFactorReauthenticateSuite(t *testing.T) {
+ suite.Run(t, new(FirstFactorReauthenticateSuite))
+ suite.Run(t, new(FirstFactorReauthenticateRedirectionSuite))
+}
+
+const (
+ testValue = "test"
+)