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