diff --git a/actions.go b/actions.go index ff9dba47717cd59011f228450c86b0aaa40f54cb..3d63fd517833bc5b92142b22f39f5850871e0df4 100644 --- a/actions.go +++ b/actions.go @@ -695,10 +695,11 @@ type CreateResourcesResponse struct { } // ApplyTemplate fills in default values for the resources in the request. -func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) { +func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, _ TX, s *AccountService, user *User) error { for _, r := range req.Resources { s.resourceTemplates.applyTemplate(ctx, r, user) } + return nil } // ValidationUser returns the user to be used for validation purposes. @@ -782,17 +783,33 @@ type CreateUserRequest struct { } // ApplyTemplate fills in default values for the resources in the request. -func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) { +func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, tx TX, s *AccountService, _ *User) error { + // Some fields should be unset because there are specific + // methods to modify those attributes. + req.User.Has2FA = false + req.User.HasEncryptionKeys = false + req.User.PasswordRecoveryHint = "" + req.User.AppSpecificPasswords = nil + if req.User.Lang == "" { + req.User.Lang = "en" + } + + // Allocate a new user ID. + uid, err := tx.NextUID(ctx) + if err != nil { + return err + } + req.User.UID = uid + + // Apply templates to all resources in the request. for _, r := range req.User.Resources { - s.resourceTemplates.applyTemplate(ctx, r, user) + s.resourceTemplates.applyTemplate(ctx, r, req.User) } + return nil } // Validate the request. func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, _ *User) error { - // Override server-generated values. - fillUserTemplate(req.User) - // Validate the user *and* all resources. if err := s.userValidator(ctx, req.User); err != nil { log.Printf("validation error while creating user %+v: %v", req.User, err) @@ -814,20 +831,6 @@ type CreateUserResponse struct { Password string `json:"password"` } -// Make sure that only user-settable fields are set in the User in a -// CreateUserRequest. -func fillUserTemplate(user *User) { - // Some fields should be unset because there are specific - // methods to modify those attributes. - user.Has2FA = false - user.HasEncryptionKeys = false - user.PasswordRecoveryHint = "" - user.AppSpecificPasswords = nil - if user.Lang == "" { - user.Lang = "en" - } -} - // CreateUser creates a new user along with the associated resources. func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserRequest) (*CreateUserResponse, error) { var resp CreateUserResponse diff --git a/actions_test.go b/actions_test.go index f9cf9245ae4cb85f43897edef67a0e132a11b678..393416fce41fd8b17aa01989d16a6848ddc83af6 100644 --- a/actions_test.go +++ b/actions_test.go @@ -26,6 +26,10 @@ func (b *fakeBackend) Commit(_ context.Context) error { return nil } +func (b *fakeBackend) NextUID(_ context.Context) (int, error) { + return 42, nil +} + func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) { return b.users[username], nil } @@ -176,6 +180,7 @@ func createFakeBackend() *fakeBackend { } fb.addUser(&User{ Name: "testuser", + UID: 4242, Resources: []*Resource{ { ID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"), diff --git a/backend/model.go b/backend/model.go index 6d3cb4577e65ddc3517a1c1c95c7f0d3e661df15..b0108d0a810e45ddaf2f711699098f6c7888f03f 100644 --- a/backend/model.go +++ b/backend/model.go @@ -2,6 +2,9 @@ package backend import ( "context" + "fmt" + "math/rand" + "strconv" "strings" ldaputil "git.autistici.org/ai3/go-common/ldap" @@ -22,6 +25,7 @@ const ( storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" passwordLDAPAttr = "userPassword" u2fRegistrationsLDAPAttr = "u2fRegistration" + uidNumberLDAPAttr = "uidNumber" ) // backend is the interface to an LDAP-backed user database. @@ -36,8 +40,16 @@ type backend struct { userQuery *queryTemplate userResourceQueries []*queryTemplate resources *resourceRegistry + + // Range for new user IDs. + minUID, maxUID int } +const ( + defaultMinUID = 10000 + defaultMaxUID = 1000000 +) + // backendTX holds the business logic (that runs within a single // transaction). type backendTX struct { @@ -95,6 +107,8 @@ func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) { }, }, resources: rsrc, + minUID: defaultMinUID, + maxUID: defaultMaxUID, }, nil } @@ -107,9 +121,11 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) { // The case of password recovery attributes is more complex: // the current schema has those on email=, but we'd like to // move them to uid=, so we currently have to support both. + uidNumber, _ := strconv.Atoi(entry.GetAttributeValue(uidNumberLDAPAttr)) user := &accountserver.User{ Name: entry.GetAttributeValue("uid"), Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr), + UID: uidNumber, PasswordRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr), U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)), } @@ -131,6 +147,7 @@ func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) { {Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "totpAccount"}}, {Type: "uid", Vals: s2l(user.Name)}, {Type: "cn", Vals: s2l(user.Name)}, + {Type: uidNumberLDAPAttr, Vals: s2l(strconv.Itoa(user.UID))}, {Type: "givenName", Vals: []string{"Private"}}, {Type: "sn", Vals: []string{"Private"}}, {Type: "gecos", Vals: s2l(user.Name)}, @@ -481,6 +498,47 @@ func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resour return nil } +// NextUID returns an available user ID (uidNumber). It does so +// without keeping state by just guessing random UIDs in the +// minUID-maxUID range, and checking if they are available (so it's +// best to keep the range largely underutilized). +func (tx *backendTX) NextUID(ctx context.Context) (uid int, err error) { + // Won't actually run forever, at some point the context will expire. + var ok bool + for !ok && err == nil { + uid = tx.backend.minUID + rand.Intn(tx.backend.maxUID-tx.backend.minUID) + ok, err = tx.isUIDAvailable(ctx, uid) + } + return +} + +func (tx *backendTX) isUIDAvailable(ctx context.Context, uid int) (bool, error) { + // Try to make this query lightweight: ask for no attributes, + // use a size limit of 1 and treat "Size Limit Exceeded" + // errors as a successful result. + result, err := tx.search(ctx, ldap.NewSearchRequest( + tx.backend.baseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 1, // just one result is enough + 0, + false, + fmt.Sprintf("(uidNumber=%d)", uid), + []string{"dn"}, + nil, + )) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) { + return false, nil + } + return false, err + } + if len(result.Entries) > 0 { + return false, nil + } + return true, nil +} + var siteRoot = "/home/users/investici.org/" // The hosting directory for a website is the path component immediately after diff --git a/backend/model_test.go b/backend/model_test.go index 151b68725536a5e26b974fa4c9488de78ebdcaf8..49b3cd5e7d024e946bcf24ef90b9d49033df5720 100644 --- a/backend/model_test.go +++ b/backend/model_test.go @@ -320,3 +320,40 @@ func TestModel_SetUserEncryptionKeys_Replace(t *testing.T) { t.Fatal("Commit", err) } } + +func TestModel_NextUID(t *testing.T) { + stop, b, user := startServerAndGetUser(t) + defer stop() + tx, _ := b.NewTransaction() + + // User UID should not be available. + ok, err := tx.(*backendTX).isUIDAvailable(context.Background(), user.UID) + if err != nil { + t.Fatal("isUIDAvailable", err) + } + if ok { + t.Fatalf("oops, the uid of the existing user (%d) appears to be available", user.UID) + } + + // Check a UID that should be available instead. + freeUID := 4096 + ok, err = tx.(*backendTX).isUIDAvailable(context.Background(), freeUID) + if err != nil { + t.Fatal("isUIDAvailable", err) + } + if !ok { + t.Fatalf("oops, a supposedly free uid (%d) appears to be unavailable", freeUID) + } + + // Generate a new UID. + uid, err := tx.NextUID(context.Background()) + if err != nil { + t.Fatal("NextUID", err) + } + if uid == 0 { + t.Fatal("uid is 0") + } + if uid == user.UID { // not that it's likely + t.Fatalf("uid is the same as the existing user ID (%d)", user.UID) + } +} diff --git a/service.go b/service.go index 4f2c06d04db91a9ee07adcaa1318b4ed5f440aae..c5dd78628984c4ff0a35ce940d94d63175026439 100644 --- a/service.go +++ b/service.go @@ -62,6 +62,9 @@ type TX interface { // necessary information. GetUserEncryptedPassword(context.Context, *User) string GetUserRecoveryEncryptedPassword(context.Context, *User) string + + // Return the next (or any, really) available user ID. + NextUID(context.Context) (int, error) } // FindResourceRequest contains parameters for searching a resource by name. @@ -320,7 +323,7 @@ type hasNewContext interface { } type hasApplyTemplate interface { - ApplyTemplate(context.Context, *AccountService, *User) + ApplyTemplate(context.Context, TX, *AccountService, *User) error } type hasValidate interface { @@ -358,7 +361,9 @@ func (s *AccountService) withRequest(ctx context.Context, tx TX, req interface{} // Apply a template to the request, to fill in default values // etc., if the request has an ApplyTemplate() method. if rt, ok := req.(hasApplyTemplate); ok { - rt.ApplyTemplate(ctx, s, user) + if err := rt.ApplyTemplate(ctx, tx, s, user); err != nil { + return err + } } // If the request has a Validate() method, validate the diff --git a/types.go b/types.go index 68a3b526bc829893ab9f5b55c421f281a594f053..836a048824b45c5d621f82aa89bdc77dfeff41e8 100644 --- a/types.go +++ b/types.go @@ -27,6 +27,9 @@ type User struct { // Preferred language. Lang string `json:"lang"` + // UNIX user id. + UID int `json:"uid"` + Has2FA bool `json:"has_2fa"` HasEncryptionKeys bool `json:"has_encryption_keys"` PasswordRecoveryHint string `json:"password_recovery_hint"` @@ -303,12 +306,14 @@ type MailingList struct { // WebDAV represents a hosting account. type WebDAV struct { + UID int `json:"uid"` Homedir string `json:"homedir"` } // Website resource attributes. type Website struct { URL string `json:"url,omitempty"` + UID int `json:"uid"` ParentDomain string `json:"parent_domain,omitempty"` AcceptMail bool `json:"accept_mail"` Options []string `json:"options,omitempty"` diff --git a/validators.go b/validators.go index 8e3c4bbde0b3da648a2fa202f2a27e1bbda1d629..489db70c318e4a7c1e33553000e65f38b6761126 100644 --- a/validators.go +++ b/validators.go @@ -38,6 +38,8 @@ type validationContext struct { forbiddenPasswords stringSet minPasswordLength int maxPasswordLength int + minUID int + maxUID int webroot string domains domainBackend shards shardBackend @@ -479,6 +481,37 @@ func hasMatchingDAVAccount(user *User, r *Resource) bool { return findMatchingDAVAccount(user, r) != nil } +func (v *validationContext) validateUID(uid int, user *User) error { + if uid == 0 { + return errors.New("uid is not set") + } + if uid < v.minUID || (v.maxUID > 0 && uid > v.maxUID) { + return errors.New("uid outside of allowed range") + } + if user != nil && uid != user.UID { + return errors.New("uid of resource differs from uid of user") + } + return nil +} + +func (v *validationContext) validateWebsiteCommon(r *Resource, user *User) error { + // Document root checks: must not be empty, and the + // user must have a DAV account with a home directory + // that is a parent of this document root. + if r.Website.DocumentRoot == "" { + return errors.New("empty document_root") + } + if !isSubdir(v.webroot, r.Website.DocumentRoot) { + return errors.New("document root outside of web root") + } + if !hasMatchingDAVAccount(user, r) { + return errors.New("website has no matching DAV account") + } + + // UID checks. + return v.validateUID(r.Website.UID, user) +} + func (v *validationContext) validDomainResource() ResourceValidatorFunc { domainValidator := allOf( minLength(6), @@ -506,17 +539,7 @@ func (v *validationContext) validDomainResource() ResourceValidatorFunc { return errors.New("non-empty parent_domain on domain resource") } - // Document root checks. - if r.Website.DocumentRoot == "" { - return errors.New("empty document_root") - } - if !isSubdir(v.webroot, r.Website.DocumentRoot) { - return errors.New("document root outside of web root") - } - if !hasMatchingDAVAccount(user, r) { - return errors.New("website has no matching DAV account") - } - return nil + return v.validateWebsiteCommon(r, user) } } @@ -548,19 +571,7 @@ func (v *validationContext) validWebsiteResource() ResourceValidatorFunc { return err } - // Document root checks: must not be empty, and the - // user must have a DAV account with a home directory - // that is a parent of this document root. - if r.Website.DocumentRoot == "" { - return errors.New("empty document_root") - } - if !isSubdir(v.webroot, r.Website.DocumentRoot) { - return errors.New("document root outside of web root") - } - if !hasMatchingDAVAccount(user, r) { - return errors.New("website has no matching DAV account") - } - return nil + return v.validateWebsiteCommon(r, user) } } @@ -581,7 +592,8 @@ func (v *validationContext) validDAVResource() ResourceValidatorFunc { if !isSubdir(v.webroot, r.DAV.Homedir) { return errors.New("homedir outside of web root") } - return nil + + return v.validateUID(r.DAV.UID, user) } } @@ -726,6 +738,8 @@ func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resour r.Website.Options = []string{"nomail"} } + r.Website.UID = user.UID + dav := findMatchingDAVAccount(user, r) c.setResourceShard(ctx, r, dav) c.setResourceStatus(r) @@ -741,6 +755,7 @@ func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name()) } r.DAV.Homedir = filepath.Clean(r.DAV.Homedir) + r.DAV.UID = user.UID c.setResourceShard(ctx, r, nil) c.setResourceStatus(r)