mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-10 00:27:02 +02:00
260 lines
7.2 KiB
Go
260 lines
7.2 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
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 || passReadPeriod <= 0 {
|
|
return nil, fmt.Errorf("error parsing 'password_read_period' value into a positive 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)
|
|
}
|
|
}
|
|
}
|