diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go new file mode 100644 index 000000000..619d55c82 --- /dev/null +++ b/builtin/credential/ldap/backend.go @@ -0,0 +1,50 @@ +package ldap + +import ( + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func Factory(map[string]string) (logical.Backend, error) { + return Backend(), nil +} + +func Backend() *framework.Backend { + var b backend + b.Backend = &framework.Backend{ + Help: backendHelp, + + PathsSpecial: &logical.Paths{ + Root: []string{ + "config", + }, + + Unauthenticated: []string{ + "login", + }, + }, + + Paths: append([]*framework.Path{ + pathLogin(&b), + pathConfig(&b), + }), + + // AuthRenew: b.pathLoginRenew, + } + + return b.Backend +} + +type backend struct { + *framework.Backend +} + +const backendHelp = ` +The "ldap" credential provider allows authentication querying +a LDAP server, checking username and password, and associating groups +to set of policies. + +Configuration of the server is done through the "config" and "groups" +endpoints by a user with root access. Authentication is then done +by suppying the two fields for "login". +` diff --git a/builtin/credential/ldap/cli.go b/builtin/credential/ldap/cli.go new file mode 100644 index 000000000..6eadaa5b9 --- /dev/null +++ b/builtin/credential/ldap/cli.go @@ -0,0 +1,62 @@ +package ldap + +import ( + "fmt" + "os" + "strings" + + "github.com/hashicorp/vault/api" + pwd "github.com/hashicorp/vault/helper/password" +) + +type CLIHandler struct{} + +func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (string, error) { + mount, ok := m["mount"] + if !ok { + mount = "ldap" + } + + username, ok := m["username"] + if !ok { + return "", fmt.Errorf("'username' var must be set") + } + password, ok := m["password"] + if !ok { + fmt.Printf("Password (will be hidden): ") + var err error + password, err = pwd.Read(os.Stdin) + fmt.Println() + if err != nil { + return "", err + } + } + + path := fmt.Sprintf("auth/%s/login", mount) + secret, err := c.Logical().Write(path, map[string]interface{}{ + "username": username, + "password": password, + }) + if err != nil { + return "", err + } + if secret == nil { + return "", fmt.Errorf("empty response from credential provider") + } + + return secret.Auth.ClientToken, nil +} + +func (h *CLIHandler) Help() string { + help := ` +The LDAP credential provider allows you to authenticate with LDAP. +To use it, first configure it through the "config" endpoint, and then +login by specifying username and password. If password is not provided +on the command line, it will be read from stdin. + + Example: vault auth -method=ldap username=john + + ` + + return strings.TrimSpace(help) +} diff --git a/builtin/credential/ldap/path_config.go b/builtin/credential/ldap/path_config.go new file mode 100644 index 000000000..5ab6b9a46 --- /dev/null +++ b/builtin/credential/ldap/path_config.go @@ -0,0 +1,143 @@ +package ldap + +import ( + "net/url" + "strings" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathConfig(b *backend) *framework.Path { + return &framework.Path{ + Pattern: `config`, + Fields: map[string]*framework.FieldSchema{ + "url": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "ldap URL to connect to (default: ldap://127.0.0.1)", + }, + "domain": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "LDAP domain to use (eg: dc=example,dc=org)", + }, + "userattr": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Attribute used for users (default: cn)", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathConfigRead, + logical.WriteOperation: b.pathConfigWrite, + }, + + HelpSynopsis: pathConfigHelpSyn, + HelpDescription: pathConfigHelpDesc, + } +} + +func (b *backend) Config(req *logical.Request) (*ConfigEntry, error) { + entry, err := req.Storage.Get("config") + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + var result ConfigEntry + result.SetDefaults() + if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + return &result, nil +} + +func (b *backend) pathConfigRead( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + + cfg, err := b.Config(req) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, nil + } + + return &logical.Response{ + Data: map[string]interface{}{ + "url": cfg.Url, + "domain": cfg.Domain, + "userattr": cfg.UserAttr, + }, + }, nil +} + +func (b *backend) pathConfigWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + + cfg := &ConfigEntry{} + url := d.Get("url").(string) + if url != "" { + cfg.Url = strings.ToLower(url) + } + userattr := d.Get("userattr").(string) + if url != "" { + cfg.UserAttr = strings.ToLower(userattr) + } + domain := d.Get("domain").(string) + if url != "" { + cfg.Domain = domain + } + + if !cfg.ValidateURL() { + return logical.ErrorResponse("LDAP URL is malformed"), nil + } + + entry, err := logical.StorageEntryJSON("config", cfg) + if err != nil { + return nil, err + } + if err := req.Storage.Put(entry); err != nil { + return nil, err + } + + return nil, nil +} + +type ConfigEntry struct { + Url string + Domain string + UserAttr string +} + +func (c *ConfigEntry) ValidateURL() bool { + u, err := url.Parse(c.Url) + if err != nil { + return false + } + if u.Scheme != "ldap" && u.Scheme != "ldaps" { + return false + } + if u.Path != "" { + return false + } + return true +} + +func (c *ConfigEntry) SetDefaults() { + c.Url = "ldap://127.0.0.1" + c.UserAttr = "cn" +} + +const pathConfigHelpSyn = ` +Configure the LDAP server to connect to. +` + +const pathConfigHelpDesc = ` +This endpoint allows you to configure the LDAP server to connect to, and give +basic information of the schema of that server. + +The LDAP URL can use either the "ldap://" or "ldaps://" schema. In the former +case, an unencrypted connection will be done, with default port 389; in the latter +case, a SSL connection will be done, with default port 636. +` diff --git a/builtin/credential/ldap/path_login.go b/builtin/credential/ldap/path_login.go new file mode 100644 index 000000000..6ac09968f --- /dev/null +++ b/builtin/credential/ldap/path_login.go @@ -0,0 +1,117 @@ +package ldap + +import ( + "fmt" + "net" + "net/url" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" + "github.com/mmitton/ldap" +) + +func pathLogin(b *backend) *framework.Path { + return &framework.Path{ + Pattern: `login`, + Fields: map[string]*framework.FieldSchema{ + "username": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "DN (distinguished name) to be used for login.", + }, + + "password": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Password for this user.", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: b.pathLogin, + }, + + HelpSynopsis: pathLoginSyn, + HelpDescription: pathLoginDesc, + } +} + +func (b *backend) pathLogin( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + username := d.Get("username").(string) + password := d.Get("password").(string) + + cfg, err := b.Config(req) + if err != nil { + return nil, err + } + if cfg == nil { + return logical.ErrorResponse("ldap backend not configured"), nil + } + + u, err := url.Parse(cfg.Url) + if err != nil { + return nil, err + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + host = u.Host + } + + var c *ldap.Conn + var cerr *ldap.Error + switch u.Scheme { + case "ldap": + if port == "" { + port = "389" + } + c, cerr = ldap.Dial("tcp", host+":"+port) + case "ldaps": + if port == "" { + port = "636" + } + c, cerr = ldap.DialSSL("tcp", host+":"+port) + default: + return logical.ErrorResponse("invalid LDAP URL scheme"), nil + } + if cerr != nil { + return nil, cerr + } + + binddn := fmt.Sprintf("%s=%s,%s", cfg.UserAttr, username, cfg.Domain) + cerr = c.Bind(binddn, password) + if cerr != nil { + return logical.ErrorResponse(fmt.Sprintf("LDAP bind failed: %s", cerr.Error())), nil + } + + return &logical.Response{ + Auth: &logical.Auth{ + Policies: []string{"root"}, + Metadata: map[string]string{ + "username": username, + }, + DisplayName: username, + }, + }, nil +} + +// func (b *backend) pathLoginRenew( +// req *logical.Request, d *framework.FieldData) (*logical.Response, error) { +// // Get the user and validate auth +// user, err := b.User(req.Storage, req.Auth.Metadata["username"]) +// if err != nil { +// return nil, err +// } +// if user == nil { +// // User no longer exists, do not renew +// return nil, nil +// } + +// return framework.LeaseExtend(1*time.Hour, 0)(req, d) +// } + +const pathLoginSyn = ` +Log in with a username and password. +` + +const pathLoginDesc = ` +This endpoint authenticates using a username and password. +` diff --git a/cli/commands.go b/cli/commands.go index d876241e6..f4d1832aa 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -9,6 +9,7 @@ import ( credAppId "github.com/hashicorp/vault/builtin/credential/app-id" credCert "github.com/hashicorp/vault/builtin/credential/cert" credGitHub "github.com/hashicorp/vault/builtin/credential/github" + credLdap "github.com/hashicorp/vault/builtin/credential/ldap" credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" "github.com/hashicorp/vault/builtin/logical/aws" @@ -58,6 +59,7 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { "app-id": credAppId.Factory, "github": credGitHub.Factory, "userpass": credUserpass.Factory, + "ldap": credLdap.Factory, }, LogicalBackends: map[string]logical.Factory{ "aws": aws.Factory, @@ -81,6 +83,7 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { Handlers: map[string]command.AuthHandler{ "github": &credGitHub.CLIHandler{}, "userpass": &credUserpass.CLIHandler{}, + "ldap": &credLdap.CLIHandler{}, }, }, nil },