Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v15] Remove expirations for managed users #48162

Merged
merged 1 commit into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions integration/hostuser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,94 @@ func TestRootHostUsers(t *testing.T) {
})
}
})

t.Run("Test expiration removal", func(t *testing.T) {
expiredUser := "expired-user"
backendExpiredUser := "backend-expired-user"
t.Cleanup(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 @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -420,6 +450,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)
Expand All @@ -434,6 +465,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
}

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 @@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions lib/srv/usermgmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,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 @@ -19,6 +19,7 @@
package host

import (
"bufio"
"bytes"
"errors"
"os"
Expand Down Expand Up @@ -169,6 +170,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
Loading