Skip to content

Commit

Permalink
Support ldap authentication in vault agent (#21641)
Browse files Browse the repository at this point in the history
* Support ldap authentication in vault agent

* Update documentation

* Add changelog entry
  • Loading branch information
sebinjohn authored Aug 14, 2023
1 parent 510cce5 commit ebd4002
Show file tree
Hide file tree
Showing 6 changed files with 563 additions and 0 deletions.
3 changes: 3 additions & 0 deletions changelog/21641.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
auto-auth: support ldap auth
```
259 changes: 259 additions & 0 deletions command/agentproxyshared/auth/ldap/ldap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package ldap

import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"

hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/command/agentproxyshared/auth"
"github.com/hashicorp/vault/sdk/helper/parseutil"
)

type ldapMethod struct {
logger hclog.Logger
mountPath string

username string
passwordFilePath string
removePasswordAfterReading bool
removePasswordFollowsSymlinks bool
credsFound chan struct{}
watchCh chan string
stopCh chan struct{}
doneCh chan struct{}
credSuccessGate chan struct{}
ticker *time.Ticker
once *sync.Once
latestPass *atomic.Value
}

// NewLdapMethod reads the user configuration and returns a configured
// LdapAuthMethod
func NewLdapAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) {
if conf == nil {
return nil, errors.New("empty config")
}
if conf.Config == nil {
return nil, errors.New("empty config data")
}

k := &ldapMethod{
logger: conf.Logger,
mountPath: conf.MountPath,
removePasswordAfterReading: true,
credsFound: make(chan struct{}),
watchCh: make(chan string),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
credSuccessGate: make(chan struct{}),
once: new(sync.Once),
latestPass: new(atomic.Value),
}

k.latestPass.Store("")
usernameRaw, ok := conf.Config["username"]
if !ok {
return nil, errors.New("missing 'username' value")
}
k.username, ok = usernameRaw.(string)
if !ok {
return nil, errors.New("could not convert 'username' config value to string")
}

passFilePathRaw, ok := conf.Config["password_file_path"]
if !ok {
return nil, errors.New("missing 'password_file_path' value")
}
k.passwordFilePath, ok = passFilePathRaw.(string)
if !ok {
return nil, errors.New("could not convert 'password_file_path' config value to string")
}
if removePassAfterReadingRaw, ok := conf.Config["remove_password_after_reading"]; ok {
removePassAfterReading, err := parseutil.ParseBool(removePassAfterReadingRaw)
if err != nil {
return nil, fmt.Errorf("error parsing 'remove_password_after_reading' value: %w", err)
}
k.removePasswordAfterReading = removePassAfterReading
}

if removePassFollowsSymlinksRaw, ok := conf.Config["remove_password_follows_symlinks"]; ok {
removePassFollowsSymlinks, err := parseutil.ParseBool(removePassFollowsSymlinksRaw)
if err != nil {
return nil, fmt.Errorf("error parsing 'remove_password_follows_symlinks' value: %w", err)
}
k.removePasswordFollowsSymlinks = removePassFollowsSymlinks
}
switch {
case k.passwordFilePath == "":
return nil, errors.New("'password_file_path' value is empty")
case k.username == "":
return nil, errors.New("'username' value is empty")
}

// Default readPeriod
readPeriod := 1 * time.Minute

if passReadPeriodRaw, ok := conf.Config["password_read_period"]; ok {
passReadPeriod, err := parseutil.ParseDurationSecond(passReadPeriodRaw)
if err != nil {
return nil, fmt.Errorf("error parsing 'pass_read_period' value: %w", err)
}
readPeriod = passReadPeriod
} else {
// If we don't delete the password after reading, use a slower reload period,
// otherwise we would re-read the whole file every 500ms, instead of just
// doing a stat on the file every 500ms.
if k.removePasswordAfterReading {
readPeriod = 500 * time.Millisecond
}
}

k.ticker = time.NewTicker(readPeriod)

go k.runWatcher()

k.logger.Info("ldap auth method created", "password_file_path", k.passwordFilePath)

return k, nil
}

func (k *ldapMethod) Authenticate(ctx context.Context, client *api.Client) (string, http.Header, map[string]interface{}, error) {
k.logger.Trace("beginning authentication")

k.ingressPass()

latestPass := k.latestPass.Load().(string)

if latestPass == "" {
return "", nil, nil, errors.New("latest known password is empty, cannot authenticate")
}
k.logger.Info("last known password in Authentication setup is")
return fmt.Sprintf("%s/login/%s", k.mountPath, k.username), nil, map[string]interface{}{
"password": latestPass,
}, nil
}

func (k *ldapMethod) NewCreds() chan struct{} {
return k.credsFound
}

func (k *ldapMethod) CredSuccess() {
k.once.Do(func() {
close(k.credSuccessGate)
})
}

func (k *ldapMethod) Shutdown() {
k.ticker.Stop()
close(k.stopCh)
<-k.doneCh
}

func (k *ldapMethod) runWatcher() {
defer close(k.doneCh)

select {
case <-k.stopCh:
return

case <-k.credSuccessGate:
// We only start the next loop once we're initially successful,
// since at startup Authenticate will be called, and we don't want
// to end up immediately re-authenticating by having found a new
// value
}

for {
select {
case <-k.stopCh:
return

case <-k.ticker.C:
latestPass := k.latestPass.Load().(string)
k.ingressPass()
newPass := k.latestPass.Load().(string)
if newPass != latestPass {
k.logger.Debug("new password file found")
k.credsFound <- struct{}{}
}
}
}
}

func (k *ldapMethod) ingressPass() {
fi, err := os.Lstat(k.passwordFilePath)
if err != nil {
if os.IsNotExist(err) {
return
}
k.logger.Error("error encountered stat'ing password file", "error", err)
return
}

// Check that the path refers to a file.
// If it's a symlink, it could still be a symlink to a directory,
// but os.ReadFile below will return a descriptive error.
evalSymlinkPath := k.passwordFilePath
switch mode := fi.Mode(); {
case mode.IsRegular():
// regular file
case mode&fs.ModeSymlink != 0:
// If our file path is a symlink, we should also return early (like above) without error
// if the file that is linked to is not present, otherwise we will error when trying
// to read that file by following the link in the os.ReadFile call.
evalSymlinkPath, err = filepath.EvalSymlinks(k.passwordFilePath)
if err != nil {
k.logger.Error("error encountered evaluating symlinks", "error", err)
return
}
_, err := os.Stat(evalSymlinkPath)
if err != nil {
if os.IsNotExist(err) {
return
}
k.logger.Error("error encountered stat'ing password file after evaluating symlinks", "error", err)
return
}
default:
k.logger.Error("password file is not a regular file or symlink")
return
}

pass, err := os.ReadFile(k.passwordFilePath)
if err != nil {
k.logger.Error("failed to read password file", "error", err)
return
}

switch len(pass) {
case 0:
k.logger.Warn("empty password file read")

default:
k.latestPass.Store(string(pass))
}

if k.removePasswordAfterReading {
pathToRemove := k.passwordFilePath
if k.removePasswordFollowsSymlinks {
// If removePassFollowsSymlinks is set, we follow the symlink and delete the password,
// not just the symlink that links to the password file
pathToRemove = evalSymlinkPath
}
if err := os.Remove(pathToRemove); err != nil {
k.logger.Error("error removing password file", "error", err)
}
}
}
Loading

0 comments on commit ebd4002

Please sign in to comment.