model.go 12.7 KB
Newer Older
ale's avatar
ale committed
1
2
3
4
5
6
7
8
9
10
11
12
package backend

import (
	"context"
	"strings"

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

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

13
14
15
16
const (
	// Names of some well-known LDAP attributes.
	totpSecretLDAPAttr        = "totpSecret"
	preferredLanguageLDAPAttr = "preferredLanguage"
ale's avatar
ale committed
17
18
	recoveryHintLDAPAttr      = "recoverQuestion"
	recoveryResponseLDAPAttr  = "recoverAnswer"
19
20
21
22
23
24
	aspLDAPAttr               = "appSpecificPassword"
	storagePublicKeyLDAPAttr  = "storagePublicKey"
	storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
	passwordLDAPAttr          = "userPassword"
)

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
	conn                ldapConn
ale's avatar
ale committed
33
	baseDN              string
ale's avatar
ale committed
34
35
	userQuery           *queryConfig
	userResourceQueries []*queryConfig
ale's avatar
ale committed
36
	resources           *resourceRegistry
ale's avatar
ale committed
37
38
}

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

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

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

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

65
func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
ale's avatar
ale committed
66
67
68
69
70
71
72
73
	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})

74
	return &backend{
ale's avatar
ale committed
75
76
		conn:   conn,
		baseDN: base,
ale's avatar
ale committed
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
		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
94
		resources: rsrc,
ale's avatar
ale committed
95
	}, nil
ale's avatar
ale committed
96
97
98
99
}

func newUser(entry *ldap.Entry) (*accountserver.User, error) {
	user := &accountserver.User{
100
101
102
103
		Name:   entry.GetAttributeValue("uid"),
		Lang:   entry.GetAttributeValue(preferredLanguageLDAPAttr),
		Has2FA: (entry.GetAttributeValue(totpSecretLDAPAttr) != ""),
		//HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0),
ale's avatar
ale committed
104
105
106
107
108
109
110
111
		//PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"),
	}
	if user.Lang == "" {
		user.Lang = "en"
	}
	return user, nil
}

ale's avatar
ale committed
112
113
func (tx *backendTX) getUserDN(user *accountserver.User) string {
	return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN)
ale's avatar
ale committed
114
115
}

ale's avatar
ale committed
116
// GetUser returns a user.
117
func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
ale's avatar
ale committed
118
119
	// First of all, find the main user object, and just that one.
	vars := map[string]string{"user": username}
120
	result, err := tx.search(ctx, tx.backend.userQuery.searchRequest(vars, nil))
ale's avatar
ale committed
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
	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.
137
138
	for _, query := range tx.backend.userResourceQueries {
		result, err = tx.search(ctx, query.searchRequest(vars, nil))
ale's avatar
ale committed
139
140
141
142
143
144
145
146
147
		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") {
ale's avatar
ale committed
148
				user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr)
149
150
				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr)))
				user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
ale's avatar
ale committed
151
			}
ale's avatar
ale committed
152

ale's avatar
ale committed
153
			// Parse the resource and add it to the User.
ale's avatar
ale committed
154
			if r, err := tx.backend.resources.FromLDAP(entry); err == nil {
ale's avatar
ale committed
155
156
157
158
159
160
161
162
163
164
				user.Resources = append(user.Resources, r)
			}
		}
	}

	groupWebResourcesByHomedir(user.Resources)

	return user, nil
}

165
func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
166
167
168
169
170
171
	dn := tx.getUserDN(user)
	tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
		dn, _ = tx.backend.resources.GetDN(r.ID)
		tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
	}
172
	return nil
ale's avatar
ale committed
173
174
}

ale's avatar
ale committed
175
176
177
178
179
180
181
func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error {
	dn := tx.getUserDN(user)
	tx.setAttr(dn, recoveryHintLDAPAttr, hint)
	tx.setAttr(dn, recoveryResponseLDAPAttr, response)
	return nil
}

182
func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
183
184
185
	r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
	dn, _ := tx.backend.resources.GetDN(r.ID)
	rawKeys := tx.readAttributeValues(ctx, dn, storagePrivateKeyLDAPAttr)
186
	return decodeUserEncryptionKeys(rawKeys), nil
ale's avatar
ale committed
187
188
}

189
func (tx *backendTX) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error {
190
	encKeys := encodeUserEncryptionKeys(keys)
191
192
193
194
	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
		dn, _ := tx.backend.resources.GetDN(r.ID)
		tx.setAttr(dn, storagePrivateKeyLDAPAttr, encKeys...)
	}
195
	return nil
ale's avatar
ale committed
196
197
}

198
func (tx *backendTX) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error {
199
200
201
202
	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
		dn, _ := tx.backend.resources.GetDN(r.ID)
		tx.setAttr(dn, storagePublicKeyLDAPAttr, string(pub))
	}
203
204
205
206
207
208
209
210
211
	return nil
}

func excludeASPFromList(asps []*appSpecificPassword, id string) []*appSpecificPassword {
	var out []*appSpecificPassword
	for _, asp := range asps {
		if asp.ID != id {
			out = append(out, asp)
		}
212
	}
213
	return out
214
215
}

216
217
func (tx *backendTX) setASPOnResource(ctx context.Context, r *accountserver.Resource, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) {
	dn, _ := tx.backend.resources.GetDN(r.ID)
ale's avatar
ale committed
218

219
	// Obtain the full list of ASPs from the backend and replace/append the new one.
220
	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, dn, aspLDAPAttr))
221
222
	asps = append(excludeASPFromList(asps, info.ID), newAppSpecificPassword(*info, encryptedPassword))
	outASPs := encodeAppSpecificPasswords(asps)
223
	tx.setAttr(dn, aspLDAPAttr, outASPs...)
ale's avatar
ale committed
224
225
}

226
227
228
func (tx *backendTX) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error {
	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
		tx.setASPOnResource(ctx, r, info, encryptedPassword)
ale's avatar
ale committed
229
	}
230
231
	return nil
}
ale's avatar
ale committed
232

233
234
235
func (tx *backendTX) deleteASPOnResource(ctx context.Context, r *accountserver.Resource, id string) {
	dn, _ := tx.backend.resources.GetDN(r.ID)
	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, dn, aspLDAPAttr))
236
237
	asps = excludeASPFromList(asps, id)
	outASPs := encodeAppSpecificPasswords(asps)
238
239
	tx.setAttr(dn, aspLDAPAttr, outASPs...)
}
ale's avatar
ale committed
240

241
242
243
244
func (tx *backendTX) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error {
	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
		tx.deleteASPOnResource(ctx, r, id)
	}
245
	return nil
ale's avatar
ale committed
246
247
}

248
func (tx *backendTX) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error {
249
	tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr, secret)
250
	return nil
ale's avatar
ale committed
251
252
}

253
func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error {
254
	tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr)
255
	return nil
ale's avatar
ale committed
256
257
}

258
func (tx *backendTX) SetResourcePassword(ctx context.Context, r *accountserver.Resource, encryptedPassword string) error {
ale's avatar
ale committed
259
	dn, _ := tx.backend.resources.GetDN(r.ID)
260
	tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
261
	return nil
ale's avatar
ale committed
262
263
}

ale's avatar
ale committed
264
265
266
267
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
268
269
270
	}

	// Make a quick LDAP search that only fetches the DN attribute.
271
	result, err := tx.search(ctx, query.searchRequest(map[string]string{
ale's avatar
ale committed
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
		"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
288
289
290
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
291
292
293
294
295
296
297
		if err != nil || has {
			return has, err
		}
	}
	return false, nil
}

298
// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
299
func (tx *backendTX) GetResource(ctx context.Context, rsrcID accountserver.ResourceID) (*accountserver.Resource, error) {
ale's avatar
ale committed
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
	// 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)
321
322
323
324
325
326
327
	if err != nil {
		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
			return nil, nil
		}
		return nil, err
	}

ale's avatar
ale committed
328
329
	// We know the resource type so we don't have to guess.
	return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0])
330
331
332
}

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

339
340
	// 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
341
	for _, attr := range tx.backend.resources.ToLDAP(r) {
342
		tx.setAttr(dn, attr.Type, attr.Vals...)
343
344
	}

345
	return nil
346
347
}

ale's avatar
ale committed
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
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
369
		switch r.ID.Type() {
370
		case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain:
ale's avatar
ale committed
371
			r.Group = getHostingDir(r.Website.DocumentRoot)
372
			webs[r.ID.String()] = r
ale's avatar
ale committed
373
374
375
376
377
378
		case accountserver.ResourceTypeDAV:
			r.Group = getHostingDir(r.DAV.Homedir)
		}
	}
	// Fix databases in a second pass.
	for _, r := range resources {
ale's avatar
ale committed
379
		if r.ID.Type() == accountserver.ResourceTypeDatabase && !r.ParentID.Empty() {
380
			r.Group = webs[r.ParentID.String()].Group
ale's avatar
ale committed
381
382
383
		}
	}
}