model.go 13.7 KB
Newer Older
ale's avatar
ale committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package backend

import (
	"context"
	"errors"
	"fmt"
	"os"
	"strings"

	ldaputil "git.autistici.org/ai3/go-common/ldap"
	"gopkg.in/ldap.v2"

	"git.autistici.org/ai3/accountserver"
)

// Generic interface to LDAP - allows us to stub out the LDAP client while
// testing.
type ldapConn interface {
	Search(context.Context, *ldap.SearchRequest) (*ldap.SearchResult, error)
20
21
	Add(context.Context, *ldap.AddRequest) error
	Modify(context.Context, *ldap.ModifyRequest) error
ale's avatar
ale committed
22
23
24
	Close()
}

25
// backend is the interface to an LDAP-backed user database.
ale's avatar
ale committed
26
27
28
29
30
//
// We keep a set of LDAP queries for each resource type, each having a
// "resource" query to return a specific resource belonging to a user,
// and a "presence" query that checks for existence of a resource for
// all users.
31
type backend struct {
ale's avatar
ale committed
32
33
34
	conn                ldapConn
	userQuery           *queryConfig
	userResourceQueries []*queryConfig
ale's avatar
ale committed
35
	resources           *resourceRegistry
ale's avatar
ale committed
36
37
}

ale's avatar
ale committed
38
39
// backendTX holds the business logic (that runs within a single
// transaction).
40
41
42
43
44
type backendTX struct {
	*ldapTX
	backend *backend
}

ale's avatar
ale committed
45
46
const ldapPoolSize = 20

47
48
49
50
51
52
53
func (b *backend) NewTransaction() (accountserver.TX, error) {
	return &backendTX{
		ldapTX:  newLDAPTX(b.conn),
		backend: b,
	}, nil
}

ale's avatar
ale committed
54
55
// NewLDAPBackend initializes an LDAPBackend object with the given LDAP
// connection pool.
56
func NewLDAPBackend(uri, bindDN, bindPw, base string) (accountserver.Backend, error) {
ale's avatar
ale committed
57
58
59
60
	pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize)
	if err != nil {
		return nil, err
	}
61
62
	return newLDAPBackendWithConn(pool, base)
}
ale's avatar
ale committed
63

64
func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
ale's avatar
ale committed
65
66
67
68
69
70
71
72
	rsrc := newResourceRegistry()
	rsrc.register(accountserver.ResourceTypeEmail, &emailResourceHandler{baseDN: base})
	rsrc.register(accountserver.ResourceTypeMailingList, &mailingListResourceHandler{baseDN: base})
	rsrc.register(accountserver.ResourceTypeDAV, &webdavResourceHandler{baseDN: base})
	rsrc.register(accountserver.ResourceTypeWebsite, &websiteResourceHandler{baseDN: base})
	rsrc.register(accountserver.ResourceTypeDomain, &domainResourceHandler{baseDN: base})
	rsrc.register(accountserver.ResourceTypeDatabase, &databaseResourceHandler{baseDN: base})

73
74
	return &backend{
		conn: conn,
ale's avatar
ale committed
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
		userQuery: mustCompileQueryConfig(&queryConfig{
			Base:  "uid=${user},ou=People," + base,
			Scope: "base",
		}),
		userResourceQueries: []*queryConfig{
			// Find all resources that are children of the main uid object.
			mustCompileQueryConfig(&queryConfig{
				Base:  "uid=${user},ou=People," + base,
				Scope: "sub",
			}),
			// Find mailing lists, which are nested under a different root.
			mustCompileQueryConfig(&queryConfig{
				Base:   "ou=Lists," + base,
				Filter: "(&(objectClass=mailingList)(listOwner=${user}))",
				Scope:  "one",
			}),
		},
ale's avatar
ale committed
92
		resources: rsrc,
ale's avatar
ale committed
93
	}, nil
ale's avatar
ale committed
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
}

func replaceVars(s string, vars map[string]string) string {
	return os.Expand(s, func(k string) string {
		return ldap.EscapeFilter(vars[k])
	})
}

// queryConfig holds the parameters for a single LDAP query.
type queryConfig struct {
	Base        string
	Filter      string
	Scope       string
	parsedScope int
}

func (q *queryConfig) validate() error {
	if q.Base == "" {
		return errors.New("empty search base")
	}
	// An empty filter is equivalent to objectClass=*.
	if q.Filter == "" {
		q.Filter = "(objectClass=*)"
	}
	q.parsedScope = ldap.ScopeWholeSubtree
	if q.Scope != "" {
		s, err := ldaputil.ParseScope(q.Scope)
		if err != nil {
			return err
		}
		q.parsedScope = s
	}
	return nil
}

func (q *queryConfig) searchRequest(vars map[string]string, attrs []string) *ldap.SearchRequest {
	return ldap.NewSearchRequest(
		replaceVars(q.Base, vars),
		q.parsedScope,
		ldap.NeverDerefAliases,
		0,
		0,
		false,
		replaceVars(q.Filter, vars),
		attrs,
		nil,
	)
}

func mustCompileQueryConfig(q *queryConfig) *queryConfig {
	if err := q.validate(); err != nil {
		panic(err)
	}
	return q
}

func s2b(s string) bool {
	switch s {
	case "yes", "y", "on", "enabled", "true":
		return true
	default:
		return false
	}
}

159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
func b2s(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

// Convert a string to a []string with a single item, or nil if the
// string is empty. Useful for optional single-valued LDAP attributes.
func s2l(s string) []string {
	if s == "" {
		return nil
	}
	return []string{s}
}

ale's avatar
ale committed
175
176
func newUser(entry *ldap.Entry) (*accountserver.User, error) {
	user := &accountserver.User{
177
178
179
180
		Name:              entry.GetAttributeValue("uid"),
		Lang:              entry.GetAttributeValue("preferredLanguage"),
		Has2FA:            (entry.GetAttributeValue("totpSecret") != ""),
		HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0),
ale's avatar
ale committed
181
182
183
184
185
186
187
188
		//PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"),
	}
	if user.Lang == "" {
		user.Lang = "en"
	}
	return user, nil
}

ale's avatar
ale committed
189
func getUserDN(user *accountserver.User) string {
190
191
	// TODO: fix this
	return fmt.Sprintf("uid=%s,ou=People,", user.Name)
ale's avatar
ale committed
192
193
}

ale's avatar
ale committed
194
// GetUser returns a user.
195
func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
ale's avatar
ale committed
196
197
	// First of all, find the main user object, and just that one.
	vars := map[string]string{"user": username}
198
	result, err := tx.search(ctx, tx.backend.userQuery.searchRequest(vars, nil))
ale's avatar
ale committed
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
	if err != nil {
		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
			return nil, nil
		}
		return nil, err
	}

	user, err := newUser(result.Entries[0])
	if err != nil {
		return nil, err
	}

	// Now run the resource queries, and accumulate results on the User
	// object we just created.
	// TODO: parallelize.
	// TODO: add support for non-LDAP resource queries.
215
216
	for _, query := range tx.backend.userResourceQueries {
		result, err = tx.search(ctx, query.searchRequest(vars, nil))
ale's avatar
ale committed
217
218
219
220
221
222
223
224
225
226
		if err != nil {
			continue
		}

		for _, entry := range result.Entries {
			// Some user-level attributes are actually stored on the email
			// object, a shortcoming of the legacy A/I database model. Set
			// them on the main User object.
			if isObjectClass(entry, "virtualMailUser") {
				user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion")
ale's avatar
ale committed
227
				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword")))
ale's avatar
ale committed
228
			}
ale's avatar
ale committed
229

ale's avatar
ale committed
230
			// Parse the resource and add it to the User.
ale's avatar
ale committed
231
			if r, err := tx.backend.resources.FromLDAP(entry); err == nil {
ale's avatar
ale committed
232
233
234
235
236
237
238
239
240
241
				user.Resources = append(user.Resources, r)
			}
		}
	}

	groupWebResourcesByHomedir(user.Resources)

	return user, nil
}

ale's avatar
ale committed
242
243
244
245
246
247
248
249
250
251
252
253
254
255
func singleAttributeQuery(dn, attribute string) *ldap.SearchRequest {
	return ldap.NewSearchRequest(
		dn,
		ldap.ScopeBaseObject,
		ldap.NeverDerefAliases,
		0,
		0,
		false,
		"(objectClass=*)",
		[]string{attribute},
		nil,
	)
}

256
func (tx *backendTX) readAttributeValues(ctx context.Context, dn, attribute string) []string {
ale's avatar
ale committed
257
	req := singleAttributeQuery(dn, attribute)
258
	result, err := tx.search(ctx, req)
ale's avatar
ale committed
259
260
261
262
263
264
265
266
267
	if err != nil {
		return nil
	}
	if len(result.Entries) < 1 {
		return nil
	}
	return result.Entries[0].GetAttributeValues(attribute)
}

268
269
270
func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
	tx.setAttr(getUserDN(user), "userPassword", encryptedPassword)
	return nil
ale's avatar
ale committed
271
272
}

273
274
func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
	rawKeys := tx.readAttributeValues(ctx, getUserDN(user), "storageEncryptionKey")
275
	return decodeUserEncryptionKeys(rawKeys), nil
ale's avatar
ale committed
276
277
}

278
func (tx *backendTX) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error {
279
	encKeys := encodeUserEncryptionKeys(keys)
280
281
	tx.setAttr(getUserDN(user), "storageEncryptionKey", encKeys...)
	return nil
ale's avatar
ale committed
282
283
}

284
285
286
287
288
289
290
291
292
293
294
func (tx *backendTX) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error {
	tx.setAttr(getUserDN(user), "storageEncryptionPublicKey", string(pub))
	return nil
}

func excludeASPFromList(asps []*appSpecificPassword, id string) []*appSpecificPassword {
	var out []*appSpecificPassword
	for _, asp := range asps {
		if asp.ID != id {
			out = append(out, asp)
		}
295
	}
296
	return out
297
298
}

299
func (tx *backendTX) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error {
ale's avatar
ale committed
300
301
302
303
	emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
	if emailRsrc == nil {
		return errors.New("no email resource")
	}
ale's avatar
ale committed
304
	emailDN, _ := tx.backend.resources.GetDN(emailRsrc.ID)
ale's avatar
ale committed
305

306
307
308
309
	// Obtain the full list of ASPs from the backend and replace/append the new one.
	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
	asps = append(excludeASPFromList(asps, info.ID), newAppSpecificPassword(*info, encryptedPassword))
	outASPs := encodeAppSpecificPasswords(asps)
ale's avatar
ale committed
310

311
312
	tx.setAttr(emailDN, "appSpecificPassword", outASPs...)
	return nil
ale's avatar
ale committed
313
314
}

315
func (tx *backendTX) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error {
ale's avatar
ale committed
316
317
318
319
	emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
	if emailRsrc == nil {
		return errors.New("no email resource")
	}
ale's avatar
ale committed
320
	emailDN, _ := tx.backend.resources.GetDN(emailRsrc.ID)
ale's avatar
ale committed
321

322
323
324
	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
	asps = excludeASPFromList(asps, id)
	outASPs := encodeAppSpecificPasswords(asps)
ale's avatar
ale committed
325

326
327
	tx.setAttr(emailDN, "appSpecificPassword", outASPs...)
	return nil
ale's avatar
ale committed
328
329
}

330
331
332
func (tx *backendTX) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error {
	tx.setAttr(getUserDN(user), "totpSecret", secret)
	return nil
ale's avatar
ale committed
333
334
}

335
336
337
func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error {
	tx.setAttr(getUserDN(user), "totpSecret")
	return nil
ale's avatar
ale committed
338
339
}

340
func (tx *backendTX) SetResourcePassword(ctx context.Context, r *accountserver.Resource, encryptedPassword string) error {
ale's avatar
ale committed
341
342
	dn, _ := tx.backend.resources.GetDN(r.ID)
	tx.setAttr(dn, "userPassword", encryptedPassword)
343
	return nil
ale's avatar
ale committed
344
345
}

ale's avatar
ale committed
346
347
348
349
func (tx *backendTX) hasResource(ctx context.Context, resourceType, resourceName string) (bool, error) {
	query, err := tx.backend.resources.SearchQuery(resourceType)
	if err != nil {
		return false, err
ale's avatar
ale committed
350
351
352
	}

	// Make a quick LDAP search that only fetches the DN attribute.
353
	result, err := tx.search(ctx, query.searchRequest(map[string]string{
ale's avatar
ale committed
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
		"resource": resourceName,
		"type":     resourceType,
	}, []string{"dn"}))
	if err != nil {
		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
			return false, nil
		}
		return false, err
	}
	if len(result.Entries) == 0 {
		return false, nil
	}
	return true, nil
}

// HasAnyResource returns true if any of the specified resources exists.
ale's avatar
ale committed
370
371
372
func (tx *backendTX) HasAnyResource(ctx context.Context, resourceIDs []accountserver.FindResourceRequest) (bool, error) {
	for _, req := range resourceIDs {
		has, err := tx.hasResource(ctx, req.Type, req.Name)
ale's avatar
ale committed
373
374
375
376
377
378
379
		if err != nil || has {
			return has, err
		}
	}
	return false, nil
}

380
// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
381
func (tx *backendTX) GetResource(ctx context.Context, rsrcID accountserver.ResourceID) (*accountserver.Resource, error) {
ale's avatar
ale committed
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
	// From the resource ID we can obtain the DN, and fetch it
	// straight from LDAP without even doing a real search.
	dn, err := tx.backend.resources.GetDN(rsrcID)
	if err != nil {
		return nil, err
	}

	// This is just a 'point' search.
	req := ldap.NewSearchRequest(
		dn,
		ldap.ScopeBaseObject,
		ldap.NeverDerefAliases,
		0,
		0,
		false,
		"(objectClass=*)",
		nil,
		nil,
	)

	result, err := tx.search(ctx, req)
403
404
405
406
407
408
409
	if err != nil {
		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
			return nil, nil
		}
		return nil, err
	}

ale's avatar
ale committed
410
411
	// We know the resource type so we don't have to guess.
	return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0])
412
413
414
}

// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
415
func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resource) error {
ale's avatar
ale committed
416
417
418
419
	dn, err := tx.backend.resources.GetDN(r.ID)
	if err != nil {
		return err
	}
420

421
422
	// We can simply dump all attribute/value pairs and let the
	// code in ldapTX do the work of finding the differences.
ale's avatar
ale committed
423
	for _, attr := range tx.backend.resources.ToLDAP(r) {
424
		tx.setAttr(dn, attr.Type, attr.Vals...)
425
426
	}

427
	return nil
428
429
}

ale's avatar
ale committed
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
func isObjectClass(entry *ldap.Entry, class string) bool {
	classes := entry.GetAttributeValues("objectClass")
	for _, c := range classes {
		if c == class {
			return true
		}
	}
	return false
}

var siteRoot = "/home/users/investici.org/"

// The hosting directory for a website is the path component immediately after
// siteRoot. This works also for sites with nested documentRoots.
func getHostingDir(path string) string {
	path = strings.TrimPrefix(path, siteRoot)
	if i := strings.Index(path, "/"); i > 0 {
		return path[:i]
	}
	return path
}

// This is a very specific function meant to address a peculiar characteristic
// of the A/I legacy data model, where DAV accounts and websites do not have an
// explicit relation.
func groupWebResourcesByHomedir(resources []*accountserver.Resource) {
	// Set the group name to be the 'hostingDir' for sites and DAV
	// accounts. Keep a reference of websites by ID so we can later fix the
	// group for databases too, via their ParentID.
	webs := make(map[string]*accountserver.Resource)
	for _, r := range resources {
ale's avatar
ale committed
461
		switch r.ID.Type() {
462
		case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain:
ale's avatar
ale committed
463
			r.Group = getHostingDir(r.Website.DocumentRoot)
464
			webs[r.ID.String()] = r
ale's avatar
ale committed
465
466
467
468
469
470
		case accountserver.ResourceTypeDAV:
			r.Group = getHostingDir(r.DAV.Homedir)
		}
	}
	// Fix databases in a second pass.
	for _, r := range resources {
ale's avatar
ale committed
471
		if r.ID.Type() == accountserver.ResourceTypeDatabase && !r.ParentID.Empty() {
472
			r.Group = webs[r.ParentID.String()].Group
ale's avatar
ale committed
473
474
475
		}
	}
}