diff --git a/cmd/authd/daemon/daemon.go b/cmd/authd/daemon/daemon.go index a7aaed4c9a..75381487f2 100644 --- a/cmd/authd/daemon/daemon.go +++ b/cmd/authd/daemon/daemon.go @@ -23,6 +23,9 @@ const cmdName = "authd" // oldDBDir is the path of the old DB directory. var oldDBDir = consts.OldDBDir +// pamDDirs are the directories containing PAM service configuration files. +var pamDDirs = []string{"/etc/pam.d", "/usr/lib/pam.d"} + // App encapsulate commands and options of the daemon, which can be controlled by env variables and config files. type App struct { rootCmd cobra.Command @@ -47,7 +50,7 @@ type daemonConfig struct { Verbosity int Paths systemPaths UsersConfig *users.Config `mapstructure:",squash" yaml:",inline"` - PAMConfig *pam.Config `mapstructure:",squash" yaml:",inline"` + PAMConfig *pam.Config `mapstructure:"pam" yaml:"pam"` } type options struct { @@ -107,6 +110,10 @@ func New(args ...Option) *App { setVerboseMode(a.config.Verbosity) log.Debugf(context.Background(), "Verbosity: %d", a.config.Verbosity) + if a.config.PAMConfig != nil { + a.config.PAMConfig.WarnOnUnknownServices(context.Background(), pamDDirs) + } + // If we are only checking the configuration, we exit now. if check, _ := cmd.Flags().GetBool("check-config"); check { return nil diff --git a/cmd/authd/daemon/daemon_test.go b/cmd/authd/daemon/daemon_test.go index 9e6b5afd8a..011be73123 100644 --- a/cmd/authd/daemon/daemon_test.go +++ b/cmd/authd/daemon/daemon_test.go @@ -14,6 +14,7 @@ import ( "github.com/canonical/authd/cmd/authd/daemon" "github.com/canonical/authd/internal/consts" "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/services/pam" "github.com/canonical/authd/internal/testutils" "github.com/canonical/authd/internal/users" userslocking "github.com/canonical/authd/internal/users/locking" @@ -311,6 +312,7 @@ func TestNoConfigSetDefaults(t *testing.T) { require.Equal(t, consts.DefaultBrokersConfPath, a.Config().Paths.BrokersConf, "Default brokers configuration path") require.Equal(t, consts.DefaultDatabaseDir, a.Config().Paths.Database, "Default database directory") require.Equal(t, &users.DefaultConfig, a.Config().UsersConfig, "Default Users Config") + require.Equal(t, &pam.DefaultConfig, a.Config().PAMConfig, "Default PAM Config") require.Equal(t, "", a.Config().Paths.Socket, "No socket address as default") } diff --git a/debian/authd-config/authd.yaml b/debian/authd-config/authd.yaml index 66cb8d0902..ae2001d0a9 100644 --- a/debian/authd-config/authd.yaml +++ b/debian/authd-config/authd.yaml @@ -23,19 +23,27 @@ #GID_MIN: 10000 #GID_MAX: 60000 -## Brute-force mitigation settings for authentication failures. -## To disable brute-force mitigation entirely, set auth_fail_delay to 0. +## PAM service settings. ## -## auth_fail_delay_threshold: number of consecutive failures for a single user -## before a delay is imposed on subsequent attempts. -#auth_fail_delay_threshold: 3 +## Brute-force mitigation settings applied to all PAM services by default. +## To disable entirely, set auth_fail_delay to 0. ## -## auth_fail_delay: duration of the delay imposed once the threshold is reached. -## Accepts durations like "2s", "500ms", "1m". -#auth_fail_delay: 2s +## auth_fail_delay_threshold: number of consecutive failures for a single +## user before a delay is imposed on subsequent attempts. +## auth_fail_delay: duration of the delay imposed once the threshold is +## reached. Accepts durations like "2s", "500ms", "1m". +## auth_fail_reset_window: duration of inactivity after the last failure +## before the failure count is automatically reset. Accepts durations +## like "15m", "1h", "30s". Set to 0 to keep failures accumulated +## indefinitely (no inactivity reset). ## -## auth_fail_reset_window: duration of inactivity after the last failure before -## the failure count is automatically reset. -## Accepts durations like "15m", "1h", "30s". Set to 0 to keep failures -## accumulated indefinitely (no inactivity reset). -#auth_fail_reset_window: 15m +## Per-service overrides can be specified under the "services:" key, using +## the PAM service name (e.g. "sshd", "gdm-authd") as the sub-key. +## Only the fields that differ from the default need to be specified. +#pam: +# auth_fail_delay_threshold: 3 +# auth_fail_delay: 2s +# auth_fail_reset_window: 15m +# services: +# sshd: +# auth_fail_delay: 5s diff --git a/internal/brokers/manager.go b/internal/brokers/manager.go index f49290375e..ce861d722f 100644 --- a/internal/brokers/manager.go +++ b/internal/brokers/manager.go @@ -27,6 +27,7 @@ type Manager struct { transactionsToBroker map[string]*Broker sessionsToUsername map[string]string + sessionsToServiceName map[string]string transactionsToBrokerMu sync.RWMutex cleanup func() @@ -111,9 +112,10 @@ func NewManager(ctx context.Context, brokersConfPath string, configuredBrokers [ brokers: brokers, brokersOrder: brokersOrder, - usersToBroker: make(map[string]*Broker), - transactionsToBroker: make(map[string]*Broker), - sessionsToUsername: make(map[string]string), + usersToBroker: make(map[string]*Broker), + transactionsToBroker: make(map[string]*Broker), + sessionsToUsername: make(map[string]string), + sessionsToServiceName: make(map[string]string), cleanup: cleanup, }, nil @@ -166,7 +168,7 @@ func (m *Manager) BrokerFromSessionID(id string) (broker *Broker, err error) { } // NewSession create a new session for the broker and store the sessionID on the manager. -func (m *Manager) NewSession(brokerID, username, lang, mode, providerID string) (sessionID string, encryptionKey string, err error) { +func (m *Manager) NewSession(brokerID, username, lang, mode, providerID, serviceName string) (sessionID string, encryptionKey string, err error) { broker, err := m.BrokerFromID(brokerID) if err != nil { return "", "", fmt.Errorf("invalid broker: %v", err) @@ -183,6 +185,7 @@ func (m *Manager) NewSession(brokerID, username, lang, mode, providerID string) sessionID, mode, username) m.transactionsToBroker[sessionID] = broker m.sessionsToUsername[sessionID] = username + m.sessionsToServiceName[sessionID] = serviceName return sessionID, encryptionKey, nil } @@ -203,6 +206,7 @@ func (m *Manager) EndSession(sessionID string) error { sessionID, b.Name) delete(m.transactionsToBroker, sessionID) delete(m.sessionsToUsername, sessionID) + delete(m.sessionsToServiceName, sessionID) m.transactionsToBrokerMu.Unlock() return nil } @@ -214,6 +218,13 @@ func (m *Manager) UsernameFromSessionID(sessionID string) string { return m.sessionsToUsername[sessionID] } +// ServiceNameFromSessionID returns the PAM service name associated with the given session ID. +func (m *Manager) ServiceNameFromSessionID(sessionID string) string { + m.transactionsToBrokerMu.RLock() + defer m.transactionsToBrokerMu.RUnlock() + return m.sessionsToServiceName[sessionID] +} + // BrokerExists returns true if the brokerID is known by the manager. func (m *Manager) BrokerExists(brokerID string) bool { _, exists := m.brokers[brokerID] diff --git a/internal/brokers/manager_test.go b/internal/brokers/manager_test.go index aa8ad197b5..e6d6758ad9 100644 --- a/internal/brokers/manager_test.go +++ b/internal/brokers/manager_test.go @@ -225,7 +225,7 @@ func TestNewSession(t *testing.T) { tc.sessionMode = "auth" } - gotID, gotEKey, err := m.NewSession(tc.brokerID, tc.username, "some_lang", tc.sessionMode, "") + gotID, gotEKey, err := m.NewSession(tc.brokerID, tc.username, "some_lang", tc.sessionMode, "", "sshd") if tc.wantErr { require.Error(t, err, "NewSession should return an error, but did not") return @@ -239,6 +239,7 @@ func TestNewSession(t *testing.T) { gotBroker, err := m.BrokerFromSessionID(gotID) require.NoError(t, err, "NewSession should have assigned a broker for the session, but did not") require.Equal(t, wantBroker.ID, gotBroker.ID, "BrokerFromSessionID should have assigned the expected broker for the session, but did not") + require.Equal(t, "sshd", m.ServiceNameFromSessionID(gotID), "NewSession should remember the PAM service name for the session") }) } } @@ -322,13 +323,13 @@ func TestStartAndEndSession(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - id, key, err := m.NewSession(b1.ID, "user1@example.com", "some_lang", "auth", "") + id, key, err := m.NewSession(b1.ID, "user1@example.com", "some_lang", "auth", "", "sshd") firstID, firstKey, firstErr = &id, &key, &err }() wg.Add(1) go func() { defer wg.Done() - id, key, err := m.NewSession(b2.ID, "user2", "some_lang", "auth", "") + id, key, err := m.NewSession(b2.ID, "user2", "some_lang", "auth", "", "gdm-authd") secondID, secondKey, secondErr = &id, &key, &err }() wg.Wait() diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index c92111a902..ffe1f2d67c 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -288,6 +288,7 @@ type SBRequest struct { Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` Lang string `protobuf:"bytes,3,opt,name=lang,proto3" json:"lang,omitempty"` Mode SessionMode `protobuf:"varint,4,opt,name=mode,proto3,enum=authd.SessionMode" json:"mode,omitempty"` + ServiceName string `protobuf:"bytes,5,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -350,6 +351,13 @@ func (x *SBRequest) GetMode() SessionMode { return SessionMode_UNDEFINED } +func (x *SBRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + type SBResponse struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -2200,12 +2208,13 @@ const file_authd_proto_rawDesc = "" + "brand_icon\x18\x03 \x01(\tH\x00R\tbrandIcon\x88\x01\x01B\r\n" + "\v_brand_icon\"\"\n" + "\x0eStringResponse\x12\x10\n" + - "\x03msg\x18\x01 \x01(\tR\x03msg\"\x80\x01\n" + + "\x03msg\x18\x01 \x01(\tR\x03msg\"\xa3\x01\n" + "\tSBRequest\x12\x1b\n" + "\tbroker_id\x18\x01 \x01(\tR\bbrokerId\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x12\n" + "\x04lang\x18\x03 \x01(\tR\x04lang\x12&\n" + - "\x04mode\x18\x04 \x01(\x0e2\x12.authd.SessionModeR\x04mode\"R\n" + + "\x04mode\x18\x04 \x01(\x0e2\x12.authd.SessionModeR\x04mode\x12!\n" + + "\fservice_name\x18\x05 \x01(\tR\vserviceName\"R\n" + "\n" + "SBResponse\x12\x1d\n" + "\n" + diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index e4fcb15654..3faa4a9bc6 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -51,6 +51,7 @@ message SBRequest { string username = 2; string lang = 3; SessionMode mode = 4; + string service_name = 5; } message SBResponse { diff --git a/internal/services/pam/auth_fail_tracker_test.go b/internal/services/pam/auth_fail_tracker_test.go index 2d9e6758bf..f761191e3a 100644 --- a/internal/services/pam/auth_fail_tracker_test.go +++ b/internal/services/pam/auth_fail_tracker_test.go @@ -10,33 +10,35 @@ import ( func TestAuthFailTracker_ResetWindow_Zero_DisablesReset(t *testing.T) { t.Parallel() - tracker := newAuthFailTracker(Config{ + cfg := BruteForceMitigationConfig{ AuthFailDelayThreshold: 3, AuthFailDelay: time.Second, AuthFailResetWindow: 0, - }) + } + tracker := newAuthFailTracker() // Three consecutive failures should each increment the counter rather than // resetting it. With the bug (resetWindow == 0 always resets), count would // stay at 1 on every call. - require.Equal(t, 1, tracker.recordFailure("user"), "first failure") - require.Equal(t, 2, tracker.recordFailure("user"), "second failure") - require.Equal(t, 3, tracker.recordFailure("user"), "third failure: counter must not have been reset") + require.Equal(t, 1, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "first failure") + require.Equal(t, 2, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "second failure") + require.Equal(t, 3, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "third failure: counter must not have been reset") } func TestAuthFailTracker_ResetWindow_NonZero_ResetsAfterInactivity(t *testing.T) { t.Parallel() - tracker := newAuthFailTracker(Config{ + cfg := BruteForceMitigationConfig{ AuthFailDelayThreshold: 3, AuthFailDelay: time.Second, AuthFailResetWindow: 50 * time.Millisecond, - }) + } + tracker := newAuthFailTracker() - require.Equal(t, 1, tracker.recordFailure("user"), "first failure") - require.Equal(t, 2, tracker.recordFailure("user"), "second failure") + require.Equal(t, 1, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "first failure") + require.Equal(t, 2, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "second failure") // After sleeping past the reset window the entry expires and the counter resets. time.Sleep(100 * time.Millisecond) - require.Equal(t, 1, tracker.recordFailure("user"), "counter should reset after inactivity") + require.Equal(t, 1, tracker.recordFailure("", "user", cfg.AuthFailResetWindow), "counter should reset after inactivity") } diff --git a/internal/services/pam/pam.go b/internal/services/pam/pam.go index e926f6c664..21a2f81444 100644 --- a/internal/services/pam/pam.go +++ b/internal/services/pam/pam.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "math" + "os" "os/user" + "path/filepath" "strings" "sync" "time" @@ -27,12 +29,12 @@ import ( var _ authd.PAMServer = Service{} -// authFailMaxTracked is the maximum number of distinct usernames tracked simultaneously -// to bound memory usage. +// authFailMaxTracked is the maximum number of distinct (service, username) pairs tracked +// simultaneously to bound memory usage. var authFailMaxTracked = 10000 -// Config holds the configurable parameters for the PAM service. -type Config struct { +// BruteForceMitigationConfig holds brute-force mitigation parameters for one PAM service context. +type BruteForceMitigationConfig struct { // AuthFailDelayThreshold is the number of consecutive authentication failures before // a delay is imposed on subsequent attempts, to mitigate brute-force attacks. AuthFailDelayThreshold int `mapstructure:"auth_fail_delay_threshold" yaml:"auth_fail_delay_threshold"` @@ -43,44 +45,119 @@ type Config struct { AuthFailResetWindow time.Duration `mapstructure:"auth_fail_reset_window" yaml:"auth_fail_reset_window"` } +// BruteForceOverride holds optional per-service overrides for BruteForceMitigationConfig. +// A nil pointer means "not set" and falls back to the default value. +type BruteForceOverride struct { + AuthFailDelayThreshold *int `mapstructure:"auth_fail_delay_threshold" yaml:"auth_fail_delay_threshold,omitempty"` + AuthFailDelay *time.Duration `mapstructure:"auth_fail_delay" yaml:"auth_fail_delay,omitempty"` + AuthFailResetWindow *time.Duration `mapstructure:"auth_fail_reset_window" yaml:"auth_fail_reset_window,omitempty"` +} + +// Config holds the configurable parameters for the PAM service. +// The BruteForceMitigationConfig fields are the defaults applied to all PAM services; +// per-service overrides can be specified in the Services map. +type Config struct { + BruteForceMitigationConfig `mapstructure:",squash" yaml:",inline"` + Services map[string]BruteForceOverride `mapstructure:"services" yaml:"services,omitempty"` +} + // DefaultConfig is the default configuration for the PAM service. var DefaultConfig = Config{ - AuthFailDelayThreshold: 3, - AuthFailDelay: 2 * time.Second, - AuthFailResetWindow: 15 * time.Minute, + BruteForceMitigationConfig: BruteForceMitigationConfig{ + AuthFailDelayThreshold: 3, + AuthFailDelay: 2 * time.Second, + AuthFailResetWindow: 15 * time.Minute, + }, + Services: map[string]BruteForceOverride{ + // SSH is a common brute-force target; use a longer delay by default. + "sshd": {AuthFailDelay: durPtr(5 * time.Second)}, + }, } +// ForService returns the BruteForceMitigationConfig for the given PAM service name, +// merging the default config with any service-specific override. +func (c Config) ForService(name string) BruteForceMitigationConfig { + override, ok := c.Services[name] + if !ok { + return c.BruteForceMitigationConfig + } + + result := c.BruteForceMitigationConfig + if override.AuthFailDelayThreshold != nil { + result.AuthFailDelayThreshold = *override.AuthFailDelayThreshold + } + if override.AuthFailDelay != nil { + result.AuthFailDelay = *override.AuthFailDelay + } + if override.AuthFailResetWindow != nil { + result.AuthFailResetWindow = *override.AuthFailResetWindow + } + return result +} + +// WarnOnUnknownServices logs a warning for each service name in cfg.Services that +// does not have a corresponding PAM configuration file in pamDDirs. +// We don't treat this as an error to avoid authd failing to start when a PAM service +// is removed from the system but still present in the config file. +func (c Config) WarnOnUnknownServices(ctx context.Context, pamDDirs []string) { + for name := range c.Services { + found := false + for _, pamDDir := range pamDDirs { + if _, err := os.Stat(filepath.Join(pamDDir, name)); err == nil { + found = true + break + } else if !os.IsNotExist(err) { + found = true + break + } + } + if !found { + log.Warningf(ctx, "PAM service %q configured in authd but not found in %s", name, strings.Join(pamDDirs, " or ")) + } + } +} + +// durPtr returns a pointer to the given duration value. +func durPtr(d time.Duration) *time.Duration { return &d } + // authFailEntry holds the failure count and the time of the most recent failure for one user. type authFailEntry struct { count int lastFail time.Time } +// authFailKey is the composite key used to track failures per (service, user) pair +// so that each service's brute-force policy is enforced independently. +type authFailKey struct { + serviceName string + username string +} + // authFailTracker counts consecutive per-user authentication failures and imposes // a delay once the threshold is reached. type authFailTracker struct { - mu sync.Mutex - entries map[string]*authFailEntry - resetWindow time.Duration + mu sync.Mutex + entries map[authFailKey]*authFailEntry } -func newAuthFailTracker(cfg Config) *authFailTracker { +func newAuthFailTracker() *authFailTracker { return &authFailTracker{ - entries: make(map[string]*authFailEntry), - resetWindow: cfg.AuthFailResetWindow, + entries: make(map[authFailKey]*authFailEntry), } } -// recordFailure increments the failure count for username and returns the new count. +// recordFailure increments the failure count for the (serviceName, username) pair +// and returns the new count. // If the previous failure is older than resetWindow the counter is reset first. // A resetWindow of 0 keeps failures accumulated indefinitely (no inactivity reset). -// When the tracker is at capacity the username is not stored, but math.MaxInt is +// When the tracker is at capacity the entry is not stored, but math.MaxInt is // returned so that the delay is still applied (fail-secure). -func (t *authFailTracker) recordFailure(username string) int { +func (t *authFailTracker) recordFailure(serviceName, username string, resetWindow time.Duration) int { t.mu.Lock() defer t.mu.Unlock() - e, ok := t.entries[username] - if ok && t.resetWindow > 0 && time.Since(e.lastFail) >= t.resetWindow { + key := authFailKey{serviceName: serviceName, username: username} + e, ok := t.entries[key] + if ok && resetWindow > 0 && time.Since(e.lastFail) >= resetWindow { // Stale entry: treat as fresh start. ok = false } @@ -93,18 +170,18 @@ func (t *authFailTracker) recordFailure(username string) int { return math.MaxInt } e = &authFailEntry{} - t.entries[username] = e + t.entries[key] = e } e.count++ e.lastFail = time.Now() return e.count } -// recordSuccess resets the failure count for username. -func (t *authFailTracker) recordSuccess(username string) { +// recordSuccess resets the failure count for the (serviceName, username) pair. +func (t *authFailTracker) recordSuccess(serviceName, username string) { t.mu.Lock() defer t.mu.Unlock() - delete(t.entries, username) + delete(t.entries, authFailKey{serviceName: serviceName, username: username}) } // Service is the implementation of the PAM module service. @@ -124,7 +201,7 @@ func NewService(ctx context.Context, userManager *users.Manager, brokerManager * return Service{ userManager: userManager, brokerManager: brokerManager, - failedAuths: newAuthFailTracker(cfg), + failedAuths: newAuthFailTracker(), authFailConfig: cfg, } } @@ -259,7 +336,7 @@ func (s Service) SelectBroker(ctx context.Context, req *authd.SBRequest) (resp * } // Create a session and Memorize selected broker for it. - sessionID, encryptionKey, err := s.brokerManager.NewSession(brokerID, username, lang, mode, userProviderID) + sessionID, encryptionKey, err := s.brokerManager.NewSession(brokerID, username, lang, mode, userProviderID, req.GetServiceName()) if err != nil { log.Errorf(ctx, "SelectBroker: Could not create session for user %q with broker %q: %v", username, brokerID, err) return nil, err @@ -378,12 +455,14 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res log.Debugf(ctx, "%s: Authentication result: %s", sessionID, access) username := s.brokerManager.UsernameFromSessionID(sessionID) + serviceName := s.brokerManager.ServiceNameFromSessionID(sessionID) + bfCfg := s.authFailConfig.ForService(serviceName) if access != auth.Granted { if access == auth.Denied || access == auth.DeniedMaxTries || access == auth.Retry { - if count := s.failedAuths.recordFailure(username); count > s.authFailConfig.AuthFailDelayThreshold { + if count := s.failedAuths.recordFailure(serviceName, username, bfCfg.AuthFailResetWindow); count > bfCfg.AuthFailDelayThreshold { log.Debugf(ctx, "%s: Delaying response after %d consecutive authentication failures for %q", sessionID, count, username) - timer := time.NewTimer(s.authFailConfig.AuthFailDelay) + timer := time.NewTimer(bfCfg.AuthFailDelay) select { case <-timer.C: case <-ctx.Done(): @@ -467,7 +546,7 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res } } - s.failedAuths.recordSuccess(username) + s.failedAuths.recordSuccess(serviceName, username) return &authd.IAResponse{ Access: access, diff --git a/internal/services/pam/pam_test.go b/internal/services/pam/pam_test.go index fb56e1aff3..c0af509bee 100644 --- a/internal/services/pam/pam_test.go +++ b/internal/services/pam/pam_test.go @@ -546,6 +546,111 @@ func TestIsAuthenticated(t *testing.T) { } } +func TestConfigWarnOnUnknownServices(t *testing.T) { + pamDDir := t.TempDir() + pamDDirAlt := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(pamDDirAlt, "sshd"), []byte{}, 0600), + "Setup: could not create PAM service file") + + cfg := pam.Config{ + BruteForceMitigationConfig: pam.DefaultConfig.BruteForceMitigationConfig, + Services: map[string]pam.BruteForceOverride{ + "sshd": {}, + "no-such-app": {}, + }, + } + + var warnings []string + log.SetLevelHandler(log.WarnLevel, func(_ context.Context, _ log.Level, format string, args ...interface{}) { + warnings = append(warnings, fmt.Sprintf(format, args...)) + }) + t.Cleanup(func() { log.SetLevelHandler(log.WarnLevel, nil) }) + + cfg.WarnOnUnknownServices(context.Background(), []string{pamDDir, pamDDirAlt}) + + require.Len(t, warnings, 1, "Expected exactly one warning for the missing service") + require.Contains(t, warnings[0], `"no-such-app"`, "Warning should mention the missing service name") +} + +func TestIsAuthenticated_FailDelay_PerService(t *testing.T) { + t.Parallel() + + overrideDelay := 200 * time.Millisecond + cfg := pam.Config{ + BruteForceMitigationConfig: pam.BruteForceMitigationConfig{ + AuthFailDelayThreshold: 0, + AuthFailDelay: 0, + AuthFailResetWindow: 15 * time.Minute, + }, + Services: map[string]pam.BruteForceOverride{ + "sshd": { + AuthFailDelayThreshold: new(int), + AuthFailDelay: &overrideDelay, + }, + }, + } + client := newPamClientWithConfig(t, nil, globalBrokerManager, cfg) + + sessionID := startSessionWithService(t, client, "ia_denied@example.com", "sshd") + iaReq := &authd.IARequest{ + SessionId: sessionID, + AuthenticationData: &authd.IARequest_AuthenticationData{}, + } + + start := time.Now() + _, err := client.IsAuthenticated(context.Background(), iaReq) + require.NoError(t, err, "IsAuthenticated should not return an error") + require.GreaterOrEqual(t, time.Since(start), overrideDelay, + "attempt should use the service-specific fail delay") +} + +func TestIsAuthenticated_FailDelay_PerService_ResetWindow(t *testing.T) { + t.Parallel() + + resetWindow := 100 * time.Millisecond + delay := 200 * time.Millisecond + cfg := pam.Config{ + BruteForceMitigationConfig: pam.BruteForceMitigationConfig{ + AuthFailDelayThreshold: 1, + AuthFailDelay: delay, + AuthFailResetWindow: time.Hour, // effectively never resets during the test + }, + Services: map[string]pam.BruteForceOverride{ + "sshd": { + AuthFailResetWindow: &resetWindow, + }, + }, + } + client := newPamClientWithConfig(t, nil, globalBrokerManager, cfg) + + makeAttempt := func() time.Duration { + t.Helper() + sessionID := startSessionWithService(t, client, "ia_denied@example.com", "sshd") + start := time.Now() + _, err := client.IsAuthenticated(context.Background(), &authd.IARequest{ + SessionId: sessionID, + AuthenticationData: &authd.IARequest_AuthenticationData{}, + }) + require.NoError(t, err, "IsAuthenticated should not return an error") + return time.Since(start) + } + + // threshold=1: first failure (count=1) is not delayed; second (count=2) is. + require.Less(t, makeAttempt(), delay, + "first failure should not trigger the fail delay") + require.GreaterOrEqual(t, makeAttempt(), delay, + "second consecutive failure should be delayed") + + // Wait past the per-service reset window so the failure counter clears. + time.Sleep(2 * resetWindow) + + // After reset, count drops back to 1 — not delayed again. + // If the global reset window (1 hour) were honoured instead, the count + // would remain at 3 and this attempt would still be delayed. + require.Less(t, makeAttempt(), delay, + "failure after per-service reset window should not be delayed") +} + func TestIsAuthenticated_FailDelay(t *testing.T) { t.Parallel() @@ -762,6 +867,11 @@ func initBrokers() (brokerConfigPath string, cleanup func(), err error) { // If the one passed is nil, this function will create the database and close it upon test teardown. func newPamClient(t *testing.T, m *users.Manager, brokerManager *brokers.Manager) (client authd.PAMClient) { t.Helper() + return newPamClientWithConfig(t, m, brokerManager, pam.DefaultConfig) +} + +func newPamClientWithConfig(t *testing.T, m *users.Manager, brokerManager *brokers.Manager, cfg pam.Config) (client authd.PAMClient) { + t.Helper() // socket path is limited in length. tmpDir, err := os.MkdirTemp("", "authd-socket-dir") @@ -778,7 +888,7 @@ func newPamClient(t *testing.T, m *users.Manager, brokerManager *brokers.Manager t.Cleanup(func() { _ = m.Stop() }) } - service := pam.NewService(context.Background(), m, brokerManager, pam.DefaultConfig) + service := pam.NewService(context.Background(), m, brokerManager, cfg) grpcServer := grpc.NewServer(permissions.WithUnixPeerCreds(), grpc.ChainUnaryInterceptor(errmessages.RedactErrorInterceptor)) authd.RegisterPAMServer(grpcServer, service) @@ -814,6 +924,11 @@ func getMockBrokerGeneratedID(brokerManager *brokers.Manager) (string, error) { // startSession is a helper that starts a session on the mock broker. func startSession(t *testing.T, client authd.PAMClient, username string) string { t.Helper() + return startSessionWithService(t, client, username, "") +} + +func startSessionWithService(t *testing.T, client authd.PAMClient, username, serviceName string) string { + t.Helper() if username == "" { username = "user@example.com" @@ -823,9 +938,10 @@ func startSession(t *testing.T, client authd.PAMClient, username string) string username = t.Name() + testutils.IDSeparator + username sbResp, err := client.SelectBroker(context.Background(), &authd.SBRequest{ - BrokerId: mockBrokerGeneratedID, - Username: username, - Mode: authd.SessionMode_LOGIN, + BrokerId: mockBrokerGeneratedID, + Username: username, + Mode: authd.SessionMode_LOGIN, + ServiceName: serviceName, }) require.NoError(t, err, "Setup: failed to create session for tests") return sbResp.GetSessionId() diff --git a/pam/internal/adapter/commands.go b/pam/internal/adapter/commands.go index 5ab935c2e6..f411045111 100644 --- a/pam/internal/adapter/commands.go +++ b/pam/internal/adapter/commands.go @@ -21,7 +21,7 @@ func sendEvent(msg tea.Msg) tea.Cmd { } // startBrokerSession returns the sessionID after marking a broker as current. -func startBrokerSession(client authd.PAMClient, brokerID, username string, mode authd.SessionMode) tea.Cmd { +func startBrokerSession(client authd.PAMClient, brokerID, username, serviceName string, mode authd.SessionMode) tea.Cmd { return func() tea.Msg { if brokerID == brokers.LocalBrokerName { return pamError{status: pam.ErrIgnore} @@ -39,10 +39,11 @@ func startBrokerSession(client authd.PAMClient, brokerID, username string, mode lang = strings.TrimSuffix(lang, ".UTF-8") sbReq := &authd.SBRequest{ - BrokerId: brokerID, - Username: username, - Lang: lang, - Mode: mode, + BrokerId: brokerID, + Username: username, + Lang: lang, + Mode: mode, + ServiceName: serviceName, } sbResp, err := client.SelectBroker(context.TODO(), sbReq) diff --git a/pam/internal/adapter/model.go b/pam/internal/adapter/model.go index 6225a4c9fe..d73b6e220f 100644 --- a/pam/internal/adapter/model.go +++ b/pam/internal/adapter/model.go @@ -65,7 +65,8 @@ type uiModel struct { sessionMode authd.SessionMode // client is the [authd.PAMClient] handle used to communicate with authd. - client authd.PAMClient + client authd.PAMClient + serviceName string sessionStartingForBroker string currentSession *sessionInfo @@ -148,6 +149,11 @@ func newUIModelForClients(mTx pam.ModuleTransaction, clientType PamClientType, m pamReturnValue: pamReturnValue, client: pamClient, } + var err error + m.serviceName, err = mTx.GetItem(pam.Service) + if err != nil { + log.Warningf(context.TODO(), "failed to get the PAM service name: %v", err) + } if m.pamReturnValue != nil { *m.pamReturnValue = pamNoReturnValue @@ -294,7 +300,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { safeMessageDebug(msg) if m.sessionStartingForBroker == "" { m.sessionStartingForBroker = msg.BrokerID - return m, startBrokerSession(m.client, msg.BrokerID, m.username(), m.sessionMode) + return m, startBrokerSession(m.client, msg.BrokerID, m.username(), m.serviceName, m.sessionMode) } if m.sessionStartingForBroker != msg.BrokerID { return m, tea.Sequence(endSession(m.client, m.currentSession), sendEvent(msg))