mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 20:36:26 +02:00
Support ldap authentication in vault agent (#21641)
* Support ldap authentication in vault agent * Update documentation * Add changelog entry
This commit is contained in:
parent
510cce5f81
commit
ebd4002b56
3
changelog/21641.txt
Normal file
3
changelog/21641.txt
Normal file
@ -0,0 +1,3 @@
|
||||
```release-note:feature
|
||||
auto-auth: support ldap auth
|
||||
```
|
||||
259
command/agentproxyshared/auth/ldap/ldap.go
Normal file
259
command/agentproxyshared/auth/ldap/ldap.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
262
command/agentproxyshared/auth/ldap/ldap_test.go
Normal file
262
command/agentproxyshared/auth/ldap/ldap_test.go
Normal file
@ -0,0 +1,262 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
hclog "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth"
|
||||
)
|
||||
|
||||
func TestIngressPass(t *testing.T) {
|
||||
const (
|
||||
dir = "dir"
|
||||
file = "file"
|
||||
empty = "empty"
|
||||
missing = "missing"
|
||||
symlinked = "symlinked"
|
||||
)
|
||||
|
||||
rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %s", err)
|
||||
}
|
||||
defer os.RemoveAll(rootDir)
|
||||
|
||||
setupTestDir := func() string {
|
||||
testDir, err := os.MkdirTemp(rootDir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.WriteFile(path.Join(testDir, file), []byte("test"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = os.Create(path.Join(testDir, empty))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.Mkdir(path.Join(testDir, dir), 0o755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.Symlink(path.Join(testDir, file), path.Join(testDir, symlinked))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return testDir
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
errString string
|
||||
}{
|
||||
{
|
||||
"happy path",
|
||||
file,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"path is directory",
|
||||
dir,
|
||||
"[ERROR] password file is not a regular file or symlink",
|
||||
},
|
||||
{
|
||||
"password file path is symlink",
|
||||
symlinked,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"password file path is missing (implies nothing for ingressPass to do)",
|
||||
missing,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"password file path is empty file",
|
||||
empty,
|
||||
"[WARN] empty password file read",
|
||||
},
|
||||
} {
|
||||
testDir := setupTestDir()
|
||||
logBuffer := bytes.Buffer{}
|
||||
ldapAuth := &ldapMethod{
|
||||
logger: hclog.New(&hclog.LoggerOptions{
|
||||
Output: &logBuffer,
|
||||
}),
|
||||
latestPass: new(atomic.Value),
|
||||
passwordFilePath: path.Join(testDir, tc.path),
|
||||
}
|
||||
|
||||
ldapAuth.ingressPass()
|
||||
|
||||
if tc.errString != "" {
|
||||
if !strings.Contains(logBuffer.String(), tc.errString) {
|
||||
t.Fatal("logs did no contain expected error", tc.errString, logBuffer.String())
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(logBuffer.String(), "[ERROR]") || strings.Contains(logBuffer.String(), "[WARN]") {
|
||||
t.Fatal("logs contained unexpected error", logBuffer.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAfterReading(t *testing.T) {
|
||||
for _, tc := range map[string]struct {
|
||||
configValue string
|
||||
shouldDelete bool
|
||||
}{
|
||||
"default": {
|
||||
"",
|
||||
true,
|
||||
},
|
||||
"explicit true": {
|
||||
"true",
|
||||
true,
|
||||
},
|
||||
"false": {
|
||||
"false",
|
||||
false,
|
||||
},
|
||||
} {
|
||||
rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %s", err)
|
||||
}
|
||||
defer os.RemoveAll(rootDir)
|
||||
passPath := path.Join(rootDir, "pass")
|
||||
err = os.WriteFile(passPath, []byte("test"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := &auth.AuthConfig{
|
||||
Config: map[string]interface{}{
|
||||
"password_file_path": passPath,
|
||||
"username": "testuser",
|
||||
},
|
||||
Logger: hclog.Default(),
|
||||
}
|
||||
if tc.configValue != "" {
|
||||
config.Config["remove_password_after_reading"] = tc.configValue
|
||||
}
|
||||
|
||||
ldapAuth, err := NewLdapAuthMethod(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ldapAuth.(*ldapMethod).ingressPass()
|
||||
|
||||
if _, err := os.Lstat(passPath); tc.shouldDelete {
|
||||
if err == nil || !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAfterReadingSymlink(t *testing.T) {
|
||||
for _, tc := range map[string]struct {
|
||||
configValue string
|
||||
shouldDelete bool
|
||||
removePassFollowsSymlinks bool
|
||||
}{
|
||||
"default": {
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
},
|
||||
"explicit true": {
|
||||
"true",
|
||||
true,
|
||||
false,
|
||||
},
|
||||
"false": {
|
||||
"false",
|
||||
false,
|
||||
false,
|
||||
},
|
||||
"default + removePassFollowsSymlinks": {
|
||||
"",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
"explicit true + removePassFollowsSymlinks": {
|
||||
"true",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
"false + removePassFollowsSymlinks": {
|
||||
"false",
|
||||
false,
|
||||
true,
|
||||
},
|
||||
} {
|
||||
rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %s", err)
|
||||
}
|
||||
defer os.RemoveAll(rootDir)
|
||||
passPath := path.Join(rootDir, "pass")
|
||||
err = os.WriteFile(passPath, []byte("test"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
symlink, err := os.CreateTemp("", "auth.ldap.symlink.test.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
symlinkName := symlink.Name()
|
||||
symlink.Close()
|
||||
os.Remove(symlinkName)
|
||||
os.Symlink(passPath, symlinkName)
|
||||
|
||||
config := &auth.AuthConfig{
|
||||
Config: map[string]interface{}{
|
||||
"password_file_path": symlinkName,
|
||||
"username": "testuser",
|
||||
},
|
||||
Logger: hclog.Default(),
|
||||
}
|
||||
if tc.configValue != "" {
|
||||
config.Config["remove_password_after_reading"] = tc.configValue
|
||||
}
|
||||
config.Config["remove_password_follows_symlinks"] = tc.removePassFollowsSymlinks
|
||||
|
||||
ldapAuth, err := NewLdapAuthMethod(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ldapAuth.(*ldapMethod).ingressPass()
|
||||
|
||||
pathToCheck := symlinkName
|
||||
if tc.removePassFollowsSymlinks {
|
||||
pathToCheck = passPath
|
||||
}
|
||||
if _, err := os.Lstat(pathToCheck); tc.shouldDelete {
|
||||
if err == nil || !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth/jwt"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth/kerberos"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth/kubernetes"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth/ldap"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/auth/oci"
|
||||
token_file "github.com/hashicorp/vault/command/agentproxyshared/auth/token-file"
|
||||
"github.com/hashicorp/vault/command/agentproxyshared/cache"
|
||||
@ -62,6 +63,8 @@ func GetAutoAuthMethodFromConfig(autoAuthMethodType string, authConfig *auth.Aut
|
||||
return token_file.NewTokenFileAuthMethod(authConfig)
|
||||
case "pcf": // Deprecated.
|
||||
return cf.NewCFAuthMethod(authConfig)
|
||||
case "ldap":
|
||||
return ldap.NewLdapAuthMethod(authConfig)
|
||||
default:
|
||||
return nil, errors.New(fmt.Sprintf("unknown auth method %q", autoAuthMethodType))
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
---
|
||||
layout: docs
|
||||
page_title: Vault Auto-Auth LDAP Method
|
||||
description: LDAP Method for Vault Auto-Auth
|
||||
---
|
||||
|
||||
# Vault Auto-Auth LDAP Method
|
||||
|
||||
The `ldap` method reads in a password from a file and sends it to the [LDAP Auth
|
||||
method](/vault/docs/auth/ldap).
|
||||
|
||||
## Configuration
|
||||
|
||||
- `password_file_path` `(string: required)` - The path to the password file
|
||||
|
||||
- `username` `(string: required)` - The username to authenticate against on Vault
|
||||
|
||||
- `remove_password_after_reading` `(bool: optional, defaults to true)` -
|
||||
This can be set to `false` to disable the default behavior of removing the
|
||||
password after it's been read.
|
||||
|
||||
- `remove_password_follows_symlinks` `(bool: optional, defaults to false)` -
|
||||
This can be set to `true` to follow symlinks when removing the password after
|
||||
it has been read when executing the `remove_password_after_reading` behaviour.
|
||||
If set to false, it will delete the symlink, not the password file. Does
|
||||
nothing if `remove_password_after_reading` is false.
|
||||
|
||||
- `password_read_period` `(duration: "0.5s", optional)` - The duration after which
|
||||
auto-auth will attempt to read the password stored at `password_file_path`.
|
||||
Defaults to `1m` if `remove_password_after_reading` is set to `true`, or `0.5s`
|
||||
otherwise. Uses [duration format
|
||||
strings](/vault/docs/concepts/duration-format).
|
||||
@ -1008,6 +1008,10 @@
|
||||
"title": "JWT",
|
||||
"path": "agent-and-proxy/autoauth/methods/jwt"
|
||||
},
|
||||
{
|
||||
"title": "LDAP",
|
||||
"path": "agent-and-proxy/autoauth/methods/ldap"
|
||||
},
|
||||
{
|
||||
"title": "Kerberos",
|
||||
"path": "agent-and-proxy/autoauth/methods/kerberos"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user