Skip to content

Commit

Permalink
adding an ending step to host user upsert that removes any expiration…
Browse files Browse the repository at this point in the history
…s or password locks for managed teleport users (#48161)
  • Loading branch information
eriktate authored Oct 31, 2024
1 parent 1f89c1d commit af07f24
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 14 deletions.
88 changes: 88 additions & 0 deletions integration/hostuser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
60 changes: 46 additions & 14 deletions lib/srv/usermgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions lib/srv/usermgmt_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions lib/srv/usermgmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
77 changes: 77 additions & 0 deletions lib/utils/host/hostusers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package host

import (
"bufio"
"bytes"
"errors"
"os/exec"
Expand Down Expand Up @@ -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 <username>'.
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 <username>
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
Expand Down

0 comments on commit af07f24

Please sign in to comment.