Files
teleport/lib/auth/windows/ldap.go
T

286 lines
10 KiB
Go

/*
Copyright 2021 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package windows
import (
"crypto/x509"
"fmt"
"strings"
"sync"
"github.com/go-ldap/ldap/v3"
"github.com/gravitational/trace"
)
// LDAPConfig contains parameters for connecting to an LDAP server.
type LDAPConfig struct {
// Addr is the LDAP server address in the form host:port.
// Standard port is 636 for LDAPS.
Addr string //nolint:unused // False-positive
// Domain is an Active Directory domain name, like "example.com".
Domain string //nolint:unused // False-positive
// Username is an LDAP username, like "EXAMPLE\Administrator", where
// "EXAMPLE" is the NetBIOS version of Domain.
Username string //nolint:unused // False-positive
// SID is the SID for the user specified by Username.
SID string //nolint:unused // False-positive
// InsecureSkipVerify decides whether we skip verifying with the LDAP server's CA when making the LDAPS connection.
InsecureSkipVerify bool //nolint:unused // False-positive
// ServerName is the name of the LDAP server for TLS.
ServerName string //nolint:unused // False-positive
// CA is an optional CA cert to be used for verification if InsecureSkipVerify is set to false.
CA *x509.Certificate //nolint:unused // False-positive
}
// Check verifies this LDAPConfig
func (cfg LDAPConfig) Check() error {
if cfg.Addr == "" {
return trace.BadParameter("missing Addr in LDAPConfig")
}
if cfg.Domain == "" {
return trace.BadParameter("missing Domain in LDAPConfig")
}
if cfg.Username == "" {
return trace.BadParameter("missing Username in LDAPConfig")
}
return nil
}
// DomainDN returns the distinguished name for the domain
func (cfg LDAPConfig) DomainDN() string {
var sb strings.Builder
parts := strings.Split(cfg.Domain, ".")
for _, p := range parts {
if sb.Len() > 0 {
sb.WriteString(",")
}
sb.WriteString("DC=")
sb.WriteString(p)
}
return sb.String()
}
// See: https://docs.microsoft.com/en-US/windows/security/identity-protection/access-control/security-identifiers
const (
// WritableDomainControllerGroupID is the windows security identifier for dcs with write permissions
WritableDomainControllerGroupID = "516"
// ReadOnlyDomainControllerGroupID is the windows security identifier for read only dcs
ReadOnlyDomainControllerGroupID = "521"
)
const (
// ClassComputer is the object class for computers in Active Directory
ClassComputer = "computer"
// ClassContainer is the object class for containers in Active Directory
ClassContainer = "container"
// ClassGMSA is the object class for group managed service accounts in Active Directory.
ClassGMSA = "msDS-GroupManagedServiceAccount"
// ClassUser is the object class for users in Active Directory
ClassUser = "user"
// CategoryPerson is object category for persons in Active Directory
CategoryPerson = "person"
// AttrName is the name of an LDAP object
AttrName = "name"
// AttrSAMAccountName is the SAM Account name of an LDAP object
AttrSAMAccountName = "sAMAccountName"
// AttrCommonName is the common name of an LDAP object, or "CN"
AttrCommonName = "cn"
// AttrDistinguishedName is the distinguished name of an LDAP object, or "DN"
AttrDistinguishedName = "distinguishedName"
// AttrDNSHostName is the DNS Host name of an LDAP object
AttrDNSHostName = "dNSHostName" // unusual capitalization is correct
// AttrObjectGUID is the globally unique identifier for an LDAP object
AttrObjectGUID = "objectGUID"
// AttrOS is the operating system of a computer object
AttrOS = "operatingSystem"
// AttrOSVersion is the operating system version of a computer object
AttrOSVersion = "operatingSystemVersion"
// AttrPrimaryGroupID is the primary group id of an LDAP object
AttrPrimaryGroupID = "primaryGroupID"
// AttrObjectSid is the Security Identifier of an LDAP object
AttrObjectSid = "objectSid"
// AttrObjectCategory is the object category of an LDAP object
AttrObjectCategory = "objectCategory"
// AttrObjectClass is the object class of an LDAP object
AttrObjectClass = "objectClass"
)
// searchPageSize is desired page size for LDAP search. In Active Directory the default search size limit is 1000 entries,
// so in most cases the 1000 search page size will result in the optimal amount of requests made to
// LDAP server.
const searchPageSize = 1000
// Note: if you want to browse LDAP on the Windows machine, run ADSIEdit.msc.
// LDAPClient is a windows LDAP client.
//
// It does not automatically detect when the underlying connection
// is closed. Callers should check for trace.ConnectionProblem errors
// and provide a new client with [SetClient].
type LDAPClient struct {
// Cfg is the LDAPConfig
Cfg LDAPConfig
mu sync.Mutex
client ldap.Client
}
// SetClient sets the underlying ldap.Client
func (c *LDAPClient) SetClient(client ldap.Client) {
c.mu.Lock()
if c.client != nil {
c.client.Close()
}
c.client = client
c.mu.Unlock()
}
// Close closes the underlying ldap.Client
func (c *LDAPClient) Close() {
c.mu.Lock()
if c.client != nil {
c.client.Close()
}
c.mu.Unlock()
}
// ReadWithFilter searches the specified DN (and its children) using the specified LDAP filter.
// See https://ldap.com/ldap-filters/ for more information on LDAP filter syntax.
func (c *LDAPClient) ReadWithFilter(dn string, filter string, attrs []string) ([]*ldap.Entry, error) {
req := ldap.NewSearchRequest(
dn,
ldap.ScopeWholeSubtree,
ldap.DerefAlways,
0, // no SizeLimit
0, // no TimeLimit
false, // TypesOnly == false, we want attribute values
filter,
attrs,
nil, // no Controls
)
c.mu.Lock()
defer c.mu.Unlock()
res, err := c.client.SearchWithPaging(req, searchPageSize)
if ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
return nil, trace.ConnectionProblem(err, "fetching LDAP object %q with filter %q: %v", dn, filter, err)
} else if err != nil {
return nil, trace.Wrap(err, "fetching LDAP object %q with filter %q: %v", dn, filter, err)
}
return res.Entries, nil
}
// Read fetches an LDAP entry at path and its children, if any. Only
// entries with the given class are returned and only with the specified
// attributes.
//
// You can browse LDAP on the Windows host to find the objectClass for a
// specific entry using ADSIEdit.msc.
// You can find the list of all AD classes at
// https://docs.microsoft.com/en-us/windows/win32/adschema/classes-all
func (c *LDAPClient) Read(dn string, class string, attrs []string) ([]*ldap.Entry, error) {
return c.ReadWithFilter(dn, fmt.Sprintf("(%s=%s)", AttrObjectClass, class), attrs)
}
// Create creates an LDAP entry at the given path, with the given class and
// attributes. Note that AD will create a bunch of attributes for each object
// class automatically and you don't need to specify all of them.
//
// You can browse LDAP on the Windows host to find the objectClass and
// attributes for similar entries using ADSIEdit.msc.
// You can find the list of all AD classes at
// https://docs.microsoft.com/en-us/windows/win32/adschema/classes-all
func (c *LDAPClient) Create(dn string, class string, attrs map[string][]string) error {
req := ldap.NewAddRequest(dn, nil)
for k, v := range attrs {
req.Attribute(k, v)
}
req.Attribute("objectClass", []string{class})
c.mu.Lock()
defer c.mu.Unlock()
if err := c.client.Add(req); err != nil {
if ldapErr, ok := err.(*ldap.Error); ok {
switch ldapErr.ResultCode {
case ldap.LDAPResultEntryAlreadyExists:
return trace.AlreadyExists("LDAP object %q already exists: %v", dn, err)
case ldap.LDAPResultConstraintViolation:
return trace.BadParameter("object constraint violation on %q: %v", dn, err)
case ldap.LDAPResultInsufficientAccessRights:
return trace.AccessDenied("insufficient permissions to create %q: %v", dn, err)
case ldap.ErrorNetwork:
return trace.ConnectionProblem(err, "network error creating %q", dn)
}
}
return trace.Wrap(err, "error creating LDAP object %q: %v", dn, err)
}
return nil
}
// CreateContainer creates an LDAP container entry if
// it doesn't already exist.
func (c *LDAPClient) CreateContainer(dn string) error {
err := c.Create(dn, ClassContainer, nil)
// Ignore the error if container already exists.
if trace.IsAlreadyExists(err) {
return nil
} else if ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
return trace.ConnectionProblem(err, "creating %v", dn)
}
return trace.Wrap(err)
}
// Update updates an LDAP entry at the given path, replacing the provided
// attributes. For each attribute in replaceAttrs, the value is completely
// replaced, not merged. If you want to modify the value of an existing
// attribute, you should read the existing value first, modify it and provide
// the final combined value in replaceAttrs.
//
// You can browse LDAP on the Windows host to find attributes of existing
// entries using ADSIEdit.msc.
func (c *LDAPClient) Update(dn string, replaceAttrs map[string][]string) error {
req := ldap.NewModifyRequest(dn, nil)
for k, v := range replaceAttrs {
req.Replace(k, v)
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.client.Modify(req); ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
return trace.ConnectionProblem(err, "updating %q", dn)
} else if err != nil {
return trace.Wrap(err, "updating %q: %v", dn, err)
}
return nil
}
// CombineLDAPFilters joins the slice of filters
func CombineLDAPFilters(filters []string) string {
return "(&" + strings.Join(filters, "") + ")"
}
func crlContainerDN(config LDAPConfig) string {
return "CN=Teleport,CN=CDP,CN=Public Key Services,CN=Services,CN=Configuration," + config.DomainDN()
}
func crlDN(clusterName string, config LDAPConfig) string {
return "CN=" + clusterName + "," + crlContainerDN(config)
}