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 = ''