1package client 2 3import ( 4 "fmt" 5 "math" 6 "strings" 7 "time" 8 9 "github.com/go-errors/errors" 10 "github.com/go-ldap/ldap" 11 "github.com/hashicorp/go-hclog" 12 "github.com/hashicorp/vault/sdk/helper/ldaputil" 13 "golang.org/x/text/encoding/unicode" 14) 15 16func NewClient(logger hclog.Logger) *Client { 17 return &Client{ 18 ldap: &ldaputil.Client{ 19 Logger: logger, 20 LDAP: ldaputil.NewLDAP(), 21 }, 22 } 23} 24 25type Client struct { 26 ldap *ldaputil.Client 27} 28 29func (c *Client) Search(cfg *ADConf, filters map[*Field][]string) ([]*Entry, error) { 30 req := &ldap.SearchRequest{ 31 BaseDN: cfg.UserDN, 32 Scope: ldap.ScopeWholeSubtree, 33 Filter: toString(filters), 34 SizeLimit: math.MaxInt32, 35 } 36 37 conn, err := c.ldap.DialLDAP(cfg.ConfigEntry) 38 if err != nil { 39 return nil, err 40 } 41 defer conn.Close() 42 43 if err := bind(cfg, conn); err != nil { 44 return nil, err 45 } 46 47 result, err := conn.Search(req) 48 if err != nil { 49 return nil, err 50 } 51 52 entries := make([]*Entry, len(result.Entries)) 53 for i, rawEntry := range result.Entries { 54 entries[i] = NewEntry(rawEntry) 55 } 56 return entries, nil 57} 58 59func (c *Client) UpdateEntry(cfg *ADConf, filters map[*Field][]string, newValues map[*Field][]string) error { 60 entries, err := c.Search(cfg, filters) 61 if err != nil { 62 return err 63 } 64 if len(entries) != 1 { 65 return fmt.Errorf("filter of %s doesn't match just one entry: %+v", filters, entries) 66 } 67 68 modifyReq := &ldap.ModifyRequest{ 69 DN: entries[0].DN, 70 } 71 72 for field, vals := range newValues { 73 modifyReq.Replace(field.String(), vals) 74 } 75 76 conn, err := c.ldap.DialLDAP(cfg.ConfigEntry) 77 if err != nil { 78 return err 79 } 80 defer conn.Close() 81 82 if err := bind(cfg, conn); err != nil { 83 return err 84 } 85 return conn.Modify(modifyReq) 86} 87 88// UpdatePassword uses a Modify call under the hood because 89// Active Directory doesn't recognize the passwordModify method. 90// See https://github.com/go-ldap/ldap/issues/106 91// for more. 92func (c *Client) UpdatePassword(cfg *ADConf, filters map[*Field][]string, newPassword string) error { 93 pwdEncoded, err := formatPassword(newPassword) 94 if err != nil { 95 return err 96 } 97 98 newValues := map[*Field][]string{ 99 FieldRegistry.UnicodePassword: {pwdEncoded}, 100 } 101 102 return c.UpdateEntry(cfg, filters, newValues) 103} 104 105// According to the MS docs, the password needs to be utf16 and enclosed in quotes. 106func formatPassword(original string) (string, error) { 107 utf16 := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM) 108 return utf16.NewEncoder().String("\"" + original + "\"") 109} 110 111// Ex. "(cn=Ellen Jones)" 112func toString(filters map[*Field][]string) string { 113 var fieldEquals []string 114 for f, values := range filters { 115 for _, v := range values { 116 fieldEquals = append(fieldEquals, fmt.Sprintf("%s=%s", f, v)) 117 } 118 } 119 result := strings.Join(fieldEquals, ",") 120 return "(" + result + ")" 121} 122 123func bind(cfg *ADConf, conn ldaputil.Connection) error { 124 if cfg.BindPassword == "" { 125 return errors.New("unable to bind due to lack of configured password") 126 } 127 128 if cfg.UPNDomain != "" { 129 origErr := conn.Bind(fmt.Sprintf("%s@%s", ldaputil.EscapeLDAPValue(cfg.BindDN), cfg.UPNDomain), cfg.BindPassword) 130 if origErr == nil { 131 return nil 132 } 133 if !shouldTryLastPwd(cfg.LastBindPassword, cfg.LastBindPasswordRotation) { 134 return origErr 135 } 136 if err := conn.Bind(fmt.Sprintf("%s@%s", ldaputil.EscapeLDAPValue(cfg.BindDN), cfg.UPNDomain), cfg.LastBindPassword); err != nil { 137 // Return the original error because it'll be more helpful for debugging. 138 return origErr 139 } 140 return nil 141 } 142 143 if cfg.BindDN != "" { 144 origErr := conn.Bind(cfg.BindDN, cfg.BindPassword) 145 if origErr == nil { 146 return nil 147 } 148 if !shouldTryLastPwd(cfg.LastBindPassword, cfg.LastBindPasswordRotation) { 149 return origErr 150 } 151 if err := conn.Bind(cfg.BindDN, cfg.LastBindPassword); err != nil { 152 // Return the original error because it'll be more helpful for debugging. 153 return origErr 154 } 155 } 156 return errors.New("must provide binddn or upndomain") 157} 158 159// shouldTryLastPwd determines if we should try a previous password. 160// Active Directory can return a variety of errors when a password is invalid. 161// Rather than attempting to catalogue these errors across multiple versions of 162// AD, we simply try the last password if it's been less than a set amount of 163// time since a rotation occurred. 164func shouldTryLastPwd(lastPwd string, lastBindPasswordRotation time.Time) bool { 165 if lastPwd == "" { 166 return false 167 } 168 if lastBindPasswordRotation.Equal(time.Time{}) { 169 return false 170 } 171 return lastBindPasswordRotation.Add(10 * time.Minute).After(time.Now()) 172} 173