mirror of
https://github.com/minio/minio.git
synced 2024-12-28 08:05:55 -05:00
cb2c2905c5
LDAP TLS dialer shouldn't be strict with ServerName, there maybe many certs talking to common DNS endpoint it is better to allow Dialer to choose appropriate public cert.
562 lines
15 KiB
Go
562 lines
15 KiB
Go
// Copyright (c) 2015-2021 MinIO, Inc.
|
|
//
|
|
// This file is part of MinIO Object Storage stack
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package ldap
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
ldap "github.com/go-ldap/ldap/v3"
|
|
"github.com/minio/minio-go/v7/pkg/set"
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/minio/minio/internal/config"
|
|
"github.com/minio/pkg/env"
|
|
)
|
|
|
|
const (
|
|
defaultLDAPExpiry = time.Hour * 1
|
|
|
|
dnDelimiter = ";"
|
|
|
|
minLDAPExpiry time.Duration = 15 * time.Minute
|
|
maxLDAPExpiry time.Duration = 365 * 24 * time.Hour
|
|
)
|
|
|
|
// Config contains AD/LDAP server connectivity information.
|
|
type Config struct {
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// E.g. "ldap.minio.io:636"
|
|
ServerAddr string `json:"serverAddr"`
|
|
|
|
// User DN search parameters
|
|
UserDNSearchBaseDN string `json:"userDNSearchBaseDN"`
|
|
UserDNSearchFilter string `json:"userDNSearchFilter"`
|
|
|
|
// Group search parameters
|
|
GroupSearchBaseDistName string `json:"groupSearchBaseDN"`
|
|
GroupSearchBaseDistNames []string `json:"-"`
|
|
GroupSearchFilter string `json:"groupSearchFilter"`
|
|
|
|
// Lookup bind LDAP service account
|
|
LookupBindDN string `json:"lookupBindDN"`
|
|
LookupBindPassword string `json:"lookupBindPassword"`
|
|
|
|
stsExpiryDuration time.Duration // contains converted value
|
|
tlsSkipVerify bool // allows skipping TLS verification
|
|
serverInsecure bool // allows plain text connection to LDAP server
|
|
serverStartTLS bool // allows using StartTLS connection to LDAP server
|
|
isUsingLookupBind bool
|
|
rootCAs *x509.CertPool
|
|
}
|
|
|
|
// LDAP keys and envs.
|
|
const (
|
|
ServerAddr = "server_addr"
|
|
LookupBindDN = "lookup_bind_dn"
|
|
LookupBindPassword = "lookup_bind_password"
|
|
UserDNSearchBaseDN = "user_dn_search_base_dn"
|
|
UserDNSearchFilter = "user_dn_search_filter"
|
|
GroupSearchFilter = "group_search_filter"
|
|
GroupSearchBaseDN = "group_search_base_dn"
|
|
TLSSkipVerify = "tls_skip_verify"
|
|
ServerInsecure = "server_insecure"
|
|
ServerStartTLS = "server_starttls"
|
|
|
|
EnvServerAddr = "MINIO_IDENTITY_LDAP_SERVER_ADDR"
|
|
EnvTLSSkipVerify = "MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY"
|
|
EnvServerInsecure = "MINIO_IDENTITY_LDAP_SERVER_INSECURE"
|
|
EnvServerStartTLS = "MINIO_IDENTITY_LDAP_SERVER_STARTTLS"
|
|
EnvUsernameFormat = "MINIO_IDENTITY_LDAP_USERNAME_FORMAT"
|
|
EnvUserDNSearchBaseDN = "MINIO_IDENTITY_LDAP_USER_DN_SEARCH_BASE_DN"
|
|
EnvUserDNSearchFilter = "MINIO_IDENTITY_LDAP_USER_DN_SEARCH_FILTER"
|
|
EnvGroupSearchFilter = "MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER"
|
|
EnvGroupSearchBaseDN = "MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN"
|
|
EnvLookupBindDN = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN"
|
|
EnvLookupBindPassword = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD"
|
|
)
|
|
|
|
var removedKeys = []string{
|
|
"sts_expiry",
|
|
"username_format",
|
|
"username_search_filter",
|
|
"username_search_base_dn",
|
|
"group_name_attribute",
|
|
}
|
|
|
|
// DefaultKVS - default config for LDAP config
|
|
var (
|
|
DefaultKVS = config.KVS{
|
|
config.KV{
|
|
Key: ServerAddr,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: UserDNSearchBaseDN,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: UserDNSearchFilter,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: GroupSearchFilter,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: GroupSearchBaseDN,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: TLSSkipVerify,
|
|
Value: config.EnableOff,
|
|
},
|
|
config.KV{
|
|
Key: ServerInsecure,
|
|
Value: config.EnableOff,
|
|
},
|
|
config.KV{
|
|
Key: ServerStartTLS,
|
|
Value: config.EnableOff,
|
|
},
|
|
config.KV{
|
|
Key: LookupBindDN,
|
|
Value: "",
|
|
},
|
|
config.KV{
|
|
Key: LookupBindPassword,
|
|
Value: "",
|
|
},
|
|
}
|
|
)
|
|
|
|
func getGroups(conn *ldap.Conn, sreq *ldap.SearchRequest) ([]string, error) {
|
|
var groups []string
|
|
sres, err := conn.Search(sreq)
|
|
if err != nil {
|
|
// Check if there is no matching result and return empty slice.
|
|
// Ref: https://ldap.com/ldap-result-code-reference/
|
|
if ldap.IsErrorWithCode(err, 32) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
for _, entry := range sres.Entries {
|
|
// We only queried one attribute,
|
|
// so we only look up the first one.
|
|
groups = append(groups, entry.DN)
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
func (l *Config) lookupBind(conn *ldap.Conn) error {
|
|
var err error
|
|
if l.LookupBindPassword == "" {
|
|
err = conn.UnauthenticatedBind(l.LookupBindDN)
|
|
} else {
|
|
err = conn.Bind(l.LookupBindDN, l.LookupBindPassword)
|
|
}
|
|
if ldap.IsErrorWithCode(err, 49) {
|
|
return fmt.Errorf("LDAP Lookup Bind user invalid credentials error: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// lookupUserDN searches for the DN of the user given their username. conn is
|
|
// assumed to be using the lookup bind service account. It is required that the
|
|
// search result in at most one result.
|
|
func (l *Config) lookupUserDN(conn *ldap.Conn, username string) (string, error) {
|
|
filter := strings.Replace(l.UserDNSearchFilter, "%s", ldap.EscapeFilter(username), -1)
|
|
searchRequest := ldap.NewSearchRequest(
|
|
l.UserDNSearchBaseDN,
|
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
|
filter,
|
|
[]string{}, // only need DN, so no pass no attributes here
|
|
nil,
|
|
)
|
|
|
|
searchResult, err := conn.Search(searchRequest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(searchResult.Entries) == 0 {
|
|
return "", fmt.Errorf("User DN for %s not found", username)
|
|
}
|
|
if len(searchResult.Entries) != 1 {
|
|
return "", fmt.Errorf("Multiple DNs for %s found - please fix the search filter", username)
|
|
}
|
|
return searchResult.Entries[0].DN, nil
|
|
}
|
|
|
|
func (l *Config) searchForUserGroups(conn *ldap.Conn, username, bindDN string) ([]string, error) {
|
|
// User groups lookup.
|
|
var groups []string
|
|
if l.GroupSearchFilter != "" {
|
|
for _, groupSearchBase := range l.GroupSearchBaseDistNames {
|
|
filter := strings.Replace(l.GroupSearchFilter, "%s", ldap.EscapeFilter(username), -1)
|
|
filter = strings.Replace(filter, "%d", ldap.EscapeFilter(bindDN), -1)
|
|
searchRequest := ldap.NewSearchRequest(
|
|
groupSearchBase,
|
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
|
filter,
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
var newGroups []string
|
|
newGroups, err := getGroups(conn, searchRequest)
|
|
if err != nil {
|
|
errRet := fmt.Errorf("Error finding groups of %s: %w", bindDN, err)
|
|
return nil, errRet
|
|
}
|
|
|
|
groups = append(groups, newGroups...)
|
|
}
|
|
}
|
|
|
|
return groups, nil
|
|
}
|
|
|
|
// LookupUserDN searches for the full DN and groups of a given username
|
|
func (l *Config) LookupUserDN(username string) (string, []string, error) {
|
|
if !l.isUsingLookupBind {
|
|
return "", nil, errors.New("current lookup mode does not support searching for User DN")
|
|
}
|
|
|
|
conn, err := l.Connect()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Bind to the lookup user account
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Lookup user DN
|
|
bindDN, err := l.lookupUserDN(conn, username)
|
|
if err != nil {
|
|
errRet := fmt.Errorf("Unable to find user DN: %w", err)
|
|
return "", nil, errRet
|
|
}
|
|
|
|
groups, err := l.searchForUserGroups(conn, username, bindDN)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return bindDN, groups, nil
|
|
}
|
|
|
|
// Bind - binds to ldap, searches LDAP and returns the distinguished name of the
|
|
// user and the list of groups.
|
|
func (l *Config) Bind(username, password string) (string, []string, error) {
|
|
conn, err := l.Connect()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
var bindDN string
|
|
// Bind to the lookup user account
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Lookup user DN
|
|
bindDN, err = l.lookupUserDN(conn, username)
|
|
if err != nil {
|
|
errRet := fmt.Errorf("Unable to find user DN: %w", err)
|
|
return "", nil, errRet
|
|
}
|
|
|
|
// Authenticate the user credentials.
|
|
err = conn.Bind(bindDN, password)
|
|
if err != nil {
|
|
errRet := fmt.Errorf("LDAP auth failed for DN %s: %w", bindDN, err)
|
|
return "", nil, errRet
|
|
}
|
|
|
|
// Bind to the lookup user account again to perform group search.
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// User groups lookup.
|
|
groups, err := l.searchForUserGroups(conn, username, bindDN)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return bindDN, groups, nil
|
|
}
|
|
|
|
// Connect connect to ldap server.
|
|
func (l *Config) Connect() (ldapConn *ldap.Conn, err error) {
|
|
if l == nil {
|
|
return nil, errors.New("LDAP is not configured")
|
|
}
|
|
|
|
_, _, err = net.SplitHostPort(l.ServerAddr)
|
|
if err != nil {
|
|
// User default LDAP port if none specified "636"
|
|
l.ServerAddr = net.JoinHostPort(l.ServerAddr, "636")
|
|
}
|
|
|
|
if l.serverInsecure {
|
|
return ldap.Dial("tcp", l.ServerAddr)
|
|
}
|
|
|
|
tlsConfig := &tls.Config{
|
|
InsecureSkipVerify: l.tlsSkipVerify,
|
|
RootCAs: l.rootCAs,
|
|
}
|
|
|
|
if l.serverStartTLS {
|
|
conn, err := ldap.Dial("tcp", l.ServerAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = conn.StartTLS(tlsConfig)
|
|
return conn, err
|
|
}
|
|
|
|
return ldap.DialTLS("tcp", l.ServerAddr, tlsConfig)
|
|
}
|
|
|
|
// GetExpiryDuration - return parsed expiry duration.
|
|
func (l Config) GetExpiryDuration(dsecs string) (time.Duration, error) {
|
|
if dsecs == "" {
|
|
return l.stsExpiryDuration, nil
|
|
}
|
|
|
|
d, err := strconv.Atoi(dsecs)
|
|
if err != nil {
|
|
return 0, auth.ErrInvalidDuration
|
|
}
|
|
|
|
dur := time.Duration(d) * time.Second
|
|
|
|
if dur < minLDAPExpiry || dur > maxLDAPExpiry {
|
|
return 0, auth.ErrInvalidDuration
|
|
}
|
|
return dur, nil
|
|
}
|
|
|
|
func (l Config) testConnection() error {
|
|
conn, err := l.Connect()
|
|
if err != nil {
|
|
return fmt.Errorf("Error creating connection to LDAP server: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return fmt.Errorf("Error connecting as LDAP Lookup Bind user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsLDAPUserDN determines if the given string could be a user DN from LDAP.
|
|
func (l Config) IsLDAPUserDN(user string) bool {
|
|
return strings.HasSuffix(user, ","+l.UserDNSearchBaseDN)
|
|
}
|
|
|
|
// GetNonEligibleUserDistNames - find user accounts (DNs) that are no longer
|
|
// present in the LDAP server or do not meet filter criteria anymore
|
|
func (l *Config) GetNonEligibleUserDistNames(userDistNames []string) ([]string, error) {
|
|
if !l.isUsingLookupBind {
|
|
return nil, errors.New("current LDAP configuration does not permit looking for expired user accounts")
|
|
}
|
|
|
|
conn, err := l.Connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Bind to the lookup user account
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Evaluate the filter again with generic wildcard instead of specific values
|
|
filter := strings.Replace(l.UserDNSearchFilter, "%s", "*", -1)
|
|
|
|
nonExistentUsers := []string{}
|
|
for _, dn := range userDistNames {
|
|
searchRequest := ldap.NewSearchRequest(
|
|
dn,
|
|
ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
|
|
filter,
|
|
[]string{}, // only need DN, so no pass no attributes here
|
|
nil,
|
|
)
|
|
|
|
searchResult, err := conn.Search(searchRequest)
|
|
if err != nil {
|
|
// Object does not exist error?
|
|
if ldap.IsErrorWithCode(err, 32) {
|
|
nonExistentUsers = append(nonExistentUsers, dn)
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
if len(searchResult.Entries) == 0 {
|
|
// DN was not found - this means this user account is
|
|
// expired.
|
|
nonExistentUsers = append(nonExistentUsers, dn)
|
|
}
|
|
}
|
|
return nonExistentUsers, nil
|
|
}
|
|
|
|
// LookupGroupMemberships - for each DN finds the set of LDAP groups they are a
|
|
// member of.
|
|
func (l *Config) LookupGroupMemberships(userDistNames []string, userDNToUsernameMap map[string]string) (map[string]set.StringSet, error) {
|
|
if !l.isUsingLookupBind {
|
|
return nil, errors.New("current LDAP configuration does not permit this lookup")
|
|
}
|
|
|
|
conn, err := l.Connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Bind to the lookup user account
|
|
if err = l.lookupBind(conn); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := make(map[string]set.StringSet, len(userDistNames))
|
|
for _, userDistName := range userDistNames {
|
|
username := userDNToUsernameMap[userDistName]
|
|
groups, err := l.searchForUserGroups(conn, username, userDistName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res[userDistName] = set.CreateStringSet(groups...)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// EnabledWithLookupBind - checks if ldap IDP is enabled in lookup bind mode.
|
|
func (l Config) EnabledWithLookupBind() bool {
|
|
return l.Enabled && l.isUsingLookupBind
|
|
}
|
|
|
|
// Enabled returns if jwks is enabled.
|
|
func Enabled(kvs config.KVS) bool {
|
|
return kvs.Get(ServerAddr) != ""
|
|
}
|
|
|
|
// Lookup - initializes LDAP config, overrides config, if any ENV values are set.
|
|
func Lookup(kvs config.KVS, rootCAs *x509.CertPool) (l Config, err error) {
|
|
l = Config{}
|
|
|
|
// Purge all removed keys first
|
|
for _, k := range removedKeys {
|
|
kvs.Delete(k)
|
|
}
|
|
|
|
if err = config.CheckValidKeys(config.IdentityLDAPSubSys, kvs, DefaultKVS); err != nil {
|
|
return l, err
|
|
}
|
|
ldapServer := env.Get(EnvServerAddr, kvs.Get(ServerAddr))
|
|
if ldapServer == "" {
|
|
return l, nil
|
|
}
|
|
l.Enabled = true
|
|
l.rootCAs = rootCAs
|
|
l.ServerAddr = ldapServer
|
|
l.stsExpiryDuration = defaultLDAPExpiry
|
|
|
|
// LDAP connection configuration
|
|
if v := env.Get(EnvServerInsecure, kvs.Get(ServerInsecure)); v != "" {
|
|
l.serverInsecure, err = config.ParseBool(v)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
}
|
|
if v := env.Get(EnvServerStartTLS, kvs.Get(ServerStartTLS)); v != "" {
|
|
l.serverStartTLS, err = config.ParseBool(v)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
}
|
|
if v := env.Get(EnvTLSSkipVerify, kvs.Get(TLSSkipVerify)); v != "" {
|
|
l.tlsSkipVerify, err = config.ParseBool(v)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
}
|
|
|
|
// Lookup bind user configuration
|
|
lookupBindDN := env.Get(EnvLookupBindDN, kvs.Get(LookupBindDN))
|
|
lookupBindPassword := env.Get(EnvLookupBindPassword, kvs.Get(LookupBindPassword))
|
|
if lookupBindDN != "" {
|
|
l.LookupBindDN = lookupBindDN
|
|
l.LookupBindPassword = lookupBindPassword
|
|
l.isUsingLookupBind = true
|
|
|
|
// User DN search configuration
|
|
userDNSearchBaseDN := env.Get(EnvUserDNSearchBaseDN, kvs.Get(UserDNSearchBaseDN))
|
|
userDNSearchFilter := env.Get(EnvUserDNSearchFilter, kvs.Get(UserDNSearchFilter))
|
|
if userDNSearchFilter == "" || userDNSearchBaseDN == "" {
|
|
return l, errors.New("In lookup bind mode, userDN search base DN and userDN search filter are both required")
|
|
}
|
|
l.UserDNSearchBaseDN = userDNSearchBaseDN
|
|
l.UserDNSearchFilter = userDNSearchFilter
|
|
}
|
|
|
|
// Lookup bind mode is mandatory
|
|
if !l.isUsingLookupBind {
|
|
return l, errors.New("Lookup Bind mode is required")
|
|
}
|
|
|
|
// Test connection to LDAP server.
|
|
if err := l.testConnection(); err != nil {
|
|
return l, fmt.Errorf("Connection test for LDAP server failed: %w", err)
|
|
}
|
|
|
|
// Group search params configuration
|
|
grpSearchFilter := env.Get(EnvGroupSearchFilter, kvs.Get(GroupSearchFilter))
|
|
grpSearchBaseDN := env.Get(EnvGroupSearchBaseDN, kvs.Get(GroupSearchBaseDN))
|
|
|
|
// Either all group params must be set or none must be set.
|
|
if (grpSearchFilter != "" && grpSearchBaseDN == "") || (grpSearchFilter == "" && grpSearchBaseDN != "") {
|
|
return l, errors.New("All group related parameters must be set")
|
|
}
|
|
|
|
if grpSearchFilter != "" {
|
|
l.GroupSearchFilter = grpSearchFilter
|
|
l.GroupSearchBaseDistName = grpSearchBaseDN
|
|
l.GroupSearchBaseDistNames = strings.Split(l.GroupSearchBaseDistName, dnDelimiter)
|
|
}
|
|
|
|
return l, nil
|
|
}
|