Update ldap client implementation to github.com/go-ldap/ldap/v3 Add contexts to all relevant functions eg NewHandler Provide backwards compatibility for sql handlers
855 lines
35 KiB
Go
855 lines
35 KiB
Go
package handler
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/GeertJohan/yubigo"
|
|
"github.com/glauth/glauth/v2/pkg/config"
|
|
"github.com/glauth/glauth/v2/pkg/stats"
|
|
"github.com/glauth/ldaps"
|
|
"github.com/go-ldap/ldap/v3"
|
|
"github.com/pquerna/otp/totp"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
var configattributematcher = regexp.MustCompile(`(?i)\((?P<attribute>[a-zA-Z0-9]+)\s*=\s*(?P<value>.*)\)`)
|
|
var emailmatcher = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
|
|
|
type LDAPOpsHandler interface {
|
|
GetBackend() config.Backend
|
|
GetLog() *zerolog.Logger
|
|
GetCfg() *config.Config
|
|
GetYubikeyAuth() *yubigo.YubiAuth
|
|
|
|
FindUser(ctx context.Context, userName string, searchByUPN bool) (f bool, u config.User, err error)
|
|
FindGroup(ctx context.Context, groupName string) (f bool, g config.Group, err error)
|
|
FindPosixAccounts(ctx context.Context, hierarchy string) (entrylist []*ldap.Entry, err error)
|
|
FindPosixGroups(ctx context.Context, hierarchy string) (entrylist []*ldap.Entry, err error)
|
|
}
|
|
|
|
type failedBind struct {
|
|
ts time.Time
|
|
}
|
|
|
|
type sourceInfo struct {
|
|
lastSeen time.Time
|
|
failures chan failedBind
|
|
waitUntil time.Time
|
|
}
|
|
|
|
type LDAPOpsHelper struct {
|
|
sources map[string]*sourceInfo
|
|
nextPruning time.Time
|
|
|
|
tracer trace.Tracer
|
|
}
|
|
|
|
func NewLDAPOpsHelper(tracer trace.Tracer) LDAPOpsHelper {
|
|
helper := LDAPOpsHelper{
|
|
sources: make(map[string]*sourceInfo),
|
|
nextPruning: time.Now(),
|
|
tracer: tracer,
|
|
}
|
|
return helper
|
|
}
|
|
|
|
func isUnauthenticatedSimpleBind(bindDN, bindSimplePw string) bool {
|
|
return bindDN != "" && bindSimplePw == ""
|
|
}
|
|
|
|
func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindSimplePw string, conn net.Conn) (result *ldap.SimpleBindResult, err error) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.Bind")
|
|
defer span.End()
|
|
|
|
if l.isInTimeout(ctx, h, conn) {
|
|
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, ldaps.ErrEmpty)
|
|
}
|
|
|
|
bindDN = strings.ToLower(bindDN)
|
|
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("basedn", h.GetBackend().BaseDN).Str("src", conn.RemoteAddr().String()).Msg("Bind request")
|
|
|
|
stats.Frontend.Add("bind_reqs", 1)
|
|
|
|
// Special Case: bind as anonymous
|
|
if bindDN == "" && bindSimplePw == "" {
|
|
stats.Frontend.Add("bind_successes", 1)
|
|
h.GetLog().Info().Str("src", conn.RemoteAddr().String()).Msg("Anonymous Bind success")
|
|
return nil, nil
|
|
}
|
|
|
|
// A non-empty DN with an empty password is LDAP's unauthenticated bind
|
|
// mechanism. It must not establish the named user's authorization state.
|
|
if isUnauthenticatedSimpleBind(bindDN, bindSimplePw) {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("unauthenticated bind rejected")
|
|
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, ldaps.ErrEmpty)
|
|
}
|
|
|
|
user, ldapcode := l.findUser(ctx, h, bindDN, true /* checkGroup */)
|
|
if ldapcode != ldap.LDAPResultSuccess {
|
|
return nil, ldap.NewError(ldapcode, ldaps.ErrEmpty)
|
|
}
|
|
|
|
// a disabled account must never authenticate, on any backend
|
|
if user.Disabled {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind attempt on disabled account")
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
|
|
validotp := false
|
|
|
|
if len(user.Yubikey) == 0 && len(user.OTPSecret) == 0 {
|
|
validotp = true
|
|
}
|
|
|
|
if len(user.Yubikey) > 0 && h.GetYubikeyAuth() != nil {
|
|
if len(bindSimplePw) > 44 {
|
|
otp := bindSimplePw[len(bindSimplePw)-44:]
|
|
yubikeyid := otp[0:12]
|
|
bindSimplePw = bindSimplePw[:len(bindSimplePw)-44]
|
|
|
|
if user.Yubikey == yubikeyid {
|
|
_, ok, _ := h.GetYubikeyAuth().Verify(otp)
|
|
|
|
if ok {
|
|
validotp = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store the full bind password provided before possibly modifying
|
|
// in the otp check
|
|
untouchedBindSimplePw := bindSimplePw
|
|
|
|
// Test OTP if is exists
|
|
if len(user.OTPSecret) > 0 && !validotp {
|
|
if len(bindSimplePw) > 6 {
|
|
otp := bindSimplePw[len(bindSimplePw)-6:]
|
|
bindSimplePw = bindSimplePw[:len(bindSimplePw)-6]
|
|
|
|
validotp = totp.Validate(otp, user.OTPSecret)
|
|
}
|
|
}
|
|
|
|
// finally, validate user's pw
|
|
|
|
// check app passwords first
|
|
if user.PassAppBcrypt != nil {
|
|
for index, appPw := range user.PassAppBcrypt {
|
|
decoded, err := hex.DecodeString(appPw)
|
|
if err != nil {
|
|
h.GetLog().Info().Str("incorrect stored hash", "(omitted)").Msg("invalid app credentials")
|
|
} else {
|
|
if bcrypt.CompareHashAndPassword(decoded, []byte(untouchedBindSimplePw)) == nil {
|
|
stats.Frontend.Add("bind_successes", 1)
|
|
h.GetLog().Info().Int("index", index).Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind success using app pw")
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if user.PassAppSHA256 != nil {
|
|
hashFull := sha256.New()
|
|
hashFull.Write([]byte(untouchedBindSimplePw))
|
|
for index, appPw := range user.PassAppSHA256 {
|
|
if appPw != hex.EncodeToString(hashFull.Sum(nil)) {
|
|
h.GetLog().Info().Int("index", index).Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Attempt to bind app pw failed")
|
|
} else {
|
|
stats.Frontend.Add("bind_successes", 1)
|
|
h.GetLog().Info().Int("index", index).Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind success using app pw")
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
if user.PassAppCustom != nil {
|
|
err := user.PassAppCustom(user, untouchedBindSimplePw)
|
|
if err != nil {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Str("error", err.Error()).Msg("Attempt to bind app custom auth failed")
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
|
|
stats.Frontend.Add("bind_successes", 1)
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind success using app custom auth")
|
|
return nil, nil
|
|
}
|
|
|
|
// Then ensure the OTP is valid before checking
|
|
if !validotp {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid OTP token")
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
|
|
// Reaching this point without a configured primary password verifier must
|
|
// not be treated as successful authentication. App-password matches and
|
|
// custom authentication return above when they succeed.
|
|
if user.PassBcrypt == "" && user.PassSHA256 == "" {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid credentials")
|
|
l.maybePutInTimeout(ctx, h, conn, true)
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
|
|
// Now, check the pasword hash
|
|
if user.PassBcrypt != "" {
|
|
decoded, err := hex.DecodeString(user.PassBcrypt)
|
|
if err != nil {
|
|
h.GetLog().Info().Str("incorrect stored hash", "(omitted)").Msg("invalid credentials")
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
if bcrypt.CompareHashAndPassword(decoded, []byte(bindSimplePw)) != nil {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid credentials")
|
|
l.maybePutInTimeout(ctx, h, conn, true)
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
}
|
|
if user.PassSHA256 != "" {
|
|
hash := sha256.New()
|
|
hash.Write([]byte(bindSimplePw))
|
|
if user.PassSHA256 != hex.EncodeToString(hash.Sum(nil)) {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid credentials")
|
|
l.maybePutInTimeout(ctx, h, conn, true)
|
|
return nil, ldap.NewError(ldap.LDAPResultInvalidCredentials, ldaps.ErrEmpty)
|
|
}
|
|
}
|
|
|
|
stats.Frontend.Add("bind_successes", 1)
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind success")
|
|
return nil, nil
|
|
}
|
|
|
|
/*
|
|
* TODO #1:
|
|
* Is it possible to map, on-the-fly, ou= -> cn= to maintain backware compatibility? Could be a switch...
|
|
* Or maybe sinmply configure in the .cfg file using the nameformat and groupformat settings?
|
|
* In 3.0 we could change default from cn to ou
|
|
* TODO #2: DONE
|
|
* Returns values when scope==base or scope==sub on a group entry
|
|
* TODO #3: DONE
|
|
* Make sure that when scope==sub, we do not always return, but augment results instead
|
|
* TODO #4: DONE
|
|
* Handle groups as two distinct objectclasses like OLDAP does
|
|
* Q: Does OLDAP return the groups twice when querying root+sub?
|
|
* TODO #5:
|
|
* Document roll out of schemas
|
|
*/
|
|
func (l LDAPOpsHelper) Search(ctx context.Context, h LDAPOpsHandler, bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (result *ldap.SearchResult, err error) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.Search")
|
|
defer span.End()
|
|
|
|
if l.isInTimeout(ctx, h, conn) {
|
|
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("Source is in a timeout"))
|
|
}
|
|
|
|
bindDN = strings.ToLower(bindDN)
|
|
baseDN := strings.ToLower(h.GetBackend().BaseDN)
|
|
searchBaseDN := strings.ToLower(searchReq.BaseDN)
|
|
|
|
anonymous := len(bindDN) < 1
|
|
|
|
var boundUser *config.User
|
|
var ldapcode uint16
|
|
|
|
if !anonymous {
|
|
if bindDN, boundUser, ldapcode = l.searchCheckBindDN(ctx, h, baseDN, bindDN, anonymous); ldapcode != ldap.LDAPResultSuccess {
|
|
return nil, ldap.NewError(ldapcode, fmt.Errorf("Search Error: Potential bypass of BindDN %s", bindDN))
|
|
}
|
|
}
|
|
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("basedn", baseDN).Str("searchbasedn", searchBaseDN).Str("src", conn.RemoteAddr().String()).Int("scope", searchReq.Scope).Str("filter", searchReq.Filter).Msg("Search request")
|
|
stats.Frontend.Add("search_reqs", 1)
|
|
|
|
switch entries, ldapcode := l.searchMaybeRootDSEQuery(ctx, h, baseDN, searchBaseDN, searchReq, anonymous); ldapcode {
|
|
case ldap.LDAPResultUnwillingToPerform:
|
|
return nil, ldap.NewError(ldapcode, fmt.Errorf("Search Error: No BaseDN provided"))
|
|
case ldap.LDAPResultInsufficientAccessRights:
|
|
return nil, ldap.NewError(ldapcode, fmt.Errorf("Root DSE Search Error: Anonymous BindDN not allowed %s", bindDN))
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
// Past this point, there is no reason to allow anonymous searches
|
|
if anonymous {
|
|
return nil, ldap.NewError(ldap.LDAPResultInsufficientAccessRights, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", bindDN))
|
|
}
|
|
|
|
switch entries, ldapcode, attributename := l.searchMaybeSchemaQuery(ctx, h, baseDN, searchBaseDN, searchReq, anonymous); ldapcode {
|
|
case ldap.LDAPResultOperationsError:
|
|
return nil, ldap.NewError(ldapcode, fmt.Errorf("Schema Error: attribute %s cannot be read", *attributename))
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
// Past this further point, we are looking at tree searches... not all standard searches yet, though
|
|
|
|
// But first, let's only allow legal searches
|
|
if !strings.HasSuffix(bindDN, fmt.Sprintf(",%s", baseDN)) {
|
|
return nil, ldap.NewError(ldap.LDAPResultInsufficientAccessRights, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", bindDN, h.GetBackend().BaseDN))
|
|
}
|
|
if !strings.HasSuffix(searchBaseDN, h.GetBackend().BaseDN) {
|
|
return nil, ldap.NewError(ldap.LDAPResultInsufficientAccessRights, fmt.Errorf("Search Error: search BaseDN %s is not in our BaseDN %s", searchBaseDN, h.GetBackend().BaseDN))
|
|
}
|
|
// Unless globally ignored, we will check that a user has capabilities allowing them to perform a search in the requested BaseDN
|
|
if !h.GetCfg().Behaviors.IgnoreCapabilities && !l.checkCapability(ctx, *boundUser, "search", []string{"*", searchBaseDN}) {
|
|
return nil, ldap.NewError(ldap.LDAPResultInsufficientAccessRights, fmt.Errorf("Search Error: no capability allowing BindDN %s to perform search in %s", bindDN, searchBaseDN))
|
|
}
|
|
|
|
switch entries, ldapcode := l.searchMaybeTopLevelNodes(ctx, h, baseDN, searchBaseDN, searchReq); ldapcode {
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
switch entries, ldapcode := l.searchMaybeTopLevelGroupsNode(ctx, h, baseDN, searchBaseDN, searchReq); ldapcode {
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
switch entries, ldapcode := l.searchMaybeTopLevelUsersNode(ctx, h, baseDN, searchBaseDN, searchReq); ldapcode {
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
filterEntity, err := ldaps.GetFilterAttribute(searchReq.Filter, "objectclass")
|
|
if err != nil {
|
|
return nil, ldap.NewError(ldap.LDAPResultOperationsError, fmt.Errorf("Search Error: error parsing filter: %s", searchReq.Filter))
|
|
}
|
|
|
|
switch entries, ldapcode := l.searchMaybePosixGroups(ctx, h, baseDN, searchBaseDN, searchReq, filterEntity); ldapcode {
|
|
case ldap.LDAPResultSuccess:
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
switch entries, ldapcode := l.searchMaybePosixAccounts(ctx, h, baseDN, searchBaseDN, searchReq, filterEntity); ldapcode {
|
|
case ldap.LDAPResultSuccess:
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Search OK")
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
// So, this should be an ERROR condition! Right..?
|
|
entries := []*ldap.Entry{}
|
|
return &ldap.SearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}}, nil
|
|
}
|
|
|
|
// Returns: LDAPResultSuccess or any ldap code returned by findUser
|
|
func (l LDAPOpsHelper) searchCheckBindDN(ctx context.Context, h LDAPOpsHandler, baseDN string, bindDN string, anonymous bool) (newBindDN string, boundUser *config.User, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchCheckBindDN")
|
|
defer span.End()
|
|
|
|
boundUser, ldapcode := l.findUser(ctx, h, bindDN, false /* checkGroup */)
|
|
if ldapcode != ldap.LDAPResultSuccess {
|
|
return "", nil, ldapcode
|
|
}
|
|
// What if this user was bound using their UPN? We still want to enforce baseDN etc so we
|
|
// have to rewire them to their original DN which is of course a waste of cycles.
|
|
// TODO Down the road we would want to perform lightweight memoization of DNs to UPNs
|
|
if emailmatcher.MatchString(bindDN) {
|
|
// cn=serviceuser,ou=svcaccts,dc=glauth,dc=com
|
|
bindDN = fmt.Sprintf("cn=%s,%s", boundUser.Name, baseDN)
|
|
}
|
|
return bindDN, boundUser, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Search RootDSE and return information on the server
|
|
// Returns: LDAPResultSuccess, LDAPResultOther, LDAPResultUnwillingToPerform, LDAPResultInsufficientAccessRights
|
|
func (l LDAPOpsHelper) searchMaybeRootDSEQuery(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest, anonymous bool) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybeRootDSEQuery")
|
|
defer span.End()
|
|
|
|
if searchBaseDN != "" {
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
/// Only base scope searches allowed if no basedn is provided
|
|
if searchReq.Scope != ldap.ScopeBaseObject {
|
|
h.GetLog().Info().Interface("src", searchReq.Controls).Msg("Search Error: No BaseDN provided")
|
|
return nil, ldap.LDAPResultUnwillingToPerform // KO
|
|
}
|
|
if anonymous && !h.GetBackend().AnonymousDSE {
|
|
return nil, ldap.LDAPResultInsufficientAccessRights // KO
|
|
}
|
|
|
|
h.GetLog().Info().Str("special case", "root DSE").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
attrs := []*ldap.EntryAttribute{}
|
|
// unfortunately, objectClass is not to be included so we will respect that
|
|
// attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"*"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedSASLMechanisms", Values: []string{}})
|
|
//attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedSASLMechanisms", Values: []string{"GSSAPI", "PLAIN", "EXTERNAL"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedLDAPVersion", Values: []string{"3"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedControl", Values: []string{}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "supportedCapabilities", Values: []string{}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "subschemaSubentry", Values: []string{"cn=schema"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "serverName", Values: []string{"unknown"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "namingContexts", Values: []string{baseDN}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "defaultNamingContext", Values: []string{baseDN}})
|
|
attrs = l.collectRequestedAttributesBack(ctx, attrs, searchReq)
|
|
entries = append(entries, &ldap.Entry{DN: searchBaseDN, Attributes: attrs})
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Root Search OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Search and return the information, after indirection from the RootDSE
|
|
// Returns: LDAPResultSuccess, LDAPResultOther, LDAPResultOperationsError
|
|
func (l LDAPOpsHelper) searchMaybeSchemaQuery(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest, anonymous bool) (resultentries []*ldap.Entry, ldapresultcode uint16, attributename *string) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybeSchemaQuery")
|
|
defer span.End()
|
|
|
|
if searchBaseDN != "cn=schema" {
|
|
return nil, ldap.LDAPResultOther, nil // OK
|
|
}
|
|
|
|
h.GetLog().Info().Str("special case", "schema discovery").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
attrs := []*ldap.EntryAttribute{}
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "cn", Values: []string{"schema"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "hasSubordinates", Values: []string{"false"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "modifiersName", Values: []string{"cn=Directory Manager"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "modifyTimeStamp", Values: []string{"Mar 8, 2021, 12:46:29 PM PST (20210308204629Z)"}})
|
|
// Iterate through schema attributes provided in schema/ directory
|
|
filenames, _ := os.ReadDir("schema")
|
|
for _, filename := range filenames {
|
|
attributename := new(string)
|
|
*attributename = filename.Name()
|
|
file, err := os.Open(filepath.Join("schema", *attributename))
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError, attributename
|
|
}
|
|
defer file.Close()
|
|
values := []string{}
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
values = append(values, line)
|
|
}
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: filename.Name(), Values: values})
|
|
}
|
|
attrs = l.collectRequestedAttributesBack(ctx, attrs, searchReq)
|
|
entries = append(entries, &ldap.Entry{DN: searchBaseDN, Attributes: attrs})
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Schema Discovery OK")
|
|
return entries, ldap.LDAPResultSuccess, nil
|
|
}
|
|
|
|
// Retrieve the top-levell nodes, i.e. the baseDN, groups, members...
|
|
// Returns: LDAPResultSuccess, LDAPResultOther
|
|
func (l LDAPOpsHelper) searchMaybeTopLevelNodes(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybeTopLevelNodes")
|
|
defer span.End()
|
|
|
|
if baseDN != searchBaseDN {
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
h.GetLog().Info().Str("special case", "top-level browse").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
if searchReq.Scope == ldap.ScopeBaseObject || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
entries = append(entries, l.topLevelRootNode(ctx, searchBaseDN))
|
|
}
|
|
entries = append(entries, l.topLevelGroupsNode(ctx, searchBaseDN, "groups"))
|
|
entries = append(entries, l.topLevelUsersNode(ctx, searchBaseDN))
|
|
if searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
groupentries, err := h.FindPosixGroups(ctx, "ou=users")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, groupentries...)
|
|
|
|
userentries, err := h.FindPosixAccounts(ctx, "ou=users")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, userentries...)
|
|
}
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Top-Level Browse OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Search starting from and including the ou=groups node
|
|
// Returns: LDAPResultSuccess, LDAPResultOther
|
|
func (l LDAPOpsHelper) searchMaybeTopLevelGroupsNode(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybeTopLevelGroupsNode")
|
|
defer span.End()
|
|
|
|
if searchBaseDN != fmt.Sprintf("ou=groups,%s", baseDN) {
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
h.GetLog().Info().Str("special case", "top-level groups node").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
if searchReq.Scope == ldap.ScopeBaseObject || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
entries = append(entries, l.topLevelGroupsNode(ctx, searchBaseDN, "groups"))
|
|
}
|
|
if searchReq.Scope == ldap.ScopeSingleLevel || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
groupentries, err := h.FindPosixGroups(ctx, "ou=groups")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, groupentries...)
|
|
}
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Top-Level Groups Browse OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Search starting from and including the ou=users node
|
|
// Returns: LDAPResultSuccess, LDAPResultOther
|
|
func (l LDAPOpsHelper) searchMaybeTopLevelUsersNode(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybeTopLevelUsersNode")
|
|
defer span.End()
|
|
|
|
if searchBaseDN != fmt.Sprintf("ou=users,%s", baseDN) {
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
h.GetLog().Info().Str("special case", "top-level users node").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
if searchReq.Scope == ldap.ScopeBaseObject || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
entries = append(entries, l.topLevelUsersNode(ctx, searchBaseDN))
|
|
}
|
|
if searchReq.Scope == ldap.ScopeSingleLevel || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
groupentries, err := h.FindPosixGroups(ctx, "ou=users")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, groupentries...)
|
|
}
|
|
if searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
userentries, err := h.FindPosixAccounts(ctx, "ou=users")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, userentries...)
|
|
}
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Top-Level Users Browse OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Look up posixgroup entries, either through objectlass or parent is ou=groups or ou=users
|
|
// Returns: LDAPResultSuccess, LDAPResultOther, LDAPResultOperationsError
|
|
func (l LDAPOpsHelper) searchMaybePosixGroups(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest, filterEntity string) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybePosixGroups")
|
|
defer span.End()
|
|
|
|
hierarchy := "ou=groups"
|
|
if filterEntity != "posixgroup" {
|
|
bits := strings.Split(strings.Replace(searchBaseDN, baseDN, "", 1), ",")
|
|
if len(bits) != 3 || (bits[1] != "ou=groups" && bits[1] != "ou=users") {
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
hierarchy = bits[1]
|
|
}
|
|
h.GetLog().Info().Str("special case", "posix groups").Msg("Search request")
|
|
entries := []*ldap.Entry{}
|
|
if searchReq.Scope == ldap.ScopeBaseObject || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
groupentries, err := h.FindPosixGroups(ctx, hierarchy)
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, l.preFilterEntries(ctx, searchBaseDN, groupentries)...)
|
|
}
|
|
if searchReq.Scope == ldap.ScopeSingleLevel || searchReq.Scope == ldap.ScopeWholeSubtree {
|
|
if hierarchy == "ou=users" {
|
|
userentries, err := h.FindPosixAccounts(ctx, "ou=users")
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
entries = append(entries, l.preFilterEntries(ctx, searchBaseDN, userentries)...)
|
|
}
|
|
}
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Posix Groups Search OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
// Lookup posixaccount entries
|
|
// Returns: LDAPResultSuccess, LDAPResultOther, LDAPResultOperationsError
|
|
// This function ignores scopes... for now
|
|
func (l LDAPOpsHelper) searchMaybePosixAccounts(ctx context.Context, h LDAPOpsHandler, baseDN string, searchBaseDN string, searchReq ldap.SearchRequest, filterEntity string) (resultentries []*ldap.Entry, ldapresultcode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.searchMaybePosixAccounts")
|
|
defer span.End()
|
|
|
|
switch filterEntity {
|
|
case "posixaccount", "shadowaccount", "":
|
|
h.GetLog().Info().Str("default case", filterEntity).Msg("Search request")
|
|
default:
|
|
return nil, ldap.LDAPResultOther // OK
|
|
}
|
|
|
|
// FixUp: we may be in the process of browsing users from a group ou
|
|
hierarchyString := ""
|
|
if strings.HasSuffix(searchBaseDN, fmt.Sprintf("ou=users,%s", baseDN)) {
|
|
hierarchyString = "ou=users"
|
|
}
|
|
|
|
unscopedEntries, err := h.FindPosixAccounts(ctx, hierarchyString)
|
|
if err != nil {
|
|
return nil, ldap.LDAPResultOperationsError
|
|
}
|
|
|
|
// Filter out entries, that are not in the search base dn
|
|
entries := []*ldap.Entry{}
|
|
for _, e := range unscopedEntries {
|
|
if strings.HasSuffix(e.DN, searchBaseDN) {
|
|
entries = append(entries, e)
|
|
}
|
|
}
|
|
|
|
stats.Frontend.Add("search_successes", 1)
|
|
h.GetLog().Info().Str("filter", searchReq.Filter).Msg("AP: Account Search OK")
|
|
return entries, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
func (l LDAPOpsHelper) topLevelRootNode(ctx context.Context, searchBaseDN string) *ldap.Entry {
|
|
attrs := []*ldap.EntryAttribute{}
|
|
dnBits := strings.Split(searchBaseDN, ",")
|
|
for _, dnBit := range dnBits {
|
|
chunk := strings.Split(dnBit, "=")
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: chunk[0], Values: []string{chunk[1]}})
|
|
}
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"organizationalUnit", "dcObject", "top"}})
|
|
return &ldap.Entry{DN: searchBaseDN, Attributes: attrs}
|
|
}
|
|
|
|
func (l LDAPOpsHelper) topLevelGroupsNode(ctx context.Context, searchBaseDN string, hierarchy string) *ldap.Entry {
|
|
attrs := []*ldap.EntryAttribute{}
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "ou", Values: []string{"groups"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"organizationalUnit", "top"}})
|
|
hierarchyStringPrefix := fmt.Sprintf("ou=%s,", hierarchy)
|
|
dn := searchBaseDN
|
|
if !strings.HasPrefix(dn, hierarchyStringPrefix) {
|
|
dn = fmt.Sprintf("%s%s", hierarchyStringPrefix, dn)
|
|
}
|
|
return &ldap.Entry{DN: dn, Attributes: attrs}
|
|
}
|
|
|
|
func (l LDAPOpsHelper) topLevelUsersNode(ctx context.Context, searchBaseDN string) *ldap.Entry {
|
|
attrs := []*ldap.EntryAttribute{}
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "ou", Values: []string{"users"}})
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: []string{"organizationalUnit", "top"}})
|
|
dn := searchBaseDN
|
|
if !strings.HasPrefix(dn, "ou=users,") {
|
|
dn = fmt.Sprintf("ou=users,%s", dn)
|
|
}
|
|
return &ldap.Entry{DN: dn, Attributes: attrs}
|
|
}
|
|
|
|
// I am not quite sure why but I found out that, maybe due to my playing around with their DN,
|
|
// querying groups and users under a certain node (e.g. ou=users) with a scope of "sub"
|
|
// (and only in this scenario!) will defeat the LDAP library's filtering capabilities.
|
|
// Some day, hopefully, I'll fix this directly in the library.
|
|
func (l LDAPOpsHelper) preFilterEntries(ctx context.Context, searchBaseDN string, entries []*ldap.Entry) (resultentries []*ldap.Entry) {
|
|
filteredEntries := []*ldap.Entry{}
|
|
for _, entry := range entries {
|
|
if strings.HasSuffix(entry.DN, searchBaseDN) {
|
|
filteredEntries = append(filteredEntries, entry)
|
|
}
|
|
}
|
|
return filteredEntries
|
|
}
|
|
|
|
func (l LDAPOpsHelper) findUser(ctx context.Context, h LDAPOpsHandler, bindDN string, checkGroup bool) (userWhenFound *config.User, resultCode uint16) {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.findUser")
|
|
defer span.End()
|
|
|
|
var user config.User
|
|
|
|
baseDN := strings.ToLower("," + h.GetBackend().BaseDN)
|
|
|
|
// Special Case: bind using UPN
|
|
// Not using mail.ParseAddress/1 because we would allow incorrectly formatted UPNs
|
|
if emailmatcher.MatchString(bindDN) {
|
|
var foundUser bool // = false
|
|
foundUser, user, _ = h.FindUser(ctx, bindDN, true)
|
|
if !foundUser {
|
|
h.GetLog().Info().Str("userprincipalname", bindDN).Msg("User not found")
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
} else {
|
|
// parse the bindDN - ensure that the bindDN ends with the BaseDN
|
|
if !strings.HasSuffix(bindDN, baseDN) {
|
|
h.GetLog().Info().Str("binddn", bindDN).Str("basedn", h.GetBackend().BaseDN).Msg("BindDN not part of our BaseDN")
|
|
// h.GetLog().Warning(fmt.Sprintf("Bind Error: BindDN %s not our BaseDN %s", bindDN, baseDN))
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
parts := strings.Split(strings.TrimSuffix(bindDN, baseDN), ",")
|
|
groupName := ""
|
|
userName := ""
|
|
if len(parts) == 1 {
|
|
userName = strings.TrimPrefix(parts[0], h.GetBackend().NameFormatAsArray[0]+"=")
|
|
} else if len(parts) == 2 || (len(parts) == 3 && parts[2] == "ou=users") {
|
|
userName = strings.TrimPrefix(parts[0], h.GetBackend().NameFormatAsArray[0]+"=")
|
|
groupName = strings.TrimPrefix(parts[1], h.GetBackend().GroupFormatAsArray[0]+"=")
|
|
} else {
|
|
h.GetLog().Info().Str("binddn", bindDN).Int("numparts", len(parts)).Msg("BindDN should have only one or two parts")
|
|
for _, part := range parts {
|
|
h.GetLog().Info().Str("part", part).Msg("Parts")
|
|
}
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
|
|
// find the user
|
|
var foundUser bool // = false
|
|
foundUser, user, _ = h.FindUser(ctx, userName, false)
|
|
if !foundUser {
|
|
h.GetLog().Info().Str("username", userName).Msg("User not found")
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
if checkGroup {
|
|
// find the group
|
|
var group config.Group // = nil
|
|
var foundGroup bool // = false
|
|
if groupName != "" {
|
|
foundGroup, group, _ = h.FindGroup(ctx, groupName)
|
|
if !foundGroup {
|
|
h.GetLog().Info().Str("groupname", groupName).Msg("Group not found")
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
}
|
|
// validate group membership
|
|
if foundGroup {
|
|
if user.PrimaryGroup != group.GIDNumber {
|
|
h.GetLog().Info().Str("username", userName).Int("primarygroup", user.PrimaryGroup).Int("groupid", group.GIDNumber).Msg("primary group mismatch")
|
|
return nil, ldap.LDAPResultInvalidCredentials
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return &user, ldap.LDAPResultSuccess
|
|
}
|
|
|
|
func (l LDAPOpsHelper) checkCapability(ctx context.Context, user config.User, action string, objects []string) bool {
|
|
// User-level?
|
|
for _, capability := range user.Capabilities {
|
|
if capability.Action == action {
|
|
for _, object := range objects {
|
|
if capability.Object == object {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// If your query is for, say 'objectClass', then our LDAP
|
|
// library will weed out this entry since it does *not* contain an objectclass attribute
|
|
// so we are going to re-inject it to keep the LDAP library happy
|
|
func (l LDAPOpsHelper) collectRequestedAttributesBack(ctx context.Context, attrs []*ldap.EntryAttribute, searchReq ldap.SearchRequest) []*ldap.EntryAttribute {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.collectRequestedAttributesBack")
|
|
defer span.End()
|
|
|
|
attbits := configattributematcher.FindStringSubmatch(searchReq.Filter)
|
|
if len(attbits) == 3 {
|
|
foundattname := false
|
|
for _, attr := range attrs {
|
|
if strings.ToLower(attr.Name) == strings.ToLower(attbits[1]) {
|
|
foundattname = true
|
|
break
|
|
}
|
|
}
|
|
// the ugly hack: we are going to pretend that the requested attribute is in there
|
|
if !foundattname {
|
|
attrs = append(attrs, &ldap.EntryAttribute{Name: attbits[1], Values: []string{attbits[2]}})
|
|
}
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
// return true if we should not process the current operation
|
|
func (l LDAPOpsHelper) isInTimeout(ctx context.Context, handler LDAPOpsHandler, conn net.Conn) bool {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.isInTimeout")
|
|
defer span.End()
|
|
|
|
cfg := handler.GetCfg()
|
|
if !cfg.Behaviors.LimitFailedBinds {
|
|
return false
|
|
}
|
|
|
|
remoteAddr := l.getAddr(ctx, conn)
|
|
now := time.Now()
|
|
info, ok := l.sources[remoteAddr]
|
|
if !ok {
|
|
l.sources[remoteAddr] = &sourceInfo{
|
|
lastSeen: now,
|
|
failures: make(chan failedBind, cfg.Behaviors.NumberOfFailedBinds),
|
|
waitUntil: now,
|
|
}
|
|
return false
|
|
}
|
|
// update so that this source does not get pruned
|
|
info.lastSeen = now
|
|
// if we are in a time out...
|
|
if cfg.Behaviors.LimitFailedBinds && info.waitUntil.After(now) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (l *LDAPOpsHelper) maybePutInTimeout(ctx context.Context, handler LDAPOpsHandler, conn net.Conn, noteFailure bool) bool {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.maybePutInTimeout")
|
|
defer span.End()
|
|
|
|
cfg := handler.GetCfg()
|
|
if !cfg.Behaviors.LimitFailedBinds {
|
|
return false
|
|
}
|
|
|
|
remoteAddr := l.getAddr(ctx, conn)
|
|
now := time.Now()
|
|
info, _ := l.sources[remoteAddr]
|
|
// if we have a failed bind...
|
|
if noteFailure {
|
|
info.failures <- failedBind{ts: time.Now()}
|
|
// if we now have 3 failed binds in a row
|
|
if len(info.failures) == cfg.Behaviors.NumberOfFailedBinds {
|
|
// we cannot have more than 3 failed binds in our channel so pop the oldest one
|
|
pruned := <-info.failures
|
|
// if we have 3 failed bind in a row in less than 3 seconds
|
|
if pruned.ts.Add(cfg.Behaviors.PeriodOfFailedBinds * time.Second).After(now) {
|
|
// we will wait for 'n' seconds no matter what happens next
|
|
info.waitUntil = time.Now().Add(cfg.Behaviors.BlockFailedBindsFor * time.Second)
|
|
// purge our failure queue until we resume accepting operations
|
|
for len(info.failures) > 0 {
|
|
<-info.failures
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Prune old IPs
|
|
// TODO We should ensure that the time between prunings is bigger than the time to determine rapid failed binds
|
|
if l.nextPruning.Before(now) {
|
|
for sourceIP, sourceInfo := range l.sources {
|
|
if sourceInfo.lastSeen.Add(cfg.Behaviors.PruneSourcesOlderThan * time.Second).Before(now) {
|
|
delete(l.sources, sourceIP)
|
|
}
|
|
}
|
|
l.nextPruning = time.Now().Add(cfg.Behaviors.PruneSourceTableEvery * time.Second)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (l LDAPOpsHelper) getAddr(ctx context.Context, conn net.Conn) string {
|
|
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.getAddr")
|
|
defer span.End()
|
|
|
|
fullAddr := conn.RemoteAddr().String()
|
|
sep := strings.LastIndex(fullAddr, ":")
|
|
if sep == -1 {
|
|
return fullAddr
|
|
}
|
|
return fullAddr[0:sep]
|
|
}
|