diff --git a/integration/hostuser_test.go b/integration/hostuser_test.go index 23f385d045c4d..242908525cdf3 100644 --- a/integration/hostuser_test.go +++ b/integration/hostuser_test.go @@ -513,6 +513,94 @@ func TestRootHostUsers(t *testing.T) { require.NoError(t, err) assert.Equal(t, expectedShell, userShells[namedShellUser]) }) + + t.Run("Test expiration removal", func(t *testing.T) { + expiredUser := "expired-user" + backendExpiredUser := "backend-expired-user" + t.Cleanup(func() { cleanupUsersAndGroups([]string{expiredUser, backendExpiredUser}, []string{"test-group"}) }) + + defaultBackend, err := srv.DefaultHostUsersBackend() + require.NoError(t, err) + + backend := &hostUsersBackendWithExp{HostUsersBackend: defaultBackend} + users := srv.NewHostUsers(context.Background(), presence, "host_uuid", srv.WithHostUsersBackend(backend)) + + // Make sure the backend actually creates expired users + err = backend.CreateUser("backend-expired-user", nil, host.UserOpts{}) + require.NoError(t, err) + + hasExpirations, _, err := host.UserHasExpirations(backendExpiredUser) + require.NoError(t, err) + require.True(t, hasExpirations) + + // Upsert a new user which should have the expirations removed + _, err = users.UpsertUser(expiredUser, services.HostUsersInfo{ + Mode: services.HostUserModeKeep, + }) + require.NoError(t, err) + + hasExpirations, _, err = host.UserHasExpirations(expiredUser) + require.NoError(t, err) + require.False(t, hasExpirations) + + // Expire existing user so we can test that updates also remove expirations + expireUser := func(username string) error { + chageBin, err := exec.LookPath("chage") + require.NoError(t, err) + + cmd := exec.Command(chageBin, "-E", "1", "-I", "1", "-M", "1", username) + return cmd.Run() + } + require.NoError(t, expireUser(expiredUser)) + hasExpirations, _, err = host.UserHasExpirations(expiredUser) + require.NoError(t, err) + require.True(t, hasExpirations) + + // Update user without any changes + _, err = users.UpsertUser(expiredUser, services.HostUsersInfo{ + Mode: services.HostUserModeKeep, + }) + require.NoError(t, err) + + hasExpirations, _, err = host.UserHasExpirations(expiredUser) + require.NoError(t, err) + require.False(t, hasExpirations) + + // Reinstate expirations again + require.NoError(t, expireUser(expiredUser)) + hasExpirations, _, err = host.UserHasExpirations(expiredUser) + require.NoError(t, err) + require.True(t, hasExpirations) + + // Update user with changes + _, err = users.UpsertUser(expiredUser, services.HostUsersInfo{ + Mode: services.HostUserModeKeep, + Groups: []string{"test-group"}, + }) + require.NoError(t, err) + + hasExpirations, _, err = host.UserHasExpirations(expiredUser) + require.NoError(t, err) + require.False(t, hasExpirations) + }) +} + +type hostUsersBackendWithExp struct { + srv.HostUsersBackend +} + +func (u *hostUsersBackendWithExp) CreateUser(name string, groups []string, opts host.UserOpts) error { + if err := u.HostUsersBackend.CreateUser(name, groups, opts); err != nil { + return trace.Wrap(err) + } + + chageBin, err := exec.LookPath("chage") + if err != nil { + return trace.Wrap(err) + } + + cmd := exec.Command(chageBin, "-E", "1", "-I", "1", "-M", "1", name) + return cmd.Run() } func TestRootLoginAsHostUser(t *testing.T) { diff --git a/lib/srv/usermgmt.go b/lib/srv/usermgmt.go index efe16947bc924..a4460afbc9529 100644 --- a/lib/srv/usermgmt.go +++ b/lib/srv/usermgmt.go @@ -42,27 +42,54 @@ import ( "github.com/gravitational/teleport/lib/utils/host" ) -// NewHostUsers initialize a new HostUsers object -func NewHostUsers(ctx context.Context, storage services.PresenceInternal, uuid string) HostUsers { - //nolint:staticcheck // SA4023. False positive on macOS. - backend, err := newHostUsersBackend() - switch { - case trace.IsNotImplemented(err), trace.IsNotFound(err): - slog.DebugContext(ctx, "Skipping host user management", "error", err) - return nil - case err != nil: //nolint:staticcheck // linter fails on non-linux system as only linux implementation returns useful values. - slog.WarnContext(ctx, "Error making new HostUsersBackend", "error", err) - return nil +type HostUsersOpt = func(hostUsers *HostUserManagement) + +// WithHostUsersBackend injects a custom backend to be used within HostUserManagement +func WithHostUsersBackend(backend HostUsersBackend) HostUsersOpt { + return func(hostUsers *HostUserManagement) { + hostUsers.backend = backend } +} + +// DefaultHostUsersBackend returns the default HostUsersBackend for the host operating system +func DefaultHostUsersBackend() (HostUsersBackend, error) { + return newHostUsersBackend() +} + +// NewHostUsers initialize a new HostUsers object +func NewHostUsers(ctx context.Context, storage services.PresenceInternal, uuid string, opts ...HostUsersOpt) HostUsers { + // handle fields that must be specified or aren't configurable cancelCtx, cancelFunc := context.WithCancel(ctx) - return &HostUserManagement{ + hostUsers := &HostUserManagement{ log: slog.With(teleport.ComponentKey, teleport.ComponentHostUsers), - backend: backend, ctx: cancelCtx, cancel: cancelFunc, storage: storage, userGrace: time.Second * 30, } + + // set configurable fields that don't have to be specified + for _, opt := range opts { + opt(hostUsers) + } + + // set default values for required fields that don't have to be specified + if hostUsers.backend == nil { + //nolint:staticcheck // SA4023. False positive on macOS. + backend, err := newHostUsersBackend() + switch { + case trace.IsNotImplemented(err), trace.IsNotFound(err): + slog.DebugContext(ctx, "Skipping host user management", "error", err) + return nil + case err != nil: //nolint:staticcheck // linter fails on non-linux system as only linux implementation returns useful values. + slog.WarnContext(ctx, "Error making new HostUsersBackend", "error", err) + return nil + } + + hostUsers.backend = backend + } + + return hostUsers } func NewHostSudoers(uuid string) HostSudoers { @@ -113,7 +140,10 @@ type HostUsersBackend interface { // CreateHomeDirectory creates the users home directory and copies in /etc/skel CreateHomeDirectory(userHome string, uid, gid string) error // GetDefaultHomeDirectory returns the default home directory path for the given user - GetDefaultHomeDirectory(user string) (string, error) + GetDefaultHomeDirectory(name string) (string, error) + // RemoveExpirations removes any sort of password or account expiration from the user + // that may have been placed by password policies. + RemoveExpirations(name string) error } type userCloser struct { @@ -436,6 +466,7 @@ func (u *HostUserManagement) UpsertUser(name string, ui services.HostUsersInfo) } } + defer u.backend.RemoveExpirations(name) if hostUser == nil { if err := u.createUser(name, ui); err != nil { return nil, trace.Wrap(err) @@ -450,6 +481,7 @@ func (u *HostUserManagement) UpsertUser(name string, ui services.HostUsersInfo) } } + // attempt to remove password expirations from managed users if they've been added return closer, nil } diff --git a/lib/srv/usermgmt_linux.go b/lib/srv/usermgmt_linux.go index e756f265ffa94..c6ba42ba35d8a 100644 --- a/lib/srv/usermgmt_linux.go +++ b/lib/srv/usermgmt_linux.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "os" + "os/exec" "os/user" "path/filepath" "strconv" @@ -49,6 +50,16 @@ type HostSudoersProvisioningBackend struct { // newHostUsersBackend initializes a new OS specific HostUsersBackend func newHostUsersBackend() (HostUsersBackend, error) { + var missing []string + for _, requiredBin := range []string{"usermod", "useradd", "getent", "groupadd", "visudo", "chage"} { + if _, err := exec.LookPath(requiredBin); err != nil { + missing = append(missing, requiredBin) + } + } + if len(missing) != 0 { + return nil, trace.NotFound("missing required binaries: %s", strings.Join(missing, ",")) + } + return &HostUsersProvisioningBackend{}, nil } @@ -272,3 +283,8 @@ func (u *HostUsersProvisioningBackend) CreateHomeDirectory(userHome, uidS, gidS return nil } + +func (u *HostUsersProvisioningBackend) RemoveExpirations(username string) error { + _, err := host.RemoveUserExpirations(username) + return trace.Wrap(err) +} diff --git a/lib/srv/usermgmt_test.go b/lib/srv/usermgmt_test.go index fb07adddf6a34..1a1908e0f431d 100644 --- a/lib/srv/usermgmt_test.go +++ b/lib/srv/usermgmt_test.go @@ -184,6 +184,10 @@ func (*testHostUserBackend) CheckSudoers(contents []byte) error { return errors.New("invalid") } +func (*testHostUserBackend) RemoveExpirations(user string) error { + return nil +} + // WriteSudoersFile implements HostUsersBackend func (tm *testHostUserBackend) WriteSudoersFile(user string, entries []byte) error { entry := strings.TrimSpace(string(entries)) diff --git a/lib/utils/host/hostusers.go b/lib/utils/host/hostusers.go index 2ccc9b322a0ab..1e7f50e47b523 100644 --- a/lib/utils/host/hostusers.go +++ b/lib/utils/host/hostusers.go @@ -19,6 +19,7 @@ package host import ( + "bufio" "bytes" "errors" "os" @@ -195,6 +196,82 @@ func GetAllUsers() ([]string, int, error) { return users, -1, nil } +// UserHasExpirations determines if the given username has an expired password, inactive password, or expired account +// by parsing the output of 'chage -l '. +func UserHasExpirations(username string) (bool bool, exitCode int, err error) { + chageBin, err := exec.LookPath("chage") + if err != nil { + return false, -1, trace.NotFound("cannot find chage binary: %s", err) + } + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd := exec.Command(chageBin, "-l", username) + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return false, cmd.ProcessState.ExitCode(), trace.WrapWithMessage(err, "running chage: %s", stderr.String()) + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + // ignore empty lines + continue + } + + key, value, validLine := strings.Cut(line, ":") + if !validLine { + return false, -1, trace.Errorf("chage output invalid") + } + + if strings.TrimSpace(value) == "never" { + continue + } + + switch strings.TrimSpace(key) { + case "Password expires", "Password inactive", "Account expires": + return true, 0, nil + } + } + + return false, cmd.ProcessState.ExitCode(), nil +} + +// RemoveUserExpirations uses chage to remove any future or past expirations associated with the given username. It also uses usermod to remove any account locks that may have been placed. +func RemoveUserExpirations(username string) (exitCode int, err error) { + chageBin, err := exec.LookPath("chage") + if err != nil { + return -1, trace.NotFound("cannot find chage binary: %s", err) + } + + usermodBin, err := exec.LookPath("usermod") + if err != nil { + return -1, trace.NotFound("cannot find usermod binary: %s", err) + } + + // remove all expirations from user + // chage -E -1 -I -1 + cmd := exec.Command(chageBin, "-E", "-1", "-I", "-1", "-M", "-1", username) + var errs []error + if err := cmd.Run(); err != nil { + errs = append(errs, trace.Wrap(err, "removing expirations with chage")) + } + + // unlock user password if locked + cmd = exec.Command(usermodBin, "-U", username) + if err := cmd.Run(); err != nil { + errs = append(errs, trace.Wrap(err, "removing lock with usermod")) + } + + if len(errs) > 0 { + return cmd.ProcessState.ExitCode(), trace.NewAggregate(errs...) + } + + return cmd.ProcessState.ExitCode(), nil +} + var ErrInvalidSudoers = errors.New("visudo: invalid sudoers file") // CheckSudoers tests a suders file using `visudo`. The contents