diff --git a/integration/hostuser_test.go b/integration/hostuser_test.go index e0199470e0539..eb767f72963af 100644 --- a/integration/hostuser_test.go +++ b/integration/hostuser_test.go @@ -444,6 +444,94 @@ func TestRootHostUsers(t *testing.T) { }) } }) + + 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, "", "", "") + 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: types.CreateHostUserMode_HOST_USER_MODE_KEEP, + }) + 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: types.CreateHostUserMode_HOST_USER_MODE_KEEP, + }) + 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: types.CreateHostUserMode_HOST_USER_MODE_KEEP, + 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, home, uid, gid string) error { + if err := u.HostUsersBackend.CreateUser(name, groups, home, uid, gid); 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 5a1bc714afd8f..b114ee5f95658 100644 --- a/lib/srv/usermgmt.go +++ b/lib/srv/usermgmt.go @@ -38,26 +38,53 @@ import ( "github.com/gravitational/teleport/lib/services/local" ) -// NewHostUsers initialize a new HostUsers object -func NewHostUsers(ctx context.Context, storage *local.PresenceService, uuid string) HostUsers { - //nolint:staticcheck // SA4023. False positive on macOS. - backend, err := newHostUsersBackend() - switch { - case trace.IsNotImplemented(err): - log.Debugf("Skipping host user management: %v", err) - return nil - case err != nil: //nolint:staticcheck // linter fails on non-linux system as only linux implementation returns useful values. - log.Warnf("Error making new HostUsersBackend: %s", 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 *local.PresenceService, uuid string, opts ...HostUsersOpt) HostUsers { + // handle fields that must be specified or aren't configurable cancelCtx, cancelFunc := context.WithCancel(ctx) - return &HostUserManagement{ - backend: backend, + hostUsers := &HostUserManagement{ 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): + log.WithError(err).Debug("Skipping host user management") + return nil + case err != nil: //nolint:staticcheck // linter fails on non-linux system as only linux implementation returns useful values. + log.WithError(err).Debug(ctx, "Error making new HostUsersBackend") + return nil + } + + hostUsers.backend = backend + } + + return hostUsers } func NewHostSudoers(uuid string) HostSudoers { @@ -107,7 +134,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 { @@ -433,6 +463,8 @@ func (u *HostUserManagement) UpsertUser(name string, ui services.HostUsersInfo) } } + // attempt to remove password expirations from managed users if they've been added + defer u.backend.RemoveExpirations(name) if err := u.updateUser(name, ui); err != nil { if !errors.Is(err, user.UnknownUserError(name)) { return nil, trace.Wrap(err) diff --git a/lib/srv/usermgmt_linux.go b/lib/srv/usermgmt_linux.go index 2c3fb0ef34b7a..3dfa35c5ae4bd 100644 --- a/lib/srv/usermgmt_linux.go +++ b/lib/srv/usermgmt_linux.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "os/exec" "os/user" "path/filepath" "strconv" @@ -47,6 +48,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 } @@ -285,3 +296,8 @@ func (u *HostUsersProvisioningBackend) CreateHomeDirectory(user string, uidS, gi 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 10a2e836c62ef..a8c05961de5d0 100644 --- a/lib/srv/usermgmt_test.go +++ b/lib/srv/usermgmt_test.go @@ -181,6 +181,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 8db299a1018b0..614d83d3a339a 100644 --- a/lib/utils/host/hostusers.go +++ b/lib/utils/host/hostusers.go @@ -17,6 +17,7 @@ limitations under the License. package host import ( + "bufio" "bytes" "errors" "os/exec" @@ -154,6 +155,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