From cea3e6e037d77291ba392a69ba0dc7c6c9cc67c2 Mon Sep 17 00:00:00 2001 From: CL-Andrew <96407253+CL-Andrew@users.noreply.github.com> Date: Fri, 3 Nov 2023 15:15:10 -0700 Subject: [PATCH] Optional and Configurable LDAP User/Session Management Support and Reworked Pluggable Auth Driver Interface (#9750) * Initial commit of LDAP Auth driver support with toml config docs and parser driver, pluggable auth interface defined and localauth (default) moved to scoped module * 'orm sessions.UserManager' to 'um sessions.UserManager' * Add missing checks for the UserApiTokenEnabled config field for token related calls, rename ServerTls to ServerTLS * Update docs toml LDAP section to clarify how the fields are used and specify LDAP terminology * Clarify LDAP 'cn' in toml docs for LDAP * Fix WebServer TOML and config definitions, split types for WebServerLDAPSecrets to following config and secret toml convention, improved error handling on startup for missing WebServer and LDAP fields * Error application startup if authentication method is not one of the valid options, instead of defaulting to local * Don't export unneeded ldapGroupMembersListToUser in ldap module * Bugfixes for LDAP find user when no results of passed email, address the two ways local CLI can attempt auth using local client for LDAP implementation of createSession, moved ErrNotSupported up to authentication types level so router can expose to API response when type message * Rework LDAP function to check if list of provided query emails possess the 'active' attribute/group in a single query. Now returns list of bools one to one for the passed in emails array and correctly handles the case for querying more than one email at a time, changing function return signature from just error * Update LDAP field naming for Cn, Dn to Go convention CN, DN in toml and types, tidy god mod, fix path imports for test files, fix toml comments * Post merge toml module rename LDAP model fixes Populate test config and test secrets toml file with new LDAP config fields, use secrets parse type for LDAPSecrets interface * Update top level application struct to accomodate new sibling AuthProvider field to preserve always available local admin auth This commit splits the newly added UserManager interface (now renamed) into two interfaces where the existing local user ORM auth provider covers the implementation for the required always available Admin commands. These are used when configuring the node initially (creating the admin user) or assuming the admin role from the command line, which should work locally as well regardless of the configured Authentication Provider. Renamed new UserManager interface to AuthenticationProvider, which no longer has the boilerplate Admin prefix functions. * Update all err comparison checks for ErrNotSupported to errors.Is * go generate mocks * Tidy unecessary string cast, bump ldap library to latest and use v3, test forward compatibility, mod tidy * Clean up auth provider config switch statement * Update checked in test txtar output * Update gql test and mocks with new authprovider mock, updated in mock struct. Add missing TestPassword call * Update remaining test config toml files with new WebServer LDAP fields, populated where test case makes sense, add toml config validation checks on parse for LDAP fields non empty, update LDAP Server field to Models Secret URL, add parsing test for secrets * Update LDAP module with missing API token implementation - creation, use, and deletion Bugfixes and logic improvements for LDAP session reaper/upstream sync. Reaper now correctly syncs roles and users from upstream via sleeper task tied to LDAP auth actions. User sessions and API tokens are correctly removed when the expire TTL is met, the local LDAP sessions and API tokens role is updated and synced with the state of the upstream LDAP server as part of the logic of this sleeper task, and if a user is no longer present in any of the defined groups they are automatically removed from the LDAP sessions and API tokens tables (checked on cadence of sleeper task Work call) Update LDAP webconfigToken duration to match wrapped models.Duration type Add const for LDAPUniqueMemberAttribute/uniqueMember in ldapauth module Add info logger connection attempt message in case of hang on node startup Fix expired LDAP api tokens purge issue Nicer error for session missing / expired on attempt of sesion token use (remove error within stdout) * Add missing support for local CLI user and auth when using LDAP Authentication module Fixed edge cases for FindUser, check local users table as well, local user API Token creation and support Add localauth_user flag for ldap specific tables to support node usage by the initial required local admin user, add logic in CreateSession * Update config UpstreamSyncInterval and UpstreamSyncRateLimit functionality for LDAP Sync daemon Implement .Work call on timer for LDAP sync in the background, independent of Auth related calls. The implementation of SleeperTask calls Work when hooked into Auth events, being called on login or logout. Now if UpstreamSyncInterval is defined as non 0, a background timer call will call the sync function, respecting the new UpstreamSyncRateLimit field * LDAP Fix for checks of optional isactive property on group query, find user functionality, and admin functions Bug fix for ldap driver not supporting local admin users case of change password and list users, FindUser functionality can now return matches of local admin users List Users now includes local users and works as expected for upstream LDAP users who have any of the defined groups required for node access. Bugfix for group search query in both the sync and ldap providers modules. Factored out group query functionality for both call sites Set Password support for only local admin users as functionality is still supported and required when using LDAP auth, upstream user modification remains unsupported Bugfix for shell local initialization not using local admin auth ORM, causing issue with initial assume user step in ListUsers * bump migration file index * remove incorrect rebased merge resolution for Explorer removal * Change default config definitions for LDAP 'Is Active' attribute checks to empty, as not all LDAP providers will use 'ActiveAttribute', or retain group member access when inactive. Fix error handling in find users for case when NoRows, dont log error automatically with Transaction middleware * Simplify sessions purge sql exec using pq.Array instead of manually generating placeholders, and Regenerate mocks * Merge go mod require groups, gotidy * Rename changed authentication provider session ORM in test files, fix config test reordering, add missing mock, update const err strings * Add mock value for one test case of config ldap is active attribute, revert purge sessions api token test file, migrate to new errors module and update how errors are wrapped, lowercase all error messages * Factor out unsupported action error message in user controller with new errUnsupportedForAuth type * Rebase, update migration index * Update config_test full case, error case for missing fields * Fix tests with missing Mocks for cmd shell, config resolver, and sessions localauth Missing mocks for LocalAdminUsersORM Revert change unrelated to LDAP feature in AuthorizedUserWithSession (refactored) Fix leaked internal error over HTTP response + test case for delete user Fix mocks and missing TestPassword call cases for graphql mutation tests, update incorrect password test case Add expected LDAP config fields for config resolver tests * Bump migration file index for ldap tables * Linter fixes - application.go localAdminUsersORM in initialized one line Fix config sesion timeout interface naming (r -> l) Typos fix in ldap.go docstring Rework logic in checkErr for FindUser logic of testing admin table query before failing (rework to avoid error shadowing) Invert logic for err != nil in case for local admin user found Fix missing errors.Is comparison for sessions.ErrUserSessionExpired in ldap module Run docs generate Fix typo in txtar test LDAP config * Add missing ldap fields to warnings.txtar, fix err shadowing linter errors in ldap.go and sync.go Run go mod tidy * Fix linter import order and groupings * More import ordering lint * Rebase, bump sql migraiton index * use correct guregu/null.v4 version * Implement test cases for ldap module, create LDAP client and LDAPConn wrapper interfaces and mocks New LDAPClient and LDAPConn interfaces allow test mocks to handle Bind and Search functionality. the ldap implementation has been updated to store the ldapClient (still ephemeral single use, like a factory) in the struct such that the test harness can swap the implementation with the mocks. Create helpers_test.go following codebase convention to allow a Setter method to be defined for the ldapClient field, but separated from the production build. This allows the ldap struct and field properties to remain unexported. Test helper contains test mock configand helper constructor function New ldap_test.go module with cases for ldap query functionality and local admin support assertions ldap.go module improvements, return struct in constructor instead of interface type for authentication provider, define user facing error consts (test assertion), store ldapClient in struct, nicer error handling for user not found in FindUser, fix err shadowing reuse error in token expired case, fix typos in ListUsers LDAP Sync rework for new ldapclient field, use new interface to support mocking Remove dangling commited localauth orm.mock, which was being imported by a missed test. Test now imports the correct mock (new authentication provider mocks) * Updated CHANGELOG.md * Update go.mod * Linter fixes UserNoLDAPGroups -> ErrUserNoLDAPGroups, shadowing * module -> package, format package docstring properly for ldapauth to support godoc render Remove redundant LDAP prefix for UniqueMemberAttribute * Remove rebased gomod line * define const NodeAdmins* for mocked tests in helpers_test, reference [WebServer].AuthenticationMethod in changelog * Add missing returns in sync Work call when failed to establish LDAP connection as it is required for the sync functionality, flip return flow in TestPassword for admin fallback * Updating naming and address nits LocalAdminUsersORM -> BasicAdminUsersORM, regenerate mocks Save indent in WebServer ValidateConfig when ldapauth Update comments and rename CreateEphemeralClient -> CreateEphemeralConnection * Add missed go generate Application change for BasicAdminUsersORM rename --- core/cmd/admin_commands_test.go | 6 +- core/cmd/app_test.go | 1 + core/cmd/shell.go | 12 +- core/cmd/shell_local.go | 7 +- core/cmd/shell_local_test.go | 11 +- core/cmd/shell_remote_test.go | 16 +- core/cmd/shell_test.go | 13 +- core/config/docs/core.toml | 40 + core/config/docs/secrets.toml | 9 + core/config/toml/types.go | 146 +++ core/config/web_config.go | 25 + core/internal/cltest/cltest.go | 2 +- core/internal/cltest/mocks.go | 4 +- core/internal/features/features_test.go | 2 +- core/internal/mocks/application.go | 48 +- core/scripts/go.mod | 3 + core/scripts/go.sum | 15 + core/services/chainlink/application.go | 49 +- core/services/chainlink/config.go | 24 +- core/services/chainlink/config_general.go | 2 +- .../services/chainlink/config_general_test.go | 10 + core/services/chainlink/config_test.go | 51 +- core/services/chainlink/config_web_server.go | 145 +++ .../testdata/config-empty-effective.toml | 20 + .../chainlink/testdata/config-full.toml | 20 + .../chainlink/testdata/config-invalid.toml | 22 + .../config-multi-chain-effective.toml | 20 + .../secrets-webserver-ldap.toml | 4 + .../testdata/secrets-full-redacted.toml | 6 + .../chainlink/testdata/secrets-full.toml | 6 + core/sessions/authentication.go | 66 ++ core/sessions/ldapauth/client.go | 47 + core/sessions/ldapauth/helpers_test.go | 131 +++ core/sessions/ldapauth/ldap.go | 858 ++++++++++++++++++ core/sessions/ldapauth/ldap_test.go | 639 +++++++++++++ core/sessions/ldapauth/mocks/ldap_client.go | 53 ++ core/sessions/ldapauth/mocks/ldap_conn.go | 82 ++ core/sessions/ldapauth/sync.go | 343 +++++++ core/sessions/{ => localauth}/orm.go | 111 +-- core/sessions/{ => localauth}/orm_test.go | 10 +- core/sessions/{ => localauth}/reaper.go | 2 +- core/sessions/{ => localauth}/reaper_test.go | 40 +- .../{orm.go => authentication_provider.go} | 62 +- core/sessions/mocks/basic_admin_users_orm.go | 91 ++ core/sessions/session.go | 74 ++ core/sessions/user.go | 64 -- core/sessions/webauthn.go | 4 +- .../0208_create_ldap_sessions_table.sql | 22 + core/web/auth/auth.go | 5 +- core/web/auth/auth_test.go | 4 +- core/web/auth/gql_test.go | 4 +- core/web/resolver/api_token_test.go | 64 +- core/web/resolver/mutation.go | 20 +- core/web/resolver/resolver_test.go | 6 +- .../testdata/config-empty-effective.toml | 20 + core/web/resolver/testdata/config-full.toml | 20 + .../config-multi-chain-effective.toml | 20 + core/web/resolver/user_test.go | 26 +- core/web/router.go | 12 +- core/web/sessions_controller.go | 6 +- core/web/sessions_controller_test.go | 18 +- core/web/user_controller.go | 76 +- core/web/user_controller_test.go | 4 +- core/web/webauthn_controller.go | 6 +- docs/CHANGELOG.md | 5 + docs/CONFIG.md | 133 +++ docs/SECRETS.md | 27 + go.mod | 3 + go.sum | 15 + integration-tests/go.mod | 3 + integration-tests/go.sum | 9 + testdata/scripts/node/validate/default.txtar | 20 + .../disk-based-logging-disabled.txtar | 20 + .../validate/disk-based-logging-no-dir.txtar | 20 + .../node/validate/disk-based-logging.txtar | 20 + testdata/scripts/node/validate/invalid.txtar | 20 + testdata/scripts/node/validate/valid.txtar | 20 + testdata/scripts/node/validate/warnings.txtar | 20 + 78 files changed, 3743 insertions(+), 341 deletions(-) create mode 100644 core/services/chainlink/testdata/mergingsecretsdata/secrets-webserver-ldap.toml create mode 100644 core/sessions/authentication.go create mode 100644 core/sessions/ldapauth/client.go create mode 100644 core/sessions/ldapauth/helpers_test.go create mode 100644 core/sessions/ldapauth/ldap.go create mode 100644 core/sessions/ldapauth/ldap_test.go create mode 100644 core/sessions/ldapauth/mocks/ldap_client.go create mode 100644 core/sessions/ldapauth/mocks/ldap_conn.go create mode 100644 core/sessions/ldapauth/sync.go rename core/sessions/{ => localauth}/orm.go (80%) rename core/sessions/{ => localauth}/orm_test.go (95%) rename core/sessions/{ => localauth}/reaper.go (98%) rename core/sessions/{ => localauth}/reaper_test.go (69%) rename core/sessions/mocks/{orm.go => authentication_provider.go} (75%) create mode 100644 core/sessions/mocks/basic_admin_users_orm.go create mode 100644 core/sessions/session.go create mode 100644 core/store/migrate/migrations/0208_create_ldap_sessions_table.sql diff --git a/core/cmd/admin_commands_test.go b/core/cmd/admin_commands_test.go index a5512fdddaa..954e3577d3d 100644 --- a/core/cmd/admin_commands_test.go +++ b/core/cmd/admin_commands_test.go @@ -62,7 +62,7 @@ func TestShell_ChangeRole(t *testing.T) { app := startNewApplicationV2(t, nil) client, _ := app.NewShellAndRenderer() user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) tests := []struct { name string @@ -101,7 +101,7 @@ func TestShell_DeleteUser(t *testing.T) { app := startNewApplicationV2(t, nil) client, _ := app.NewShellAndRenderer() user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.BasicAdminUsersORM().CreateUser(&user)) tests := []struct { name string @@ -135,7 +135,7 @@ func TestShell_ListUsers(t *testing.T) { app := startNewApplicationV2(t, nil) client, _ := app.NewShellAndRenderer() user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) set := flag.NewFlagSet("test", 0) cltest.FlagSetApplyFromAction(client.ListUsers, set, "") diff --git a/core/cmd/app_test.go b/core/cmd/app_test.go index bbb00bff3ec..e5e29406426 100644 --- a/core/cmd/app_test.go +++ b/core/cmd/app_test.go @@ -151,6 +151,7 @@ func Test_initServerConfig(t *testing.T) { "../services/chainlink/testdata/mergingsecretsdata/secrets-mercury-split-one.toml", "../services/chainlink/testdata/mergingsecretsdata/secrets-mercury-split-two.toml", "../services/chainlink/testdata/mergingsecretsdata/secrets-threshold.toml", + "../services/chainlink/testdata/mergingsecretsdata/secrets-webserver-ldap.toml", }, }, wantErr: false, diff --git a/core/cmd/shell.go b/core/cmd/shell.go index 308ebf8da8c..80ecd2590b0 100644 --- a/core/cmd/shell.go +++ b/core/cmd/shell.go @@ -776,8 +776,8 @@ func (f *fileSessionRequestBuilder) Build(file string) (sessions.SessionRequest, // APIInitializer is the interface used to create the API User credentials // needed to access the API. Does nothing if API user already exists. type APIInitializer interface { - // Initialize creates a new user for API access, or does nothing if one exists. - Initialize(orm sessions.ORM, lggr logger.Logger) (sessions.User, error) + // Initialize creates a new local Admin user for API access, or does nothing if one exists. + Initialize(orm sessions.BasicAdminUsersORM, lggr logger.Logger) (sessions.User, error) } type promptingAPIInitializer struct { @@ -791,11 +791,11 @@ func NewPromptingAPIInitializer(prompter Prompter) APIInitializer { } // Initialize uses the terminal to get credentials that it then saves in the store. -func (t *promptingAPIInitializer) Initialize(orm sessions.ORM, lggr logger.Logger) (sessions.User, error) { +func (t *promptingAPIInitializer) Initialize(orm sessions.BasicAdminUsersORM, lggr logger.Logger) (sessions.User, error) { // Load list of users to determine which to assume, or if a user needs to be created dbUsers, err := orm.ListUsers() if err != nil { - return sessions.User{}, err + return sessions.User{}, errors.Wrap(err, "Unable to List users for initialization") } // If there are no users in the database, prompt for initial admin user creation @@ -845,7 +845,7 @@ func NewFileAPIInitializer(file string) APIInitializer { return fileAPIInitializer{file: file} } -func (f fileAPIInitializer) Initialize(orm sessions.ORM, lggr logger.Logger) (sessions.User, error) { +func (f fileAPIInitializer) Initialize(orm sessions.BasicAdminUsersORM, lggr logger.Logger) (sessions.User, error) { request, err := credentialsFromFile(f.file, lggr) if err != nil { return sessions.User{}, err @@ -854,7 +854,7 @@ func (f fileAPIInitializer) Initialize(orm sessions.ORM, lggr logger.Logger) (se // Load list of users to determine which to assume, or if a user needs to be created dbUsers, err := orm.ListUsers() if err != nil { - return sessions.User{}, err + return sessions.User{}, errors.Wrap(err, "Unable to List users for initialization") } // If there are no users in the database, create initial admin user from session request from file creds diff --git a/core/cmd/shell_local.go b/core/cmd/shell_local.go index 401375238d8..dea9a29359e 100644 --- a/core/cmd/shell_local.go +++ b/core/cmd/shell_local.go @@ -362,7 +362,8 @@ func (s *Shell) runNode(c *cli.Context) error { return s.errorOut(errors.Wrap(err, "fatal error instantiating application")) } - sessionORM := app.SessionORM() + // Local shell initialization always uses local auth users table for admin auth + authProviderORM := app.BasicAdminUsersORM() keyStore := app.GetKeyStore() err = s.KeyStoreAuthenticator.authenticate(keyStore, s.Config.Password()) if err != nil { @@ -449,11 +450,11 @@ func (s *Shell) runNode(c *cli.Context) error { } var user sessions.User - if user, err = NewFileAPIInitializer(c.String("api")).Initialize(sessionORM, lggr); err != nil { + if user, err = NewFileAPIInitializer(c.String("api")).Initialize(authProviderORM, lggr); err != nil { if !errors.Is(err, ErrNoCredentialFile) { return errors.Wrap(err, "error creating api initializer") } - if user, err = s.FallbackAPIInitializer.Initialize(sessionORM, lggr); err != nil { + if user, err = s.FallbackAPIInitializer.Initialize(authProviderORM, lggr); err != nil { if errors.Is(err, ErrorNoAPICredentialsAvailable) { return errors.WithStack(err) } diff --git a/core/cmd/shell_local_test.go b/core/cmd/shell_local_test.go index 89b8704f87b..df60e16423e 100644 --- a/core/cmd/shell_local_test.go +++ b/core/cmd/shell_local_test.go @@ -25,7 +25,7 @@ import ( chainlinkmocks "github.com/smartcontractkit/chainlink/v2/core/services/chainlink/mocks" "github.com/smartcontractkit/chainlink/v2/core/services/pg" evmrelayer "github.com/smartcontractkit/chainlink/v2/core/services/relay/evm" - "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/localauth" "github.com/smartcontractkit/chainlink/v2/core/store/dialects" "github.com/smartcontractkit/chainlink/v2/core/store/models" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -79,7 +79,7 @@ func TestShell_RunNodeWithPasswords(t *testing.T) { }) db := pgtest.NewSqlxDB(t) keyStore := cltest.NewKeyStore(t, db, cfg.Database()) - sessionORM := sessions.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) + authProviderORM := localauth.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) lggr := logger.TestLogger(t) @@ -100,7 +100,8 @@ func TestShell_RunNodeWithPasswords(t *testing.T) { pgtest.MustExec(t, db, "DELETE FROM users;") app := mocks.NewApplication(t) - app.On("SessionORM").Return(sessionORM).Maybe() + app.On("AuthenticationProvider").Return(authProviderORM).Maybe() + app.On("BasicAdminUsersORM").Return(authProviderORM).Maybe() app.On("GetKeyStore").Return(keyStore).Maybe() app.On("GetRelayers").Return(testRelayers).Maybe() app.On("Start", mock.Anything).Maybe().Return(nil) @@ -171,7 +172,7 @@ func TestShell_RunNodeWithAPICredentialsFile(t *testing.T) { c.Insecure.OCRDevelopmentMode = nil }) db := pgtest.NewSqlxDB(t) - sessionORM := sessions.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) + authProviderORM := localauth.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) // Clear out fixture users/users created from the other test cases // This asserts that on initial run with an empty users table that the credentials file will instantiate and @@ -199,7 +200,7 @@ func TestShell_RunNodeWithAPICredentialsFile(t *testing.T) { } testRelayers := genTestEVMRelayers(t, opts, keyStore) app := mocks.NewApplication(t) - app.On("SessionORM").Return(sessionORM) + app.On("BasicAdminUsersORM").Return(authProviderORM) app.On("GetKeyStore").Return(keyStore) app.On("GetRelayers").Return(testRelayers).Maybe() app.On("Start", mock.Anything).Maybe().Return(nil) diff --git a/core/cmd/shell_remote_test.go b/core/cmd/shell_remote_test.go index 7f998225f63..91b56ee53a4 100644 --- a/core/cmd/shell_remote_test.go +++ b/core/cmd/shell_remote_test.go @@ -258,7 +258,7 @@ func TestShell_DestroyExternalInitiator_NotFound(t *testing.T) { func TestShell_RemoteLogin(t *testing.T) { app := startNewApplicationV2(t, nil) - orm := app.SessionORM() + orm := app.AuthenticationProvider() u := cltest.NewUserWithSession(t, orm) @@ -301,7 +301,7 @@ func TestShell_RemoteBuildCompatibility(t *testing.T) { t.Parallel() app := startNewApplicationV2(t, nil) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) enteredStrings := []string{u.Email, cltest.Password} prompter := &cltest.MockCountingPrompter{T: t, EnteredStrings: append(enteredStrings, enteredStrings...)} client := app.NewAuthenticatingShell(prompter) @@ -340,7 +340,7 @@ func TestShell_CheckRemoteBuildCompatibility(t *testing.T) { t.Parallel() app := startNewApplicationV2(t, nil) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) tests := []struct { name string remoteVersion, remoteSha string @@ -416,7 +416,7 @@ func TestShell_ChangePassword(t *testing.T) { t.Parallel() app := startNewApplicationV2(t, nil) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) enteredStrings := []string{u.Email, cltest.Password} prompter := &cltest.MockCountingPrompter{T: t, EnteredStrings: enteredStrings} @@ -466,7 +466,7 @@ func TestShell_Profile_InvalidSecondsParam(t *testing.T) { t.Parallel() app := startNewApplicationV2(t, nil) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) enteredStrings := []string{u.Email, cltest.Password} prompter := &cltest.MockCountingPrompter{T: t, EnteredStrings: enteredStrings} @@ -497,7 +497,7 @@ func TestShell_Profile(t *testing.T) { t.Parallel() app := startNewApplicationV2(t, nil) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) enteredStrings := []string{u.Email, cltest.Password} prompter := &cltest.MockCountingPrompter{T: t, EnteredStrings: enteredStrings} @@ -648,7 +648,7 @@ func TestShell_AutoLogin(t *testing.T) { app := startNewApplicationV2(t, nil) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.BasicAdminUsersORM().CreateUser(&user)) sr := sessions.SessionRequest{ Email: user.Email, @@ -676,7 +676,7 @@ func TestShell_AutoLogin_AuthFails(t *testing.T) { app := startNewApplicationV2(t, nil) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.BasicAdminUsersORM().CreateUser(&user)) sr := sessions.SessionRequest{ Email: user.Email, diff --git a/core/cmd/shell_test.go b/core/cmd/shell_test.go index 9b87e8fb1da..2a8c2c55861 100644 --- a/core/cmd/shell_test.go +++ b/core/cmd/shell_test.go @@ -26,6 +26,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/mocks" "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/localauth" "github.com/smartcontractkit/chainlink/v2/plugins" ) @@ -33,7 +34,7 @@ func TestTerminalCookieAuthenticator_AuthenticateWithoutSession(t *testing.T) { t.Parallel() app := cltest.NewApplicationEVMDisabled(t) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) tests := []struct { name, email, pwd string @@ -65,7 +66,7 @@ func TestTerminalCookieAuthenticator_AuthenticateWithSession(t *testing.T) { app := cltest.NewApplicationEVMDisabled(t) require.NoError(t, app.Start(testutils.Context(t))) - u := cltest.NewUserWithSession(t, app.SessionORM()) + u := cltest.NewUserWithSession(t, app.AuthenticationProvider()) tests := []struct { name, email, pwd string @@ -155,7 +156,7 @@ func TestTerminalAPIInitializer_InitializeWithoutAPIUser(t *testing.T) { t.Run(test.name, func(t *testing.T) { db := pgtest.NewSqlxDB(t) lggr := logger.TestLogger(t) - orm := sessions.NewORM(db, time.Minute, lggr, pgtest.NewQConfig(true), audit.NoopLogger) + orm := localauth.NewORM(db, time.Minute, lggr, pgtest.NewQConfig(true), audit.NoopLogger) mock := &cltest.MockCountingPrompter{T: t, EnteredStrings: test.enteredStrings, NotTerminal: !test.isTerminal} tai := cmd.NewPromptingAPIInitializer(mock) @@ -186,7 +187,7 @@ func TestTerminalAPIInitializer_InitializeWithExistingAPIUser(t *testing.T) { db := pgtest.NewSqlxDB(t) cfg := configtest.NewGeneralConfig(t, nil) lggr := logger.TestLogger(t) - orm := sessions.NewORM(db, time.Minute, lggr, cfg.Database(), audit.NoopLogger) + orm := localauth.NewORM(db, time.Minute, lggr, cfg.Database(), audit.NoopLogger) // Clear out fixture users/users created from the other test cases // This asserts that on initial run with an empty users table that the credentials file will instantiate and @@ -223,7 +224,7 @@ func TestFileAPIInitializer_InitializeWithoutAPIUser(t *testing.T) { t.Run(test.name, func(t *testing.T) { db := pgtest.NewSqlxDB(t) lggr := logger.TestLogger(t) - orm := sessions.NewORM(db, time.Minute, lggr, pgtest.NewQConfig(true), audit.NoopLogger) + orm := localauth.NewORM(db, time.Minute, lggr, pgtest.NewQConfig(true), audit.NoopLogger) // Clear out fixture users/users created from the other test cases // This asserts that on initial run with an empty users table that the credentials file will instantiate and @@ -248,7 +249,7 @@ func TestFileAPIInitializer_InitializeWithoutAPIUser(t *testing.T) { func TestFileAPIInitializer_InitializeWithExistingAPIUser(t *testing.T) { db := pgtest.NewSqlxDB(t) cfg := configtest.NewGeneralConfig(t, nil) - orm := sessions.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) + orm := localauth.NewORM(db, time.Minute, logger.TestLogger(t), cfg.Database(), audit.NoopLogger) tests := []struct { name string diff --git a/core/config/docs/core.toml b/core/config/docs/core.toml index 1ca4c656a7f..0a8e6aba3be 100644 --- a/core/config/docs/core.toml +++ b/core/config/docs/core.toml @@ -161,6 +161,8 @@ MaxAgeDays = 0 # Default MaxBackups = 1 # Default [WebServer] +# AuthenticationMethod defines which pluggable auth interface to use for user login and role assumption. Options include 'local' and 'ldap'. See docs for more details +AuthenticationMethod = 'local' # Default # AllowOrigins controls the URLs Chainlink nodes emit in the `Allow-Origins` header of its API responses. The setting can be a comma-separated list with no spaces. You might experience CORS issues if this is not set correctly. # # You should set this to the external URL that you use to access the Chainlink UI. @@ -191,6 +193,44 @@ StartTimeout = '15s' # Default # ListenIP specifies the IP to bind the HTTP server to ListenIP = '0.0.0.0' # Default +# Optional LDAP config if WebServer.AuthenticationMethod is set to 'ldap' +# LDAP queries are all parameterized to support custom LDAP 'dn', 'cn', and attributes +[WebServer.LDAP] +# ServerTLS defines the option to require the secure ldaps +ServerTLS = true # Default +# SessionTimeout determines the amount of idle time to elapse before session cookies expire. This signs out GUI users from their sessions. +SessionTimeout = '15m0s' # Default +# QueryTimeout defines how long queries should wait before timing out, defined in seconds +QueryTimeout = '2m0s' # Default +# BaseUserAttr defines the base attribute used to populate LDAP queries such as "uid=$", default is example +BaseUserAttr = 'uid' # Default +# BaseDN defines the base LDAP 'dn' search filter to apply to every LDAP query, replace example,com with the appropriate LDAP server's structure +BaseDN = 'dc=custom,dc=example,dc=com' # Example +# UsersDN defines the 'dn' query to use when querying for the 'users' 'ou' group +UsersDN = 'ou=users' # Default +# GroupsDN defines the 'dn' query to use when querying for the 'groups' 'ou' group +GroupsDN = 'ou=groups' # Default +# ActiveAttribute is an optional user field to check truthiness for if a user is valid/active. This is only required if the LDAP provider lists inactive users as members of groups +ActiveAttribute = '' # Default +# ActiveAttributeAllowedValue is the value to check against for the above optional user attribute +ActiveAttributeAllowedValue = '' # Default +# AdminUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Admin' role +AdminUserGroupCN = 'NodeAdmins' # Default +# EditUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Edit' role +EditUserGroupCN = 'NodeEditors' # Default +# RunUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Run' role +RunUserGroupCN = 'NodeRunners' # Default +# ReadUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Read' role +ReadUserGroupCN = 'NodeReadOnly' # Default +# UserApiTokenEnabled enables the users to issue API tokens with the same access of their role +UserApiTokenEnabled = false # Default +# UserAPITokenDuration is the duration of time an API token is active for before expiring +UserAPITokenDuration = '240h0m0s' # Default +# UpstreamSyncInterval is the interval at which the background LDAP sync task will be called. A '0s' value disables the background sync being run on an interval. This check is already performed during login/logout actions, all sessions and API tokens stored in the local ldap tables are updated to match the remote server +UpstreamSyncInterval = '0s' # Default +# UpstreamSyncRateLimit defines a duration to limit the number of query/API calls to the upstream LDAP provider. It prevents the sync functionality from being called multiple times within the defined duration +UpstreamSyncRateLimit = '2m0s' # Default + [WebServer.RateLimit] # Authenticated defines the threshold to which authenticated requests get limited. More than this many authenticated requests per `AuthenticatedRateLimitPeriod` will be rejected. Authenticated = 1000 # Default diff --git a/core/config/docs/secrets.toml b/core/config/docs/secrets.toml index 2b491a77497..4ed2325dfb2 100644 --- a/core/config/docs/secrets.toml +++ b/core/config/docs/secrets.toml @@ -14,6 +14,15 @@ BackupURL = "postgresql://user:pass@read-replica.example.com:5432/dbname?sslmode # Environment variable: `CL_DATABASE_ALLOW_SIMPLE_PASSWORDS` AllowSimplePasswords = false # Default +# Optional LDAP config +[WebServer.LDAP] +# ServerAddress is the full ldaps:// address of the ldap server to authenticate with and query +ServerAddress = 'ldaps://127.0.0.1' # Example +# ReadOnlyUserLogin is the username of the read only root user used to authenticate the requested LDAP queries +ReadOnlyUserLogin = 'viewer@example.com' # Example +# ReadOnlyUserPass is the password for the above account +ReadOnlyUserPass = 'password' # Example + [Password] # Keystore is the password for the node's account. # diff --git a/core/config/toml/types.go b/core/config/toml/types.go index b7c8cfbc473..61962d43e5f 100644 --- a/core/config/toml/types.go +++ b/core/config/toml/types.go @@ -20,6 +20,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/config/parse" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ethkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" + "github.com/smartcontractkit/chainlink/v2/core/sessions" "github.com/smartcontractkit/chainlink/v2/core/store/dialects" "github.com/smartcontractkit/chainlink/v2/core/store/models" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -101,6 +102,7 @@ func (c *Core) ValidateConfig() (err error) { type Secrets struct { Database DatabaseSecrets `toml:",omitempty"` Password Passwords `toml:",omitempty"` + WebServer WebServerSecrets `toml:",omitempty"` Pyroscope PyroscopeSecrets `toml:",omitempty"` Prometheus PrometheusSecrets `toml:",omitempty"` Mercury MercurySecrets `toml:",omitempty"` @@ -592,6 +594,7 @@ func (l *LogFile) setFrom(f *LogFile) { } type WebServer struct { + AuthenticationMethod *string AllowOrigins *string BridgeResponseURL *models.URL BridgeCacheTTL *models.Duration @@ -604,12 +607,16 @@ type WebServer struct { StartTimeout *models.Duration ListenIP *net.IP + LDAP WebServerLDAP `toml:",omitempty"` MFA WebServerMFA `toml:",omitempty"` RateLimit WebServerRateLimit `toml:",omitempty"` TLS WebServerTLS `toml:",omitempty"` } func (w *WebServer) setFrom(f *WebServer) { + if v := f.AuthenticationMethod; v != nil { + w.AuthenticationMethod = v + } if v := f.AllowOrigins; v != nil { w.AllowOrigins = v } @@ -644,11 +651,46 @@ func (w *WebServer) setFrom(f *WebServer) { w.HTTPMaxSize = v } + w.LDAP.setFrom(&f.LDAP) w.MFA.setFrom(&f.MFA) w.RateLimit.setFrom(&f.RateLimit) w.TLS.setFrom(&f.TLS) } +func (w *WebServer) ValidateConfig() (err error) { + // Validate LDAP fields when authentication method is LDAPAuth + if *w.AuthenticationMethod != string(sessions.LDAPAuth) { + return + } + + // Assert LDAP fields when AuthMethod set to LDAP + if *w.LDAP.BaseDN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.BaseDN", Msg: "LDAP BaseDN can not be empty"}) + } + if *w.LDAP.BaseUserAttr == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.BaseUserAttr", Msg: "LDAP BaseUserAttr can not be empty"}) + } + if *w.LDAP.UsersDN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.UsersDN", Msg: "LDAP UsersDN can not be empty"}) + } + if *w.LDAP.GroupsDN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.GroupsDN", Msg: "LDAP GroupsDN can not be empty"}) + } + if *w.LDAP.AdminUserGroupCN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.AdminUserGroupCN", Msg: "LDAP AdminUserGroupCN can not be empty"}) + } + if *w.LDAP.EditUserGroupCN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.RunUserGroupCN", Msg: "LDAP ReadUserGroupCN can not be empty"}) + } + if *w.LDAP.RunUserGroupCN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.RunUserGroupCN", Msg: "LDAP RunUserGroupCN can not be empty"}) + } + if *w.LDAP.ReadUserGroupCN == "" { + err = multierr.Append(err, configutils.ErrInvalid{Name: "LDAP.ReadUserGroupCN", Msg: "LDAP ReadUserGroupCN can not be empty"}) + } + return err +} + type WebServerMFA struct { RPID *string RPOrigin *string @@ -715,6 +757,110 @@ func (w *WebServerTLS) setFrom(f *WebServerTLS) { } } +type WebServerLDAP struct { + ServerTLS *bool + SessionTimeout *models.Duration + QueryTimeout *models.Duration + BaseUserAttr *string + BaseDN *string + UsersDN *string + GroupsDN *string + ActiveAttribute *string + ActiveAttributeAllowedValue *string + AdminUserGroupCN *string + EditUserGroupCN *string + RunUserGroupCN *string + ReadUserGroupCN *string + UserApiTokenEnabled *bool + UserAPITokenDuration *models.Duration + UpstreamSyncInterval *models.Duration + UpstreamSyncRateLimit *models.Duration +} + +func (w *WebServerLDAP) setFrom(f *WebServerLDAP) { + if v := f.ServerTLS; v != nil { + w.ServerTLS = v + } + if v := f.SessionTimeout; v != nil { + w.SessionTimeout = v + } + if v := f.SessionTimeout; v != nil { + w.SessionTimeout = v + } + if v := f.QueryTimeout; v != nil { + w.QueryTimeout = v + } + if v := f.BaseUserAttr; v != nil { + w.BaseUserAttr = v + } + if v := f.BaseDN; v != nil { + w.BaseDN = v + } + if v := f.UsersDN; v != nil { + w.UsersDN = v + } + if v := f.GroupsDN; v != nil { + w.GroupsDN = v + } + if v := f.ActiveAttribute; v != nil { + w.ActiveAttribute = v + } + if v := f.ActiveAttributeAllowedValue; v != nil { + w.ActiveAttributeAllowedValue = v + } + if v := f.AdminUserGroupCN; v != nil { + w.AdminUserGroupCN = v + } + if v := f.EditUserGroupCN; v != nil { + w.EditUserGroupCN = v + } + if v := f.RunUserGroupCN; v != nil { + w.RunUserGroupCN = v + } + if v := f.ReadUserGroupCN; v != nil { + w.ReadUserGroupCN = v + } + if v := f.UserApiTokenEnabled; v != nil { + w.UserApiTokenEnabled = v + } + if v := f.UserAPITokenDuration; v != nil { + w.UserAPITokenDuration = v + } + if v := f.UpstreamSyncInterval; v != nil { + w.UpstreamSyncInterval = v + } + if v := f.UpstreamSyncRateLimit; v != nil { + w.UpstreamSyncRateLimit = v + } +} + +type WebServerLDAPSecrets struct { + ServerAddress *models.SecretURL + ReadOnlyUserLogin *models.Secret + ReadOnlyUserPass *models.Secret +} + +func (w *WebServerLDAPSecrets) setFrom(f *WebServerLDAPSecrets) { + if v := f.ServerAddress; v != nil { + w.ServerAddress = v + } + if v := f.ReadOnlyUserLogin; v != nil { + w.ReadOnlyUserLogin = v + } + if v := f.ReadOnlyUserPass; v != nil { + w.ReadOnlyUserPass = v + } +} + +type WebServerSecrets struct { + LDAP WebServerLDAPSecrets `toml:",omitempty"` +} + +func (w *WebServerSecrets) SetFrom(f *WebServerSecrets) error { + w.LDAP.setFrom(&f.LDAP) + return nil +} + type JobPipeline struct { ExternalInitiatorsEnabled *bool MaxRunDuration *models.Duration diff --git a/core/config/web_config.go b/core/config/web_config.go index 12209a02670..429a31e7e82 100644 --- a/core/config/web_config.go +++ b/core/config/web_config.go @@ -32,7 +32,31 @@ type MFA interface { RPOrigin() string } +type LDAP interface { + ServerAddress() string + ReadOnlyUserLogin() string + ReadOnlyUserPass() string + ServerTLS() bool + SessionTimeout() models.Duration + QueryTimeout() time.Duration + BaseUserAttr() string + BaseDN() string + UsersDN() string + GroupsDN() string + ActiveAttribute() string + ActiveAttributeAllowedValue() string + AdminUserGroupCN() string + EditUserGroupCN() string + RunUserGroupCN() string + ReadUserGroupCN() string + UserApiTokenEnabled() bool + UserAPITokenDuration() models.Duration + UpstreamSyncInterval() models.Duration + UpstreamSyncRateLimit() models.Duration +} + type WebServer interface { + AuthenticationMethod() string AllowOrigins() string BridgeCacheTTL() time.Duration BridgeResponseURL() *url.URL @@ -49,4 +73,5 @@ type WebServer interface { TLS() TLS RateLimit() RateLimit MFA() MFA + LDAP() LDAP } diff --git a/core/internal/cltest/cltest.go b/core/internal/cltest/cltest.go index fb4a69cf30c..66162aef102 100644 --- a/core/internal/cltest/cltest.go +++ b/core/internal/cltest/cltest.go @@ -628,7 +628,7 @@ func (ta *TestApplication) NewHTTPClient(user *User) HTTPClientCleaner { u, err := clsessions.NewUser(user.Email, Password, user.Role) require.NoError(ta.t, err) - err = ta.SessionORM().CreateUser(&u) + err = ta.BasicAdminUsersORM().CreateUser(&u) require.NoError(ta.t, err) sessionID := ta.MustSeedNewSession(user.Email) diff --git a/core/internal/cltest/mocks.go b/core/internal/cltest/mocks.go index 9fdbcbb373d..00f72199dd9 100644 --- a/core/internal/cltest/mocks.go +++ b/core/internal/cltest/mocks.go @@ -309,7 +309,7 @@ func MustRandomUser(t testing.TB) sessions.User { return r } -func NewUserWithSession(t testing.TB, orm sessions.ORM) sessions.User { +func NewUserWithSession(t testing.TB, orm sessions.AuthenticationProvider) sessions.User { u := MustRandomUser(t) require.NoError(t, orm.CreateUser(&u)) @@ -330,7 +330,7 @@ func NewMockAPIInitializer(t testing.TB) *MockAPIInitializer { return &MockAPIInitializer{t: t} } -func (m *MockAPIInitializer) Initialize(orm sessions.ORM, lggr logger.Logger) (sessions.User, error) { +func (m *MockAPIInitializer) Initialize(orm sessions.BasicAdminUsersORM, lggr logger.Logger) (sessions.User, error) { if user, err := orm.FindUser(APIEmailAdmin); err == nil { return user, err } diff --git a/core/internal/features/features_test.go b/core/internal/features/features_test.go index 3293066191f..23451bf29fe 100644 --- a/core/internal/features/features_test.go +++ b/core/internal/features/features_test.go @@ -266,7 +266,7 @@ func TestIntegration_AuthToken(t *testing.T) { mockUser := cltest.MustRandomUser(t) key, secret := uuid.New().String(), uuid.New().String() apiToken := auth.Token{AccessKey: key, Secret: secret} - orm := app.SessionORM() + orm := app.AuthenticationProvider() require.NoError(t, orm.CreateUser(&mockUser)) require.NoError(t, orm.SetAuthToken(&mockUser, &apiToken)) diff --git a/core/internal/mocks/application.go b/core/internal/mocks/application.go index ec656509afd..7853361db93 100644 --- a/core/internal/mocks/application.go +++ b/core/internal/mocks/application.go @@ -63,6 +63,38 @@ func (_m *Application) AddJobV2(ctx context.Context, _a1 *job.Job) error { return r0 } +// AuthenticationProvider provides a mock function with given fields: +func (_m *Application) AuthenticationProvider() sessions.AuthenticationProvider { + ret := _m.Called() + + var r0 sessions.AuthenticationProvider + if rf, ok := ret.Get(0).(func() sessions.AuthenticationProvider); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(sessions.AuthenticationProvider) + } + } + + return r0 +} + +// BasicAdminUsersORM provides a mock function with given fields: +func (_m *Application) BasicAdminUsersORM() sessions.BasicAdminUsersORM { + ret := _m.Called() + + var r0 sessions.BasicAdminUsersORM + if rf, ok := ret.Get(0).(func() sessions.BasicAdminUsersORM); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(sessions.BasicAdminUsersORM) + } + } + + return r0 +} + // BridgeORM provides a mock function with given fields: func (_m *Application) BridgeORM() bridges.ORM { ret := _m.Called() @@ -439,22 +471,6 @@ func (_m *Application) SecretGenerator() chainlink.SecretGenerator { return r0 } -// SessionORM provides a mock function with given fields: -func (_m *Application) SessionORM() sessions.ORM { - ret := _m.Called() - - var r0 sessions.ORM - if rf, ok := ret.Get(0).(func() sessions.ORM); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(sessions.ORM) - } - } - - return r0 -} - // SetLogLevel provides a mock function with given fields: lvl func (_m *Application) SetLogLevel(lvl zapcore.Level) error { ret := _m.Called(lvl) diff --git a/core/scripts/go.mod b/core/scripts/go.mod index 65dcec563e5..17c2cff1039 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -44,6 +44,7 @@ require ( filippo.io/edwards25519 v1.0.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.1 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/ChainSafe/go-schnorrkel v0.0.0-20200405005733-88cbf1b4c40d // indirect github.com/CosmWasm/wasmd v0.40.1 // indirect github.com/CosmWasm/wasmvm v1.2.4 // indirect @@ -119,8 +120,10 @@ require ( github.com/gin-contrib/size v0.0.0-20230212012657-e14a14094dc4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-kit/kit v0.12.0 // indirect github.com/go-kit/log v0.2.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/core/scripts/go.sum b/core/scripts/go.sum index 781eed46ceb..ae1f924c0f7 100644 --- a/core/scripts/go.sum +++ b/core/scripts/go.sum @@ -79,6 +79,8 @@ github.com/AndreasBriese/bbloom v0.0.0-20180913140656-343706a395b7/go.mod h1:bOv github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -124,6 +126,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= @@ -424,6 +428,8 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -438,6 +444,8 @@ github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEai github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8= +github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -1745,6 +1753,7 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1785,6 +1794,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1846,6 +1856,7 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1872,6 +1883,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1975,6 +1987,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1987,6 +2000,7 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2063,6 +2077,7 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go index 63a9b2696cf..3285acdc07a 100644 --- a/core/services/chainlink/application.go +++ b/core/services/chainlink/application.go @@ -52,6 +52,8 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/vrf" "github.com/smartcontractkit/chainlink/v2/core/services/webhook" "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/ldapauth" + "github.com/smartcontractkit/chainlink/v2/core/sessions/localauth" "github.com/smartcontractkit/chainlink/v2/core/utils" "github.com/smartcontractkit/chainlink/v2/plugins" ) @@ -82,7 +84,8 @@ type Application interface { EVMORM() evmtypes.Configs PipelineORM() pipeline.ORM BridgeORM() bridges.ORM - SessionORM() sessions.ORM + BasicAdminUsersORM() sessions.BasicAdminUsersORM + AuthenticationProvider() sessions.AuthenticationProvider TxmStorageService() txmgr.EvmTxStore AddJobV2(ctx context.Context, job *job.Job) error DeleteJob(ctx context.Context, jobID int32) error @@ -115,7 +118,8 @@ type ChainlinkApplication struct { pipelineORM pipeline.ORM pipelineRunner pipeline.Runner bridgeORM bridges.ORM - sessionORM sessions.ORM + localAdminUsersORM sessions.BasicAdminUsersORM + authenticationProvider sessions.AuthenticationProvider txmStorageService txmgr.EvmTxStore FeedsService feeds.Service webhookJobRunner webhook.JobRunner @@ -245,10 +249,36 @@ func NewApplication(opts ApplicationOpts) (Application, error) { return nil, fmt.Errorf("no evm chains found") } + // Initialize Local Users ORM and Authentication Provider specified in config + // BasicAdminUsersORM is initialized and required regardless of separate Authentication Provider + localAdminUsersORM := localauth.NewORM(db, cfg.WebServer().SessionTimeout().Duration(), globalLogger, cfg.Database(), auditLogger) + + // Initialize Sessions ORM based on environment configured authenticator + // localDB auth or remote LDAP auth + authMethod := cfg.WebServer().AuthenticationMethod() + var authenticationProvider sessions.AuthenticationProvider + var sessionReaper utils.SleeperTask + + switch sessions.AuthenticationProviderName(authMethod) { + case sessions.LDAPAuth: + var err error + authenticationProvider, err = ldapauth.NewLDAPAuthenticator( + db, cfg.Database(), cfg.WebServer().LDAP(), cfg.Insecure().DevWebServer(), globalLogger, auditLogger, + ) + if err != nil { + return nil, errors.Wrap(err, "NewApplication: failed to initialize LDAP Authentication module") + } + sessionReaper = ldapauth.NewLDAPServerStateSync(db, cfg.Database(), cfg.WebServer().LDAP(), globalLogger) + case sessions.LocalAuth: + authenticationProvider = localauth.NewORM(db, cfg.WebServer().SessionTimeout().Duration(), globalLogger, cfg.Database(), auditLogger) + sessionReaper = localauth.NewSessionReaper(db.DB, cfg.WebServer(), globalLogger) + default: + return nil, errors.Errorf("NewApplication: Unexpected 'AuthenticationMethod': %s supported values: %s, %s", authMethod, sessions.LocalAuth, sessions.LDAPAuth) + } + var ( pipelineORM = pipeline.NewORM(db, globalLogger, cfg.Database(), cfg.JobPipeline().MaxSuccessfulRuns()) bridgeORM = bridges.NewORM(db, globalLogger, cfg.Database()) - sessionORM = sessions.NewORM(db, cfg.WebServer().SessionTimeout().Duration(), globalLogger, cfg.Database(), auditLogger) mercuryORM = mercury.NewORM(db, globalLogger, cfg.Database()) pipelineRunner = pipeline.NewRunner(pipelineORM, bridgeORM, cfg.JobPipeline(), cfg.WebServer(), legacyEVMChains, keyStore.Eth(), keyStore.VRF(), globalLogger, restrictedHTTPClient, unrestrictedHTTPClient) jobORM = job.NewORM(db, pipelineORM, bridgeORM, keyStore, globalLogger, cfg.Database()) @@ -440,13 +470,14 @@ func NewApplication(opts ApplicationOpts) (Application, error) { pipelineRunner: pipelineRunner, pipelineORM: pipelineORM, bridgeORM: bridgeORM, - sessionORM: sessionORM, + localAdminUsersORM: localAdminUsersORM, + authenticationProvider: authenticationProvider, txmStorageService: txmORM, FeedsService: feedsService, Config: cfg, webhookJobRunner: webhookJobRunner, KeyStore: keyStore, - SessionReaper: sessions.NewSessionReaper(db.DB, cfg.WebServer(), globalLogger), + SessionReaper: sessionReaper, ExternalInitiatorManager: externalInitiatorManager, HealthChecker: healthChecker, Nurse: nurse, @@ -612,8 +643,12 @@ func (app *ChainlinkApplication) BridgeORM() bridges.ORM { return app.bridgeORM } -func (app *ChainlinkApplication) SessionORM() sessions.ORM { - return app.sessionORM +func (app *ChainlinkApplication) BasicAdminUsersORM() sessions.BasicAdminUsersORM { + return app.localAdminUsersORM +} + +func (app *ChainlinkApplication) AuthenticationProvider() sessions.AuthenticationProvider { + return app.authenticationProvider } // TODO BCF-2516 remove this all together remove EVM specifics diff --git a/core/services/chainlink/config.go b/core/services/chainlink/config.go index 3f55a2dc00f..10598718f97 100644 --- a/core/services/chainlink/config.go +++ b/core/services/chainlink/config.go @@ -168,28 +168,32 @@ type Secrets struct { } func (s *Secrets) SetFrom(f *Secrets) (err error) { - if err1 := s.Database.SetFrom(&f.Database); err1 != nil { - err = multierr.Append(err, config.NamedMultiErrorList(err1, "Database")) + if err2 := s.Database.SetFrom(&f.Database); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "Database")) } if err2 := s.Password.SetFrom(&f.Password); err2 != nil { err = multierr.Append(err, config.NamedMultiErrorList(err2, "Password")) } - if err3 := s.Pyroscope.SetFrom(&f.Pyroscope); err3 != nil { - err = multierr.Append(err, config.NamedMultiErrorList(err3, "Pyroscope")) + if err2 := s.WebServer.SetFrom(&f.WebServer); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "WebServer")) } - if err4 := s.Prometheus.SetFrom(&f.Prometheus); err4 != nil { - err = multierr.Append(err, config.NamedMultiErrorList(err4, "Prometheus")) + if err2 := s.Pyroscope.SetFrom(&f.Pyroscope); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "Pyroscope")) } - if err5 := s.Mercury.SetFrom(&f.Mercury); err5 != nil { - err = multierr.Append(err, config.NamedMultiErrorList(err5, "Mercury")) + if err2 := s.Prometheus.SetFrom(&f.Prometheus); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "Prometheus")) } - if err6 := s.Threshold.SetFrom(&f.Threshold); err6 != nil { - err = multierr.Append(err, config.NamedMultiErrorList(err6, "Threshold")) + if err2 := s.Mercury.SetFrom(&f.Mercury); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "Mercury")) + } + + if err2 := s.Threshold.SetFrom(&f.Threshold); err2 != nil { + err = multierr.Append(err, config.NamedMultiErrorList(err2, "Threshold")) } _, err = utils.MultiErrorList(err) diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index 81e38833359..6a835e09c89 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -348,7 +348,7 @@ func (g *generalConfig) StarkNetEnabled() bool { } func (g *generalConfig) WebServer() config.WebServer { - return &webServerConfig{c: g.c.WebServer, rootDir: g.RootDir} + return &webServerConfig{c: g.c.WebServer, s: g.secrets.WebServer, rootDir: g.RootDir} } func (g *generalConfig) AutoPprofBlockProfileRate() int { diff --git a/core/services/chainlink/config_general_test.go b/core/services/chainlink/config_general_test.go index 46931e53e2b..c122f8f968c 100644 --- a/core/services/chainlink/config_general_test.go +++ b/core/services/chainlink/config_general_test.go @@ -149,6 +149,9 @@ var mercurySecretsTOMLSplitTwo string //go:embed testdata/mergingsecretsdata/secrets-threshold.toml var thresholdSecretsTOML string +//go:embed testdata/mergingsecretsdata/secrets-webserver-ldap.toml +var WebServerLDAPSecretsTOML string + func TestConfig_SecretsMerging(t *testing.T) { t.Run("verify secrets merging in GeneralConfigOpts.New()", func(t *testing.T) { databaseSecrets, err := parseSecrets(databaseSecretsTOML) @@ -165,6 +168,8 @@ func TestConfig_SecretsMerging(t *testing.T) { require.NoErrorf(t, err6, "error: %s", err6) thresholdSecrets, err7 := parseSecrets(thresholdSecretsTOML) require.NoErrorf(t, err7, "error: %s", err7) + webserverLDAPSecrets, err8 := parseSecrets(WebServerLDAPSecretsTOML) + require.NoErrorf(t, err8, "error: %s", err8) opts := new(GeneralConfigOpts) configFiles := []string{ @@ -178,6 +183,7 @@ func TestConfig_SecretsMerging(t *testing.T) { "testdata/mergingsecretsdata/secrets-mercury-split-one.toml", "testdata/mergingsecretsdata/secrets-mercury-split-two.toml", "testdata/mergingsecretsdata/secrets-threshold.toml", + "testdata/mergingsecretsdata/secrets-webserver-ldap.toml", } err = opts.Setup(configFiles, secretsFiles) require.NoErrorf(t, err, "error: %s", err) @@ -194,6 +200,10 @@ func TestConfig_SecretsMerging(t *testing.T) { assert.Equal(t, (string)(*prometheusSecrets.Prometheus.AuthToken), (string)(*opts.Secrets.Prometheus.AuthToken)) assert.Equal(t, (string)(*thresholdSecrets.Threshold.ThresholdKeyShare), (string)(*opts.Secrets.Threshold.ThresholdKeyShare)) + assert.Equal(t, webserverLDAPSecrets.WebServer.LDAP.ServerAddress.URL().String(), opts.Secrets.WebServer.LDAP.ServerAddress.URL().String()) + assert.Equal(t, webserverLDAPSecrets.WebServer.LDAP.ReadOnlyUserLogin, opts.Secrets.WebServer.LDAP.ReadOnlyUserLogin) + assert.Equal(t, webserverLDAPSecrets.WebServer.LDAP.ReadOnlyUserPass, opts.Secrets.WebServer.LDAP.ReadOnlyUserPass) + err = assertDeepEqualityMercurySecrets(*merge(mercurySecrets_a.Mercury, mercurySecrets_b.Mercury), opts.Secrets.Mercury) require.NoErrorf(t, err, "merged mercury secrets unequal") }) diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 986b98d9367..96e6db42c80 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -308,6 +308,7 @@ func TestConfig_Marshal(t *testing.T) { }, } full.WebServer = toml.WebServer{ + AuthenticationMethod: ptr("local"), AllowOrigins: ptr("*"), BridgeResponseURL: mustURL("https://bridge.response"), BridgeCacheTTL: models.MustNewDuration(10 * time.Second), @@ -323,6 +324,25 @@ func TestConfig_Marshal(t *testing.T) { RPID: ptr("test-rpid"), RPOrigin: ptr("test-rp-origin"), }, + LDAP: toml.WebServerLDAP{ + ServerTLS: ptr(true), + SessionTimeout: models.MustNewDuration(15 * time.Minute), + QueryTimeout: models.MustNewDuration(2 * time.Minute), + BaseUserAttr: ptr("uid"), + BaseDN: ptr("dc=custom,dc=example,dc=com"), + UsersDN: ptr("ou=users"), + GroupsDN: ptr("ou=groups"), + ActiveAttribute: ptr("organizationalStatus"), + ActiveAttributeAllowedValue: ptr("ACTIVE"), + AdminUserGroupCN: ptr("NodeAdmins"), + EditUserGroupCN: ptr("NodeEditors"), + RunUserGroupCN: ptr("NodeRunners"), + ReadUserGroupCN: ptr("NodeReadOnly"), + UserApiTokenEnabled: ptr(false), + UserAPITokenDuration: models.MustNewDuration(240 * time.Hour), + UpstreamSyncInterval: models.MustNewDuration(0 * time.Second), + UpstreamSyncRateLimit: models.MustNewDuration(2 * time.Minute), + }, RateLimit: toml.WebServerRateLimit{ Authenticated: ptr[int64](42), AuthenticatedPeriod: models.MustNewDuration(time.Second), @@ -738,6 +758,7 @@ MaxAgeDays = 17 MaxBackups = 9 `}, {"WebServer", Config{Core: toml.Core{WebServer: full.WebServer}}, `[WebServer] +AuthenticationMethod = 'local' AllowOrigins = '*' BridgeResponseURL = 'https://bridge.response' BridgeCacheTTL = '10s' @@ -750,6 +771,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '192.158.1.37' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = 'dc=custom,dc=example,dc=com' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = 'organizationalStatus' +ActiveAttributeAllowedValue = 'ACTIVE' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = 'test-rpid' RPOrigin = 'test-rp-origin' @@ -1118,8 +1158,17 @@ func TestConfig_Validate(t *testing.T) { toml string exp string }{ - {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 5 errors: + {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 6 errors: - Database.Lock.LeaseRefreshInterval: invalid value (6s): must be less than or equal to half of LeaseDuration (10s) + - WebServer: 8 errors: + - LDAP.BaseDN: invalid value (): LDAP BaseDN can not be empty + - LDAP.BaseUserAttr: invalid value (): LDAP BaseUserAttr can not be empty + - LDAP.UsersDN: invalid value (): LDAP UsersDN can not be empty + - LDAP.GroupsDN: invalid value (): LDAP GroupsDN can not be empty + - LDAP.AdminUserGroupCN: invalid value (): LDAP AdminUserGroupCN can not be empty + - LDAP.RunUserGroupCN: invalid value (): LDAP ReadUserGroupCN can not be empty + - LDAP.RunUserGroupCN: invalid value (): LDAP RunUserGroupCN can not be empty + - LDAP.ReadUserGroupCN: invalid value (): LDAP ReadUserGroupCN can not be empty - EVM: 8 errors: - 1.ChainID: invalid value (1): duplicate - must be unique - 0.Nodes.1.Name: invalid value (foo): duplicate - must be unique diff --git a/core/services/chainlink/config_web_server.go b/core/services/chainlink/config_web_server.go index a931d67f386..06db398e2ea 100644 --- a/core/services/chainlink/config_web_server.go +++ b/core/services/chainlink/config_web_server.go @@ -98,6 +98,7 @@ func (m *mfaConfig) RPOrigin() string { type webServerConfig struct { c toml.WebServer + s toml.WebServerSecrets rootDir func() string } @@ -113,6 +114,14 @@ func (w *webServerConfig) MFA() config.MFA { return &mfaConfig{c: w.c.MFA} } +func (w *webServerConfig) LDAP() config.LDAP { + return &ldapConfig{c: w.c.LDAP, s: w.s.LDAP} +} + +func (w *webServerConfig) AuthenticationMethod() string { + return *w.c.AuthenticationMethod +} + func (w *webServerConfig) AllowOrigins() string { return *w.c.AllowOrigins } @@ -168,3 +177,139 @@ func (w *webServerConfig) SessionTimeout() models.Duration { func (w *webServerConfig) ListenIP() net.IP { return *w.c.ListenIP } + +type ldapConfig struct { + c toml.WebServerLDAP + s toml.WebServerLDAPSecrets +} + +func (l *ldapConfig) ServerAddress() string { + if l.s.ServerAddress == nil { + return "" + } + return l.s.ServerAddress.URL().String() +} + +func (l *ldapConfig) ReadOnlyUserLogin() string { + if l.s.ReadOnlyUserLogin == nil { + return "" + } + return string(*l.s.ReadOnlyUserLogin) +} + +func (l *ldapConfig) ReadOnlyUserPass() string { + if l.s.ReadOnlyUserPass == nil { + return "" + } + return string(*l.s.ReadOnlyUserPass) +} + +func (l *ldapConfig) ServerTLS() bool { + if l.c.ServerTLS == nil { + return false + } + return *l.c.ServerTLS +} + +func (l *ldapConfig) SessionTimeout() models.Duration { + return *l.c.SessionTimeout +} + +func (l *ldapConfig) QueryTimeout() time.Duration { + return l.c.QueryTimeout.Duration() +} + +func (l *ldapConfig) UserAPITokenDuration() models.Duration { + return *l.c.UserAPITokenDuration +} + +func (l *ldapConfig) BaseUserAttr() string { + if l.c.BaseUserAttr == nil { + return "" + } + return *l.c.BaseUserAttr +} + +func (l *ldapConfig) BaseDN() string { + if l.c.BaseDN == nil { + return "" + } + return *l.c.BaseDN +} + +func (l *ldapConfig) UsersDN() string { + if l.c.UsersDN == nil { + return "" + } + return *l.c.UsersDN +} + +func (l *ldapConfig) GroupsDN() string { + if l.c.GroupsDN == nil { + return "" + } + return *l.c.GroupsDN +} + +func (l *ldapConfig) ActiveAttribute() string { + if l.c.ActiveAttribute == nil { + return "" + } + return *l.c.ActiveAttribute +} + +func (l *ldapConfig) ActiveAttributeAllowedValue() string { + if l.c.ActiveAttributeAllowedValue == nil { + return "" + } + return *l.c.ActiveAttributeAllowedValue +} + +func (l *ldapConfig) AdminUserGroupCN() string { + if l.c.AdminUserGroupCN == nil { + return "" + } + return *l.c.AdminUserGroupCN +} + +func (l *ldapConfig) EditUserGroupCN() string { + if l.c.EditUserGroupCN == nil { + return "" + } + return *l.c.EditUserGroupCN +} + +func (l *ldapConfig) RunUserGroupCN() string { + if l.c.RunUserGroupCN == nil { + return "" + } + return *l.c.RunUserGroupCN +} + +func (l *ldapConfig) ReadUserGroupCN() string { + if l.c.ReadUserGroupCN == nil { + return "" + } + return *l.c.ReadUserGroupCN +} + +func (l *ldapConfig) UserApiTokenEnabled() bool { + if l.c.UserApiTokenEnabled == nil { + return false + } + return *l.c.UserApiTokenEnabled +} + +func (l *ldapConfig) UpstreamSyncInterval() models.Duration { + if l.c.UpstreamSyncInterval == nil { + return models.Duration{} + } + return *l.c.UpstreamSyncInterval +} + +func (l *ldapConfig) UpstreamSyncRateLimit() models.Duration { + if l.c.UpstreamSyncRateLimit == nil { + return models.Duration{} + } + return *l.c.UpstreamSyncRateLimit +} diff --git a/core/services/chainlink/testdata/config-empty-effective.toml b/core/services/chainlink/testdata/config-empty-effective.toml index 48d432138a8..f5d775fe744 100644 --- a/core/services/chainlink/testdata/config-empty-effective.toml +++ b/core/services/chainlink/testdata/config-empty-effective.toml @@ -61,6 +61,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -73,6 +74,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml index 7ce0d185b1c..5ede10ef695 100644 --- a/core/services/chainlink/testdata/config-full.toml +++ b/core/services/chainlink/testdata/config-full.toml @@ -67,6 +67,7 @@ MaxAgeDays = 17 MaxBackups = 9 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = '*' BridgeResponseURL = 'https://bridge.response' BridgeCacheTTL = '10s' @@ -79,6 +80,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '192.158.1.37' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = 'dc=custom,dc=example,dc=com' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = 'organizationalStatus' +ActiveAttributeAllowedValue = 'ACTIVE' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = 'test-rpid' RPOrigin = 'test-rp-origin' diff --git a/core/services/chainlink/testdata/config-invalid.toml b/core/services/chainlink/testdata/config-invalid.toml index 3b7e89299f6..4d8c9bc29a9 100644 --- a/core/services/chainlink/testdata/config-invalid.toml +++ b/core/services/chainlink/testdata/config-invalid.toml @@ -2,6 +2,28 @@ LeaseRefreshInterval='6s' LeaseDuration='10s' +[WebServer] +AuthenticationMethod = 'ldap' + +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = '' +BaseDN = '' +UsersDN = '' +GroupsDN = '' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = '' +EditUserGroupCN = '' +RunUserGroupCN = '' +ReadUserGroupCN = '' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [[EVM]] ChainID = '1' Transactions.MaxInFlight= 10 diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml index 1dcbfe3a830..9dd0be8f5d2 100644 --- a/core/services/chainlink/testdata/config-multi-chain-effective.toml +++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml @@ -61,6 +61,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -73,6 +74,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/core/services/chainlink/testdata/mergingsecretsdata/secrets-webserver-ldap.toml b/core/services/chainlink/testdata/mergingsecretsdata/secrets-webserver-ldap.toml new file mode 100644 index 00000000000..f73efcff0cc --- /dev/null +++ b/core/services/chainlink/testdata/mergingsecretsdata/secrets-webserver-ldap.toml @@ -0,0 +1,4 @@ +[WebServer.LDAP] +ServerAddress = 'ldaps://127.0.0.1' +ReadOnlyUserLogin = 'viewer@example.com' +ReadOnlyUserPass = 'password' \ No newline at end of file diff --git a/core/services/chainlink/testdata/secrets-full-redacted.toml b/core/services/chainlink/testdata/secrets-full-redacted.toml index 740c3250edb..9d91d79cb51 100644 --- a/core/services/chainlink/testdata/secrets-full-redacted.toml +++ b/core/services/chainlink/testdata/secrets-full-redacted.toml @@ -7,6 +7,12 @@ AllowSimplePasswords = false Keystore = 'xxxxx' VRF = 'xxxxx' +[WebServer] +[WebServer.LDAP] +ServerAddress = 'xxxxx' +ReadOnlyUserLogin = 'xxxxx' +ReadOnlyUserPass = 'xxxxx' + [Pyroscope] AuthToken = 'xxxxx' diff --git a/core/services/chainlink/testdata/secrets-full.toml b/core/services/chainlink/testdata/secrets-full.toml index 37e5dafc7d7..37a3e2e7dc2 100644 --- a/core/services/chainlink/testdata/secrets-full.toml +++ b/core/services/chainlink/testdata/secrets-full.toml @@ -6,6 +6,12 @@ BackupURL = "postgresql://user:pass@localhost:5432/backupdbname?sslmode=disable" Keystore = "keystore_pass" VRF = "VRF_pass" +[WebServer] +[WebServer.LDAP] +ServerAddress = 'ldaps://127.0.0.1' +ReadOnlyUserLogin = 'viewer@example.com' +ReadOnlyUserPass = 'password' + [Pyroscope] AuthToken = "pyroscope-token" diff --git a/core/sessions/authentication.go b/core/sessions/authentication.go new file mode 100644 index 00000000000..0f0dda3bf33 --- /dev/null +++ b/core/sessions/authentication.go @@ -0,0 +1,66 @@ +package sessions + +import ( + "errors" + "fmt" + + "github.com/smartcontractkit/chainlink/v2/core/auth" + "github.com/smartcontractkit/chainlink/v2/core/bridges" +) + +// Application config constant options +type AuthenticationProviderName string + +const ( + LocalAuth AuthenticationProviderName = "local" + LDAPAuth AuthenticationProviderName = "ldap" +) + +// ErrUserSessionExpired defines the error triggered when the user session has expired +var ErrUserSessionExpired = errors.New("session missing or expired, please login again") + +// ErrNotSupported defines the error where interface functionality doesn't align with the underlying Auth Provider +var ErrNotSupported = fmt.Errorf("functionality not supported with current authentication provider: %w", errors.ErrUnsupported) + +// ErrEmptySessionID captures the empty case error message +var ErrEmptySessionID = errors.New("session ID cannot be empty") + +//go:generate mockery --quiet --name BasicAdminUsersORM --output ./mocks/ --case=underscore + +// BasicAdminUsersORM is the interface that defines the functionality required for supporting basic admin functionality +// adjacent to the identity provider authentication provider implementation. It is currently implemented by the local +// users/sessions ORM containing local admin CLI actions. This is separate from the AuthenticationProvider, +// as local admin management (ie initial core node setup, initial admin user creation), is always +// required no matter what the pluggable AuthenticationProvider implementation is. +type BasicAdminUsersORM interface { + ListUsers() ([]User, error) + CreateUser(user *User) error + FindUser(email string) (User, error) +} + +//go:generate mockery --quiet --name AuthenticationProvider --output ./mocks/ --case=underscore + +// AuthenticationProvider is an interface that abstracts the required application calls to a user management backend +// Currently localauth (users table DB) or LDAP server (readonly) +type AuthenticationProvider interface { + FindUser(email string) (User, error) + FindUserByAPIToken(apiToken string) (User, error) + ListUsers() ([]User, error) + AuthorizedUserWithSession(sessionID string) (User, error) + DeleteUser(email string) error + DeleteUserSession(sessionID string) error + CreateSession(sr SessionRequest) (string, error) + ClearNonCurrentSessions(sessionID string) error + CreateUser(user *User) error + UpdateRole(email, newRole string) (User, error) + SetAuthToken(user *User, token *auth.Token) error + CreateAndSetAuthToken(user *User) (*auth.Token, error) + DeleteAuthToken(user *User) error + SetPassword(user *User, newPassword string) error + TestPassword(email, password string) error + Sessions(offset, limit int) ([]Session, error) + GetUserWebAuthn(email string) ([]WebAuthn, error) + SaveWebAuthn(token *WebAuthn) error + + FindExternalInitiator(eia *auth.Token) (initiator *bridges.ExternalInitiator, err error) +} diff --git a/core/sessions/ldapauth/client.go b/core/sessions/ldapauth/client.go new file mode 100644 index 00000000000..bb259f8c9a2 --- /dev/null +++ b/core/sessions/ldapauth/client.go @@ -0,0 +1,47 @@ +package ldapauth + +import ( + "fmt" + + "github.com/go-ldap/ldap/v3" + + "github.com/smartcontractkit/chainlink/v2/core/config" +) + +type ldapClient struct { + config config.LDAP +} + +//go:generate mockery --quiet --name LDAPClient --output ./mocks/ --case=underscore + +// Wrapper for creating a handle to a *ldap.Conn/LDAPConn interface +type LDAPClient interface { + CreateEphemeralConnection() (LDAPConn, error) +} + +//go:generate mockery --quiet --name LDAPConn --output ./mocks/ --case=underscore + +// Wrapper for ldap connection and mock testing, implemented by *ldap.Conn +type LDAPConn interface { + Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) + Bind(username string, password string) error + Close() (err error) +} + +func newLDAPClient(config config.LDAP) LDAPClient { + return &ldapClient{config} +} + +// CreateEphemeralConnection returns a valid, active LDAP connection for upstream Search and Bind queries +func (l *ldapClient) CreateEphemeralConnection() (LDAPConn, error) { + conn, err := ldap.DialURL(l.config.ServerAddress()) + if err != nil { + return nil, fmt.Errorf("failed to Dial LDAP Server: %w", err) + } + // Root level root user auth with credentials provided from config + bindStr := l.config.BaseUserAttr() + "=" + l.config.ReadOnlyUserLogin() + "," + l.config.BaseDN() + if err := conn.Bind(bindStr, l.config.ReadOnlyUserPass()); err != nil { + return nil, fmt.Errorf("unable to login as initial root LDAP user: %w", err) + } + return conn, nil +} diff --git a/core/sessions/ldapauth/helpers_test.go b/core/sessions/ldapauth/helpers_test.go new file mode 100644 index 00000000000..c554d5436ed --- /dev/null +++ b/core/sessions/ldapauth/helpers_test.go @@ -0,0 +1,131 @@ +package ldapauth + +import ( + "time" + + "github.com/smartcontractkit/sqlx" + + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/logger/audit" + "github.com/smartcontractkit/chainlink/v2/core/services/pg" + "github.com/smartcontractkit/chainlink/v2/core/store/models" +) + +// Returns an instantiated ldapAuthenticator struct without validation for testing +func NewTestLDAPAuthenticator( + db *sqlx.DB, + pgCfg pg.QConfig, + ldapCfg config.LDAP, + dev bool, + lggr logger.Logger, + auditLogger audit.AuditLogger, +) (*ldapAuthenticator, error) { + namedLogger := lggr.Named("LDAPAuthenticationProvider") + ldapAuth := ldapAuthenticator{ + q: pg.NewQ(db, namedLogger, pgCfg), + ldapClient: newLDAPClient(ldapCfg), + config: ldapCfg, + lggr: lggr.Named("LDAPAuthenticationProvider"), + auditLogger: auditLogger, + } + + return &ldapAuth, nil +} + +// Default server group name mappings for test config and mocked ldap search results +const ( + NodeAdminsGroupCN = "NodeAdmins" + NodeEditorsGroupCN = "NodeEditors" + NodeRunnersGroupCN = "NodeRunners" + NodeReadOnlyGroupCN = "NodeReadOnly" +) + +// Implement a setter function within the _test file so that the ldapauth_test module can set the unexported field with a mock +func (l *ldapAuthenticator) SetLDAPClient(newClient LDAPClient) { + l.ldapClient = newClient +} + +// Implements config.LDAP +type TestConfig struct { +} + +func (t *TestConfig) ServerAddress() string { + return "ldaps://MOCK" +} + +func (t *TestConfig) ReadOnlyUserLogin() string { + return "mock-readonly" +} + +func (t *TestConfig) ReadOnlyUserPass() string { + return "mock-password" +} + +func (t *TestConfig) ServerTLS() bool { + return false +} + +func (t *TestConfig) SessionTimeout() models.Duration { + return models.MustMakeDuration(time.Duration(0)) +} + +func (t *TestConfig) QueryTimeout() time.Duration { + return time.Duration(0) +} + +func (t *TestConfig) UserAPITokenDuration() models.Duration { + return models.MustMakeDuration(time.Duration(0)) +} + +func (t *TestConfig) BaseUserAttr() string { + return "uid" +} + +func (t *TestConfig) BaseDN() string { + return "dc=custom,dc=example,dc=com" +} + +func (t *TestConfig) UsersDN() string { + return "ou=users" +} + +func (t *TestConfig) GroupsDN() string { + return "ou=groups" +} + +func (t *TestConfig) ActiveAttribute() string { + return "organizationalStatus" +} + +func (t *TestConfig) ActiveAttributeAllowedValue() string { + return "ACTIVE" +} + +func (t *TestConfig) AdminUserGroupCN() string { + return NodeAdminsGroupCN +} + +func (t *TestConfig) EditUserGroupCN() string { + return NodeEditorsGroupCN +} + +func (t *TestConfig) RunUserGroupCN() string { + return NodeRunnersGroupCN +} + +func (t *TestConfig) ReadUserGroupCN() string { + return NodeReadOnlyGroupCN +} + +func (t *TestConfig) UserApiTokenEnabled() bool { + return true +} + +func (t *TestConfig) UpstreamSyncInterval() models.Duration { + return models.MustMakeDuration(time.Duration(0)) +} + +func (t *TestConfig) UpstreamSyncRateLimit() models.Duration { + return models.MustMakeDuration(time.Duration(0)) +} diff --git a/core/sessions/ldapauth/ldap.go b/core/sessions/ldapauth/ldap.go new file mode 100644 index 00000000000..188f2684e7e --- /dev/null +++ b/core/sessions/ldapauth/ldap.go @@ -0,0 +1,858 @@ +/* +The LDAP authentication package forwards the credentials in the user session request +for authentication with a configured upstream LDAP server + +This package relies on the two following local database tables: + + ldap_sessions: Upon successful LDAP response, creates a keyed local copy of the user email + ldap_user_api_tokens: User created API tokens, tied to the node, storing user email. + +Note: user can have only one API token at a time, and token expiration is enforced + +User session and roles are cached and revalidated with the upstream service at the interval defined in +the local LDAP config through the Application.sessionReaper implementation in reaper.go. + +Changes to the upstream identity server will propagate through and update local tables (web sessions, API tokens) +by either removing the entries or updating the roles. This sync happens for every auth endpoint hit, and +via the defined sync interval. One goroutine is created to coordinate the sync timing in the New function + +This implementation is read only; user mutation actions such as Delete are not supported. + +MFA is supported via the remote LDAP server implementation. Sufficient request time out should accommodate +for a blocking auth call while the user responds to a potential push notification callback. +*/ +package ldapauth + +import ( + "crypto/subtle" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/smartcontractkit/sqlx" + + "github.com/smartcontractkit/chainlink/v2/core/auth" + "github.com/smartcontractkit/chainlink/v2/core/bridges" + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/logger/audit" + "github.com/smartcontractkit/chainlink/v2/core/services/pg" + "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/utils" + "github.com/smartcontractkit/chainlink/v2/core/utils/mathutil" +) + +const ( + UniqueMemberAttribute = "uniqueMember" +) + +var ErrUserNotInUpstream = errors.New("LDAP query returned no matching users") +var ErrUserNoLDAPGroups = errors.New("user present in directory, but matching no role groups assigned") + +type ldapAuthenticator struct { + q pg.Q + ldapClient LDAPClient + config config.LDAP + lggr logger.Logger + auditLogger audit.AuditLogger +} + +// ldapAuthenticator implements sessions.AuthenticationProvider interface +var _ sessions.AuthenticationProvider = (*ldapAuthenticator)(nil) + +func NewLDAPAuthenticator( + db *sqlx.DB, + pgCfg pg.QConfig, + ldapCfg config.LDAP, + dev bool, + lggr logger.Logger, + auditLogger audit.AuditLogger, +) (*ldapAuthenticator, error) { + namedLogger := lggr.Named("LDAPAuthenticationProvider") + + // If not chainlink dev and not tls, error + if !dev && !ldapCfg.ServerTLS() { + return nil, errors.New("LDAP Authentication driver requires TLS when running in Production mode") + } + + // Ensure all RBAC role mappings to LDAP Groups are defined, and required fields populated, or error on startup + if ldapCfg.AdminUserGroupCN() == "" || ldapCfg.EditUserGroupCN() == "" || + ldapCfg.RunUserGroupCN() == "" || ldapCfg.ReadUserGroupCN() == "" { + return nil, errors.New("LDAP Group mapping from server group name for all local RBAC role required. Set group names for `_UserGroupCN` fields") + } + if ldapCfg.ServerAddress() == "" { + return nil, errors.New("LDAP ServerAddress config required") + } + if ldapCfg.ReadOnlyUserLogin() == "" { + return nil, errors.New("LDAP ReadOnlyUserLogin config required") + } + + ldapAuth := ldapAuthenticator{ + q: pg.NewQ(db, namedLogger, pgCfg), + ldapClient: newLDAPClient(ldapCfg), + config: ldapCfg, + lggr: lggr.Named("LDAPAuthenticationProvider"), + auditLogger: auditLogger, + } + + // Single override of library defined global + ldap.DefaultTimeout = ldapCfg.QueryTimeout() + + // Test initial connection and credentials + lggr.Infof("Attempting initial connection to configured LDAP server with bind as API user") + conn, err := ldapAuth.ldapClient.CreateEphemeralConnection() + if err != nil { + return nil, fmt.Errorf("unable to establish connection to LDAP server with provided URL and credentials: %w", err) + } + conn.Close() + + // Store LDAP connection config for auth/new connection per request instead of persisted connection with reconnect + return &ldapAuth, nil +} + +// FindUser will attempt to return an LDAP user with mapped role by email. +func (l *ldapAuthenticator) FindUser(email string) (sessions.User, error) { + email = strings.ToLower(email) + foundUser := sessions.User{} + + // First check for the supported local admin users table + var foundLocalAdminUser sessions.User + checkErr := l.q.Transaction(func(tx pg.Queryer) error { + sql := "SELECT * FROM users WHERE lower(email) = lower($1)" + return tx.Get(&foundLocalAdminUser, sql, email) + }) + if checkErr != nil { + // If error is not nil, there was either an issue or no local users found + if !errors.Is(checkErr, sql.ErrNoRows) { + // If the error is not that no local user was found, log and exit + l.lggr.Errorf("error searching users table: %v", checkErr) + return sessions.User{}, errors.New("error Finding user") + } + } else { + // Error was nil, local user found. Return + return foundLocalAdminUser, nil + } + + // First query for user "is active" property if defined + usersActive, err := l.validateUsersActive([]string{email}) + if err != nil { + if errors.Is(err, ErrUserNotInUpstream) { + return foundUser, ErrUserNotInUpstream + } + l.lggr.Errorf("error in validateUsers call: %v", err) + return foundUser, errors.New("error running query to validate user active") + } + if !usersActive[0] { + return foundUser, errors.New("user not active") + } + + conn, err := l.ldapClient.CreateEphemeralConnection() + if err != nil { + l.lggr.Errorf("error in LDAP dial: ", err) + return foundUser, errors.New("unable to establish connection to LDAP server with provided URL and credentials") + } + defer conn.Close() + + // User email and role are the only upstream data that needs queried for. + // List query user groups using the provided email, on success is a list of group the uniquemember belongs to + // data is readily available + escapedEmail := ldap.EscapeFilter(email) + searchBaseDN := fmt.Sprintf("%s, %s", l.config.GroupsDN(), l.config.BaseDN()) + filterQuery := fmt.Sprintf("(&(uniquemember=%s=%s,%s,%s))", l.config.BaseUserAttr(), escapedEmail, l.config.UsersDN(), l.config.BaseDN()) + searchRequest := ldap.NewSearchRequest( + searchBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, int(l.config.QueryTimeout().Seconds()), false, + filterQuery, + []string{"cn"}, + nil, + ) + + // Query the server + result, err := conn.Search(searchRequest) + if err != nil { + l.lggr.Errorf("error searching users in LDAP query: %v", err) + return foundUser, errors.New("error searching users in LDAP directory") + } + + if len(result.Entries) == 0 { + // Provided email is not present in upstream LDAP server, local admin CLI auth is supported + // So query and check the users table as well before failing + if err = l.q.Transaction(func(tx pg.Queryer) error { + var localUserRole sessions.UserRole + if err = tx.Get(&localUserRole, "SELECT role FROM users WHERE email = $1", email); err != nil { + return err + } + foundUser = sessions.User{ + Email: email, + Role: localUserRole, + } + return nil + }); err != nil { + // Above query for local user unsuccessful, return error + l.lggr.Warnf("No local users table user found with email %s", email) + return foundUser, errors.New("no users found with provided email") + } + + // If the above query to the local users table was successful, return that local user's role + return foundUser, nil + } + + // Populate found user by email and role based on matched group names + userRole, err := l.groupSearchResultsToUserRole(result.Entries) + if err != nil { + l.lggr.Warnf("User '%s' found but no matching assigned groups in LDAP to assume role", email) + return sessions.User{}, err + } + + // Convert search result to sessions.User type with required fields + foundUser = sessions.User{ + Email: email, + Role: userRole, + } + + return foundUser, nil +} + +// FindUserByAPIToken retrieves a possible stored user and role from the ldap_user_api_tokens table store +func (l *ldapAuthenticator) FindUserByAPIToken(apiToken string) (sessions.User, error) { + if !l.config.UserApiTokenEnabled() { + return sessions.User{}, errors.New("API token is not enabled ") + } + + var foundUser sessions.User + err := l.q.Transaction(func(tx pg.Queryer) error { + // Query the ldap user API token table for given token, user role and email are cached so + // no further upstream LDAP query is performed, sessions and tokens are synced against the upstream server + // via the UpstreamSyncInterval config and reaper.go sync implementation + var foundUserToken struct { + UserEmail string + UserRole sessions.UserRole + Valid bool + } + if err := tx.Get(&foundUserToken, + "SELECT user_email, user_role, created_at + $2 >= now() as valid FROM ldap_user_api_tokens WHERE token_key = $1", + apiToken, l.config.UserAPITokenDuration().Duration(), + ); err != nil { + return err + } + if !foundUserToken.Valid { + return sessions.ErrUserSessionExpired + } + foundUser = sessions.User{ + Email: foundUserToken.UserEmail, + Role: foundUserToken.UserRole, + } + return nil + }) + if err != nil { + if errors.Is(err, sessions.ErrUserSessionExpired) { + // API Token expired, purge + if _, execErr := l.q.Exec("DELETE FROM ldap_user_api_tokens WHERE token_key = $1", apiToken); err != nil { + l.lggr.Errorf("error purging stale ldap API token session: %v", execErr) + } + } + return sessions.User{}, err + } + return foundUser, nil +} + +// ListUsers will load and return all active users in applicable LDAP groups, extended with local admin users as well +func (l *ldapAuthenticator) ListUsers() ([]sessions.User, error) { + // For each defined role/group, query for the list of group members to gather the full list of possible users + users := []sessions.User{} + var err error + + conn, err := l.ldapClient.CreateEphemeralConnection() + if err != nil { + l.lggr.Errorf("error in LDAP dial: ", err) + return users, errors.New("unable to establish connection to LDAP server with provided URL and credentials") + } + defer conn.Close() + + // Query for list of uniqueMember IDs present in Admin group + adminUsers, err := l.ldapGroupMembersListToUser(conn, l.config.AdminUserGroupCN(), sessions.UserRoleAdmin) + if err != nil { + l.lggr.Errorf("error in ldapGroupMembersListToUser: ", err) + return users, errors.New("unable to list group users") + } + // Query for list of uniqueMember IDs present in Edit group + editUsers, err := l.ldapGroupMembersListToUser(conn, l.config.EditUserGroupCN(), sessions.UserRoleEdit) + if err != nil { + l.lggr.Errorf("error in ldapGroupMembersListToUser: ", err) + return users, errors.New("unable to list group users") + } + // Query for list of uniqueMember IDs present in Run group + runUsers, err := l.ldapGroupMembersListToUser(conn, l.config.RunUserGroupCN(), sessions.UserRoleRun) + if err != nil { + l.lggr.Errorf("error in ldapGroupMembersListToUser: ", err) + return users, errors.New("unable to list group users") + } + // Query for list of uniqueMember IDs present in Read group + readUsers, err := l.ldapGroupMembersListToUser(conn, l.config.ReadUserGroupCN(), sessions.UserRoleView) + if err != nil { + l.lggr.Errorf("error in ldapGroupMembersListToUser: ", err) + return users, errors.New("unable to list group users") + } + + // Aggregate full list + users = append(users, adminUsers...) + users = append(users, editUsers...) + users = append(users, runUsers...) + users = append(users, readUsers...) + + // Dedupe preserving order of highest role + uniqueRef := make(map[string]struct{}) + dedupedUsers := []sessions.User{} + for _, user := range users { + if _, ok := uniqueRef[user.Email]; !ok { + uniqueRef[user.Email] = struct{}{} + dedupedUsers = append(dedupedUsers, user) + } + } + + // If no active attribute to check is defined, user simple being assigned the group is enough, return full list + if l.config.ActiveAttribute() == "" { + return dedupedUsers, nil + } + + // Now optionally validate that all uniqueMembers are active in the org/LDAP server + emails := []string{} + for _, user := range dedupedUsers { + emails = append(emails, user.Email) + } + activeUsers, err := l.validateUsersActive(emails) + if err != nil { + l.lggr.Errorf("error validating supplied user list: ", err) + return users, errors.New("error validating supplied user list") + } + + // Filter non active users + returnUsers := []sessions.User{} + for i, active := range activeUsers { + if active { + returnUsers = append(returnUsers, dedupedUsers[i]) + } + } + + // Extend with local admin users + var localAdminUsers []sessions.User + if err := l.q.Transaction(func(tx pg.Queryer) error { + sql := "SELECT * FROM users ORDER BY email ASC;" + return tx.Select(&localAdminUsers, sql) + }); err != nil { + l.lggr.Errorf("error extending upstream LDAP users with local admin users in users table: ", err) + } else { + returnUsers = append(returnUsers, localAdminUsers...) + } + + return returnUsers, nil +} + +// ldapGroupMembersListToUser queries the LDAP server given a conn for a list of uniqueMember who are part of the parameterized group +func (l *ldapAuthenticator) ldapGroupMembersListToUser(conn LDAPConn, groupNameCN string, roleToAssign sessions.UserRole) ([]sessions.User, error) { + users, err := ldapGroupMembersListToUser( + conn, groupNameCN, roleToAssign, l.config.GroupsDN(), + l.config.BaseDN(), l.config.QueryTimeout(), + l.lggr, + ) + if err != nil { + l.lggr.Errorf("error listing members of group (%s): %v", groupNameCN, err) + return users, errors.New("error searching group members in LDAP directory") + } + return users, nil +} + +// AuthorizedUserWithSession will return the API user associated with the Session ID if it +// exists and hasn't expired, and update session's LastUsed field. The state of the upstream LDAP server +// is polled and synced at the defined interval via a SleeperTask +func (l *ldapAuthenticator) AuthorizedUserWithSession(sessionID string) (sessions.User, error) { + if len(sessionID) == 0 { + return sessions.User{}, errors.New("session ID cannot be empty") + } + var foundUser sessions.User + err := l.q.Transaction(func(tx pg.Queryer) error { + // Query the ldap_sessions table for given session ID, user role and email are cached so + // no further upstream LDAP query is performed + var foundSession struct { + UserEmail string + UserRole sessions.UserRole + Valid bool + } + if err := tx.Get(&foundSession, + "SELECT user_email, user_role, created_at + $2 >= now() as valid FROM ldap_sessions WHERE id = $1", + sessionID, l.config.SessionTimeout().Duration(), + ); err != nil { + return sessions.ErrUserSessionExpired + } + if !foundSession.Valid { + // Sessions expired, purge + return sessions.ErrUserSessionExpired + } + foundUser = sessions.User{ + Email: foundSession.UserEmail, + Role: foundSession.UserRole, + } + return nil + }) + if err != nil { + if errors.Is(err, sessions.ErrUserSessionExpired) { + if _, execErr := l.q.Exec("DELETE FROM ldap_sessions WHERE id = $1", sessionID); err != nil { + l.lggr.Errorf("error purging stale ldap session: %v", execErr) + } + } + return sessions.User{}, err + } + return foundUser, nil +} + +// DeleteUser is not supported for read only LDAP +func (l *ldapAuthenticator) DeleteUser(email string) error { + return sessions.ErrNotSupported +} + +// DeleteUserSession removes an ldapSession table entry by ID +func (l *ldapAuthenticator) DeleteUserSession(sessionID string) error { + _, err := l.q.Exec("DELETE FROM ldap_sessions WHERE id = $1", sessionID) + return err +} + +// GetUserWebAuthn returns an empty stub, MFA token prompt is handled either by the upstream +// server blocking callback, or an error code to pass a OTP +func (l *ldapAuthenticator) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) { + return []sessions.WebAuthn{}, nil +} + +// CreateSession will forward the session request credentials to the +// LDAP server, querying for a user + role response if username and +// password match. The API call is blocking with timeout, so a sufficient timeout +// should allow the user to respond to potential MFA push notifications +func (l *ldapAuthenticator) CreateSession(sr sessions.SessionRequest) (string, error) { + conn, err := l.ldapClient.CreateEphemeralConnection() + if err != nil { + return "", errors.New("unable to establish connection to LDAP server with provided URL and credentials") + } + defer conn.Close() + + var returnErr error + + // Attempt to LDAP Bind with user provided credentials + escapedEmail := ldap.EscapeFilter(strings.ToLower(sr.Email)) + searchBaseDN := fmt.Sprintf("%s=%s,%s,%s", l.config.BaseUserAttr(), escapedEmail, l.config.UsersDN(), l.config.BaseDN()) + if err = conn.Bind(searchBaseDN, sr.Password); err != nil { + l.lggr.Infof("Error binding user authentication request in LDAP Bind: %v", err) + returnErr = errors.New("unable to log in with LDAP server. Check credentials") + } + + // Bind was successful meaning user and credentials are present in LDAP directory + // Reuse FindUser functionality to fetch user roles used to create ldap_session entry + // with cached user email and role + foundUser, err := l.FindUser(escapedEmail) + if err != nil { + l.lggr.Infof("Successful user login, but error querying for user groups: user: %s, error %v", escapedEmail, err) + returnErr = errors.New("log in successful, but no assigned groups to assume role") + } + + isLocalUser := false + if returnErr != nil { + // Unable to log in against LDAP server, attempt fallback local auth with credentials, case of local CLI Admin account + // Successful local user sessions can not be managed by the upstream server and have expiration handled by the reaper sync module + foundUser, returnErr = l.localLoginFallback(sr) + isLocalUser = true + } + + // If err is still populated, return + if returnErr != nil { + return "", returnErr + } + + l.lggr.Infof("Successful LDAP login request for user %s - %s", sr.Email, foundUser.Role) + + // Save session, user, and role to database. Given a session ID for future queries, the LDAP server will not be queried + // Sessions are set to expire after the duration + creation date elapsed, and are synced on an interval against the upstream + // LDAP server + session := sessions.NewSession() + _, err = l.q.Exec( + "INSERT INTO ldap_sessions (id, user_email, user_role, localauth_user, created_at) VALUES ($1, $2, $3, $4, now())", + session.ID, + strings.ToLower(sr.Email), + foundUser.Role, + isLocalUser, + ) + if err != nil { + l.lggr.Errorf("unable to create new session in ldap_sessions table %v", err) + return "", fmt.Errorf("error creating local LDAP session: %w", err) + } + + l.auditLogger.Audit(audit.AuthLoginSuccessNo2FA, map[string]interface{}{"email": sr.Email}) + + return session.ID, nil +} + +// ClearNonCurrentSessions removes all ldap_sessions but the id passed in. +func (l *ldapAuthenticator) ClearNonCurrentSessions(sessionID string) error { + _, err := l.q.Exec("DELETE FROM ldap_sessions where id != $1", sessionID) + return err +} + +// CreateUser is not supported for read only LDAP +func (l *ldapAuthenticator) CreateUser(user *sessions.User) error { + return sessions.ErrNotSupported +} + +// UpdateRole is not supported for read only LDAP +func (l *ldapAuthenticator) UpdateRole(email, newRole string) (sessions.User, error) { + return sessions.User{}, sessions.ErrNotSupported +} + +// SetPassword for remote users is not supported via the read only LDAP implementation, however change password +// in the context of updating a local admin user's password is required +func (l *ldapAuthenticator) SetPassword(user *sessions.User, newPassword string) error { + // Ensure specified user is part of the local admins user table + var localAdminUser sessions.User + if err := l.q.Transaction(func(tx pg.Queryer) error { + sql := "SELECT * FROM users WHERE lower(email) = lower($1)" + return tx.Get(&localAdminUser, sql, user.Email) + }); err != nil { + l.lggr.Infof("Can not change password, local user with email not found in users table: %s, err: %v", user.Email, err) + return sessions.ErrNotSupported + } + + // User is local admin, save new password + hashedPassword, err := utils.HashPassword(newPassword) + if err != nil { + return err + } + if err := l.q.Transaction(func(tx pg.Queryer) error { + sql := "UPDATE users SET hashed_password = $1, updated_at = now() WHERE email = $2 RETURNING *" + return tx.Get(user, sql, hashedPassword, user.Email) + }); err != nil { + l.lggr.Errorf("unable to set password for user: %s, err: %v", user.Email, err) + return errors.New("unable to save password") + } + return nil +} + +// TestPassword tests if an LDAP login bind can be performed with provided credentials, returns nil if success +func (l *ldapAuthenticator) TestPassword(email string, password string) error { + conn, err := l.ldapClient.CreateEphemeralConnection() + if err != nil { + return errors.New("unable to establish connection to LDAP server with provided URL and credentials") + } + defer conn.Close() + + // Attempt to LDAP Bind with user provided credentials + escapedEmail := ldap.EscapeFilter(strings.ToLower(email)) + searchBaseDN := fmt.Sprintf("%s=%s,%s,%s", l.config.BaseUserAttr(), escapedEmail, l.config.UsersDN(), l.config.BaseDN()) + err = conn.Bind(searchBaseDN, password) + if err == nil { + return nil + } + l.lggr.Infof("Error binding user authentication request in TestPassword call LDAP Bind: %v", err) + + // Fall back to test local users table in case of supported local CLI users as well + var hashedPassword string + if err := l.q.Get(&hashedPassword, "SELECT hashed_password FROM users WHERE lower(email) = lower($1)", email); err != nil { + return errors.New("invalid credentials") + } + if !utils.CheckPasswordHash(password, hashedPassword) { + return errors.New("invalid credentials") + } + + return nil +} + +// CreateAndSetAuthToken generates a new credential token with the user role +func (l *ldapAuthenticator) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) { + newToken := auth.NewToken() + + err := l.SetAuthToken(user, newToken) + if err != nil { + return nil, err + } + + return newToken, nil +} + +// SetAuthToken updates the user to use the given Authentication Token. +func (l *ldapAuthenticator) SetAuthToken(user *sessions.User, token *auth.Token) error { + if !l.config.UserApiTokenEnabled() { + return errors.New("API token is not enabled ") + } + + salt := utils.NewSecret(utils.DefaultSecretSize) + hashedSecret, err := auth.HashedSecret(token, salt) + if err != nil { + return fmt.Errorf("LDAPAuth SetAuthToken hashed secret error: %w", err) + } + + err = l.q.Transaction(func(tx pg.Queryer) error { + // Is this user a local CLI Admin or upstream LDAP user? + // Check presence in local users table. Set localauth_user column true if present. + // This flag omits the session/token from being purged by the sync daemon/reaper.go + isLocalCLIAdmin := false + err = l.q.QueryRow("SELECT EXISTS (SELECT 1 FROM users WHERE email = $1)", user.Email).Scan(&isLocalCLIAdmin) + if err != nil { + return fmt.Errorf("error checking user presence in users table: %w", err) + } + + // Remove any existing API tokens + if _, err = l.q.Exec("DELETE FROM ldap_user_api_tokens WHERE user_email = $1", user.Email); err != nil { + return fmt.Errorf("error executing DELETE FROM ldap_user_api_tokens: %w", err) + } + // Create new API token for user + _, err = l.q.Exec( + "INSERT INTO ldap_user_api_tokens (user_email, user_role, localauth_user, token_key, token_salt, token_hashed_secret, created_at) VALUES ($1, $2, $3, $4, $5, $6, now())", + user.Email, + user.Role, + isLocalCLIAdmin, + token.AccessKey, + salt, + hashedSecret, + ) + if err != nil { + return fmt.Errorf("failed insert into ldap_user_api_tokens: %w", err) + } + return nil + }) + if err != nil { + return errors.New("error creating API token") + } + + l.auditLogger.Audit(audit.APITokenCreated, map[string]interface{}{"user": user.Email}) + return nil +} + +// DeleteAuthToken clears and disables the users Authentication Token. +func (l *ldapAuthenticator) DeleteAuthToken(user *sessions.User) error { + _, err := l.q.Exec("DELETE FROM ldap_user_api_tokens WHERE email = $1") + return err +} + +// SaveWebAuthn is not supported for read only LDAP +func (l *ldapAuthenticator) SaveWebAuthn(token *sessions.WebAuthn) error { + return sessions.ErrNotSupported +} + +// Sessions returns all sessions limited by the parameters. +func (l *ldapAuthenticator) Sessions(offset, limit int) ([]sessions.Session, error) { + var sessions []sessions.Session + sql := `SELECT * FROM ldap_sessions ORDER BY created_at, id LIMIT $1 OFFSET $2;` + if err := l.q.Select(&sessions, sql, limit, offset); err != nil { + return sessions, nil + } + return sessions, nil +} + +// FindExternalInitiator supports the 'Run' role external intiator header auth functionality +func (l *ldapAuthenticator) FindExternalInitiator(eia *auth.Token) (*bridges.ExternalInitiator, error) { + exi := &bridges.ExternalInitiator{} + err := l.q.Get(exi, `SELECT * FROM external_initiators WHERE access_key = $1`, eia.AccessKey) + return exi, err +} + +// localLoginFallback tests the credentials provided against the 'local' authentication method +// This covers the case of local CLI API calls requiring local login separate from the LDAP server +func (l *ldapAuthenticator) localLoginFallback(sr sessions.SessionRequest) (sessions.User, error) { + var user sessions.User + sql := "SELECT * FROM users WHERE lower(email) = lower($1)" + err := l.q.Get(&user, sql, sr.Email) + if err != nil { + return user, err + } + if !constantTimeEmailCompare(strings.ToLower(sr.Email), strings.ToLower(user.Email)) { + l.auditLogger.Audit(audit.AuthLoginFailedEmail, map[string]interface{}{"email": sr.Email}) + return user, errors.New("invalid email") + } + + if !utils.CheckPasswordHash(sr.Password, user.HashedPassword) { + l.auditLogger.Audit(audit.AuthLoginFailedPassword, map[string]interface{}{"email": sr.Email}) + return user, errors.New("invalid password") + } + + return user, nil +} + +// validateUsersActive performs an additional LDAP server query for the supplied emails, checking the +// returned user data for an 'active' property defined optionally in the config. +// Returns same length bool 'valid' array, indexed by sorted email +func (l *ldapAuthenticator) validateUsersActive(emails []string) ([]bool, error) { + validUsers := make([]bool, len(emails)) + // If active attribute to check is not defined in config, skip + if l.config.ActiveAttribute() == "" { + // fill with valids + for i := range emails { + validUsers[i] = true + } + return validUsers, nil + } + + conn, err := l.ldapClient.CreateEphemeralConnection() + if err != nil { + l.lggr.Errorf("error in LDAP dial: ", err) + return validUsers, errors.New("unable to establish connection to LDAP server with provided URL and credentials") + } + defer conn.Close() + + // Build the full email list query to pull all 'isActive' information for each user specified in one query + filterQuery := "(|" + for _, email := range emails { + escapedEmail := ldap.EscapeFilter(email) + filterQuery = fmt.Sprintf("%s(%s=%s)", filterQuery, l.config.BaseUserAttr(), escapedEmail) + } + filterQuery = fmt.Sprintf("(&%s))", filterQuery) + searchBaseDN := fmt.Sprintf("%s,%s", l.config.UsersDN(), l.config.BaseDN()) + searchRequest := ldap.NewSearchRequest( + searchBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, int(l.config.QueryTimeout().Seconds()), false, + filterQuery, + []string{l.config.BaseUserAttr(), l.config.ActiveAttribute()}, + nil, + ) + // Query LDAP server for the ActiveAttribute property of each specified user + results, err := conn.Search(searchRequest) + if err != nil { + l.lggr.Errorf("error searching user in LDAP query: %v", err) + return validUsers, errors.New("error searching users in LDAP directory") + } + + // Ensure user response entries + if len(results.Entries) == 0 { + return validUsers, ErrUserNotInUpstream + } + + // Pull expected ActiveAttribute value from list of string possible values + // keyed on email for final step to return flag bool list where order is preserved + emailToActiveMap := make(map[string]bool) + for _, result := range results.Entries { + isActiveAttribute := result.GetAttributeValue(l.config.ActiveAttribute()) + uidAttribute := result.GetAttributeValue(l.config.BaseUserAttr()) + emailToActiveMap[uidAttribute] = isActiveAttribute == l.config.ActiveAttributeAllowedValue() + } + for i, email := range emails { + active, ok := emailToActiveMap[email] + if ok && active { + validUsers[i] = true + } + } + + return validUsers, nil +} + +// ldapGroupMembersListToUser queries the LDAP server given a conn for a list of uniqueMember who are part of the parameterized group. Reused by sync.go +func ldapGroupMembersListToUser( + conn LDAPConn, + groupNameCN string, + roleToAssign sessions.UserRole, + groupsDN string, + baseDN string, + queryTimeout time.Duration, + lggr logger.Logger, +) ([]sessions.User, error) { + users := []sessions.User{} + // Prepare and query the GroupsDN for the specified group name + searchBaseDN := fmt.Sprintf("%s, %s", groupsDN, baseDN) + filterQuery := fmt.Sprintf("(&(cn=%s))", groupNameCN) + searchRequest := ldap.NewSearchRequest( + searchBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, int(queryTimeout.Seconds()), false, + filterQuery, + []string{UniqueMemberAttribute}, + nil, + ) + result, err := conn.Search(searchRequest) + if err != nil { + lggr.Errorf("error searching group members in LDAP query: %v", err) + return users, errors.New("error searching group members in LDAP directory") + } + + // The result.Entry query response here is for the 'group' type of LDAP resource. The result should be a single entry, containing + // a single Attribute named 'uniqueMember' containing a list of string Values. These Values are strings that should be returned in + // the format "uid=test.user@example.com,ou=users,dc=example,dc=com". The 'uid' is then manually parsed here as the library does + // not expose the functionality + if len(result.Entries) != 1 { + lggr.Errorf("unexpected length of query results for group user members, expected one got %d", len(result.Entries)) + return users, errors.New("error searching group members in LDAP directory") + } + + // Get string list of members from 'uniqueMember' attribute + uniqueMemberValues := result.Entries[0].GetAttributeValues(UniqueMemberAttribute) + for _, uniqueMemberEntry := range uniqueMemberValues { + parts := strings.Split(uniqueMemberEntry, ",") // Split attribute value on comma (uid, ou, dc parts) + uidComponent := "" + for _, part := range parts { // Iterate parts for "uid=" + if strings.HasPrefix(part, "uid=") { + uidComponent = part + break + } + } + if uidComponent == "" { + lggr.Errorf("unexpected LDAP group query response for unique members - expected list of LDAP Values for uniqueMember containing LDAP strings in format uid=test.user@example.com,ou=users,dc=example,dc=com. Got %s", uniqueMemberEntry) + continue + } + // Map each user email to the sessions.User struct + userEmail := strings.TrimPrefix(uidComponent, "uid=") + users = append(users, sessions.User{ + Email: userEmail, + Role: roleToAssign, + }) + } + return users, nil +} + +// groupSearchResultsToUserRole takes a list of LDAP group search result entries and returns the associated +// internal user role based on the group name mappings defined in the configuration +func (l *ldapAuthenticator) groupSearchResultsToUserRole(ldapGroups []*ldap.Entry) (sessions.UserRole, error) { + return GroupSearchResultsToUserRole( + ldapGroups, + l.config.AdminUserGroupCN(), + l.config.EditUserGroupCN(), + l.config.RunUserGroupCN(), + l.config.ReadUserGroupCN(), + ) +} + +func GroupSearchResultsToUserRole(ldapGroups []*ldap.Entry, adminCN string, editCN string, runCN string, readCN string) (sessions.UserRole, error) { + // If defined Admin group name is present in groups search result, return UserRoleAdmin + for _, group := range ldapGroups { + if group.GetAttributeValue("cn") == adminCN { + return sessions.UserRoleAdmin, nil + } + } + // Check edit role + for _, group := range ldapGroups { + if group.GetAttributeValue("cn") == editCN { + return sessions.UserRoleEdit, nil + } + } + // Check run role + for _, group := range ldapGroups { + if group.GetAttributeValue("cn") == runCN { + return sessions.UserRoleRun, nil + } + } + // Check view role + for _, group := range ldapGroups { + if group.GetAttributeValue("cn") == readCN { + return sessions.UserRoleView, nil + } + } + // No role group found, error + return sessions.UserRoleView, ErrUserNoLDAPGroups +} + +const constantTimeEmailLength = 256 + +func constantTimeEmailCompare(left, right string) bool { + length := mathutil.Max(constantTimeEmailLength, len(left), len(right)) + leftBytes := make([]byte, length) + rightBytes := make([]byte, length) + copy(leftBytes, left) + copy(rightBytes, right) + return subtle.ConstantTimeCompare(leftBytes, rightBytes) == 1 +} diff --git a/core/sessions/ldapauth/ldap_test.go b/core/sessions/ldapauth/ldap_test.go new file mode 100644 index 00000000000..261141d66e9 --- /dev/null +++ b/core/sessions/ldapauth/ldap_test.go @@ -0,0 +1,639 @@ +package ldapauth_test + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/sqlx" + + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/pgtest" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/logger/audit" + "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/ldapauth" + "github.com/smartcontractkit/chainlink/v2/core/sessions/ldapauth/mocks" +) + +// Setup LDAP Auth authenticator +func setupAuthenticationProvider(t *testing.T, ldapClient ldapauth.LDAPClient) (*sqlx.DB, sessions.AuthenticationProvider) { + t.Helper() + + cfg := ldapauth.TestConfig{} + db := pgtest.NewSqlxDB(t) + ldapAuthProvider, err := ldapauth.NewTestLDAPAuthenticator(db, pgtest.NewQConfig(true), &cfg, true, logger.TestLogger(t), &audit.AuditLoggerService{}) + if err != nil { + t.Fatalf("Error constructing NewTestLDAPAuthenticator: %v\n", err) + } + + // Override the LDAPClient responsible for returning the *ldap.Conn struct with Mock + ldapAuthProvider.SetLDAPClient(ldapClient) + return db, ldapAuthProvider +} + +func TestORM_FindUser_Empty(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // User not in upstream, return no entry + expectedResults := ldap.SearchResult{} + + // On search performed for validateUsersActive + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedResults, nil) + + // Not in upstream, no local admin users, expect error + _, err := ldapAuthProvider.FindUser("unknown-user") + require.ErrorContains(t, err, "LDAP query returned no matching users") +} + +func TestORM_FindUser_NoGroups(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // User present in Upstream but no groups assigned + user1 := cltest.MustRandomUser(t) + expectedResults := ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=User One,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "organizationalStatus", + Values: []string{"ACTIVE"}, + }, + { + Name: "uid", + Values: []string{user1.Email}, + }, + }, + }, + }, + } + + // On search performed for validateUsersActive + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedResults, nil) + + // No Groups, expect error + _, err := ldapAuthProvider.FindUser(user1.Email) + require.ErrorContains(t, err, "user present in directory, but matching no role groups assigned") +} + +func TestORM_FindUser_NotActive(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // User present in Upstream but not active + user1 := cltest.MustRandomUser(t) + expectedResults := ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=User One,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "organizationalStatus", + Values: []string{"INACTIVE"}, + }, + { + Name: "uid", + Values: []string{user1.Email}, + }, + }, + }, + }, + } + + // On search performed for validateUsersActive + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedResults, nil) + + // User not active, expect error + _, err := ldapAuthProvider.FindUser(user1.Email) + require.ErrorContains(t, err, "user not active") +} + +func TestORM_FindUser_Single(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // User present and valid + user1 := cltest.MustRandomUser(t) + expectedResults := ldap.SearchResult{ // Users query + Entries: []*ldap.Entry{ + { + DN: "cn=User One,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "organizationalStatus", + Values: []string{"ACTIVE"}, + }, + { + Name: "uid", + Values: []string{user1.Email}, + }, + }, + }, + }, + } + expectedGroupResults := ldap.SearchResult{ // Groups query + Entries: []*ldap.Entry{ + { + DN: "cn=NodeEditors,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{"NodeEditors"}, + }, + }, + }, + }, + } + + // On search performed for validateUsersActive + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedResults, nil).Once() + + // Second call on user groups search + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedGroupResults, nil).Once() + + // User active, and has editor group. Expect success + user, err := ldapAuthProvider.FindUser(user1.Email) + require.NoError(t, err) + require.Equal(t, user1.Email, user.Email) + require.Equal(t, sessions.UserRoleEdit, user.Role) +} + +func TestORM_FindUser_FallbackMatchLocalAdmin(t *testing.T) { + t.Parallel() + + // Initilaize LDAP Authentication Provider with mock client + mockLdapClient := mocks.NewLDAPClient(t) + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Not in upstream, but utilize text fixture admin user presence in test DB. Succeed + user, err := ldapAuthProvider.FindUser(cltest.APIEmailAdmin) + require.NoError(t, err) + require.Equal(t, cltest.APIEmailAdmin, user.Email) + require.Equal(t, sessions.UserRoleAdmin, user.Role) +} + +func TestORM_FindUserByAPIToken_Success(t *testing.T) { + // Initilaize LDAP Authentication Provider with mock client + mockLdapClient := mocks.NewLDAPClient(t) + db, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Ensure valid tokens return a user with role + testEmail := "test@test.com" + apiToken := "example" + _, err := db.Exec("INSERT INTO ldap_user_api_tokens values ($1, 'edit', false, $2, '', '', now())", testEmail, apiToken) + require.NoError(t, err) + + // Found user by API token in specific ldap_user_api_tokens table + user, err := ldapAuthProvider.FindUserByAPIToken(apiToken) + require.NoError(t, err) + require.Equal(t, testEmail, user.Email) + require.Equal(t, sessions.UserRoleEdit, user.Role) +} + +func TestORM_FindUserByAPIToken_Expired(t *testing.T) { + cfg := ldapauth.TestConfig{} + + // Initilaize LDAP Authentication Provider with mock client + mockLdapClient := mocks.NewLDAPClient(t) + db, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Ensure valid tokens return a user with role + testEmail := "test@test.com" + apiToken := "example" + expiredTime := time.Now().Add(-cfg.UserAPITokenDuration().Duration()) + _, err := db.Exec("INSERT INTO ldap_user_api_tokens values ($1, 'edit', false, $2, '', '', $3)", testEmail, apiToken, expiredTime) + require.NoError(t, err) + + // Token found, but expired. Expect error + _, err = ldapAuthProvider.FindUserByAPIToken(apiToken) + require.Equal(t, sessions.ErrUserSessionExpired, err) +} + +func TestORM_ListUsers_Full(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + user1 := cltest.MustRandomUser(t) + user2 := cltest.MustRandomUser(t) + user3 := cltest.MustRandomUser(t) + user4 := cltest.MustRandomUser(t) + user5 := cltest.MustRandomUser(t) + user6 := cltest.MustRandomUser(t) + + // LDAP Group queries per role - admin + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeAdminsGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapauth.UniqueMemberAttribute, + Values: []string{ + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user1.Email), + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user2.Email), + }, + }, + }, + }, + }, + }, nil).Once() + // LDAP Group queries per role - edit + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeEditorsGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapauth.UniqueMemberAttribute, + Values: []string{ + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user3.Email), + }, + }, + }, + }, + }, + }, nil).Once() + // LDAP Group queries per role - run + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=NodeRunners,ou=Groups,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapauth.UniqueMemberAttribute, + Values: []string{ + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user4.Email), + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user4.Email), // Test deduped + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user5.Email), + }, + }, + }, + }, + }, + }, nil).Once() + // LDAP Group queries per role - view + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=NodeReadOnly,ou=Groups,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapauth.UniqueMemberAttribute, + Values: []string{ + fmt.Sprintf("uid=%s,ou=users,dc=example,dc=com", user6.Email), + }, + }, + }, + }, + }, + }, nil).Once() + // Lastly followed by IsActive lookup + type userActivePair struct { + email string + active string + } + emailsActive := []userActivePair{ + {user1.Email, "ACTIVE"}, + {user2.Email, "INACTIVE"}, + {user3.Email, "ACTIVE"}, + {user4.Email, "ACTIVE"}, + {user5.Email, "INACTIVE"}, + {user6.Email, "ACTIVE"}, + } + listUpstreamUsersQuery := ldap.SearchResult{} + for _, upstreamUser := range emailsActive { + listUpstreamUsersQuery.Entries = append(listUpstreamUsersQuery.Entries, &ldap.Entry{ + DN: "cn=User,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "organizationalStatus", + Values: []string{upstreamUser.active}, + }, + { + Name: "uid", + Values: []string{upstreamUser.email}, + }, + }, + }, + ) + } + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&listUpstreamUsersQuery, nil).Once() + + // Asserts 'uid=' parsing log in ldapGroupMembersListToUser + // Expected full list of users above, including local admin user, excluding 'inactive' and duplicate users + users, err := ldapAuthProvider.ListUsers() + require.NoError(t, err) + require.Equal(t, users[0].Email, user1.Email) + require.Equal(t, users[0].Role, sessions.UserRoleAdmin) + require.Equal(t, users[1].Email, user3.Email) // User 2 inactive + require.Equal(t, users[1].Role, sessions.UserRoleEdit) + require.Equal(t, users[2].Email, user4.Email) + require.Equal(t, users[2].Role, sessions.UserRoleRun) + require.Equal(t, users[3].Email, user6.Email) // User 5 inactive + require.Equal(t, users[3].Role, sessions.UserRoleView) + require.Equal(t, users[4].Email, cltest.APIEmailAdmin) // Text fixture user is local admin included as well + require.Equal(t, users[4].Role, sessions.UserRoleAdmin) +} + +func TestORM_CreateSession_UpstreamBind(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Upsream user present + user1 := cltest.MustRandomUser(t) + expectedResults := ldap.SearchResult{ // Users query + Entries: []*ldap.Entry{ + { + DN: "cn=User One,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "organizationalStatus", + Values: []string{"ACTIVE"}, + }, + { + Name: "uid", + Values: []string{user1.Email}, + }, + }, + }, + }, + } + expectedGroupResults := ldap.SearchResult{ // Groups query + Entries: []*ldap.Entry{ + { + DN: "cn=NodeEditors,ou=Users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{"NodeEditors"}, + }, + }, + }, + }, + } + + // On search performed for validateUsersActive + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedResults, nil).Once() + + // Second call on user groups search + mockLdapConnProvider.On("Search", mock.AnythingOfType("*ldap.SearchRequest")).Return(&expectedGroupResults, nil).Once() + + // User active, and has editor group. Expect success + mockLdapConnProvider.On("Bind", mock.Anything, cltest.Password).Return(nil) + sessionRequest := sessions.SessionRequest{ + Email: user1.Email, + Password: cltest.Password, + } + + _, err := ldapAuthProvider.CreateSession(sessionRequest) + require.NoError(t, err) +} + +func TestORM_CreateSession_LocalAdminFallbackLogin(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Fail the bind to trigger 'localLoginFallback' - local admin users should still be able to login + // regardless of whether the authentication provider is remote or not + mockLdapConnProvider.On("Bind", mock.Anything, cltest.Password).Return(errors.New("unable to login via LDAP server")).Once() + + // User active, and has editor group. Expect success + sessionRequest := sessions.SessionRequest{ + Email: cltest.APIEmailAdmin, + Password: cltest.Password, + } + + _, err := ldapAuthProvider.CreateSession(sessionRequest) + require.NoError(t, err) + + // Finally, assert login failing altogether + // User active, and has editor group. Expect success + mockLdapConnProvider.On("Bind", mock.Anything, "incorrect-password").Return(errors.New("unable to login via LDAP server")).Once() + sessionRequest = sessions.SessionRequest{ + Email: cltest.APIEmailAdmin, + Password: "incorrect-password", + } + + _, err = ldapAuthProvider.CreateSession(sessionRequest) + require.ErrorContains(t, err, "invalid password") +} + +func TestORM_SetPassword_LocalAdminFallbackLogin(t *testing.T) { + t.Parallel() + + mockLdapClient := mocks.NewLDAPClient(t) + mockLdapConnProvider := mocks.NewLDAPConn(t) + mockLdapClient.On("CreateEphemeralConnection").Return(mockLdapConnProvider, nil) + mockLdapConnProvider.On("Close").Return(nil) + + // Initilaize LDAP Authentication Provider with mock client + _, ldapAuthProvider := setupAuthenticationProvider(t, mockLdapClient) + + // Fail the bind to trigger 'localLoginFallback' - local admin users should still be able to login + // regardless of whether the authentication provider is remote or not + mockLdapConnProvider.On("Bind", mock.Anything, cltest.Password).Return(errors.New("unable to login via LDAP server")).Once() + + // User active, and has editor group. Expect success + sessionRequest := sessions.SessionRequest{ + Email: cltest.APIEmailAdmin, + Password: cltest.Password, + } + + _, err := ldapAuthProvider.CreateSession(sessionRequest) + require.NoError(t, err) + + // Finally, assert login failing altogether + // User active, and has editor group. Expect success + mockLdapConnProvider.On("Bind", mock.Anything, "incorrect-password").Return(errors.New("unable to login via LDAP server")).Once() + sessionRequest = sessions.SessionRequest{ + Email: cltest.APIEmailAdmin, + Password: "incorrect-password", + } + + _, err = ldapAuthProvider.CreateSession(sessionRequest) + require.ErrorContains(t, err, "invalid password") +} + +func TestORM_MapSearchGroups(t *testing.T) { + t.Parallel() + + cfg := ldapauth.TestConfig{} + + tests := []struct { + name string + groupsQuerySearchResult []*ldap.Entry + wantMappedRole sessions.UserRole + wantErr error + }{ + { + "user in admin group only", + []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeAdminsGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeAdminsGroupCN}, + }, + }, + }, + }, + sessions.UserRoleAdmin, + nil, + }, + { + "user in edit group", + []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeEditorsGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeEditorsGroupCN}, + }, + }, + }, + }, + sessions.UserRoleEdit, + nil, + }, + { + "user in run group", + []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeRunnersGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeRunnersGroupCN}, + }, + }, + }, + }, + sessions.UserRoleRun, + nil, + }, + { + "user in view role", + []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeReadOnlyGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeReadOnlyGroupCN}, + }, + }, + }, + }, + sessions.UserRoleView, + nil, + }, + { + "user in none", + []*ldap.Entry{}, + sessions.UserRole(""), // ignored, error case + ldapauth.ErrUserNoLDAPGroups, + }, + { + "user in run and view", + []*ldap.Entry{ + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeRunnersGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeRunnersGroupCN}, + }, + }, + }, + { + DN: fmt.Sprintf("cn=%s,ou=Groups,dc=example,dc=com", ldapauth.NodeReadOnlyGroupCN), + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{ldapauth.NodeReadOnlyGroupCN}, + }, + }, + }, + }, + sessions.UserRoleRun, // Take highest role + nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + role, err := ldapauth.GroupSearchResultsToUserRole( + test.groupsQuerySearchResult, + cfg.AdminUserGroupCN(), + cfg.EditUserGroupCN(), + cfg.RunUserGroupCN(), + cfg.ReadUserGroupCN(), + ) + if test.wantErr != nil { + assert.Equal(t, test.wantErr, err) + } else { + assert.Equal(t, test.wantMappedRole, role) + } + }) + } +} diff --git a/core/sessions/ldapauth/mocks/ldap_client.go b/core/sessions/ldapauth/mocks/ldap_client.go new file mode 100644 index 00000000000..7a44778dcaa --- /dev/null +++ b/core/sessions/ldapauth/mocks/ldap_client.go @@ -0,0 +1,53 @@ +// Code generated by mockery v2.35.4. DO NOT EDIT. + +package mocks + +import ( + ldapauth "github.com/smartcontractkit/chainlink/v2/core/sessions/ldapauth" + mock "github.com/stretchr/testify/mock" +) + +// LDAPClient is an autogenerated mock type for the LDAPClient type +type LDAPClient struct { + mock.Mock +} + +// CreateEphemeralConnection provides a mock function with given fields: +func (_m *LDAPClient) CreateEphemeralConnection() (ldapauth.LDAPConn, error) { + ret := _m.Called() + + var r0 ldapauth.LDAPConn + var r1 error + if rf, ok := ret.Get(0).(func() (ldapauth.LDAPConn, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() ldapauth.LDAPConn); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ldapauth.LDAPConn) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewLDAPClient creates a new instance of LDAPClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLDAPClient(t interface { + mock.TestingT + Cleanup(func()) +}) *LDAPClient { + mock := &LDAPClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/sessions/ldapauth/mocks/ldap_conn.go b/core/sessions/ldapauth/mocks/ldap_conn.go new file mode 100644 index 00000000000..c05fb6c4fa6 --- /dev/null +++ b/core/sessions/ldapauth/mocks/ldap_conn.go @@ -0,0 +1,82 @@ +// Code generated by mockery v2.35.4. DO NOT EDIT. + +package mocks + +import ( + ldap "github.com/go-ldap/ldap/v3" + + mock "github.com/stretchr/testify/mock" +) + +// LDAPConn is an autogenerated mock type for the LDAPConn type +type LDAPConn struct { + mock.Mock +} + +// Bind provides a mock function with given fields: username, password +func (_m *LDAPConn) Bind(username string, password string) error { + ret := _m.Called(username, password) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(username, password) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Close provides a mock function with given fields: +func (_m *LDAPConn) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Search provides a mock function with given fields: searchRequest +func (_m *LDAPConn) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { + ret := _m.Called(searchRequest) + + var r0 *ldap.SearchResult + var r1 error + if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) (*ldap.SearchResult, error)); ok { + return rf(searchRequest) + } + if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) *ldap.SearchResult); ok { + r0 = rf(searchRequest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.SearchResult) + } + } + + if rf, ok := ret.Get(1).(func(*ldap.SearchRequest) error); ok { + r1 = rf(searchRequest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewLDAPConn creates a new instance of LDAPConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLDAPConn(t interface { + mock.TestingT + Cleanup(func()) +}) *LDAPConn { + mock := &LDAPConn{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/sessions/ldapauth/sync.go b/core/sessions/ldapauth/sync.go new file mode 100644 index 00000000000..ce7a338f40e --- /dev/null +++ b/core/sessions/ldapauth/sync.go @@ -0,0 +1,343 @@ +package ldapauth + +import ( + "errors" + "fmt" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/lib/pq" + "github.com/smartcontractkit/sqlx" + + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/pg" + "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/utils" +) + +type LDAPServerStateSyncer struct { + q pg.Q + ldapClient LDAPClient + config config.LDAP + lggr logger.Logger + nextSyncTime time.Time +} + +// NewLDAPServerStateSync creates a reaper that cleans stale sessions from the store. +func NewLDAPServerStateSync( + db *sqlx.DB, + pgCfg pg.QConfig, + config config.LDAP, + lggr logger.Logger, +) utils.SleeperTask { + namedLogger := lggr.Named("LDAPServerStateSync") + serverSync := LDAPServerStateSyncer{ + q: pg.NewQ(db, namedLogger, pgCfg), + ldapClient: newLDAPClient(config), + config: config, + lggr: namedLogger, + nextSyncTime: time.Time{}, + } + // If enabled, start a background task that calls the Sync/Work function on an + // interval without needing an auth event to trigger it + // Use IsInstant to check 0 value to omit functionality. + if !config.UpstreamSyncInterval().IsInstant() { + lggr.Info("LDAP Config UpstreamSyncInterval is non-zero, sync functionality will be called on a timer, respecting the UpstreamSyncRateLimit value") + serverSync.StartWorkOnTimer() + } else { + // Ensure upstream server state is synced on startup manually if interval check not set + serverSync.Work() + } + + // Start background Sync call task reactive to auth related events + serverSyncSleeperTask := utils.NewSleeperTask(&serverSync) + return serverSyncSleeperTask +} + +func (ldSync *LDAPServerStateSyncer) Name() string { + return "LDAPServerStateSync" +} + +func (ldSync *LDAPServerStateSyncer) StartWorkOnTimer() { + time.AfterFunc(ldSync.config.UpstreamSyncInterval().Duration(), ldSync.StartWorkOnTimer) + ldSync.Work() +} + +func (ldSync *LDAPServerStateSyncer) Work() { + // Purge expired ldap_sessions and ldap_user_api_tokens + recordCreationStaleThreshold := ldSync.config.SessionTimeout().Before(time.Now()) + err := ldSync.deleteStaleSessions(recordCreationStaleThreshold) + if err != nil { + ldSync.lggr.Error("unable to expire local LDAP sessions: ", err) + } + recordCreationStaleThreshold = ldSync.config.UserAPITokenDuration().Before(time.Now()) + err = ldSync.deleteStaleAPITokens(recordCreationStaleThreshold) + if err != nil { + ldSync.lggr.Error("unable to expire user API tokens: ", err) + } + + // Optional rate limiting check to limit the amount of upstream LDAP server queries performed + if !ldSync.config.UpstreamSyncRateLimit().IsInstant() { + if !time.Now().After(ldSync.nextSyncTime) { + return + } + + // Enough time has elapsed to sync again, store the time for when next sync is allowed and begin sync + ldSync.nextSyncTime = time.Now().Add(ldSync.config.UpstreamSyncRateLimit().Duration()) + } + + ldSync.lggr.Info("Begin Upstream LDAP provider state sync after checking time against config UpstreamSyncInterval and UpstreamSyncRateLimit") + + // For each defined role/group, query for the list of group members to gather the full list of possible users + users := []sessions.User{} + + conn, err := ldSync.ldapClient.CreateEphemeralConnection() + if err != nil { + ldSync.lggr.Errorf("Failed to Dial LDAP Server", err) + return + } + // Root level root user auth with credentials provided from config + bindStr := ldSync.config.BaseUserAttr() + "=" + ldSync.config.ReadOnlyUserLogin() + "," + ldSync.config.BaseDN() + if err = conn.Bind(bindStr, ldSync.config.ReadOnlyUserPass()); err != nil { + ldSync.lggr.Errorf("Unable to login as initial root LDAP user", err) + } + defer conn.Close() + + // Query for list of uniqueMember IDs present in Admin group + adminUsers, err := ldSync.ldapGroupMembersListToUser(conn, ldSync.config.AdminUserGroupCN(), sessions.UserRoleAdmin) + if err != nil { + ldSync.lggr.Errorf("Error in ldapGroupMembersListToUser: ", err) + return + } + // Query for list of uniqueMember IDs present in Edit group + editUsers, err := ldSync.ldapGroupMembersListToUser(conn, ldSync.config.EditUserGroupCN(), sessions.UserRoleEdit) + if err != nil { + ldSync.lggr.Errorf("Error in ldapGroupMembersListToUser: ", err) + return + } + // Query for list of uniqueMember IDs present in Edit group + runUsers, err := ldSync.ldapGroupMembersListToUser(conn, ldSync.config.RunUserGroupCN(), sessions.UserRoleRun) + if err != nil { + ldSync.lggr.Errorf("Error in ldapGroupMembersListToUser: ", err) + return + } + // Query for list of uniqueMember IDs present in Edit group + readUsers, err := ldSync.ldapGroupMembersListToUser(conn, ldSync.config.ReadUserGroupCN(), sessions.UserRoleView) + if err != nil { + ldSync.lggr.Errorf("Error in ldapGroupMembersListToUser: ", err) + return + } + + users = append(users, adminUsers...) + users = append(users, editUsers...) + users = append(users, runUsers...) + users = append(users, readUsers...) + + // Dedupe preserving order of highest role (sorted) + // Preserve members as a map for future lookup + upstreamUserStateMap := make(map[string]sessions.User) + dedupedEmails := []string{} + for _, user := range users { + if _, ok := upstreamUserStateMap[user.Email]; !ok { + upstreamUserStateMap[user.Email] = user + dedupedEmails = append(dedupedEmails, user.Email) + } + } + + // For each unique user in list of active sessions, check for 'Is Active' propery if defined in the config. Some LDAP providers + // list group members that are no longer marked as active + usersActiveFlags, err := ldSync.validateUsersActive(dedupedEmails, conn) + if err != nil { + ldSync.lggr.Errorf("Error validating supplied user list: ", err) + } + // Remove users in the upstreamUserStateMap source of truth who are part of groups but marked as deactivated/no-active + for i, active := range usersActiveFlags { + if !active { + delete(upstreamUserStateMap, dedupedEmails[i]) + } + } + + // upstreamUserStateMap is now the most up to date source of truth + // Now sync database sessions and roles with new data + err = ldSync.q.Transaction(func(tx pg.Queryer) error { + // First, purge users present in the local ldap_sessions table but not in the upstream server + type LDAPSession struct { + UserEmail string + UserRole sessions.UserRole + } + var existingSessions []LDAPSession + if err = tx.Select(&existingSessions, "SELECT user_email, user_role FROM ldap_sessions WHERE localauth_user = false"); err != nil { + return fmt.Errorf("unable to query ldap_sessions table: %w", err) + } + var existingAPITokens []LDAPSession + if err = tx.Select(&existingAPITokens, "SELECT user_email, user_role FROM ldap_user_api_tokens WHERE localauth_user = false"); err != nil { + return fmt.Errorf("unable to query ldap_user_api_tokens table: %w", err) + } + + // Create existing sessions and API tokens lookup map for later + existingSessionsMap := make(map[string]LDAPSession) + for _, sess := range existingSessions { + existingSessionsMap[sess.UserEmail] = sess + } + existingAPITokensMap := make(map[string]LDAPSession) + for _, sess := range existingAPITokens { + existingAPITokensMap[sess.UserEmail] = sess + } + + // Populate list of session emails present in the local session table but not in the upstream state + emailsToPurge := []interface{}{} + for _, ldapSession := range existingSessions { + if _, ok := upstreamUserStateMap[ldapSession.UserEmail]; !ok { + emailsToPurge = append(emailsToPurge, ldapSession.UserEmail) + } + } + // Likewise for API Tokens table + apiTokenEmailsToPurge := []interface{}{} + for _, ldapSession := range existingAPITokens { + if _, ok := upstreamUserStateMap[ldapSession.UserEmail]; !ok { + apiTokenEmailsToPurge = append(apiTokenEmailsToPurge, ldapSession.UserEmail) + } + } + + // Remove any active sessions this user may have + if len(emailsToPurge) > 0 { + _, err = ldSync.q.Exec("DELETE FROM ldap_sessions WHERE user_email = ANY($1)", pq.Array(emailsToPurge)) + if err != nil { + return err + } + } + + // Remove any active API tokens this user may have + if len(apiTokenEmailsToPurge) > 0 { + _, err = ldSync.q.Exec("DELETE FROM ldap_user_api_tokens WHERE user_email = ANY($1)", pq.Array(apiTokenEmailsToPurge)) + if err != nil { + return err + } + } + + // For each user session row, update role to match state of user map from upstream source + queryWhenClause := "" + emailValues := []interface{}{} + // Prepare CASE WHEN query statement with parameterized argument $n placeholders and matching role based on index + for email, user := range upstreamUserStateMap { + // Only build on SET CASE statement per local session and API token role, not for each upstream user value + _, sessionOk := existingSessionsMap[email] + _, tokenOk := existingAPITokensMap[email] + if !sessionOk && !tokenOk { + continue + } + emailValues = append(emailValues, email) + queryWhenClause += fmt.Sprintf("WHEN user_email = $%d THEN '%s' ", len(emailValues), user.Role) + } + + // If there are remaining user entries to update + if len(emailValues) != 0 { + // Set new role state for all rows in single Exec + query := fmt.Sprintf("UPDATE ldap_sessions SET user_role = CASE %s ELSE user_role END", queryWhenClause) + _, err = ldSync.q.Exec(query, emailValues...) + if err != nil { + return err + } + + // Update role of API tokens as well + query = fmt.Sprintf("UPDATE ldap_user_api_tokens SET user_role = CASE %s ELSE user_role END", queryWhenClause) + _, err = ldSync.q.Exec(query, emailValues...) + if err != nil { + return err + } + } + + ldSync.lggr.Info("local ldap_sessions and ldap_user_api_tokens table successfully synced with upstream LDAP state") + return nil + }) + if err != nil { + ldSync.lggr.Errorf("Error syncing local database state: ", err) + } + ldSync.lggr.Info("Upstream LDAP sync complete") +} + +// deleteStaleSessions deletes all ldap_sessions before the passed time. +func (ldSync *LDAPServerStateSyncer) deleteStaleSessions(before time.Time) error { + _, err := ldSync.q.Exec("DELETE FROM ldap_sessions WHERE created_at < $1", before) + return err +} + +// deleteStaleAPITokens deletes all ldap_user_api_tokens before the passed time. +func (ldSync *LDAPServerStateSyncer) deleteStaleAPITokens(before time.Time) error { + _, err := ldSync.q.Exec("DELETE FROM ldap_user_api_tokens WHERE created_at < $1", before) + return err +} + +// ldapGroupMembersListToUser queries the LDAP server given a conn for a list of uniqueMember who are part of the parameterized group +func (ldSync *LDAPServerStateSyncer) ldapGroupMembersListToUser(conn LDAPConn, groupNameCN string, roleToAssign sessions.UserRole) ([]sessions.User, error) { + users, err := ldapGroupMembersListToUser( + conn, groupNameCN, roleToAssign, ldSync.config.GroupsDN(), + ldSync.config.BaseDN(), ldSync.config.QueryTimeout(), + ldSync.lggr, + ) + if err != nil { + ldSync.lggr.Errorf("Error listing members of group (%s): %v", groupNameCN, err) + return users, errors.New("error searching group members in LDAP directory") + } + return users, nil +} + +// validateUsersActive performs an additional LDAP server query for the supplied emails, checking the +// returned user data for an 'active' property defined optionally in the config. +// Returns same length bool 'valid' array, order preserved +func (ldSync *LDAPServerStateSyncer) validateUsersActive(emails []string, conn LDAPConn) ([]bool, error) { + validUsers := make([]bool, len(emails)) + // If active attribute to check is not defined in config, skip + if ldSync.config.ActiveAttribute() == "" { + // pre fill with valids + for i := range emails { + validUsers[i] = true + } + return validUsers, nil + } + + // Build the full email list query to pull all 'isActive' information for each user specified in one query + filterQuery := "(|" + for _, email := range emails { + escapedEmail := ldap.EscapeFilter(email) + filterQuery = fmt.Sprintf("%s(%s=%s)", filterQuery, ldSync.config.BaseUserAttr(), escapedEmail) + } + filterQuery = fmt.Sprintf("(&%s))", filterQuery) + searchBaseDN := fmt.Sprintf("%s,%s", ldSync.config.UsersDN(), ldSync.config.BaseDN()) + searchRequest := ldap.NewSearchRequest( + searchBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, int(ldSync.config.QueryTimeout().Seconds()), false, + filterQuery, + []string{ldSync.config.BaseUserAttr(), ldSync.config.ActiveAttribute()}, + nil, + ) + // Query LDAP server for the ActiveAttribute property of each specified user + results, err := conn.Search(searchRequest) + if err != nil { + ldSync.lggr.Errorf("Error searching user in LDAP query: %v", err) + return validUsers, errors.New("error searching users in LDAP directory") + } + // Ensure user response entries + if len(results.Entries) == 0 { + return validUsers, errors.New("no users matching email query") + } + + // Pull expected ActiveAttribute value from list of string possible values + // keyed on email for final step to return flag bool list where order is preserved + emailToActiveMap := make(map[string]bool) + for _, result := range results.Entries { + isActiveAttribute := result.GetAttributeValue(ldSync.config.ActiveAttribute()) + uidAttribute := result.GetAttributeValue(ldSync.config.BaseUserAttr()) + emailToActiveMap[uidAttribute] = isActiveAttribute == ldSync.config.ActiveAttributeAllowedValue() + } + for i, email := range emails { + active, ok := emailToActiveMap[email] + if ok && active { + validUsers[i] = true + } + } + + return validUsers, nil +} diff --git a/core/sessions/orm.go b/core/sessions/localauth/orm.go similarity index 80% rename from core/sessions/orm.go rename to core/sessions/localauth/orm.go index eaac211f242..d6fb8cd5788 100644 --- a/core/sessions/orm.go +++ b/core/sessions/localauth/orm.go @@ -1,4 +1,4 @@ -package sessions +package localauth import ( "crypto/subtle" @@ -14,34 +14,11 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/logger/audit" "github.com/smartcontractkit/chainlink/v2/core/services/pg" + "github.com/smartcontractkit/chainlink/v2/core/sessions" "github.com/smartcontractkit/chainlink/v2/core/utils" "github.com/smartcontractkit/chainlink/v2/core/utils/mathutil" ) -//go:generate mockery --quiet --name ORM --output ./mocks/ --case=underscore - -type ORM interface { - FindUser(email string) (User, error) - FindUserByAPIToken(apiToken string) (User, error) - ListUsers() ([]User, error) - AuthorizedUserWithSession(sessionID string) (User, error) - DeleteUser(email string) error - DeleteUserSession(sessionID string) error - CreateSession(sr SessionRequest) (string, error) - ClearNonCurrentSessions(sessionID string) error - CreateUser(user *User) error - UpdateRole(email, newRole string) (User, error) - SetAuthToken(user *User, token *auth.Token) error - CreateAndSetAuthToken(user *User) (*auth.Token, error) - DeleteAuthToken(user *User) error - SetPassword(user *User, newPassword string) error - Sessions(offset, limit int) ([]Session, error) - GetUserWebAuthn(email string) ([]WebAuthn, error) - SaveWebAuthn(token *WebAuthn) error - - FindExternalInitiator(eia *auth.Token) (initiator *bridges.ExternalInitiator, err error) -} - type orm struct { q pg.Q sessionDuration time.Duration @@ -49,38 +26,40 @@ type orm struct { auditLogger audit.AuditLogger } -var _ ORM = (*orm)(nil) +// orm implements sessions.AuthenticationProvider and sessions.BasicAdminUsersORM interfaces +var _ sessions.AuthenticationProvider = (*orm)(nil) +var _ sessions.BasicAdminUsersORM = (*orm)(nil) -func NewORM(db *sqlx.DB, sd time.Duration, lggr logger.Logger, cfg pg.QConfig, auditLogger audit.AuditLogger) ORM { - lggr = lggr.Named("SessionsORM") +func NewORM(db *sqlx.DB, sd time.Duration, lggr logger.Logger, cfg pg.QConfig, auditLogger audit.AuditLogger) sessions.AuthenticationProvider { + namedLogger := lggr.Named("LocalAuthAuthenticationProviderORM") return &orm{ - q: pg.NewQ(db, lggr, cfg), + q: pg.NewQ(db, namedLogger, cfg), sessionDuration: sd, - lggr: lggr, + lggr: lggr.Named("LocalAuthAuthenticationProviderORM"), auditLogger: auditLogger, } } // FindUser will attempt to return an API user by email. -func (o *orm) FindUser(email string) (User, error) { +func (o *orm) FindUser(email string) (sessions.User, error) { return o.findUser(email) } // FindUserByAPIToken will attempt to return an API user via the user's table token_key column. -func (o *orm) FindUserByAPIToken(apiToken string) (user User, err error) { +func (o *orm) FindUserByAPIToken(apiToken string) (user sessions.User, err error) { sql := "SELECT * FROM users WHERE token_key = $1" err = o.q.Get(&user, sql, apiToken) return } -func (o *orm) findUser(email string) (user User, err error) { +func (o *orm) findUser(email string) (user sessions.User, err error) { sql := "SELECT * FROM users WHERE lower(email) = lower($1)" err = o.q.Get(&user, sql, email) return } // ListUsers will load and return all user rows from the db. -func (o *orm) ListUsers() (users []User, err error) { +func (o *orm) ListUsers() (users []sessions.User, err error) { sql := "SELECT * FROM users ORDER BY email ASC;" err = o.q.Select(&users, sql) return @@ -100,31 +79,27 @@ func (o *orm) updateSessionLastUsed(sessionID string) error { return o.q.ExecQ("UPDATE sessions SET last_used = now() WHERE id = $1", sessionID) } -// ErrUserSessionExpired defines the error triggered when the user session has expired -var ( - ErrUserSessionExpired = errors.New("user session missing or expired, please login again") - ErrEmptySessionID = errors.New("session ID cannot be empty") -) - // AuthorizedUserWithSession will return the API user associated with the Session ID if it // exists and hasn't expired, and update session's LastUsed field. -func (o *orm) AuthorizedUserWithSession(sessionID string) (user User, err error) { +// AuthorizedUserWithSession will return the API user associated with the Session ID if it +// exists and hasn't expired, and update session's LastUsed field. +func (o *orm) AuthorizedUserWithSession(sessionID string) (user sessions.User, err error) { if len(sessionID) == 0 { - return User{}, ErrEmptySessionID + return sessions.User{}, sessions.ErrEmptySessionID } email, err := o.findValidSession(sessionID) if err != nil { - return User{}, ErrUserSessionExpired + return sessions.User{}, sessions.ErrUserSessionExpired } user, err = o.findUser(email) if err != nil { - return User{}, ErrUserSessionExpired + return sessions.User{}, sessions.ErrUserSessionExpired } if err := o.updateSessionLastUsed(sessionID); err != nil { - return User{}, err + return sessions.User{}, err } return user, nil @@ -151,8 +126,8 @@ func (o *orm) DeleteUserSession(sessionID string) error { // tokens for the user. This list must be used when logging in (for obvious reasons) but // must also be used for registration to prevent the user from enrolling the same hardware // token multiple times. -func (o *orm) GetUserWebAuthn(email string) ([]WebAuthn, error) { - var uwas []WebAuthn +func (o *orm) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) { + var uwas []sessions.WebAuthn err := o.q.Select(&uwas, "SELECT email, public_key_data FROM web_authns WHERE LOWER(email) = $1", strings.ToLower(email)) if err != nil { return uwas, err @@ -165,7 +140,7 @@ func (o *orm) GetUserWebAuthn(email string) ([]WebAuthn, error) { // CreateSession will check the password in the SessionRequest against // the hashed API User password in the db. Also will check WebAuthn if it's // enabled for that user. -func (o *orm) CreateSession(sr SessionRequest) (string, error) { +func (o *orm) CreateSession(sr sessions.SessionRequest) (string, error) { user, err := o.FindUser(sr.Email) if err != nil { return "", err @@ -196,7 +171,7 @@ func (o *orm) CreateSession(sr SessionRequest) (string, error) { // No webauthn tokens registered for the current user, so normal authentication is now complete if len(uwas) == 0 { lggr.Infof("No MFA for user. Creating Session") - session := NewSession() + session := sessions.NewSession() _, err = o.q.Exec("INSERT INTO sessions (id, email, last_used, created_at) VALUES ($1, $2, now(), now())", session.ID, user.Email) o.auditLogger.Audit(audit.AuthLoginSuccessNo2FA, map[string]interface{}{"email": sr.Email}) return session.ID, err @@ -207,7 +182,7 @@ func (o *orm) CreateSession(sr SessionRequest) (string, error) { // data in the next round trip request (tap key to include webauthn data on the login page) if sr.WebAuthnData == "" { lggr.Warnf("Attempted login to MFA user. Generating challenge for user.") - options, webauthnError := BeginWebAuthnLogin(user, uwas, sr) + options, webauthnError := sessions.BeginWebAuthnLogin(user, uwas, sr) if webauthnError != nil { lggr.Errorf("Could not begin WebAuthn verification: %v", webauthnError) return "", errors.New("MFA Error") @@ -225,7 +200,7 @@ func (o *orm) CreateSession(sr SessionRequest) (string, error) { // The user is at the final stage of logging in with MFA. We have an // attestation back from the user, we now need to verify that it is // correct. - err = FinishWebAuthnLogin(user, uwas, sr) + err = sessions.FinishWebAuthnLogin(user, uwas, sr) if err != nil { // The user does have WebAuthn enabled but failed the check @@ -236,7 +211,7 @@ func (o *orm) CreateSession(sr SessionRequest) (string, error) { lggr.Infof("User passed MFA authentication and login will proceed") // This is a success so we can create the sessions - session := NewSession() + session := sessions.NewSession() _, err = o.q.Exec("INSERT INTO sessions (id, email, last_used, created_at) VALUES ($1, $2, now(), now())", session.ID, user.Email) if err != nil { return "", err @@ -271,14 +246,14 @@ func (o *orm) ClearNonCurrentSessions(sessionID string) error { } // CreateUser creates a new API user -func (o *orm) CreateUser(user *User) error { +func (o *orm) CreateUser(user *sessions.User) error { sql := "INSERT INTO users (email, hashed_password, role, created_at, updated_at) VALUES ($1, $2, $3, now(), now()) RETURNING *" return o.q.Get(user, sql, strings.ToLower(user.Email), user.HashedPassword, user.Role) } // UpdateRole overwrites role field of the user specified by email. -func (o *orm) UpdateRole(email, newRole string) (User, error) { - var userToEdit User +func (o *orm) UpdateRole(email, newRole string) (sessions.User, error) { + var userToEdit sessions.User if newRole == "" { return userToEdit, errors.New("user role must be specified") @@ -291,7 +266,7 @@ func (o *orm) UpdateRole(email, newRole string) (User, error) { } // Patch validated role - userRole, err := GetUserRole(newRole) + userRole, err := sessions.GetUserRole(newRole) if err != nil { return err } @@ -316,7 +291,7 @@ func (o *orm) UpdateRole(email, newRole string) (User, error) { } // SetAuthToken updates the user to use the given Authentication Token. -func (o *orm) SetPassword(user *User, newPassword string) error { +func (o *orm) SetPassword(user *sessions.User, newPassword string) error { hashedPassword, err := utils.HashPassword(newPassword) if err != nil { return err @@ -325,7 +300,19 @@ func (o *orm) SetPassword(user *User, newPassword string) error { return o.q.Get(user, sql, hashedPassword, user.Email) } -func (o *orm) CreateAndSetAuthToken(user *User) (*auth.Token, error) { +// TestPassword checks plaintext user provided password with hashed database password, returns nil if matched +func (o *orm) TestPassword(email string, password string) error { + var hashedPassword string + if err := o.q.Get(&hashedPassword, "SELECT hashed_password FROM users WHERE lower(email) = lower($1)", email); err != nil { + return errors.New("no matching user for provided email") + } + if !utils.CheckPasswordHash(password, hashedPassword) { + return errors.New("passwords don't match") + } + return nil +} + +func (o *orm) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) { newToken := auth.NewToken() err := o.SetAuthToken(user, newToken) @@ -337,7 +324,7 @@ func (o *orm) CreateAndSetAuthToken(user *User) (*auth.Token, error) { } // SetAuthToken updates the user to use the given Authentication Token. -func (o *orm) SetAuthToken(user *User, token *auth.Token) error { +func (o *orm) SetAuthToken(user *sessions.User, token *auth.Token) error { salt := utils.NewSecret(utils.DefaultSecretSize) hashedSecret, err := auth.HashedSecret(token, salt) if err != nil { @@ -348,20 +335,20 @@ func (o *orm) SetAuthToken(user *User, token *auth.Token) error { } // DeleteAuthToken clears and disables the users Authentication Token. -func (o *orm) DeleteAuthToken(user *User) error { +func (o *orm) DeleteAuthToken(user *sessions.User) error { sql := "UPDATE users SET token_salt = '', token_key = '', token_hashed_secret = '', updated_at = now() WHERE email = $1 RETURNING *" return o.q.Get(user, sql, user.Email) } // SaveWebAuthn saves new WebAuthn token information. -func (o *orm) SaveWebAuthn(token *WebAuthn) error { +func (o *orm) SaveWebAuthn(token *sessions.WebAuthn) error { sql := "INSERT INTO web_authns (email, public_key_data) VALUES ($1, $2)" _, err := o.q.Exec(sql, token.Email, token.PublicKeyData) return err } // Sessions returns all sessions limited by the parameters. -func (o *orm) Sessions(offset, limit int) (sessions []Session, err error) { +func (o *orm) Sessions(offset, limit int) (sessions []sessions.Session, err error) { sql := `SELECT * FROM sessions ORDER BY created_at, id LIMIT $1 OFFSET $2;` if err = o.q.Select(&sessions, sql, limit, offset); err != nil { return diff --git a/core/sessions/orm_test.go b/core/sessions/localauth/orm_test.go similarity index 95% rename from core/sessions/orm_test.go rename to core/sessions/localauth/orm_test.go index 5decb823086..7868937ad08 100644 --- a/core/sessions/orm_test.go +++ b/core/sessions/localauth/orm_test.go @@ -1,4 +1,4 @@ -package sessions_test +package localauth_test import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,14 +19,15 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/logger/audit" "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/localauth" "github.com/smartcontractkit/chainlink/v2/core/utils" ) -func setupORM(t *testing.T) (*sqlx.DB, sessions.ORM) { +func setupORM(t *testing.T) (*sqlx.DB, sessions.AuthenticationProvider) { t.Helper() db := pgtest.NewSqlxDB(t) - orm := sessions.NewORM(db, time.Minute, logger.TestLogger(t), pgtest.NewQConfig(true), &audit.AuditLoggerService{}) + orm := localauth.NewORM(db, time.Minute, logger.TestLogger(t), pgtest.NewQConfig(true), &audit.AuditLoggerService{}) return db, orm } @@ -66,7 +68,7 @@ func TestORM_AuthorizedUserWithSession(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { db := pgtest.NewSqlxDB(t) - orm := sessions.NewORM(db, test.sessionDuration, logger.TestLogger(t), pgtest.NewQConfig(true), &audit.AuditLoggerService{}) + orm := localauth.NewORM(db, test.sessionDuration, logger.TestLogger(t), pgtest.NewQConfig(true), &audit.AuditLoggerService{}) user := cltest.MustRandomUser(t) require.NoError(t, orm.CreateUser(&user)) diff --git a/core/sessions/reaper.go b/core/sessions/localauth/reaper.go similarity index 98% rename from core/sessions/reaper.go rename to core/sessions/localauth/reaper.go index c4f0ed6796c..77d1b1abef2 100644 --- a/core/sessions/reaper.go +++ b/core/sessions/localauth/reaper.go @@ -1,4 +1,4 @@ -package sessions +package localauth import ( "database/sql" diff --git a/core/sessions/reaper_test.go b/core/sessions/localauth/reaper_test.go similarity index 69% rename from core/sessions/reaper_test.go rename to core/sessions/localauth/reaper_test.go index a96c3822ef5..43a263d0321 100644 --- a/core/sessions/reaper_test.go +++ b/core/sessions/localauth/reaper_test.go @@ -1,4 +1,4 @@ -package sessions_test +package localauth_test import ( "testing" @@ -9,8 +9,10 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/logger/audit" "github.com/smartcontractkit/chainlink/v2/core/sessions" + "github.com/smartcontractkit/chainlink/v2/core/sessions/localauth" "github.com/smartcontractkit/chainlink/v2/core/store/models" + "github.com/onsi/gomega" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,10 +33,9 @@ func TestSessionReaper_ReapSessions(t *testing.T) { db := pgtest.NewSqlxDB(t) config := sessionReaperConfig{} lggr := logger.TestLogger(t) - orm := sessions.NewORM(db, config.SessionTimeout().Duration(), lggr, pgtest.NewQConfig(true), audit.NoopLogger) - - r := sessions.NewSessionReaper(db.DB, config, lggr) + orm := localauth.NewORM(db, config.SessionTimeout().Duration(), lggr, pgtest.NewQConfig(true), audit.NoopLogger) + r := localauth.NewSessionReaper(db.DB, config, lggr) t.Cleanup(func() { assert.NoError(t, r.Stop()) }) @@ -53,31 +54,28 @@ func TestSessionReaper_ReapSessions(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - user := cltest.MustRandomUser(t) - require.NoError(t, orm.CreateUser(&user)) - - session := sessions.NewSession() - session.Email = user.Email - - _, err := db.Exec("INSERT INTO sessions (last_used, email, id, created_at) VALUES ($1, $2, $3, now())", test.lastUsed, user.Email, test.name) - require.NoError(t, err) - t.Cleanup(func() { - _, err2 := db.Exec("DELETE FROM sessions where email = $1", user.Email) + _, err2 := db.Exec("DELETE FROM sessions where email = $1", cltest.APIEmailAdmin) require.NoError(t, err2) }) + _, err := db.Exec("INSERT INTO sessions (last_used, email, id, created_at) VALUES ($1, $2, $3, now())", test.lastUsed, cltest.APIEmailAdmin, test.name) + require.NoError(t, err) + r.WakeUp() - <-r.(interface { - WorkDone() <-chan struct{} - }).WorkDone() - sessions, err := orm.Sessions(0, 10) - assert.NoError(t, err) if test.wantReap { - assert.Len(t, sessions, 0) + gomega.NewWithT(t).Eventually(func() []sessions.Session { + sessions, err := orm.Sessions(0, 10) + assert.NoError(t, err) + return sessions + }).Should(gomega.HaveLen(0)) } else { - assert.Len(t, sessions, 1) + gomega.NewWithT(t).Consistently(func() []sessions.Session { + sessions, err := orm.Sessions(0, 10) + assert.NoError(t, err) + return sessions + }).Should(gomega.HaveLen(1)) } }) } diff --git a/core/sessions/mocks/orm.go b/core/sessions/mocks/authentication_provider.go similarity index 75% rename from core/sessions/mocks/orm.go rename to core/sessions/mocks/authentication_provider.go index 5699b9f8892..d6e33d11e45 100644 --- a/core/sessions/mocks/orm.go +++ b/core/sessions/mocks/authentication_provider.go @@ -11,13 +11,13 @@ import ( sessions "github.com/smartcontractkit/chainlink/v2/core/sessions" ) -// ORM is an autogenerated mock type for the ORM type -type ORM struct { +// AuthenticationProvider is an autogenerated mock type for the AuthenticationProvider type +type AuthenticationProvider struct { mock.Mock } // AuthorizedUserWithSession provides a mock function with given fields: sessionID -func (_m *ORM) AuthorizedUserWithSession(sessionID string) (sessions.User, error) { +func (_m *AuthenticationProvider) AuthorizedUserWithSession(sessionID string) (sessions.User, error) { ret := _m.Called(sessionID) var r0 sessions.User @@ -41,7 +41,7 @@ func (_m *ORM) AuthorizedUserWithSession(sessionID string) (sessions.User, error } // ClearNonCurrentSessions provides a mock function with given fields: sessionID -func (_m *ORM) ClearNonCurrentSessions(sessionID string) error { +func (_m *AuthenticationProvider) ClearNonCurrentSessions(sessionID string) error { ret := _m.Called(sessionID) var r0 error @@ -55,7 +55,7 @@ func (_m *ORM) ClearNonCurrentSessions(sessionID string) error { } // CreateAndSetAuthToken provides a mock function with given fields: user -func (_m *ORM) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) { +func (_m *AuthenticationProvider) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) { ret := _m.Called(user) var r0 *auth.Token @@ -81,7 +81,7 @@ func (_m *ORM) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) { } // CreateSession provides a mock function with given fields: sr -func (_m *ORM) CreateSession(sr sessions.SessionRequest) (string, error) { +func (_m *AuthenticationProvider) CreateSession(sr sessions.SessionRequest) (string, error) { ret := _m.Called(sr) var r0 string @@ -105,7 +105,7 @@ func (_m *ORM) CreateSession(sr sessions.SessionRequest) (string, error) { } // CreateUser provides a mock function with given fields: user -func (_m *ORM) CreateUser(user *sessions.User) error { +func (_m *AuthenticationProvider) CreateUser(user *sessions.User) error { ret := _m.Called(user) var r0 error @@ -119,7 +119,7 @@ func (_m *ORM) CreateUser(user *sessions.User) error { } // DeleteAuthToken provides a mock function with given fields: user -func (_m *ORM) DeleteAuthToken(user *sessions.User) error { +func (_m *AuthenticationProvider) DeleteAuthToken(user *sessions.User) error { ret := _m.Called(user) var r0 error @@ -133,7 +133,7 @@ func (_m *ORM) DeleteAuthToken(user *sessions.User) error { } // DeleteUser provides a mock function with given fields: email -func (_m *ORM) DeleteUser(email string) error { +func (_m *AuthenticationProvider) DeleteUser(email string) error { ret := _m.Called(email) var r0 error @@ -147,7 +147,7 @@ func (_m *ORM) DeleteUser(email string) error { } // DeleteUserSession provides a mock function with given fields: sessionID -func (_m *ORM) DeleteUserSession(sessionID string) error { +func (_m *AuthenticationProvider) DeleteUserSession(sessionID string) error { ret := _m.Called(sessionID) var r0 error @@ -161,7 +161,7 @@ func (_m *ORM) DeleteUserSession(sessionID string) error { } // FindExternalInitiator provides a mock function with given fields: eia -func (_m *ORM) FindExternalInitiator(eia *auth.Token) (*bridges.ExternalInitiator, error) { +func (_m *AuthenticationProvider) FindExternalInitiator(eia *auth.Token) (*bridges.ExternalInitiator, error) { ret := _m.Called(eia) var r0 *bridges.ExternalInitiator @@ -187,7 +187,7 @@ func (_m *ORM) FindExternalInitiator(eia *auth.Token) (*bridges.ExternalInitiato } // FindUser provides a mock function with given fields: email -func (_m *ORM) FindUser(email string) (sessions.User, error) { +func (_m *AuthenticationProvider) FindUser(email string) (sessions.User, error) { ret := _m.Called(email) var r0 sessions.User @@ -211,7 +211,7 @@ func (_m *ORM) FindUser(email string) (sessions.User, error) { } // FindUserByAPIToken provides a mock function with given fields: apiToken -func (_m *ORM) FindUserByAPIToken(apiToken string) (sessions.User, error) { +func (_m *AuthenticationProvider) FindUserByAPIToken(apiToken string) (sessions.User, error) { ret := _m.Called(apiToken) var r0 sessions.User @@ -235,7 +235,7 @@ func (_m *ORM) FindUserByAPIToken(apiToken string) (sessions.User, error) { } // GetUserWebAuthn provides a mock function with given fields: email -func (_m *ORM) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) { +func (_m *AuthenticationProvider) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) { ret := _m.Called(email) var r0 []sessions.WebAuthn @@ -261,7 +261,7 @@ func (_m *ORM) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) { } // ListUsers provides a mock function with given fields: -func (_m *ORM) ListUsers() ([]sessions.User, error) { +func (_m *AuthenticationProvider) ListUsers() ([]sessions.User, error) { ret := _m.Called() var r0 []sessions.User @@ -287,7 +287,7 @@ func (_m *ORM) ListUsers() ([]sessions.User, error) { } // SaveWebAuthn provides a mock function with given fields: token -func (_m *ORM) SaveWebAuthn(token *sessions.WebAuthn) error { +func (_m *AuthenticationProvider) SaveWebAuthn(token *sessions.WebAuthn) error { ret := _m.Called(token) var r0 error @@ -301,7 +301,7 @@ func (_m *ORM) SaveWebAuthn(token *sessions.WebAuthn) error { } // Sessions provides a mock function with given fields: offset, limit -func (_m *ORM) Sessions(offset int, limit int) ([]sessions.Session, error) { +func (_m *AuthenticationProvider) Sessions(offset int, limit int) ([]sessions.Session, error) { ret := _m.Called(offset, limit) var r0 []sessions.Session @@ -327,7 +327,7 @@ func (_m *ORM) Sessions(offset int, limit int) ([]sessions.Session, error) { } // SetAuthToken provides a mock function with given fields: user, token -func (_m *ORM) SetAuthToken(user *sessions.User, token *auth.Token) error { +func (_m *AuthenticationProvider) SetAuthToken(user *sessions.User, token *auth.Token) error { ret := _m.Called(user, token) var r0 error @@ -341,7 +341,7 @@ func (_m *ORM) SetAuthToken(user *sessions.User, token *auth.Token) error { } // SetPassword provides a mock function with given fields: user, newPassword -func (_m *ORM) SetPassword(user *sessions.User, newPassword string) error { +func (_m *AuthenticationProvider) SetPassword(user *sessions.User, newPassword string) error { ret := _m.Called(user, newPassword) var r0 error @@ -354,8 +354,22 @@ func (_m *ORM) SetPassword(user *sessions.User, newPassword string) error { return r0 } +// TestPassword provides a mock function with given fields: email, password +func (_m *AuthenticationProvider) TestPassword(email string, password string) error { + ret := _m.Called(email, password) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(email, password) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateRole provides a mock function with given fields: email, newRole -func (_m *ORM) UpdateRole(email string, newRole string) (sessions.User, error) { +func (_m *AuthenticationProvider) UpdateRole(email string, newRole string) (sessions.User, error) { ret := _m.Called(email, newRole) var r0 sessions.User @@ -378,13 +392,13 @@ func (_m *ORM) UpdateRole(email string, newRole string) (sessions.User, error) { return r0, r1 } -// NewORM creates a new instance of ORM. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// NewAuthenticationProvider creates a new instance of AuthenticationProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. -func NewORM(t interface { +func NewAuthenticationProvider(t interface { mock.TestingT Cleanup(func()) -}) *ORM { - mock := &ORM{} +}) *AuthenticationProvider { + mock := &AuthenticationProvider{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) diff --git a/core/sessions/mocks/basic_admin_users_orm.go b/core/sessions/mocks/basic_admin_users_orm.go new file mode 100644 index 00000000000..845e2d8880e --- /dev/null +++ b/core/sessions/mocks/basic_admin_users_orm.go @@ -0,0 +1,91 @@ +// Code generated by mockery v2.35.4. DO NOT EDIT. + +package mocks + +import ( + sessions "github.com/smartcontractkit/chainlink/v2/core/sessions" + mock "github.com/stretchr/testify/mock" +) + +// BasicAdminUsersORM is an autogenerated mock type for the BasicAdminUsersORM type +type BasicAdminUsersORM struct { + mock.Mock +} + +// CreateUser provides a mock function with given fields: user +func (_m *BasicAdminUsersORM) CreateUser(user *sessions.User) error { + ret := _m.Called(user) + + var r0 error + if rf, ok := ret.Get(0).(func(*sessions.User) error); ok { + r0 = rf(user) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// FindUser provides a mock function with given fields: email +func (_m *BasicAdminUsersORM) FindUser(email string) (sessions.User, error) { + ret := _m.Called(email) + + var r0 sessions.User + var r1 error + if rf, ok := ret.Get(0).(func(string) (sessions.User, error)); ok { + return rf(email) + } + if rf, ok := ret.Get(0).(func(string) sessions.User); ok { + r0 = rf(email) + } else { + r0 = ret.Get(0).(sessions.User) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(email) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListUsers provides a mock function with given fields: +func (_m *BasicAdminUsersORM) ListUsers() ([]sessions.User, error) { + ret := _m.Called() + + var r0 []sessions.User + var r1 error + if rf, ok := ret.Get(0).(func() ([]sessions.User, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []sessions.User); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]sessions.User) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewBasicAdminUsersORM creates a new instance of BasicAdminUsersORM. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewBasicAdminUsersORM(t interface { + mock.TestingT + Cleanup(func()) +}) *BasicAdminUsersORM { + mock := &BasicAdminUsersORM{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/sessions/session.go b/core/sessions/session.go new file mode 100644 index 00000000000..90964596e9a --- /dev/null +++ b/core/sessions/session.go @@ -0,0 +1,74 @@ +package sessions + +import ( + "crypto/subtle" + "time" + + "github.com/pkg/errors" + "gopkg.in/guregu/null.v4" + + "github.com/smartcontractkit/chainlink/v2/core/auth" + "github.com/smartcontractkit/chainlink/v2/core/utils" +) + +// SessionRequest encapsulates the fields needed to generate a new SessionID, +// including the hashed password. +type SessionRequest struct { + Email string `json:"email"` + Password string `json:"password"` + WebAuthnData string `json:"webauthndata"` + WebAuthnConfig WebAuthnConfiguration + SessionStore *WebAuthnSessionStore +} + +// Session holds the unique id for the authenticated session. +type Session struct { + ID string `json:"id"` + Email string `json:"email"` + LastUsed time.Time `json:"lastUsed"` + CreatedAt time.Time `json:"createdAt"` +} + +// NewSession returns a session instance with ID set to a random ID and +// LastUsed to now. +func NewSession() Session { + return Session{ + ID: utils.NewBytes32ID(), + LastUsed: time.Now(), + } +} + +// Changeauth.TokenRequest is sent when updating a User's authentication token. +type ChangeAuthTokenRequest struct { + Password string `json:"password"` +} + +// GenerateAuthToken randomly generates and sets the users Authentication +// Token. +func (u *User) GenerateAuthToken() (*auth.Token, error) { + token := auth.NewToken() + return token, u.SetAuthToken(token) +} + +// SetAuthToken updates the user to use the given Authentication Token. +func (u *User) SetAuthToken(token *auth.Token) error { + salt := utils.NewSecret(utils.DefaultSecretSize) + hashedSecret, err := auth.HashedSecret(token, salt) + if err != nil { + return errors.Wrap(err, "user") + } + u.TokenSalt = null.StringFrom(salt) + u.TokenKey = null.StringFrom(token.AccessKey) + u.TokenHashedSecret = null.StringFrom(hashedSecret) + return nil +} + +// AuthenticateUserByToken returns true on successful authentication of the +// user against the given Authentication Token. +func AuthenticateUserByToken(token *auth.Token, user *User) (bool, error) { + hashedSecret, err := auth.HashedSecret(token, user.TokenSalt.ValueOrZero()) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare([]byte(hashedSecret), []byte(user.TokenHashedSecret.ValueOrZero())) == 1, nil +} diff --git a/core/sessions/user.go b/core/sessions/user.go index a1208744323..f2e4827b922 100644 --- a/core/sessions/user.go +++ b/core/sessions/user.go @@ -1,7 +1,6 @@ package sessions import ( - "crypto/subtle" "fmt" "net/mail" "time" @@ -9,7 +8,6 @@ import ( "github.com/pkg/errors" "gopkg.in/guregu/null.v4" - "github.com/smartcontractkit/chainlink/v2/core/auth" "github.com/smartcontractkit/chainlink/v2/core/utils" ) @@ -108,65 +106,3 @@ func GetUserRole(role string) (UserRole, error) { ) return UserRole(""), errors.New(errStr) } - -// SessionRequest encapsulates the fields needed to generate a new SessionID, -// including the hashed password. -type SessionRequest struct { - Email string `json:"email"` - Password string `json:"password"` - WebAuthnData string `json:"webauthndata"` - WebAuthnConfig WebAuthnConfiguration - SessionStore *WebAuthnSessionStore -} - -// Session holds the unique id for the authenticated session. -type Session struct { - ID string `json:"id"` - Email string `json:"email"` - LastUsed time.Time `json:"lastUsed"` - CreatedAt time.Time `json:"createdAt"` -} - -// NewSession returns a session instance with ID set to a random ID and -// LastUsed to now. -func NewSession() Session { - return Session{ - ID: utils.NewBytes32ID(), - LastUsed: time.Now(), - } -} - -// Changeauth.TokenRequest is sent when updating a User's authentication token. -type ChangeAuthTokenRequest struct { - Password string `json:"password"` -} - -// GenerateAuthToken randomly generates and sets the users Authentication -// Token. -func (u *User) GenerateAuthToken() (*auth.Token, error) { - token := auth.NewToken() - return token, u.SetAuthToken(token) -} - -// SetAuthToken updates the user to use the given Authentication Token. -func (u *User) SetAuthToken(token *auth.Token) error { - salt := utils.NewSecret(utils.DefaultSecretSize) - hashedSecret, err := auth.HashedSecret(token, salt) - if err != nil { - return errors.Wrap(err, "user") - } - u.TokenSalt = null.StringFrom(salt) - u.TokenKey = null.StringFrom(token.AccessKey) - u.TokenHashedSecret = null.StringFrom(hashedSecret) - return nil -} - -// AuthenticateUserByToken returns true on successful authentication of the -// user against the given Authentication Token. -func AuthenticateUserByToken(token *auth.Token, user *User) (bool, error) { - hashedSecret, err := auth.HashedSecret(token, user.TokenSalt.ValueOrZero()) - if err != nil { - return false, err - } - return subtle.ConstantTimeCompare([]byte(hashedSecret), []byte(user.TokenHashedSecret.ValueOrZero())) == 1, nil -} diff --git a/core/sessions/webauthn.go b/core/sessions/webauthn.go index 0dd8242dc8a..41e31d7aaa8 100644 --- a/core/sessions/webauthn.go +++ b/core/sessions/webauthn.go @@ -279,7 +279,7 @@ func (store *WebAuthnSessionStore) GetWebauthnSession(key string) (data webauthn return } -func AddCredentialToUser(o ORM, email string, credential *webauthn.Credential) error { +func AddCredentialToUser(ap AuthenticationProvider, email string, credential *webauthn.Credential) error { credj, err := json.Marshal(credential) if err != nil { return err @@ -289,5 +289,5 @@ func AddCredentialToUser(o ORM, email string, credential *webauthn.Credential) e Email: email, PublicKeyData: sqlxTypes.JSONText(credj), } - return o.SaveWebAuthn(&token) + return ap.SaveWebAuthn(&token) } diff --git a/core/store/migrate/migrations/0208_create_ldap_sessions_table.sql b/core/store/migrate/migrations/0208_create_ldap_sessions_table.sql new file mode 100644 index 00000000000..f788cdab076 --- /dev/null +++ b/core/store/migrate/migrations/0208_create_ldap_sessions_table.sql @@ -0,0 +1,22 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS ldap_sessions ( + id text PRIMARY KEY, + user_email text NOT NULL, + user_role user_roles, + localauth_user BOOLEAN, + created_at timestamp with time zone NOT NULL +); + +CREATE TABLE IF NOT EXISTS ldap_user_api_tokens ( + user_email text PRIMARY KEY, + user_role user_roles, + localauth_user BOOLEAN, + token_key text UNIQUE NOT NULL, + token_salt text NOT NULL, + token_hashed_secret text NOT NULL, + created_at timestamp with time zone NOT NULL +); + +-- +goose Down +DROP TABLE ldap_sessions; +DROP TABLE ldap_user_api_tokens; diff --git a/core/web/auth/auth.go b/core/web/auth/auth.go index a0a9df58c79..c2458f52627 100644 --- a/core/web/auth/auth.go +++ b/core/web/auth/auth.go @@ -78,6 +78,9 @@ func AuthenticateByToken(c *gin.Context, authr Authenticator) error { AccessKey: c.GetHeader(APIKey), Secret: c.GetHeader(APISecret), } + if token.AccessKey == "" { + return auth.ErrorAuthFailed + } if token.AccessKey == "" { return auth.ErrorAuthFailed @@ -86,7 +89,7 @@ func AuthenticateByToken(c *gin.Context, authr Authenticator) error { // We need to first load the user row so we can compare tokens using the stored salt user, err := authr.FindUserByAPIToken(token.AccessKey) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, clsessions.ErrUserSessionExpired) { return auth.ErrorAuthFailed } return err diff --git a/core/web/auth/auth_test.go b/core/web/auth/auth_test.go index 896542915ae..f0b4e5068fb 100644 --- a/core/web/auth/auth_test.go +++ b/core/web/auth/auth_test.go @@ -33,7 +33,7 @@ func authSuccess(*gin.Context, webauth.Authenticator) error { } type userFindFailer struct { - sessions.ORM + sessions.AuthenticationProvider err error } @@ -46,7 +46,7 @@ func (u userFindFailer) FindUserByAPIToken(token string) (sessions.User, error) } type userFindSuccesser struct { - sessions.ORM + sessions.AuthenticationProvider user sessions.User } diff --git a/core/web/auth/gql_test.go b/core/web/auth/gql_test.go index 4688f62a336..4f3f8e27baf 100644 --- a/core/web/auth/gql_test.go +++ b/core/web/auth/gql_test.go @@ -21,7 +21,7 @@ import ( func Test_AuthenticateGQL_Unauthenticated(t *testing.T) { t.Parallel() - sessionORM := mocks.NewORM(t) + sessionORM := mocks.NewAuthenticationProvider(t) sessionStore := cookie.NewStore([]byte("secret")) r := gin.Default() @@ -44,7 +44,7 @@ func Test_AuthenticateGQL_Unauthenticated(t *testing.T) { func Test_AuthenticateGQL_Authenticated(t *testing.T) { t.Parallel() - sessionORM := mocks.NewORM(t) + sessionORM := mocks.NewAuthenticationProvider(t) sessionStore := cookie.NewStore([]byte(cltest.SessionSecret)) sessionID := "sessionID" diff --git a/core/web/resolver/api_token_test.go b/core/web/resolver/api_token_test.go index b5ed52be3c5..fae0204caf5 100644 --- a/core/web/resolver/api_token_test.go +++ b/core/web/resolver/api_token_test.go @@ -39,6 +39,11 @@ func TestResolver_CreateAPIToken(t *testing.T) { "password": defaultPassword, }, } + variablesIncorrect := map[string]interface{}{ + "input": map[string]interface{}{ + "password": "wrong-password", + }, + } gError := errors.New("error") testCases := []GQLTestCase{ @@ -56,12 +61,13 @@ func TestResolver_CreateAPIToken(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("CreateAndSetAuthToken", session.User).Return(&auth.Token{ + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, defaultPassword).Return(nil) + f.Mocks.authProvider.On("CreateAndSetAuthToken", session.User).Return(&auth.Token{ Secret: "new-secret", AccessKey: "new-access-key", }, nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -83,13 +89,12 @@ func TestResolver_CreateAPIToken(t *testing.T) { require.True(t, ok) require.NotNil(t, session) - session.User.HashedPassword = "wrong-password" - - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, "wrong-password").Return(gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, - variables: variables, + variables: variablesIncorrect, result: ` { "createAPIToken": { @@ -114,8 +119,8 @@ func TestResolver_CreateAPIToken(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, gError) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -142,9 +147,10 @@ func TestResolver_CreateAPIToken(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("CreateAndSetAuthToken", session.User).Return(nil, gError) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, defaultPassword).Return(nil) + f.Mocks.authProvider.On("CreateAndSetAuthToken", session.User).Return(nil, gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -189,6 +195,11 @@ func TestResolver_DeleteAPIToken(t *testing.T) { "password": defaultPassword, }, } + variablesIncorrect := map[string]interface{}{ + "input": map[string]interface{}{ + "password": "wrong-password", + }, + } gError := errors.New("error") testCases := []GQLTestCase{ @@ -208,9 +219,10 @@ func TestResolver_DeleteAPIToken(t *testing.T) { err = session.User.TokenKey.UnmarshalText([]byte("new-access-key")) require.NoError(t, err) - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("DeleteAuthToken", session.User).Return(nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, defaultPassword).Return(nil) + f.Mocks.authProvider.On("DeleteAuthToken", session.User).Return(nil) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -231,13 +243,12 @@ func TestResolver_DeleteAPIToken(t *testing.T) { require.True(t, ok) require.NotNil(t, session) - session.User.HashedPassword = "wrong-password" - - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, "wrong-password").Return(gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, - variables: variables, + variables: variablesIncorrect, result: ` { "deleteAPIToken": { @@ -262,8 +273,8 @@ func TestResolver_DeleteAPIToken(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, gError) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -290,9 +301,10 @@ func TestResolver_DeleteAPIToken(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("DeleteAuthToken", session.User).Return(gError) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("TestPassword", session.User.Email, defaultPassword).Return(nil) + f.Mocks.authProvider.On("DeleteAuthToken", session.User).Return(gError) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, diff --git a/core/web/resolver/mutation.go b/core/web/resolver/mutation.go index 68cbb0b7896..f9eee0734a3 100644 --- a/core/web/resolver/mutation.go +++ b/core/web/resolver/mutation.go @@ -882,7 +882,7 @@ func (r *Resolver) UpdateUserPassword(ctx context.Context, args struct { return nil, errors.New("couldn't retrieve user session") } - dbUser, err := r.App.SessionORM().FindUser(session.User.Email) + dbUser, err := r.App.AuthenticationProvider().FindUser(session.User.Email) if err != nil { return nil, err } @@ -895,11 +895,11 @@ func (r *Resolver) UpdateUserPassword(ctx context.Context, args struct { }), nil } - if err = r.App.SessionORM().ClearNonCurrentSessions(session.SessionID); err != nil { + if err = r.App.AuthenticationProvider().ClearNonCurrentSessions(session.SessionID); err != nil { return nil, clearSessionsError{} } - err = r.App.SessionORM().SetPassword(&dbUser, args.Input.NewPassword) + err = r.App.AuthenticationProvider().SetPassword(&dbUser, args.Input.NewPassword) if err != nil { return nil, failedPasswordUpdateError{} } @@ -937,12 +937,13 @@ func (r *Resolver) CreateAPIToken(ctx context.Context, args struct { if !ok { return nil, errors.New("Failed to obtain current user from context") } - dbUser, err := r.App.SessionORM().FindUser(session.User.Email) + dbUser, err := r.App.AuthenticationProvider().FindUser(session.User.Email) if err != nil { return nil, err } - if !utils.CheckPasswordHash(args.Input.Password, dbUser.HashedPassword) { + err = r.App.AuthenticationProvider().TestPassword(dbUser.Email, args.Input.Password) + if err != nil { r.App.GetAuditLogger().Audit(audit.APITokenCreateAttemptPasswordMismatch, map[string]interface{}{"user": dbUser.Email}) return NewCreateAPITokenPayload(nil, map[string]string{ @@ -950,7 +951,7 @@ func (r *Resolver) CreateAPIToken(ctx context.Context, args struct { }), nil } - newToken, err := r.App.SessionORM().CreateAndSetAuthToken(&dbUser) + newToken, err := r.App.AuthenticationProvider().CreateAndSetAuthToken(&dbUser) if err != nil { return nil, err } @@ -970,12 +971,13 @@ func (r *Resolver) DeleteAPIToken(ctx context.Context, args struct { if !ok { return nil, errors.New("Failed to obtain current user from context") } - dbUser, err := r.App.SessionORM().FindUser(session.User.Email) + dbUser, err := r.App.AuthenticationProvider().FindUser(session.User.Email) if err != nil { return nil, err } - if !utils.CheckPasswordHash(args.Input.Password, dbUser.HashedPassword) { + err = r.App.AuthenticationProvider().TestPassword(dbUser.Email, args.Input.Password) + if err != nil { r.App.GetAuditLogger().Audit(audit.APITokenDeleteAttemptPasswordMismatch, map[string]interface{}{"user": dbUser.Email}) return NewDeleteAPITokenPayload(nil, map[string]string{ @@ -983,7 +985,7 @@ func (r *Resolver) DeleteAPIToken(ctx context.Context, args struct { }), nil } - err = r.App.SessionORM().DeleteAuthToken(&dbUser) + err = r.App.AuthenticationProvider().DeleteAuthToken(&dbUser) if err != nil { return nil, err } diff --git a/core/web/resolver/resolver_test.go b/core/web/resolver/resolver_test.go index fa8471c5e2b..85c495faaae 100644 --- a/core/web/resolver/resolver_test.go +++ b/core/web/resolver/resolver_test.go @@ -27,7 +27,7 @@ import ( pipelineMocks "github.com/smartcontractkit/chainlink/v2/core/services/pipeline/mocks" webhookmocks "github.com/smartcontractkit/chainlink/v2/core/services/webhook/mocks" clsessions "github.com/smartcontractkit/chainlink/v2/core/sessions" - sessionsMocks "github.com/smartcontractkit/chainlink/v2/core/sessions/mocks" + authProviderMocks "github.com/smartcontractkit/chainlink/v2/core/sessions/mocks" "github.com/smartcontractkit/chainlink/v2/core/web/auth" "github.com/smartcontractkit/chainlink/v2/core/web/loader" "github.com/smartcontractkit/chainlink/v2/core/web/schema" @@ -37,7 +37,7 @@ type mocks struct { bridgeORM *bridgeORMMocks.ORM evmORM *evmtest.TestConfigs jobORM *jobORMMocks.ORM - sessionsORM *sessionsMocks.ORM + authProvider *authProviderMocks.AuthenticationProvider pipelineORM *pipelineMocks.ORM feedsSvc *feedsMocks.Service cfg *chainlinkMocks.GeneralConfig @@ -97,7 +97,7 @@ func setupFramework(t *testing.T) *gqlTestFramework { evmORM: evmtest.NewTestConfigs(), jobORM: jobORMMocks.NewORM(t), feedsSvc: feedsMocks.NewService(t), - sessionsORM: sessionsMocks.NewORM(t), + authProvider: authProviderMocks.NewAuthenticationProvider(t), pipelineORM: pipelineMocks.NewORM(t), cfg: chainlinkMocks.NewGeneralConfig(t), scfg: evmConfigMocks.NewChainScopedConfig(t), diff --git a/core/web/resolver/testdata/config-empty-effective.toml b/core/web/resolver/testdata/config-empty-effective.toml index 48d432138a8..f5d775fe744 100644 --- a/core/web/resolver/testdata/config-empty-effective.toml +++ b/core/web/resolver/testdata/config-empty-effective.toml @@ -61,6 +61,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -73,6 +74,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml index f44f119075d..95d898c353b 100644 --- a/core/web/resolver/testdata/config-full.toml +++ b/core/web/resolver/testdata/config-full.toml @@ -67,6 +67,7 @@ MaxAgeDays = 17 MaxBackups = 9 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = '*' BridgeResponseURL = 'https://bridge.response' BridgeCacheTTL = '10s' @@ -79,6 +80,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '192.158.1.37' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = 'test-rpid' RPOrigin = 'test-rp-origin' diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml index 1dcbfe3a830..9dd0be8f5d2 100644 --- a/core/web/resolver/testdata/config-multi-chain-effective.toml +++ b/core/web/resolver/testdata/config-multi-chain-effective.toml @@ -61,6 +61,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -73,6 +74,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/core/web/resolver/user_test.go b/core/web/resolver/user_test.go index e3808eebcbb..bc64beeb459 100644 --- a/core/web/resolver/user_test.go +++ b/core/web/resolver/user_test.go @@ -53,10 +53,10 @@ func TestResolver_UpdateUserPassword(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("SetPassword", session.User, "new").Return(nil) - f.Mocks.sessionsORM.On("ClearNonCurrentSessions", session.SessionID).Return(nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("SetPassword", session.User, "new").Return(nil) + f.Mocks.authProvider.On("ClearNonCurrentSessions", session.SessionID).Return(nil) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -79,8 +79,8 @@ func TestResolver_UpdateUserPassword(t *testing.T) { session.User.HashedPassword = "random-string" - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -108,11 +108,11 @@ func TestResolver_UpdateUserPassword(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("ClearNonCurrentSessions", session.SessionID).Return( + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("ClearNonCurrentSessions", session.SessionID).Return( clearSessionsError{}, ) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, @@ -139,10 +139,10 @@ func TestResolver_UpdateUserPassword(t *testing.T) { session.User.HashedPassword = pwd - f.Mocks.sessionsORM.On("FindUser", session.User.Email).Return(*session.User, nil) - f.Mocks.sessionsORM.On("ClearNonCurrentSessions", session.SessionID).Return(nil) - f.Mocks.sessionsORM.On("SetPassword", session.User, "new").Return(failedPasswordUpdateError{}) - f.App.On("SessionORM").Return(f.Mocks.sessionsORM) + f.Mocks.authProvider.On("FindUser", session.User.Email).Return(*session.User, nil) + f.Mocks.authProvider.On("ClearNonCurrentSessions", session.SessionID).Return(nil) + f.Mocks.authProvider.On("SetPassword", session.User, "new").Return(failedPasswordUpdateError{}) + f.App.On("AuthenticationProvider").Return(f.Mocks.authProvider) }, query: mutation, variables: variables, diff --git a/core/web/router.go b/core/web/router.go index a873f14b708..28bd4f2170c 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -90,7 +90,7 @@ func NewRouter(app chainlink.Application, prometheus *ginprom.Prometheus) (*gin. guiAssetRoutes(engine, config.Insecure().DisableRateLimiting(), app.GetLogger()) api.POST("/query", - auth.AuthenticateGQL(app.SessionORM(), app.GetLogger().Named("GQLHandler")), + auth.AuthenticateGQL(app.AuthenticationProvider(), app.GetLogger().Named("GQLHandler")), loader.Middleware(app), graphqlHandler(app), ) @@ -170,7 +170,7 @@ func secureMiddleware(tlsRedirect bool, tlsHost string, devWebServer bool) gin.H } func debugRoutes(app chainlink.Application, r *gin.RouterGroup) { - group := r.Group("/debug", auth.Authenticate(app.SessionORM(), auth.AuthenticateBySession)) + group := r.Group("/debug", auth.Authenticate(app.AuthenticationProvider(), auth.AuthenticateBySession)) group.GET("/vars", expvar.Handler()) } @@ -207,7 +207,7 @@ func sessionRoutes(app chainlink.Application, r *gin.RouterGroup) { )) sc := NewSessionsController(app) unauth.POST("/sessions", sc.Create) - auth := r.Group("/", auth.Authenticate(app.SessionORM(), auth.AuthenticateBySession)) + auth := r.Group("/", auth.Authenticate(app.AuthenticationProvider(), auth.AuthenticateBySession)) auth.DELETE("/sessions", sc.Destroy) } @@ -231,7 +231,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { psec := PipelineJobSpecErrorsController{app} unauthedv2.PATCH("/resume/:runID", prc.Resume) - authv2 := r.Group("/v2", auth.Authenticate(app.SessionORM(), + authv2 := r.Group("/v2", auth.Authenticate(app.AuthenticationProvider(), auth.AuthenticateByToken, auth.AuthenticateBySession, )) @@ -301,7 +301,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { // duplicated from above, with `evm` instead of `eth` // legacy ones remain for backwards compatibility - ethKeysGroup := authv2.Group("", auth.Authenticate(app.SessionORM(), + ethKeysGroup := authv2.Group("", auth.Authenticate(app.AuthenticationProvider(), auth.AuthenticateByToken, auth.AuthenticateBySession, )) @@ -427,7 +427,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { } ping := PingController{app} - userOrEI := r.Group("/v2", auth.Authenticate(app.SessionORM(), + userOrEI := r.Group("/v2", auth.Authenticate(app.AuthenticationProvider(), auth.AuthenticateExternalInitiator, auth.AuthenticateByToken, auth.AuthenticateBySession, diff --git a/core/web/sessions_controller.go b/core/web/sessions_controller.go index 6f029456bd1..23ecfd3b798 100644 --- a/core/web/sessions_controller.go +++ b/core/web/sessions_controller.go @@ -39,7 +39,7 @@ func (sc *SessionsController) Create(c *gin.Context) { } // Does this user have 2FA enabled? - userWebAuthnTokens, err := sc.App.SessionORM().GetUserWebAuthn(sr.Email) + userWebAuthnTokens, err := sc.App.AuthenticationProvider().GetUserWebAuthn(sr.Email) if err != nil { sc.App.GetLogger().Errorf("Error loading user WebAuthn data: %s", err) jsonAPIError(c, http.StatusInternalServerError, errors.New("internal Server Error")) @@ -53,7 +53,7 @@ func (sc *SessionsController) Create(c *gin.Context) { sr.WebAuthnConfig = sc.App.GetWebAuthnConfiguration() } - sid, err := sc.App.SessionORM().CreateSession(sr) + sid, err := sc.App.AuthenticationProvider().CreateSession(sr) if err != nil { jsonAPIError(c, http.StatusUnauthorized, err) return @@ -78,7 +78,7 @@ func (sc *SessionsController) Destroy(c *gin.Context) { jsonAPIResponse(c, Session{Authenticated: false}, "session") return } - if err := sc.App.SessionORM().DeleteUserSession(sessionID); err != nil { + if err := sc.App.AuthenticationProvider().DeleteUserSession(sessionID); err != nil { jsonAPIError(c, http.StatusInternalServerError, err) return } diff --git a/core/web/sessions_controller_test.go b/core/web/sessions_controller_test.go index 7184e3f95b4..c2950caf3d1 100644 --- a/core/web/sessions_controller_test.go +++ b/core/web/sessions_controller_test.go @@ -27,7 +27,7 @@ func TestSessionsController_Create(t *testing.T) { require.NoError(t, app.Start(testutils.Context(t))) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) client := clhttptest.NewTestLocalOnlyHTTPClient() tests := []struct { @@ -59,7 +59,7 @@ func TestSessionsController_Create(t *testing.T) { decrypted, err := cltest.DecodeSessionCookie(sessionCookie.Value) require.NoError(t, err) - user, err := app.SessionORM().AuthorizedUserWithSession(decrypted) + user, err := app.AuthenticationProvider().AuthorizedUserWithSession(decrypted) assert.NoError(t, err) assert.Equal(t, test.email, user.Email) @@ -69,7 +69,7 @@ func TestSessionsController_Create(t *testing.T) { } else { require.True(t, resp.StatusCode >= 400, "Should not be able to create session") // Ignore fixture session - sessions, err := app.SessionORM().Sessions(1, 2) + sessions, err := app.AuthenticationProvider().Sessions(1, 2) assert.NoError(t, err) assert.Empty(t, sessions) } @@ -90,7 +90,7 @@ func TestSessionsController_Create_ReapSessions(t *testing.T) { require.NoError(t, app.Start(testutils.Context(t))) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) staleSession := cltest.NewSession() staleSession.LastUsed = time.Now().Add(-cltest.MustParseDuration(t, "241h")) @@ -107,7 +107,7 @@ func TestSessionsController_Create_ReapSessions(t *testing.T) { var s []sessions.Session gomega.NewWithT(t).Eventually(func() []sessions.Session { - s, err = app.SessionORM().Sessions(0, 10) + s, err = app.AuthenticationProvider().Sessions(0, 10) assert.NoError(t, err) return s }).Should(gomega.HaveLen(1)) @@ -124,7 +124,7 @@ func TestSessionsController_Destroy(t *testing.T) { require.NoError(t, app.Start(testutils.Context(t))) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) correctSession := sessions.NewSession() correctSession.Email = user.Email @@ -150,7 +150,7 @@ func TestSessionsController_Destroy(t *testing.T) { resp, err := client.Do(request) assert.NoError(t, err) - _, err = app.SessionORM().AuthorizedUserWithSession(test.sessionID) + _, err = app.AuthenticationProvider().AuthorizedUserWithSession(test.sessionID) assert.Error(t, err) if test.success { assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -170,7 +170,7 @@ func TestSessionsController_Destroy_ReapSessions(t *testing.T) { require.NoError(t, app.Start(testutils.Context(t))) user := cltest.MustRandomUser(t) - require.NoError(t, app.SessionORM().CreateUser(&user)) + require.NoError(t, app.AuthenticationProvider().CreateUser(&user)) correctSession := sessions.NewSession() correctSession.Email = user.Email @@ -192,7 +192,7 @@ func TestSessionsController_Destroy_ReapSessions(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) gomega.NewWithT(t).Eventually(func() []sessions.Session { - sessions, err := app.SessionORM().Sessions(0, 10) + sessions, err := app.AuthenticationProvider().Sessions(0, 10) assert.NoError(t, err) return sessions }).Should(gomega.HaveLen(0)) diff --git a/core/web/user_controller.go b/core/web/user_controller.go index 115971eafc7..857fff7b37f 100644 --- a/core/web/user_controller.go +++ b/core/web/user_controller.go @@ -30,10 +30,16 @@ type UpdatePasswordRequest struct { NewPassword string `json:"newPassword"` } +var errUnsupportedForAuth = errors.New("action is unsupported with configured authentication provider") + // Index lists all API users func (c *UserController) Index(ctx *gin.Context) { - users, err := c.App.SessionORM().ListUsers() + users, err := c.App.AuthenticationProvider().ListUsers() if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("Unable to list users", "err", err) jsonAPIError(ctx, http.StatusInternalServerError, err) return @@ -76,7 +82,7 @@ func (c *UserController) Create(ctx *gin.Context) { jsonAPIError(ctx, http.StatusBadRequest, errors.Errorf("error creating API user: %s", err)) return } - if err = c.App.SessionORM().CreateUser(&user); err != nil { + if err = c.App.AuthenticationProvider().CreateUser(&user); err != nil { // If this is a duplicate key error (code 23505), return a nicer error message var pgErr *pgconn.PgError if ok := errors.As(err, &pgErr); ok { @@ -85,6 +91,10 @@ func (c *UserController) Create(ctx *gin.Context) { return } } + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("Error creating new API user", "err", err) jsonAPIError(ctx, http.StatusInternalServerError, errors.New("error creating API user")) return @@ -132,8 +142,12 @@ func (c *UserController) UpdateRole(ctx *gin.Context) { return } - user, err := c.App.SessionORM().UpdateRole(request.Email, request.NewRole) + user, err := c.App.AuthenticationProvider().UpdateRole(request.Email, request.NewRole) if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } jsonAPIError(ctx, http.StatusInternalServerError, errors.Wrap(err, "error updating API user")) return } @@ -146,8 +160,12 @@ func (c *UserController) Delete(ctx *gin.Context) { email := ctx.Param("email") // Attempt find user by email - user, err := c.App.SessionORM().FindUser(email) + user, err := c.App.AuthenticationProvider().FindUser(email) if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } jsonAPIError(ctx, http.StatusBadRequest, errors.Errorf("specified user not found: %s", email)) return } @@ -163,7 +181,11 @@ func (c *UserController) Delete(ctx *gin.Context) { return } - if err = c.App.SessionORM().DeleteUser(email); err != nil { + if err = c.App.AuthenticationProvider().DeleteUser(email); err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("Error deleting API user", "err", err) jsonAPIError(ctx, http.StatusInternalServerError, errors.New("error deleting API user")) return @@ -185,8 +207,12 @@ func (c *UserController) UpdatePassword(ctx *gin.Context) { jsonAPIError(ctx, http.StatusInternalServerError, errors.New("failed to obtain current user from context")) return } - user, err := c.App.SessionORM().FindUser(sessionUser.Email) + user, err := c.App.AuthenticationProvider().FindUser(sessionUser.Email) if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("failed to obtain current user record: %s", err) jsonAPIError(ctx, http.StatusInternalServerError, errors.New("unable to update password")) return @@ -222,19 +248,29 @@ func (c *UserController) NewAPIToken(ctx *gin.Context) { jsonAPIError(ctx, http.StatusInternalServerError, errors.New("failed to obtain current user from context")) return } - user, err := c.App.SessionORM().FindUser(sessionUser.Email) + user, err := c.App.AuthenticationProvider().FindUser(sessionUser.Email) if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("failed to obtain current user record: %s", err) - jsonAPIError(ctx, http.StatusInternalServerError, errors.New("unable to creatae API token")) + jsonAPIError(ctx, http.StatusInternalServerError, errors.New("unable to create API token")) return } - if !utils.CheckPasswordHash(request.Password, user.HashedPassword) { + // In order to create an API token, login validation with provided password must succeed + err = c.App.AuthenticationProvider().TestPassword(sessionUser.Email, request.Password) + if err != nil { c.App.GetAuditLogger().Audit(audit.APITokenCreateAttemptPasswordMismatch, map[string]interface{}{"user": user.Email}) jsonAPIError(ctx, http.StatusUnauthorized, errors.New("incorrect password")) return } newToken := auth.NewToken() - if err := c.App.SessionORM().SetAuthToken(&user, newToken); err != nil { + if err := c.App.AuthenticationProvider().SetAuthToken(&user, newToken); err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } jsonAPIError(ctx, http.StatusInternalServerError, err) return } @@ -256,18 +292,27 @@ func (c *UserController) DeleteAPIToken(ctx *gin.Context) { jsonAPIError(ctx, http.StatusInternalServerError, errors.New("failed to obtain current user from context")) return } - user, err := c.App.SessionORM().FindUser(sessionUser.Email) + user, err := c.App.AuthenticationProvider().FindUser(sessionUser.Email) if err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } c.App.GetLogger().Errorf("failed to obtain current user record: %s", err) jsonAPIError(ctx, http.StatusInternalServerError, errors.New("unable to delete API token")) return } - if !utils.CheckPasswordHash(request.Password, user.HashedPassword) { + err = c.App.AuthenticationProvider().TestPassword(sessionUser.Email, request.Password) + if err != nil { c.App.GetAuditLogger().Audit(audit.APITokenDeleteAttemptPasswordMismatch, map[string]interface{}{"user": user.Email}) jsonAPIError(ctx, http.StatusUnauthorized, errors.New("incorrect password")) return } - if err := c.App.SessionORM().DeleteAuthToken(&user); err != nil { + if err := c.App.AuthenticationProvider().DeleteAuthToken(&user); err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + jsonAPIError(ctx, http.StatusBadRequest, errUnsupportedForAuth) + return + } jsonAPIError(ctx, http.StatusInternalServerError, err) return } @@ -291,12 +336,15 @@ func (c *UserController) updateUserPassword(ctx *gin.Context, user *clsession.Us if err != nil { return err } - orm := c.App.SessionORM() + orm := c.App.AuthenticationProvider() if err := orm.ClearNonCurrentSessions(sessionID); err != nil { c.App.GetLogger().Errorf("failed to clear non current user sessions: %s", err) return errors.New("unable to update password") } if err := orm.SetPassword(user, newPassword); err != nil { + if errors.Is(err, clsession.ErrNotSupported) { + return errUnsupportedForAuth + } c.App.GetLogger().Errorf("failed to update current user password: %s", err) return errors.New("unable to update password") } diff --git a/core/web/user_controller_test.go b/core/web/user_controller_test.go index a11082ff6a4..6baab1c396a 100644 --- a/core/web/user_controller_test.go +++ b/core/web/user_controller_test.go @@ -188,7 +188,7 @@ func TestUserController_UpdateRole(t *testing.T) { client := app.NewHTTPClient(nil) user := cltest.MustRandomUser(t) - err := app.SessionORM().CreateUser(&user) + err := app.AuthenticationProvider().CreateUser(&user) require.NoError(t, err) testCases := []struct { @@ -235,7 +235,7 @@ func TestUserController_DeleteUser(t *testing.T) { client := app.NewHTTPClient(nil) user := cltest.MustRandomUser(t) - err := app.SessionORM().CreateUser(&user) + err := app.AuthenticationProvider().CreateUser(&user) require.NoError(t, err) resp, cleanup := client.Delete(fmt.Sprintf("/v2/users/%s", url.QueryEscape(user.Email))) diff --git a/core/web/webauthn_controller.go b/core/web/webauthn_controller.go index 05090013237..41c8f268ad4 100644 --- a/core/web/webauthn_controller.go +++ b/core/web/webauthn_controller.go @@ -36,7 +36,7 @@ func (c *WebAuthnController) BeginRegistration(ctx *gin.Context) { return } - orm := c.App.SessionORM() + orm := c.App.AuthenticationProvider() uwas, err := orm.GetUserWebAuthn(user.Email) if err != nil { c.App.GetLogger().Errorf("failed to obtain current user MFA tokens: error in GetUserWebAuthn: %+v", err) @@ -66,7 +66,7 @@ func (c *WebAuthnController) FinishRegistration(ctx *gin.Context) { return } - orm := c.App.SessionORM() + orm := c.App.AuthenticationProvider() uwas, err := orm.GetUserWebAuthn(user.Email) if err != nil { c.App.GetLogger().Errorf("failed to obtain current user MFA tokens: error in GetUserWebAuthn: %s", err) @@ -83,7 +83,7 @@ func (c *WebAuthnController) FinishRegistration(ctx *gin.Context) { return } - if sessions.AddCredentialToUser(c.App.SessionORM(), user.Email, credential) != nil { + if sessions.AddCredentialToUser(c.App.AuthenticationProvider(), user.Email, credential) != nil { c.App.GetLogger().Errorf("Could not save WebAuthn credential to DB for user: %s", user.Email) jsonAPIError(ctx, http.StatusInternalServerError, errors.New("internal Server Error")) return diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f3b16c1327..b5b393542be 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [dev] +### Added + +- Added a new, optional WebServer authentication option that supports LDAP as a user identity provider. This enables user login access and user roles to be managed and provisioned via a centralized remote server that supports the LDAP protocol, which can be helpful when running multiple nodes. See the documentation for more information and config setup instructions. There is a new `[WebServer].AuthenticationMethod` config option, when set to `ldap` requires the new `[WebServer.LDAP]` config section to be defined, see the reference `docs/core.toml`. + + ### Changed - `L2Suggested` mode is now called `SuggestedPrice` diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 313e7b46aaf..23508df172a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -459,6 +459,7 @@ MaxBackups determines the maximum number of old log files to retain. Keeping thi ## WebServer ```toml [WebServer] +AuthenticationMethod = 'local' # Default AllowOrigins = 'http://localhost:3000,http://localhost:6688' # Default BridgeCacheTTL = '0s' # Default BridgeResponseURL = 'https://my-chainlink-node.example.com:6688' # Example @@ -473,6 +474,12 @@ ListenIP = '0.0.0.0' # Default ``` +### AuthenticationMethod +```toml +AuthenticationMethod = 'local' # Default +``` +AuthenticationMethod defines which pluggable auth interface to use for user login and role assumption. Options include 'local' and 'ldap'. See docs for more details + ### AllowOrigins ```toml AllowOrigins = 'http://localhost:3000,http://localhost:6688' # Default @@ -546,6 +553,132 @@ ListenIP = '0.0.0.0' # Default ``` ListenIP specifies the IP to bind the HTTP server to +## WebServer.LDAP +```toml +[WebServer.LDAP] +ServerTLS = true # Default +SessionTimeout = '15m0s' # Default +QueryTimeout = '2m0s' # Default +BaseUserAttr = 'uid' # Default +BaseDN = 'dc=custom,dc=example,dc=com' # Example +UsersDN = 'ou=users' # Default +GroupsDN = 'ou=groups' # Default +ActiveAttribute = '' # Default +ActiveAttributeAllowedValue = '' # Default +AdminUserGroupCN = 'NodeAdmins' # Default +EditUserGroupCN = 'NodeEditors' # Default +RunUserGroupCN = 'NodeRunners' # Default +ReadUserGroupCN = 'NodeReadOnly' # Default +UserApiTokenEnabled = false # Default +UserAPITokenDuration = '240h0m0s' # Default +UpstreamSyncInterval = '0s' # Default +UpstreamSyncRateLimit = '2m0s' # Default +``` +Optional LDAP config if WebServer.AuthenticationMethod is set to 'ldap' +LDAP queries are all parameterized to support custom LDAP 'dn', 'cn', and attributes + +### ServerTLS +```toml +ServerTLS = true # Default +``` +ServerTLS defines the option to require the secure ldaps + +### SessionTimeout +```toml +SessionTimeout = '15m0s' # Default +``` +SessionTimeout determines the amount of idle time to elapse before session cookies expire. This signs out GUI users from their sessions. + +### QueryTimeout +```toml +QueryTimeout = '2m0s' # Default +``` +QueryTimeout defines how long queries should wait before timing out, defined in seconds + +### BaseUserAttr +```toml +BaseUserAttr = 'uid' # Default +``` +BaseUserAttr defines the base attribute used to populate LDAP queries such as "uid=$", default is example + +### BaseDN +```toml +BaseDN = 'dc=custom,dc=example,dc=com' # Example +``` +BaseDN defines the base LDAP 'dn' search filter to apply to every LDAP query, replace example,com with the appropriate LDAP server's structure + +### UsersDN +```toml +UsersDN = 'ou=users' # Default +``` +UsersDN defines the 'dn' query to use when querying for the 'users' 'ou' group + +### GroupsDN +```toml +GroupsDN = 'ou=groups' # Default +``` +GroupsDN defines the 'dn' query to use when querying for the 'groups' 'ou' group + +### ActiveAttribute +```toml +ActiveAttribute = '' # Default +``` +ActiveAttribute is an optional user field to check truthiness for if a user is valid/active. This is only required if the LDAP provider lists inactive users as members of groups + +### ActiveAttributeAllowedValue +```toml +ActiveAttributeAllowedValue = '' # Default +``` +ActiveAttributeAllowedValue is the value to check against for the above optional user attribute + +### AdminUserGroupCN +```toml +AdminUserGroupCN = 'NodeAdmins' # Default +``` +AdminUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Admin' role + +### EditUserGroupCN +```toml +EditUserGroupCN = 'NodeEditors' # Default +``` +EditUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Edit' role + +### RunUserGroupCN +```toml +RunUserGroupCN = 'NodeRunners' # Default +``` +RunUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Run' role + +### ReadUserGroupCN +```toml +ReadUserGroupCN = 'NodeReadOnly' # Default +``` +ReadUserGroupCN is the LDAP 'cn' of the LDAP group that maps the core node's 'Read' role + +### UserApiTokenEnabled +```toml +UserApiTokenEnabled = false # Default +``` +UserApiTokenEnabled enables the users to issue API tokens with the same access of their role + +### UserAPITokenDuration +```toml +UserAPITokenDuration = '240h0m0s' # Default +``` +UserAPITokenDuration is the duration of time an API token is active for before expiring + +### UpstreamSyncInterval +```toml +UpstreamSyncInterval = '0s' # Default +``` +UpstreamSyncInterval is the interval at which the background LDAP sync task will be called. A '0s' value disables the background sync being run on an interval. This check is already performed during login/logout actions, all sessions and API tokens stored in the local ldap tables are updated to match the remote server + +### UpstreamSyncRateLimit +```toml +UpstreamSyncRateLimit = '2m0s' # Default +``` +UpstreamSyncRateLimit defines a duration to limit the number of query/API calls to the upstream LDAP provider. It prevents the sync functionality from being called multiple times within the defined duration + ## WebServer.RateLimit ```toml [WebServer.RateLimit] diff --git a/docs/SECRETS.md b/docs/SECRETS.md index af316cab14b..fa7ba76df42 100644 --- a/docs/SECRETS.md +++ b/docs/SECRETS.md @@ -51,6 +51,33 @@ AllowSimplePasswords skips the password complexity check normally enforced on UR Environment variable: `CL_DATABASE_ALLOW_SIMPLE_PASSWORDS` +## WebServer.LDAP +```toml +[WebServer.LDAP] +ServerAddress = 'ldaps://127.0.0.1' # Example +ReadOnlyUserLogin = 'viewer@example.com' # Example +ReadOnlyUserPass = 'password' # Example +``` +Optional LDAP config + +### ServerAddress +```toml +ServerAddress = 'ldaps://127.0.0.1' # Example +``` +ServerAddress is the full ldaps:// address of the ldap server to authenticate with and query + +### ReadOnlyUserLogin +```toml +ReadOnlyUserLogin = 'viewer@example.com' # Example +``` +ReadOnlyUserLogin is the username of the read only root user used to authenticate the requested LDAP queries + +### ReadOnlyUserPass +```toml +ReadOnlyUserPass = 'password' # Example +``` +ReadOnlyUserPass is the password for the above account + ## Password ```toml [Password] diff --git a/go.mod b/go.mod index 820e42c3308..999c1b0402f 100644 --- a/go.mod +++ b/go.mod @@ -114,6 +114,7 @@ require ( filippo.io/edwards25519 v1.0.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.1 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/ChainSafe/go-schnorrkel v0.0.0-20200405005733-88cbf1b4c40d // indirect github.com/CosmWasm/wasmd v0.40.1 // indirect github.com/CosmWasm/wasmvm v1.2.4 // indirect @@ -169,8 +170,10 @@ require ( github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 // indirect github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-kit/kit v0.12.0 // indirect github.com/go-kit/log v0.2.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.5 github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 155e54646d7..ee06cc9b751 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/AndreasBriese/bbloom v0.0.0-20180913140656-343706a395b7/go.mod h1:bOv github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -129,6 +131,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= @@ -419,6 +423,8 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -433,6 +439,8 @@ github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEai github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8= +github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -1748,6 +1756,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1788,6 +1797,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1850,6 +1860,7 @@ golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1876,6 +1887,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1980,6 +1992,7 @@ golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1993,6 +2006,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2068,6 +2082,7 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 127980a2cb9..93820c6ebfe 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -58,6 +58,7 @@ require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/ChainSafe/go-schnorrkel v0.0.0-20200405005733-88cbf1b4c40d // indirect github.com/CosmWasm/wasmd v0.40.1 // indirect github.com/CosmWasm/wasmvm v1.2.4 // indirect @@ -152,9 +153,11 @@ require ( github.com/gin-contrib/sessions v0.0.5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-kit/kit v0.12.0 // indirect github.com/go-kit/log v0.2.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/integration-tests/go.sum b/integration-tests/go.sum index 24da9467176..60805eae825 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -575,6 +575,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -641,6 +643,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= @@ -1027,6 +1031,8 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -1047,6 +1053,8 @@ github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-ldap/ldap/v3 v3.4.5 h1:ekEKmaDrpvR2yf5Nc/DClsGG9lAmdDixe44mLzlW5r8= +github.com/go-ldap/ldap/v3 v3.4.5/go.mod h1:bMGIq3AGbytbaMwf8wdv5Phdxz0FWHTIYMSzyrYgnQs= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= @@ -2713,6 +2721,7 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/testdata/scripts/node/validate/default.txtar b/testdata/scripts/node/validate/default.txtar index 189476bfa84..8a3b1af96fa 100644 --- a/testdata/scripts/node/validate/default.txtar +++ b/testdata/scripts/node/validate/default.txtar @@ -73,6 +73,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -85,6 +86,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar index 593aa0b21d0..31fded1b423 100644 --- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar @@ -117,6 +117,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -129,6 +130,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar index 7b8aa5e3836..78fc976912c 100644 --- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar @@ -117,6 +117,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -129,6 +130,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar index ef6548619e1..226a7bbb3b4 100644 --- a/testdata/scripts/node/validate/disk-based-logging.txtar +++ b/testdata/scripts/node/validate/disk-based-logging.txtar @@ -117,6 +117,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -129,6 +130,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar index 87b877bc882..5cd3d567467 100644 --- a/testdata/scripts/node/validate/invalid.txtar +++ b/testdata/scripts/node/validate/invalid.txtar @@ -107,6 +107,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -119,6 +120,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar index c607da10644..fd24150b587 100644 --- a/testdata/scripts/node/validate/valid.txtar +++ b/testdata/scripts/node/validate/valid.txtar @@ -114,6 +114,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -126,6 +127,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = '' diff --git a/testdata/scripts/node/validate/warnings.txtar b/testdata/scripts/node/validate/warnings.txtar index ee7926f8f5f..828d953da9a 100644 --- a/testdata/scripts/node/validate/warnings.txtar +++ b/testdata/scripts/node/validate/warnings.txtar @@ -110,6 +110,7 @@ MaxAgeDays = 0 MaxBackups = 1 [WebServer] +AuthenticationMethod = 'local' AllowOrigins = 'http://localhost:3000,http://localhost:6688' BridgeResponseURL = '' BridgeCacheTTL = '0s' @@ -122,6 +123,25 @@ HTTPMaxSize = '32.77kb' StartTimeout = '15s' ListenIP = '0.0.0.0' +[WebServer.LDAP] +ServerTLS = true +SessionTimeout = '15m0s' +QueryTimeout = '2m0s' +BaseUserAttr = 'uid' +BaseDN = '' +UsersDN = 'ou=users' +GroupsDN = 'ou=groups' +ActiveAttribute = '' +ActiveAttributeAllowedValue = '' +AdminUserGroupCN = 'NodeAdmins' +EditUserGroupCN = 'NodeEditors' +RunUserGroupCN = 'NodeRunners' +ReadUserGroupCN = 'NodeReadOnly' +UserApiTokenEnabled = false +UserAPITokenDuration = '240h0m0s' +UpstreamSyncInterval = '0s' +UpstreamSyncRateLimit = '2m0s' + [WebServer.MFA] RPID = '' RPOrigin = ''