diff --git a/API.md b/API.md index 884c69bb432cb2142a5cbc78ee9c6351c6d076f4..61219b8071246eff1643aa593925b1e2e2d92d1b 100644 --- a/API.md +++ b/API.md @@ -130,3 +130,157 @@ Actions: ### web hosting (website / db / FTP account or equivalent) ... + + +# API Reference + +## User endpoints + +The following API endpoints invoke operations on an individual +user. Access is allowed for admins, and for the user itself. + +Most requests dealing with encryption keys are so-called *privileged* +requests, and will require the user's password in the *cur_password* +parameter in order to decrypt the existing encryption keys. + +### `/api/user/get` + +Retrieve information about a user and all its associated resources. + +Request parameters: + +* `username` - user to fetch +* `sso` - SSO ticket + +### `/api/user/change_password` + +Change the primary authentication password for a user. This operation +will update the user's storage encryption keys, or initialize them if +they do not exist. + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket +* `cur_password` - current valid password for the user +* `password` - new password (unencrypted) + +### `/api/user/set_password_recovery_hint` + +Sets the secondary authentication password (a hint / response pair, +used to recover the primary credentials) for a user. This operation +will update the user's storage encryption keys, or initialize them if +they do not exist yet. + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket +* `cur_password` - current valid password for the user +* `hint` - the secondary authentication hint +* `response` - the secondary authentication response (effectively a + password, unencrypted) + +### `/api/user/enable_otp` + +Enable TOTP for a user. The server can generate a new TOTP secret if +necessary, or it can be generated by the caller (usually better as it +allows for a better validation UX). + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket +* `totp_secret` - new TOTP secret (optional) + +### `/api/user/disable_otp` + +Disable TOTP for a user. Existing 2FA credentials will be wiped as +well. + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket + +### `/api/user/create_app_specific_password` + +Create a new application-specific password. 2FA must already be +enabled for the user. A new random password will be generated by the +server and returned in the response. A new copy of the encryption key +will be encrypted with the new application-specific password. + +ASPs are identified by a unique random ID that is also automatically +generated by the server. + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket +* `cur_password` - current valid password for the user +* `service` - service that the password should be valid for +* `notes` - a user-controlled comment about the client + +### `/api/user/delete_app_specific_password` + +Delete an application-specific password (and the associated encryption +key). + +Request parameters: + +* `username` - name of the user +* `sso` - SSO ticket +* `asp_id` - ID of the app-specific password + + +## Resource endpoints + +These API endpoints manipulate individual resources regardless of +which user they belong to. Access is normally granted to admins and to +the user that owns a resource, but some operations are restricted to +admins only. + +### `/api/resource/enable` + +Enable a resource (admin-only). + +Request parameters: + +* `resource_id` - resource ID +* `sso` - SSO ticket +* `comment` - notes on the operation + +### `/api/resource/disable` + +Disable a resource (admin-only). + +Request parameters: + +* `resource_id` - resource ID +* `sso` - SSO ticket +* `comment` - notes on the operation + +### `/api/resource/move` + +Move a resource between shards (admin-only). + +Resources that are part of a group (for instance websites and DAV +accounts) are moved together, so this request might end up moving more +than one resource. + +Request parameters: + +* `resource_id` - resource ID +* `sso` - SSO ticket +* `shard` - new shard + +### `/api/resource/create` + +Create one or more resources associated with a user. Note that if +creating multiple resources, they must all belong to the same user. + +Request parameters: + +* `sso` - SSO ticket +* `resources` - list of resource objects to create + diff --git a/DATAMODEL.md b/DATAMODEL.md new file mode 100644 index 0000000000000000000000000000000000000000..f72cad566f8fa7df70a80da352da25bcf15314c6 --- /dev/null +++ b/DATAMODEL.md @@ -0,0 +1,46 @@ +A few notes on the data model +====== + +The data model used by the accountserver tries to be straightforward +and simple, but there are numerous aspects about the assumed semantics +that require further discussion. + +At its core, it represents *users* and their associated +*resources*. Resources can represent accounts on services of different +type, and can be nested if there is a direct and relevant hierarchical +relation. + +## Authentication + +The major issue with the LDAP backend is that it's not easy to do +joins (you need to do two separate queries, and most open source +clients aren't even coded to do that). This makes it hard to go, for +example, from the email resource object to the main user account +object. + +The issue of authentication is made complex by the choice of specific +services and desired UX that we have adopted. Specifically, the +decision of having the e-mail coincide with the primary user identity +(along with the availability of web-based single sign-on), and the UX +decision of presenting a single "authentication control surface" to +the user (so that, for instance, you don't have to separately set up +2FA for the main user account and your email) determine the need to +unify authentication details for the main user object and the email +resource(s). + +In practice, this means that the API offers its authentication-related +methods on the user object rather than on the email resource. While +the implementation on a SQL backend would be straightforward +(credentials would simply be fields, or subtables, of the *user* +table), in the LDAP backend we are going to rely on denormalization +(i.e. copying the same attribute to multiple objects) and careful +splitting of some attributes to where they are used. For an example of +the latter, consider 2FA, where the split between interactive and +non-interactive clients allows us to: + +* put the TOTP secret on the user object, because that's used by the + interactive authentication service (the single sign-on login server + application); +* put application-specific passwords (used by non-interactive clients) + on the email resource LDAP objects, which is where the email service + authenticates its users. Same for the storage encryption keys. diff --git a/README.md b/README.md index 6ad021b049b25abf36e0945e3205667c41f77b30..939b2ac619279f3dea0be89e856cb534f5c520a0 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,18 @@ esprimendo una relazione di associazione di qualche tipo (come ad esempio tra siti web e database MySQL). Lo schema è definito esplicitamente in [types.go](types.go). + + +# Testing + +Per testare questo attrezzo purtroppo serve Java (basta un JRE, il +runtime environment). Questo perché non è affatto facile implementare +un server LDAP per i test, quindi si è scelto di usarne uno esistente: +in particolare un'implementazione Java... su un sistema Debian: + +``` +sudo apt install default-jre-headless +``` + +dovrebbe bastare (oltre a *golang-go* ovviamente) per poter eseguire i +test con successo. diff --git a/actions.go b/actions.go index 3914cca5dfae5062fd329dc41ef8af0b840adb2e..440e40c1bfa2a6dc418fca939bb24bc09acfc41d 100644 --- a/actions.go +++ b/actions.go @@ -5,160 +5,53 @@ import ( "crypto/rand" "encoding/base64" "errors" + "fmt" + "log" "git.autistici.org/ai3/go-common/pwhash" - "git.autistici.org/id/go-sso" - "git.autistici.org/id/keystore/userenckey" "github.com/pquerna/otp/totp" ) -// Backend user database interface. -// -// All methods share similar semantics: Get methods will return nil if -// the requested object is not found, and only return an error in case -// of trouble reaching the backend itself. -// -// The backend enforces strict public/private data separation by -// having Get methods return public objects (as defined in types.go), -// and using specialized methods to modify the private -// (authentication-related) attributes. -// -// We might add more sophisticated resource query methods later, as -// admin-level functionality. -// -type Backend interface { - GetUser(context.Context, string) (*User, error) - GetResource(context.Context, string, string) (*Resource, error) - UpdateResource(context.Context, string, *Resource) error - SetUserPassword(context.Context, *User, string) error - SetResourcePassword(context.Context, string, *Resource, string) error - GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error) - SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error - SetUserEncryptionPublicKey(context.Context, *User, []byte) error - SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error - DeleteApplicationSpecificPassword(context.Context, *User, string) error - SetUserTOTPSecret(context.Context, *User, string) error - DeleteUserTOTPSecret(context.Context, *User) error - - HasAnyResource(context.Context, []string) (bool, error) -} - -// AccountService implements the business logic and high-level -// functionality of the user accounts management service. -type AccountService struct { - backend Backend - - validator sso.Validator - ssoService string - ssoGroups []string - ssoAdminGroup string - - dataValidators map[string]ValidatorFunc - adminDataValidators map[string]ValidatorFunc -} - -func NewAccountService(backend Backend, config *Config) (*AccountService, error) { - ssoValidator, err := config.ssoValidator() - if err != nil { - return nil, err - } +// RequestBase contains parameters shared by all request types. +type RequestBase struct { + Username string `json:"username"` + SSO string `json:"sso"` - return newAccountServiceWithSSO(backend, config, ssoValidator), nil + // Optional comment, will end up in audit logs. + Comment string `json:"comment,omitempty"` } -func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService { - s := &AccountService{ - backend: backend, - validator: ssoValidator, - ssoService: config.SSO.Service, - ssoGroups: config.SSO.Groups, - ssoAdminGroup: config.SSO.AdminGroup, - } +type userCtxKeyType int - validationConfig := config.validationConfig() - domainBackend := config.domainBackend() - s.dataValidators = map[string]ValidatorFunc{ - ResourceTypeEmail: validHostedEmail(validationConfig, domainBackend, backend), - ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend), - } - - return s -} +var userCtxKey userCtxKeyType -func (s *AccountService) isAdmin(tkt *sso.Ticket) bool { - for _, g := range tkt.Groups { - if g == s.ssoAdminGroup { - return true - } +func userFromContext(ctx context.Context) string { + s, ok := ctx.Value(userCtxKey).(string) + if ok { + return s } - return false + return "" } -var ( - ErrUnauthorized = errors.New("unauthorized") - ErrUserNotFound = errors.New("user not found") - ErrResourceNotFound = errors.New("resource not found") -) - -func (s *AccountService) authorizeAdmin(ctx context.Context, username, ssoToken string) (*User, error) { - // Validate the SSO ticket. - tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups) - if err != nil { - return nil, newAuthError(err) - } +type commentCtxKeyType int - // Requests are allowed if the SSO ticket corresponds to an admin, or if - // it identifies the same user that we're querying. - if !s.isAdmin(tkt) { - return nil, newAuthError(ErrUnauthorized) - } +var commentCtxKey commentCtxKeyType - user, err := s.backend.GetUser(ctx, username) - if err != nil { - return nil, newBackendError(err) +func commentFromContext(ctx context.Context) string { + s, ok := ctx.Value(commentCtxKey).(string) + if ok { + return s } - if user == nil { - return nil, ErrUserNotFound - } - return user, nil + return "" } -func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken string) (*User, error) { - // First, check that the username matches the SSO ticket - // username (or that the SSO ticket has admin permissions). - tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups) - if err != nil { - return nil, newAuthError(err) - } - - // Requests are allowed if the SSO ticket corresponds to an admin, or if - // it identifies the same user that we're querying. - if !s.isAdmin(tkt) && tkt.User != username { - return nil, newAuthError(ErrUnauthorized) - } - - user, err := s.backend.GetUser(ctx, username) - if err != nil { - return nil, newBackendError(err) - } - if user == nil { - return nil, ErrUserNotFound +// NewContext returns a new Context with some request-related values set. +func (r RequestBase) NewContext(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, userCtxKey, r.Username) + if r.Comment != "" { + ctx = context.WithValue(ctx, commentCtxKey, r.Comment) } - return user, nil -} - -// Extended version of authorizeUser that also directly checks the -// user password. Used for account-privileged operations related to -// credential manipulation. -func (s *AccountService) authorizeUserWithPassword(ctx context.Context, username, ssoToken, password string) (*User, error) { - // TODO: call out to the auth-server? - return s.authorizeUser(ctx, username, ssoToken) -} - -// RequestBase contains parameters shared by all request types. -type RequestBase struct { - Username string `json:"username"` - SSO string `json:"sso"` + return ctx } // PrivilegedRequestBase extends RequestBase with the user password, @@ -168,251 +61,333 @@ type PrivilegedRequestBase struct { CurPassword string `json:"cur_password"` } +// ResourceRequestBase is the base type for resource-level requests. +type ResourceRequestBase struct { + ResourceID ResourceID `json:"resource_id"` + SSO string `json:"sso"` + + // Optional comment, will end up in audit logs. + Comment string `json:"comment,omitempty"` +} + +// NewContext returns a new Context with some request-related values set. +func (r ResourceRequestBase) NewContext(ctx context.Context) context.Context { + if u := r.ResourceID.User(); u != "" { + ctx = context.WithValue(ctx, userCtxKey, u) + } + if r.Comment != "" { + ctx = context.WithValue(ctx, commentCtxKey, r.Comment) + } + return ctx +} + +// GetUserRequest is the request type for AccountService.GetUser(). type GetUserRequest struct { RequestBase } // GetUser returns public information about a user. -func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) { - return s.authorizeUser(ctx, req.Username, req.SSO) +func (s *AccountService) GetUser(ctx context.Context, tx TX, req *GetUserRequest) (resp *User, err error) { + err = s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error { + resp = user + return nil + }) + return } // setResourceStatus sets the status of a single resource (shared // logic between enable / disable resource methods). -func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error { - r, err := s.backend.GetResource(ctx, username, resourceID) - if err != nil { - return newBackendError(err) - } - if r == nil { - return ErrResourceNotFound - } +func (s *AccountService) setResourceStatus(ctx context.Context, tx TX, r *Resource, status string) error { r.Status = status - if err := s.backend.UpdateResource(ctx, username, r); err != nil { + if err := tx.UpdateResource(ctx, r); err != nil { return newBackendError(err) } + s.audit.Log(ctx, r.ID, fmt.Sprintf("status set to %s", status)) return nil } +// DisableResourceRequest is the request type for AccountService.DisableResource(). type DisableResourceRequest struct { - RequestBase - ResourceID string `json:"resource_id"` + ResourceRequestBase } // DisableResource disables a resource belonging to the user. -func (s *AccountService) DisableResource(ctx context.Context, req *DisableResourceRequest) error { - if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil { - return err - } - return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusInactive) +func (s *AccountService) DisableResource(ctx context.Context, tx TX, req *DisableResourceRequest) error { + return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error { + return s.setResourceStatus(ctx, tx, r, ResourceStatusInactive) + }) } +// EnableResourceRequest is the request type for AccountService.EnableResource(). type EnableResourceRequest struct { - RequestBase - ResourceID string `json:"resource_id"` + ResourceRequestBase } // EnableResource enables a resource belonging to the user. -func (s *AccountService) EnableResource(ctx context.Context, req *EnableResourceRequest) error { - if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil { - return err - } - return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusActive) +func (s *AccountService) EnableResource(ctx context.Context, tx TX, req *EnableResourceRequest) error { + return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error { + return s.setResourceStatus(ctx, tx, r, ResourceStatusActive) + }) } +// ChangeUserPasswordRequest is the request type for AccountService.ChangeUserPassword(). type ChangeUserPasswordRequest struct { PrivilegedRequestBase Password string `json:"password"` } -func (r *ChangeUserPasswordRequest) Validate() error { - if r.Password == "" { - return errors.New("empty 'password' attribute") - } - return nil +// Validate the request. +func (r *ChangeUserPasswordRequest) Validate(ctx context.Context, s *AccountService) error { + return s.fieldValidators.password(ctx, r.Password) } // ChangeUserPassword updates a user's password. It will also take // care of re-encrypting the user encryption key, if present. -func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error { - user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword) - if err != nil { - return err - } +func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *ChangeUserPasswordRequest) error { + return s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error { + return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password) + }) +} - if err = req.Validate(); err != nil { - return newRequestError(err) - } +// PasswordRecoveryRequest is the request type for AccountService.RecoverPassword(). +// It is not authenticated with SSO. +type PasswordRecoveryRequest struct { + Username string `json:"username"` + RecoveryPassword string `json:"recovery_password"` + Password string `json:"password"` +} - // If the user does not yet have an encryption key, generate one now. - if !user.HasEncryptionKeys { - err = s.initializeUserEncryptionKeys(ctx, user, req.CurPassword) - } else { - err = s.updateUserEncryptionKeys(ctx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID) - } +// Validate the request. +func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error { + return s.fieldValidators.password(ctx, r.Password) +} + +// RecoverPassword lets users reset their password by providing +// secondary credentials, which we authenticate ourselves. +// +// TODO: call out to auth-server for secondary authentication? +func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) error { + user, err := getUserOrDie(ctx, tx, req.Username) if err != nil { return err } + // TODO: authenticate with the secret recovery password. + ctx = context.WithValue(ctx, authUserCtxKey, req.Username) - // Set the encrypted password attribute on the user and email resources. - encPass := pwhash.Encrypt(req.Password) - if err := s.backend.SetUserPassword(ctx, user, encPass); err != nil { - return newBackendError(err) - } - for _, r := range user.GetResourcesByType(ResourceTypeEmail) { - if err := s.backend.SetResourcePassword(ctx, user.Name, r, encPass); err != nil { + return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error { + // Change the user password (the recovery password does not change). + return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password) + }) +} + +// ResetPasswordRequest is the request type for AccountService.ResetPassword(). +type ResetPasswordRequest struct { + RequestBase + Password string `json:"password"` +} + +// Validate the request. +func (r *ResetPasswordRequest) Validate(ctx context.Context, s *AccountService) error { + return s.fieldValidators.password(ctx, r.Password) +} + +// ResetPassword is an admin operation to forcefully reset the +// password for an account. The user will lose access to all stored +// email (because the encryption keys will be reset) and to 2FA. +func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPasswordRequest) error { + return s.handleUserRequest(ctx, tx, req, s.authAdmin(req.RequestBase), func(ctx context.Context, user *User) error { + // Disable 2FA. + if err := s.disable2FA(ctx, tx, user); err != nil { + return err + } + + // Reset encryption keys and set the new password. + return s.changeUserPasswordAndResetEncryptionKeys(ctx, tx, user, req.Password) + }) +} + +// SetPasswordRecoveryHintRequest is the request type for +// AccountService.SetPasswordRecoveryHint(). +type SetPasswordRecoveryHintRequest struct { + PrivilegedRequestBase + Hint string `json:"recovery_hint"` + Response string `json:"recovery_response"` +} + +// Validate the request. +func (r *SetPasswordRecoveryHintRequest) Validate(ctx context.Context, s *AccountService) error { + return s.fieldValidators.password(ctx, r.Response) +} + +// SetPasswordRecoveryHint lets users set the password recovery hint +// and expected response (secondary password). +func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req *SetPasswordRecoveryHintRequest) error { + return s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error { + // If the encryption keys are not set up yet, use the + // CurPassword to initialize them. + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword) + if err != nil { + return err + } + + keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyRecoveryID, req.Response) + if err != nil { + return err + } + + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { return newBackendError(err) } - } - return nil + encResponse := pwhash.Encrypt(req.Response) + return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse) + }) } -// Initialize the user encryption key list, by creating a new "main" key -// encrypted with the given password (which must be the primary password for the -// user). -func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, user *User, curPassword string) error { - // Generate a new key pair. - pub, priv, err := userenckey.GenerateKey() +// Change the user password and update encryption keys, provided we +// have a password that we can use to decrypt them first. +func (s *AccountService) changeUserPasswordAndUpdateEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) error { + // If the user does not yet have an encryption key, generate one now. + var err error + + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, oldPassword, newPassword) if err != nil { return err } - // Encrypt the private key with the password. - enc, err := userenckey.Encrypt(priv, []byte(curPassword)) + keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword) if err != nil { return err } - keys := []*UserEncryptionKey{ - &UserEncryptionKey{ - ID: UserEncryptionKeyMainID, - Key: enc, - }, - } - // Update the backend database. - if err := s.backend.SetUserEncryptionKeys(ctx, user, keys); err != nil { + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { return newBackendError(err) } - if err := s.backend.SetUserEncryptionPublicKey(ctx, user, pub); err != nil { + + // Set the encrypted password attribute on the user (will set it on emails too). + encPass := pwhash.Encrypt(newPassword) + if err := tx.SetUserPassword(ctx, user, encPass); err != nil { return newBackendError(err) } + s.audit.Log(ctx, ResourceID{}, "password changed") + return nil } -// Re-encrypt the specified user encryption key with newPassword. For this -// operation to succeed, we must be able to decrypt one of the keys (not -// necessarily the same one) with curPassword. -func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, user *User, curPassword, newPassword, keyID string) error { - keys, err := s.backend.GetUserEncryptionKeys(ctx, user) +// Change the user password and reset all encryption keys. Existing email +// won't be readable anymore. Existing 2FA credentials will be deleted. +func (s *AccountService) changeUserPasswordAndResetEncryptionKeys(ctx context.Context, tx TX, user *User, newPassword string) error { + // Calling initialize will wipe the current keys and replace + // them with a new one. + keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword) if err != nil { - return newBackendError(err) + return err } - keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID) - if err != nil { - return newRequestError(err) + + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { + return newBackendError(err) } - if err := s.backend.SetUserEncryptionKeys(ctx, user, keys); err != nil { + + // Set the encrypted password attribute on the user (will set it on emails too). + encPass := pwhash.Encrypt(newPassword) + if err := tx.SetUserPassword(ctx, user, encPass); err != nil { return newBackendError(err) } + + s.audit.Log(ctx, ResourceID{}, "password reset") + return nil } -// Decode the user encyrption key using the given password, then generate a new -// list of encryption keys by replacing the specified encryption key with one -// encrypted with the given password (or adding it if it does not exist). -func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) { - // userenckey.Decrypt wants a slice of []byte. - var rawKeys [][]byte - for _, k := range keys { - rawKeys = append(rawKeys, k.Key) - } - decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword)) - if err != nil { - return nil, err - } - encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword)) - if err != nil { - return nil, err +// Disable 2FA for a user account. +func (s *AccountService) disable2FA(ctx context.Context, tx TX, user *User) error { + // Disable OTP. + if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil { + return newBackendError(err) } - var keysOut []*UserEncryptionKey - for _, key := range keys { - if key.ID != keyID { - keysOut = append(keysOut, key) + // Wipe all app-specific passwords. + for _, asp := range user.AppSpecificPasswords { + if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil { + return newBackendError(err) } } - keysOut = append(keysOut, &UserEncryptionKey{ - ID: keyID, - Key: encrypted, - }) - return keysOut, nil + return nil } +// CreateApplicationSpecificPasswordRequest is the request type for +// AccountService.CreateApplicationSpecificPassword(). type CreateApplicationSpecificPasswordRequest struct { PrivilegedRequestBase Service string `json:"service"` - Comment string `json:"comment"` + Notes string `json:"notes"` } -func (r *CreateApplicationSpecificPasswordRequest) Validate() error { +// Validate the request. +func (r *CreateApplicationSpecificPasswordRequest) Validate(_ context.Context, _ *AccountService) error { if r.Service == "" { return errors.New("empty 'service' attribute") } return nil } +// CreateApplicationSpecificPasswordResponse is the response type for +// AccountService.CreateApplicationSpecificPassword(). type CreateApplicationSpecificPasswordResponse struct { Password string `json:"password"` } // CreateApplicationSpecificPassword will generate a new // application-specific password for the given service. -func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) { - user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword) - if err != nil { - return nil, err - } - - if err := req.Validate(); err != nil { - return nil, newRequestError(err) - } - - // No application-specific passwords unless 2FA is enabled. - if !user.Has2FA { - return nil, newRequestError(errors.New("2FA is not enabled for this user")) - } +func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, tx TX, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) { + var resp CreateApplicationSpecificPasswordResponse + err := s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error { + // No application-specific passwords unless 2FA is enabled. + if !user.Has2FA { + return newRequestError(errors.New("2FA is not enabled for this user")) + } - // Create a new application-specific password and set it in - // the database. We don't need to update the User object as - // we're not reusing it. - asp := &AppSpecificPasswordInfo{ - ID: randomAppSpecificPasswordID(), - Service: req.Service, - Comment: req.Comment, - } - password := randomAppSpecificPassword() - encPass := pwhash.Encrypt(password) - if err := s.backend.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil { - return nil, newBackendError(err) - } + // Create a new application-specific password and set it in + // the database. We don't need to update the User object as + // we're not reusing it. + asp := &AppSpecificPasswordInfo{ + ID: randomAppSpecificPasswordID(), + Service: req.Service, + Comment: req.Notes, + } + password := randomAppSpecificPassword() + encPass := pwhash.Encrypt(password) + if err := tx.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil { + return newBackendError(err) + } - // Create or update the user encryption key associated with - // this password. The user encryption key IDs for ASPs all - // have an 'asp_' prefix, followed by the ASP ID. - if user.HasEncryptionKeys { - keyID := "asp_" + asp.ID - if err := s.updateUserEncryptionKeys(ctx, user, req.CurPassword, password, keyID); err != nil { - return nil, err + // Create or update the user encryption key associated with + // this password. The user encryption key IDs for ASPs all + // have an 'asp_' prefix, followed by the ASP ID. + if user.HasEncryptionKeys { + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword) + if err != nil { + return err + } + keyID := "asp_" + asp.ID + keys, err = updateEncryptionKey(keys, decrypted, keyID, password) + if err != nil { + return err + } + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { + return newBackendError(err) + } } - } - return &CreateApplicationSpecificPasswordResponse{ - Password: password, - }, nil + resp.Password = password + return nil + }) + return &resp, err } +// DeleteApplicationSpecificPasswordRequest is the request type for +// AccountService.DeleteApplicationSpecificPassword(). type DeleteApplicationSpecificPasswordRequest struct { RequestBase AspID string `json:"asp_id"` @@ -420,82 +395,40 @@ type DeleteApplicationSpecificPasswordRequest struct { // DeleteApplicationSpecificPassword destroys an application-specific // password, identified by its unique ID. -func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, req *DeleteApplicationSpecificPasswordRequest) error { - user, err := s.authorizeUser(ctx, req.Username, req.SSO) - if err != nil { - return err - } - - if err = s.backend.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil { - return err - } - - // Delete the user encryption key associated with this - // password (we're going to find it via its ID). - keys, err := s.backend.GetUserEncryptionKeys(ctx, user) - if err != nil { - return err - } - if len(keys) == 0 { - return nil - } - aspKeyID := "asp_" + req.AspID - var newKeys []*UserEncryptionKey - for _, k := range keys { - if k.ID != aspKeyID { - newKeys = append(newKeys, k) +func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, tx TX, req *DeleteApplicationSpecificPasswordRequest) error { + return s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error { + if err := tx.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil { + return err } - } - return s.backend.SetUserEncryptionKeys(ctx, user, newKeys) -} - -type ChangeResourcePasswordRequest struct { - RequestBase - ResourceID string `json:"resource_id"` - Password string `json:"password"` -} - -func (r *ChangeResourcePasswordRequest) Validate() error { - if r.Password == "" { - return errors.New("empty 'password' attribute") - } - return nil -} -// ChangeResourcePassword modifies the password associated with a -// specific resource. Resources that do not support this method should -// return an error from the backend. -func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error { - _, err := s.authorizeUser(ctx, req.Username, req.SSO) - if err != nil { - return err - } - - if err = req.Validate(); err != nil { - return newRequestError(err) - } - - r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID) - if err != nil { - return newBackendError(err) - } - if r == nil { - return ErrResourceNotFound - } - - encPass := pwhash.Encrypt(req.Password) - if err := s.backend.SetResourcePassword(ctx, req.Username, r, encPass); err != nil { - return newBackendError(err) - } - return nil + // Delete the user encryption key associated with this + // password (we're going to find it via its ID). + keys, err := tx.GetUserEncryptionKeys(ctx, user) + if err != nil { + return err + } + if len(keys) == 0 { + return nil + } + aspKeyID := "asp_" + req.AspID + var newKeys []*UserEncryptionKey + for _, k := range keys { + if k.ID != aspKeyID { + newKeys = append(newKeys, k) + } + } + return tx.SetUserEncryptionKeys(ctx, user, newKeys) + }) } +// MoveResourceRequest is the request type for AccountService.MoveResource(). type MoveResourceRequest struct { RequestBase - ResourceID string `json:"resource_id"` - Shard string `json:"shard"` + ResourceID ResourceID `json:"resource_id"` + Shard string `json:"shard"` } +// MoveResourceResponse is the response type for AccountService.MoveResource(). type MoveResourceResponse struct { MovedIDs []string `json:"moved_ids"` } @@ -504,42 +437,41 @@ type MoveResourceResponse struct { // between shards. Resources that are part of a group are moved all at // once regardless of which individual ResourceID is provided as long // as it belongs to the group. -func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) { - user, err := s.authorizeAdmin(ctx, req.Username, req.SSO) - if err != nil { - return nil, err - } - - // Collect all related resources, as they should all be moved at once. - r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID) - if err != nil { - return nil, err - } - var resources []*Resource - if r.Group != "" { - resources = append(resources, user.GetResourcesByGroup(r.Group)...) - } else { - resources = []*Resource{r} - } - +func (s *AccountService) MoveResource(ctx context.Context, tx TX, req *MoveResourceRequest) (*MoveResourceResponse, error) { var resp MoveResourceResponse - for _, r := range resources { - r.Shard = req.Shard - if err := s.backend.UpdateResource(ctx, req.Username, r); err != nil { - return nil, err + err := s.handleUserRequest(ctx, tx, req, s.authAdmin(req.RequestBase), func(ctx context.Context, user *User) error { + // Collect all related resources, as they should all be moved at once. + r, err := tx.GetResource(ctx, req.ResourceID) + if err != nil { + return err + } + var resources []*Resource + if r.Group != "" { + resources = append(resources, user.GetResourcesByGroup(r.Group)...) + } else { + resources = []*Resource{r} } - resp.MovedIDs = append(resp.MovedIDs, r.ID) - } - return &resp, nil + for _, r := range resources { + r.Shard = req.Shard + if err := tx.UpdateResource(ctx, r); err != nil { + return err + } + resp.MovedIDs = append(resp.MovedIDs, r.ID.String()) + } + return nil + }) + return &resp, err } +// EnableOTPRequest is the request type for AccountService.EnableOTP(). type EnableOTPRequest struct { RequestBase TOTPSecret string `json:"totp_secret"` } -func (r *EnableOTPRequest) Validate() error { +// Validate the request. +func (r *EnableOTPRequest) Validate(_ context.Context, _ *AccountService) error { // TODO: the length here is bogus, replace with real value. if r.TOTPSecret != "" && len(r.TOTPSecret) != 32 { return errors.New("bad totp_secret value") @@ -547,6 +479,7 @@ func (r *EnableOTPRequest) Validate() error { return nil } +// EnableOTPResponse is the response type for AccountService.EnableOTP(). type EnableOTPResponse struct { TOTPSecret string `json:"totp_secret"` } @@ -556,50 +489,268 @@ type EnableOTPResponse struct { // (useful for UX that confirms that the user is able to login first), // or it can let the server generate a new secret by passing an empty // totp_secret. -func (s *AccountService) EnableOTP(ctx context.Context, req *EnableOTPRequest) (*EnableOTPResponse, error) { - user, err := s.authorizeUser(ctx, req.Username, req.SSO) - if err != nil { - return nil, err +func (s *AccountService) EnableOTP(ctx context.Context, tx TX, req *EnableOTPRequest) (*EnableOTPResponse, error) { + var resp EnableOTPResponse + err := s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) (err error) { + // Replace or initialize the TOTP secret. + if req.TOTPSecret == "" { + req.TOTPSecret, err = generateTOTPSecret() + if err != nil { + return err + } + } + + if err := tx.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil { + return newBackendError(err) + } + + resp.TOTPSecret = req.TOTPSecret + return nil + }) + return &resp, err +} + +// DisableOTPRequest is the request type for AccountService.DisableOTP(). +type DisableOTPRequest struct { + RequestBase +} + +// DisableOTP disables two-factor authentication for a user. +func (s *AccountService) DisableOTP(ctx context.Context, tx TX, req *DisableOTPRequest) error { + return s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error { + // Delete the TOTP secret (if present). + if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil { + return newBackendError(err) + } + return nil + }) +} + +// AddEmailAliasRequest is the request type for AccountService.AddEmailAlias(). +type AddEmailAliasRequest struct { + ResourceRequestBase + Addr string `json:"addr"` +} + +// Validate the request. +func (r *AddEmailAliasRequest) Validate(ctx context.Context, s *AccountService) error { + if r.ResourceID.Type() != ResourceTypeEmail { + return errors.New("this operation only works on email resources") } + return s.fieldValidators.email(ctx, r.Addr) +} + +const maxEmailAliases = 5 + +// AddEmailAlias adds an alias (additional address) to an email resource. +func (s *AccountService) AddEmailAlias(ctx context.Context, tx TX, req *AddEmailAliasRequest) error { + return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error { + // Allow at most 5 aliases. + if len(r.Email.Aliases) >= maxEmailAliases { + return errors.New("too many aliases") + } + + r.Email.Aliases = append(r.Email.Aliases, req.Addr) + if err := tx.UpdateResource(ctx, r); err != nil { + return err + } + + s.audit.Log(ctx, r.ID, fmt.Sprintf("added alias %s", req.Addr)) + return nil + }) +} + +// DeleteEmailAliasRequest is the request type for AccountService.DeleteEmailAlias(). +type DeleteEmailAliasRequest struct { + ResourceRequestBase + Addr string `json:"addr"` +} - if err = req.Validate(); err != nil { - return nil, newRequestError(err) +// Validate the request. +func (r *DeleteEmailAliasRequest) Validate(ctx context.Context, s *AccountService) error { + if r.ResourceID.Type() != ResourceTypeEmail { + return errors.New("this operation only works on email resources") } + return nil +} - // Replace or initialize the TOTP secret. - if req.TOTPSecret == "" { - req.TOTPSecret, err = generateTOTPSecret() - if err != nil { - return nil, err +// DeleteEmailAlias removes an alias from an email resource. +func (s *AccountService) DeleteEmailAlias(ctx context.Context, tx TX, req *DeleteEmailAliasRequest) error { + return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error { + var aliases []string + for _, a := range r.Email.Aliases { + if a != req.Addr { + aliases = append(aliases, a) + } + } + r.Email.Aliases = aliases + if err := tx.UpdateResource(ctx, r); err != nil { + return err + } + + s.audit.Log(ctx, r.ID, fmt.Sprintf("removed alias %s", req.Addr)) + return nil + }) +} + +// CreateResourcesRequest is the request type for AccountService.CreateResources(). +type CreateResourcesRequest struct { + SSO string `json:"sso"` + Resources []*Resource `json:"resources"` +} + +// CreateResourcesResponse is the response type for AccountService.CreateResources(). +type CreateResourcesResponse struct { + // Resources to create. All must either be global resources + // (no user ownership), or belong to the same user. + Resources []*Resource `json:"resources"` +} + +// ApplyTemplate fills in default values for the resources in the request. +func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) { + for _, r := range req.Resources { + s.resourceTemplates.applyTemplate(ctx, r, user) + } +} + +// ValidationUser returns the user to be used for validation purposes. +func (req *CreateResourcesRequest) ValidationUser(ctx context.Context, tx TX) *User { + // Fetch the user associated with the first resource (if + // any). Since resource validation might reference other + // resources, we need to provide it with a view of what the + // future resources will be. So we merge the resources from + // the database with those from the request, using a local + // copy of the User object. + if len(req.Resources) > 0 { + if username := req.Resources[0].ID.User(); username != "" { + u, err := getUserOrDie(ctx, tx, username) + if err != nil { + return nil + } + user := *u + user.Resources = mergeResources(u.Resources, req.Resources) + return &user + } + } + return nil +} + +// Validate the request. +func (req *CreateResourcesRequest) Validate(ctx context.Context, s *AccountService, user *User) error { + var owner string + if user != nil { + owner = user.Name + } + for _, r := range req.Resources { + // Check same-user ownership. + if r.ID.User() != owner { + return errors.New("resources owned by different users") + } + + // Validate the resource. + if err := s.resourceValidator.validateResource(ctx, r, user); err != nil { + log.Printf("validation error while creating resource %+v: %v", r, err) + return err + } + } + return nil +} + +// CreateResources can create one or more resources. +func (s *AccountService) CreateResources(ctx context.Context, tx TX, req *CreateResourcesRequest) (*CreateResourcesResponse, error) { + var resp CreateResourcesResponse + err := s.handleAdminRequest(ctx, tx, req, req.SSO, func(ctx context.Context) error { + for _, r := range req.Resources { + if err := tx.CreateResource(ctx, r); err != nil { + return err + } + resp.Resources = append(resp.Resources, r) + } + return nil + }) + return &resp, err +} + +// Merge two resource lists by ID (the second one wins), return a new list. +func mergeResources(a, b []*Resource) []*Resource { + tmp := make(map[string]*Resource) + for _, l := range [][]*Resource{a, b} { + for _, r := range l { + tmp[r.ID.String()] = r } } - if err := s.backend.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil { - return nil, newBackendError(err) + out := make([]*Resource, 0, len(tmp)) + for _, r := range tmp { + out = append(out, r) } + return out +} - return &EnableOTPResponse{ - TOTPSecret: req.TOTPSecret, - }, nil +// CreateUserRequest is the request type for AccountService.CreateUser(). +type CreateUserRequest struct { + SSO string `json:"sso"` + User *User `json:"user"` } -type DisableOTPRequest struct { - RequestBase +// ApplyTemplate fills in default values for the resources in the request. +func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) { + for _, r := range req.User.Resources { + s.resourceTemplates.applyTemplate(ctx, r, user) + } } -// DisableOTP disables two-factor authentication for a user. -func (s *AccountService) DisableOTP(ctx context.Context, req *DisableOTPRequest) error { - user, err := s.authorizeUser(ctx, req.Username, req.SSO) - if err != 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) return err } - - // Delete the TOTP secret (if present). - if err := s.backend.DeleteUserTOTPSecret(ctx, user); err != nil { - return newBackendError(err) + for _, r := range req.User.Resources { + if err := s.resourceValidator.validateResource(ctx, r, req.User); err != nil { + log.Printf("validation error while creating resource %+v: %v", r, err) + return err + } } + return nil } +// CreateUserResponse is the request type for AccountService.CreateUser(). +type CreateUserResponse struct { + User *User `json:"user,omitempty"` +} + +// 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 + err := s.handleAdminRequest(ctx, tx, req, req.SSO, func(ctx context.Context) (err error) { + if err := tx.CreateUser(ctx, req.User); err != nil { + return err + } + resp.User = req.User + return nil + }) + return &resp, err +} + const appSpecificPasswordLen = 64 func randomBase64(n int) string { diff --git a/actions_test.go b/actions_test.go index 4c9443da21cb1657efb952163b96cb27442dc657..ad0a6fa74ba298e9f8d779cec9976d30fa30c392 100644 --- a/actions_test.go +++ b/actions_test.go @@ -2,6 +2,7 @@ package accountserver import ( "context" + "errors" "testing" sso "git.autistici.org/id/go-sso" @@ -15,15 +16,38 @@ type fakeBackend struct { encryptionKeys map[string][]*UserEncryptionKey } +func (b *fakeBackend) NewTransaction() (TX, error) { + return b, nil +} + +func (b *fakeBackend) Commit(_ context.Context) error { + return nil +} + func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) { return b.users[username], nil } -func (b *fakeBackend) GetResource(_ context.Context, username, resourceID string) (*Resource, error) { - return b.resources[username][resourceID], nil +func (b *fakeBackend) CreateUser(_ context.Context, user *User) error { + b.users[user.Name] = user + return nil +} + +func (b *fakeBackend) GetResource(_ context.Context, resourceID ResourceID) (*Resource, error) { + return b.resources[resourceID.User()][resourceID.String()], nil } -func (b *fakeBackend) UpdateResource(_ context.Context, username string, r *Resource) error { +func (b *fakeBackend) UpdateResource(_ context.Context, r *Resource) error { + b.resources[r.ID.User()][r.ID.String()] = r + return nil +} + +func (b *fakeBackend) CreateResource(_ context.Context, r *Resource) error { + if _, ok := b.resources[r.ID.User()][r.ID.String()]; ok { + return errors.New("resource already exists") + } + + b.resources[r.ID.User()][r.ID.String()] = r return nil } @@ -32,7 +56,11 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st return nil } -func (b *fakeBackend) SetResourcePassword(_ context.Context, username string, r *Resource, password string) error { +func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error { + return nil +} + +func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error { return nil } @@ -42,6 +70,7 @@ func (b *fakeBackend) GetUserEncryptionKeys(_ context.Context, user *User) ([]*U func (b *fakeBackend) SetUserEncryptionKeys(_ context.Context, user *User, keys []*UserEncryptionKey) error { b.encryptionKeys[user.Name] = keys + b.users[user.Name].HasEncryptionKeys = true return nil } @@ -66,7 +95,16 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error return nil } -func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []string) (bool, error) { +func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []FindResourceRequest) (bool, error) { + for _, fr := range rsrcs { + for _, ur := range b.resources { + for _, r := range ur { + if r.ID.Type() == fr.Type && r.ID.Name() == fr.Name { + return true, nil + } + } + } + } return false, nil } @@ -76,7 +114,7 @@ type fakeValidator struct { adminUser string } -func (v *fakeValidator) Validate(tkt string, nonce string, service string, _ []string) (*sso.Ticket, error) { +func (v *fakeValidator) Validate(tkt, nonce, service string, _ []string) (*sso.Ticket, error) { // The sso ticket username is just the ticket itself. var groups []string if tkt == v.adminUser { @@ -90,40 +128,79 @@ func (v *fakeValidator) Validate(tkt string, nonce string, service string, _ []s }, nil } +func (b *fakeBackend) addUser(user *User) { + b.users[user.Name] = user + b.resources[user.Name] = make(map[string]*Resource) + for _, r := range user.Resources { + b.resources[user.Name][r.ID.String()] = r + } +} + func createFakeBackend() *fakeBackend { fb := &fakeBackend{ - users: map[string]*User{ - "testuser": &User{ - Name: "testuser", - Resources: []*Resource{ - { - ID: "email/testuser@example.com", - Name: "testuser@example.com", - Type: ResourceTypeEmail, - Status: ResourceStatusActive, - Email: &Email{}, - }, - }, - }, + users: make(map[string]*User), + resources: map[string]map[string]*Resource{ + // For global (user-less) resources, where CreateUser is not called. + "": make(map[string]*Resource), }, - resources: make(map[string]map[string]*Resource), passwords: make(map[string]string), appSpecificPasswords: make(map[string][]*AppSpecificPasswordInfo), encryptionKeys: make(map[string][]*UserEncryptionKey), } + fb.addUser(&User{ + Name: "testuser", + Resources: []*Resource{ + { + ID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"), + Name: "testuser@example.com", + Status: ResourceStatusActive, + Email: &Email{ + Maildir: "example.com/testuser", + }, + }, + { + ID: NewResourceID(ResourceTypeDAV, "testuser", "dav1"), + Name: "dav1", + Status: ResourceStatusActive, + DAV: &WebDAV{ + Homedir: "/home/dav1", + }, + }, + }, + }) return fb } func testConfig() *Config { var c Config + c.ForbiddenUsernames = []string{"root"} + c.AvailableDomains = map[string][]string{ + ResourceTypeEmail: []string{"example.com"}, + ResourceTypeMailingList: []string{"example.com"}, + } c.SSO.Domain = "mydomain" c.SSO.Service = "service/" c.SSO.AdminGroup = testAdminGroupName + c.Shards.Available = map[string][]string{ + ResourceTypeEmail: []string{"host1", "host2", "host3"}, + ResourceTypeMailingList: []string{"host1", "host2", "host3"}, + ResourceTypeWebsite: []string{"host1", "host2", "host3"}, + ResourceTypeDomain: []string{"host1", "host2", "host3"}, + ResourceTypeDAV: []string{"host1", "host2", "host3"}, + } + c.Shards.Allowed = c.Shards.Available return &c } +func testService(admin string) (*AccountService, TX) { + be := createFakeBackend() + svc, _ := newAccountServiceWithSSO(be, testConfig(), &fakeValidator{admin}) + tx, _ := be.NewTransaction() + return svc, tx +} + func TestService_GetUser(t *testing.T) { - svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{}) + svc, tx := testService("") req := &GetUserRequest{ RequestBase: RequestBase{ @@ -131,7 +208,7 @@ func TestService_GetUser(t *testing.T) { SSO: "testuser", }, } - resp, err := svc.GetUser(context.TODO(), req) + resp, err := svc.GetUser(context.TODO(), tx, req) if err != nil { t.Fatal(err) } @@ -141,7 +218,7 @@ func TestService_GetUser(t *testing.T) { } func TestService_Auth(t *testing.T) { - svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{"adminuser"}) + svc, tx := testService("adminuser") for _, td := range []struct { sso string @@ -157,7 +234,7 @@ func TestService_Auth(t *testing.T) { SSO: td.sso, }, } - _, err := svc.GetUser(context.TODO(), req) + _, err := svc.GetUser(context.TODO(), tx, req) if err != nil { if !IsAuthError(err) { t.Errorf("error for sso_user=%s is not an auth error: %v", td.sso, err) @@ -172,21 +249,38 @@ func TestService_Auth(t *testing.T) { func TestService_ChangePassword(t *testing.T) { fb := createFakeBackend() - svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{}) + tx, _ := fb.NewTransaction() + svc, _ := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{}) - req := &ChangeUserPasswordRequest{ - PrivilegedRequestBase: PrivilegedRequestBase{ - RequestBase: RequestBase{ - Username: "testuser", - SSO: "testuser", - }, - CurPassword: "cur", - }, - Password: "password", + testdata := []struct { + password string + newPassword string + expectedOk bool + }{ + // Ordering is important as it is meant to emulate + // setting the password, failing to reset it, then + // succeeding. + {"password", "new_password", true}, + {"BADPASS", "new_password_2", false}, + {"new_password", "new_password_2", true}, } - err := svc.ChangeUserPassword(context.TODO(), req) - if err != nil { - t.Fatal(err) + for _, td := range testdata { + req := &ChangeUserPasswordRequest{ + PrivilegedRequestBase: PrivilegedRequestBase{ + RequestBase: RequestBase{ + Username: "testuser", + SSO: "testuser", + }, + CurPassword: td.password, + }, + Password: td.newPassword, + } + err := svc.ChangeUserPassword(context.TODO(), tx, req) + if err == nil && !td.expectedOk { + t.Fatalf("ChangeUserPassword(old=%s new=%s) should have failed but didn't", td.password, td.newPassword) + } else if err != nil && td.expectedOk { + t.Fatalf("ChangeUserPassword(old=%s new=%s) failed: %v", td.password, td.newPassword, err) + } } if _, ok := fb.passwords["testuser"]; !ok { @@ -196,3 +290,186 @@ func TestService_ChangePassword(t *testing.T) { t.Errorf("no encryption keys were set") } } + +// Lower level test that basically corresponds to the same operations +// as TestService_ChangePassword above, but exercises the +// initializeUserEncryptionKeys / updateUserEncryptionKeys code path +// directly. +func TestService_EncryptionKeys(t *testing.T) { + fb := createFakeBackend() + svc, _ := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{}) + tx, _ := fb.NewTransaction() + ctx := context.Background() + + user, _ := getUserOrDie(ctx, tx, "testuser") + + // Set the keys to something. + keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password") + if err != nil { + t.Fatal("init", err) + } + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { + t.Fatal("SetUserEncryptionKeys", err) + } + if n := len(fb.encryptionKeys["testuser"]); n != 1 { + t.Fatalf("found %d encryption keys, expected 1", n) + } + + // Try to read (decrypt) them again using bad / good passwords. + if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "BADPASS", "new_password"); err == nil { + t.Fatal("read with bad password did not fail") + } + if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "password", "new_password"); err != nil { + t.Fatal("readOrInitialize", err) + } +} + +// Try adding aliases to the email resource. +func TestService_AddEmailAlias(t *testing.T) { + svc, tx := testService("") + + testdata := []struct { + addr string + expectedOk bool + }{ + {"alias@example.com", true}, + {"another-example-address@example.com", true}, + {"root@example.com", false}, + {"alias@other-domain.com", false}, + } + for _, td := range testdata { + req := &AddEmailAliasRequest{ + ResourceRequestBase: ResourceRequestBase{ + SSO: "testuser", + ResourceID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"), + }, + Addr: td.addr, + } + err := svc.AddEmailAlias(context.TODO(), tx, req) + if err != nil && td.expectedOk { + t.Errorf("AddEmailAlias(%s) failed: %v", td.addr, err) + } else if err == nil && !td.expectedOk { + t.Errorf("AddEmailAlias(%s) did not fail but should have", td.addr) + } + } +} + +func TestService_CreateResource(t *testing.T) { + svc, tx := testService("admin") + + req := &CreateResourcesRequest{ + SSO: "admin", + Resources: []*Resource{ + &Resource{ + ID: NewResourceID(ResourceTypeDAV, "testuser", "dav2"), + Name: "dav2", + Status: ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + DAV: &WebDAV{ + Homedir: "/home/dav2", + }, + }, + }, + } + + // The request should succeed the first time around. + _, err := svc.CreateResources(context.Background(), tx, req) + if err != nil { + t.Fatal("CreateResources", err) + } + + // The object already exists, so the same request should fail now. + _, err = svc.CreateResources(context.Background(), tx, req) + if err == nil { + t.Fatal("creating a duplicate resource did not fail") + } +} + +func TestService_CreateResource_List(t *testing.T) { + svc, tx := testService("admin") + + // A list is an example of a user-less (global) resource. + req := &CreateResourcesRequest{ + SSO: "admin", + Resources: []*Resource{ + &Resource{ + ID: NewResourceID(ResourceTypeMailingList, "list@example.com"), + Name: "list@example.com", + Status: ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + List: &MailingList{ + Admins: []string{"testuser@example.com"}, + }, + }, + }, + } + + // The request should succeed. + _, err := svc.CreateResources(context.Background(), tx, req) + if err != nil { + t.Fatal("CreateResources", err) + } +} + +func TestService_CreateUser(t *testing.T) { + svc, tx := testService("admin") + + req := &CreateUserRequest{ + SSO: "admin", + User: &User{ + Name: "testuser2@example.com", + Resources: []*Resource{ + &Resource{ + ID: NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"), + Name: "testuser2@example.com", + Status: ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Email: &Email{ + Maildir: "example.com/testuser2", + }, + }, + }, + }, + } + + // The request should succeed the first time around. + resp, err := svc.CreateUser(context.Background(), tx, req) + if err != nil { + t.Fatal("CreateResources", err) + } + if resp.User.Name != "testuser2@example.com" { + t.Fatalf("unexpected user in response: got %s, expected testuser2", resp.User.Name) + } +} + +func TestService_CreateUser_FailIfNotAdmin(t *testing.T) { + svc, tx := testService("admin") + + req := &CreateUserRequest{ + SSO: "testuser", + User: &User{ + Name: "testuser2@example.com", + Resources: []*Resource{ + &Resource{ + ID: NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"), + Name: "testuser2@example.com", + Status: ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Email: &Email{ + Maildir: "example.com/testuser2", + }, + }, + }, + }, + } + + // The request should succeed the first time around. + _, err := svc.CreateUser(context.Background(), tx, req) + if err == nil { + t.Fatal("CreateResources did not fail") + } +} diff --git a/audit.go b/audit.go new file mode 100644 index 0000000000000000000000000000000000000000..0e339c7def0841e6c08a166506ce84ccdb7e6cf8 --- /dev/null +++ b/audit.go @@ -0,0 +1,39 @@ +package accountserver + +import ( + "context" + "encoding/json" + "log" +) + +type auditLogger interface { + Log(context.Context, ResourceID, string) +} + +type auditLogEntry struct { + User string `json:"user,omitempty"` + By string `json:"by"` + Message string `json:"message"` + Comment string `json:"comment,omitempty"` + ResourceName string `json:"resource_name,omitempty"` + ResourceType string `json:"resource_type,omitempty"` +} + +type syslogAuditLogger struct{} + +func (l *syslogAuditLogger) Log(ctx context.Context, resourceID ResourceID, what string) { + e := auditLogEntry{ + User: userFromContext(ctx), + By: authUserFromContext(ctx), + Message: what, + Comment: commentFromContext(ctx), + } + + if !resourceID.Empty() { + e.ResourceName = resourceID.Name() + e.ResourceType = resourceID.Type() + } + + data, _ := json.Marshal(&e) + log.Printf("@cee:%s", data) +} diff --git a/backend/composite_values.go b/backend/composite_values.go index e6287f54d3bca02e9309a0165221210ddaa151c0..c6c57006c0bd5714e8a7f1da6f09c9c331fbe7f5 100644 --- a/backend/composite_values.go +++ b/backend/composite_values.go @@ -2,11 +2,14 @@ package backend import ( "errors" + "fmt" "strings" "git.autistici.org/ai3/accountserver" ) +// Extend the AppSpecificPasswordInfo type, which only contains public +// information, with the encrypted password. type appSpecificPassword struct { accountserver.AppSpecificPasswordInfo Password string @@ -63,3 +66,26 @@ func getASPInfo(asps []*appSpecificPassword) []*accountserver.AppSpecificPasswor } return out } + +func decodeUserEncryptionKeys(values []string) []*accountserver.UserEncryptionKey { + var out []*accountserver.UserEncryptionKey + for _, value := range values { + idx := strings.IndexByte(value, ':') + if idx < 0 { + continue + } + out = append(out, &accountserver.UserEncryptionKey{ + ID: value[:idx], + Key: []byte(value[idx+1:]), + }) + } + return out +} + +func encodeUserEncryptionKeys(keys []*accountserver.UserEncryptionKey) []string { + var out []string + for _, key := range keys { + out = append(out, fmt.Sprintf("%s:%s", key.ID, string(key.Key))) + } + return out +} diff --git a/backend/diff.go b/backend/diff.go deleted file mode 100644 index b0c3d00ccb9e757c305e87238ff44fa728f19d19..0000000000000000000000000000000000000000 --- a/backend/diff.go +++ /dev/null @@ -1,82 +0,0 @@ -package backend - -// Implementing read-modify-update cycles with the LDAP backend. -// -// How can we track modifications to Resources in a backend-independent way? One -// method could be to expose specific methods on the Backend interface for every -// change we might want to make: EmailAddAlias, UserSetPassword, etc., but this -// scales very poorly with the number of attributes and operations. Instead, we -// add methods to de-serialize Resources to LDAP objects (or rather, sequences -// of attributes), so that we can compute differences with the original objects -// and issue the appropriate incremental ModifyRequest. -// -// To do this, GetResource keeps around a copy of the original resource, so when -// calling UpdateResource, the two serializations are compared to obtain a -// ModifyRequest: this way, other LDAP attributes that might be present in the -// database (but are not managed by this system) are left untouched on existing -// objects. Attributes explicitly unset (set to the nil value) in the Resource -// will be deleted from LDAP. - -import ( - ldap "gopkg.in/ldap.v2" - - "git.autistici.org/ai3/accountserver" -) - -func partialAttributesToMap(attrs []ldap.PartialAttribute) map[string]ldap.PartialAttribute { - m := make(map[string]ldap.PartialAttribute) - for _, attr := range attrs { - m[attr.Type] = attr - } - return m -} - -func partialAttributeEquals(a, b ldap.PartialAttribute) bool { - // We never sort lists, so we can compare them element-wise. - if len(a.Vals) != len(b.Vals) { - return false - } - for i := 0; i < len(a.Vals); i++ { - if a.Vals[i] != b.Vals[i] { - return false - } - } - return true -} - -// Populate the ldap.ModifyRequest, returns false if unchanged. -func partialAttributeMapDiff(mod *ldap.ModifyRequest, a, b map[string]ldap.PartialAttribute) bool { - var changed bool - for bkey, battr := range b { - aattr, ok := a[bkey] - if !ok { - mod.Add(battr.Type, battr.Vals) - changed = true - } else if battr.Vals == nil { - mod.Delete(battr.Type, battr.Vals) - changed = true - } else if !partialAttributeEquals(aattr, battr) { - mod.Replace(battr.Type, battr.Vals) - changed = true - } - } - return changed -} - -func diffResources(mod *ldap.ModifyRequest, a, b *accountserver.Resource) bool { - return partialAttributeMapDiff( - mod, - partialAttributesToMap(resourceToLDAP(a)), - partialAttributesToMap(resourceToLDAP(b)), - ) -} - -// Assemble a ldap.ModifyRequest object by checking differences in two -// Resources objects. If the objects are identical, nil is returned. -func createModifyRequest(dn string, a, b *accountserver.Resource) *ldap.ModifyRequest { - mod := ldap.NewModifyRequest(dn) - if diffResources(mod, a, b) { - return mod - } - return nil -} diff --git a/backend/ldap_server_test.go b/backend/ldap_server_test.go deleted file mode 100644 index 8f1ba78221ed2200e27b84e44dd393c1bae5476c..0000000000000000000000000000000000000000 --- a/backend/ldap_server_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package backend - -import ( - "fmt" - "io/ioutil" - "os/exec" - "testing" - "time" -) - -type testLDAPServerConfig struct { - Base string - Port int - LDIFs []string -} - -func startTestLDAPServer(t testing.TB, config *testLDAPServerConfig) func() { - tmpf, err := ioutil.TempFile("", "") - if err != nil { - t.Fatal(err) - } - fmt.Fprintf(tmpf, `ldap.rootDn=%s -ldap.managerDn=cn=manager,%s -ldap.managerPassword=password -ldap.port=%d -`, config.Base, config.Base, config.Port) - defer tmpf.Close() - - args := []string{tmpf.Name()} - args = append(args, config.LDIFs...) - proc := exec.Command("./unboundid-ldap-server/bin/unboundid-ldap-server", args...) - - if err := proc.Start(); err != nil { - t.Fatalf("error starting LDAP server: %v", err) - } - - time.Sleep(1 * time.Second) - - return func() { - proc.Process.Kill() - } -} diff --git a/backend/model.go b/backend/model.go index d876ad2352271c30132f4788f022b16066c67392..5f110243150b01b1e8f555a40b8c068063d6eb92 100644 --- a/backend/model.go +++ b/backend/model.go @@ -2,9 +2,6 @@ package backend import ( "context" - "errors" - "fmt" - "os" "strings" ldaputil "git.autistici.org/ai3/go-common/ldap" @@ -13,41 +10,70 @@ import ( "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) - Add(context.Context, *ldap.AddRequest) error - Modify(context.Context, *ldap.ModifyRequest) error - Close() -} +const ( + // Names of some well-known LDAP attributes. + totpSecretLDAPAttr = "totpSecret" + preferredLanguageLDAPAttr = "preferredLanguage" + recoveryHintLDAPAttr = "recoverQuestion" + recoveryResponseLDAPAttr = "recoverAnswer" + aspLDAPAttr = "appSpecificPassword" + storagePublicKeyLDAPAttr = "storagePublicKey" + storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" + passwordLDAPAttr = "userPassword" +) -// LDAPBackend is the interface to an LDAP-backed user database. +// backend is the interface to an LDAP-backed user database. // // 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. -type LDAPBackend struct { +type backend struct { conn ldapConn + baseDN string userQuery *queryConfig userResourceQueries []*queryConfig - resourceQueries map[string]*queryConfig - presenceQueries map[string]*queryConfig + resources *resourceRegistry +} + +// backendTX holds the business logic (that runs within a single +// transaction). +type backendTX struct { + *ldapTX + backend *backend } const ldapPoolSize = 20 +func (b *backend) NewTransaction() (accountserver.TX, error) { + return &backendTX{ + ldapTX: newLDAPTX(b.conn), + backend: b, + }, nil +} + // NewLDAPBackend initializes an LDAPBackend object with the given LDAP // connection pool. -func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) { +func NewLDAPBackend(uri, bindDN, bindPw, base string) (accountserver.Backend, error) { pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize) if err != nil { return nil, err } + return newLDAPBackendWithConn(pool, base) +} + +func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) { + 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}) - return &LDAPBackend{ - conn: pool, + return &backend{ + conn: conn, + baseDN: base, userQuery: mustCompileQueryConfig(&queryConfig{ Base: "uid=${user},ou=People," + base, Scope: "base", @@ -65,333 +91,71 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) { Scope: "one", }), }, - resourceQueries: map[string]*queryConfig{ - accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{ - Base: "mail=${resource},uid=${user},ou=People," + base, - Filter: "(objectClass=virtualMailUser)", - Scope: "base", - }), - accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{ - Base: "uid=${user},ou=People," + base, - Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))", - Scope: "one", - }), - accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{ - Base: "uid=${user},ou=People," + base, - Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))", - Scope: "sub", - }), - accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{ - Base: "uid=${user},ou=People," + base, - Filter: "(&(objectClass=dbMysql)(dbname=${resource}))", - Scope: "sub", - }), - accountserver.ResourceTypeMailingList: mustCompileQueryConfig(&queryConfig{ - Base: "ou=Lists," + base, - Filter: "(&(objectClass=mailingList)(listName=${resource}))", - Scope: "one", - }), - }, - presenceQueries: map[string]*queryConfig{ - accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{ - Base: "ou=People," + base, - Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))", - Scope: "sub", - }), - accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{ - Base: "ou=People," + base, - Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))", - Scope: "sub", - }), - accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{ - Base: "ou=People," + base, - Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))", - Scope: "sub", - }), - accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{ - Base: "ou=People," + base, - Filter: "(&(objectClass=dbMysql)(dbname=${resource}))", - Scope: "sub", - }), - accountserver.ResourceTypeMailingList: mustCompileQueryConfig(&queryConfig{ - Base: "ou=Lists," + base, - Filter: "(&(objectClass=mailingList)(listName=${resource}))", - Scope: "one", - }), - }, + resources: rsrc, }, nil } -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 - } -} - -func b2s(b bool) string { - if b { - return "yes" - } - return "no" -} - -func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *accountserver.Resource { - name := entry.GetAttributeValue(nameAttr) - return &accountserver.Resource{ - ID: fmt.Sprintf("%s/%s", resourceType, name), - Name: name, - Type: resourceType, - Status: entry.GetAttributeValue("status"), - Shard: entry.GetAttributeValue("host"), - OriginalShard: entry.GetAttributeValue("originalHost"), +func newUser(entry *ldap.Entry) (*accountserver.User, error) { + user := &accountserver.User{ + Name: entry.GetAttributeValue("uid"), + Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr), + Has2FA: (entry.GetAttributeValue(totpSecretLDAPAttr) != ""), + //HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0), + //PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"), } -} - -// 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 + if user.Lang == "" { + user.Lang = "en" } - return []string{s} + return user, nil } -func resourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - // Assemble LDAP attributes for this resource. Use a type-specific - // method to get attributes, then add the resource-generic ones if - // necessary. Note that it is very important that the "objectClass" - // attribute is returned first, or ldap.Add will fail. - - var attrs []ldap.PartialAttribute - switch r.Type { - case accountserver.ResourceTypeEmail: - attrs = emailResourceToLDAP(r) - case accountserver.ResourceTypeWebsite: - attrs = websiteResourceToLDAP(r) - case accountserver.ResourceTypeDAV: - attrs = webDAVResourceToLDAP(r) - case accountserver.ResourceTypeDatabase: - attrs = databaseResourceToLDAP(r) - case accountserver.ResourceTypeMailingList: - attrs = mailingListResourceToLDAP(r) - } - +func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) { + // Most attributes are read-only and have specialized methods to set them. attrs = append(attrs, []ldap.PartialAttribute{ - {Type: "status", Vals: s2l(r.Status)}, - {Type: "host", Vals: s2l(r.Shard)}, - {Type: "originalHost", Vals: s2l(r.OriginalShard)}, + {Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "totpAccount"}}, + {Type: "uid", Vals: s2l(user.Name)}, + {Type: "cn", Vals: s2l(user.Name)}, + {Type: "givenName", Vals: []string{"Private"}}, + {Type: "sn", Vals: []string{"Private"}}, + {Type: "gecos", Vals: s2l(user.Name)}, + {Type: "loginShell", Vals: []string{"/bin/false"}}, + {Type: "homeDirectory", Vals: []string{"/var/empty"}}, + {Type: "shadowLastChange", Vals: []string{"12345"}}, + {Type: "shadowWarning", Vals: []string{"7"}}, + {Type: "shadowMax", Vals: []string{"99999"}}, + {Type: preferredLanguageLDAPAttr, Vals: s2l(user.Lang)}, }...) - - return attrs -} - -func newEmailResource(entry *ldap.Entry) (*accountserver.Resource, error) { - r := newResourceFromLDAP(entry, accountserver.ResourceTypeEmail, "mail") - r.Email = &accountserver.Email{ - Aliases: entry.GetAttributeValues("mailAlternateAddr"), - Maildir: entry.GetAttributeValue("mailMessageStore"), - } - return r, nil -} - -func emailResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - return []ldap.PartialAttribute{ - {Type: "objectClass", Vals: []string{"top", "virtualMailUser"}}, - {Type: "mail", Vals: s2l(r.Name)}, - {Type: "mailAlternateAddr", Vals: r.Email.Aliases}, - {Type: "mailMessageStore", Vals: s2l(r.Email.Maildir)}, - } -} - -func newMailingListResource(entry *ldap.Entry) (*accountserver.Resource, error) { - r := newResourceFromLDAP(entry, accountserver.ResourceTypeMailingList, "listName") - r.List = &accountserver.MailingList{ - Public: s2b(entry.GetAttributeValue("public")), - Admins: entry.GetAttributeValues("listOwner"), - } - return r, nil + return } -func mailingListResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - return []ldap.PartialAttribute{ - {Type: "objectClass", Vals: []string{"top", "mailingList"}}, - {Type: "listName", Vals: s2l(r.Name)}, - {Type: "public", Vals: s2l(b2s(r.List.Public))}, - {Type: "listOwner", Vals: r.List.Admins}, - } +func (tx *backendTX) getUserDN(user *accountserver.User) string { + return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN) } -func newWebDAVResource(entry *ldap.Entry) (*accountserver.Resource, error) { - r := newResourceFromLDAP(entry, accountserver.ResourceTypeDAV, "ftpname") - r.DAV = &accountserver.WebDAV{ - Homedir: entry.GetAttributeValue("homeDirectory"), - } - return r, nil -} +// CreateUser creates a new user. +func (tx *backendTX) CreateUser(ctx context.Context, user *accountserver.User) error { + dn := tx.getUserDN(user) -func webDAVResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - return []ldap.PartialAttribute{ - {Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "ftpAccount"}}, - {Type: "ftpname", Vals: s2l(r.Name)}, - {Type: "homeDirectory", Vals: s2l(r.DAV.Homedir)}, + tx.create(dn) + for _, attr := range userToLDAP(user) { + tx.setAttr(dn, attr.Type, attr.Vals...) } -} -func newWebsiteResource(entry *ldap.Entry) (*accountserver.Resource, error) { - var r *accountserver.Resource - if isObjectClass(entry, "subSite") { - r = newResourceFromLDAP(entry, accountserver.ResourceTypeWebsite, "alias") - r.Website = &accountserver.Website{ - URL: fmt.Sprintf("https://www.%s/%s/", entry.GetAttributeValue("parentSite"), r.Name), - DisplayName: fmt.Sprintf("%s/%s", entry.GetAttributeValue("parentSite"), r.Name), - } - } else { - r = newResourceFromLDAP(entry, accountserver.ResourceTypeWebsite, "cn") - r.Website = &accountserver.Website{ - URL: fmt.Sprintf("https://%s/", r.Name), - DisplayName: r.Name, + // Create all resources. + for _, r := range user.Resources { + if err := tx.CreateResource(ctx, r); err != nil { + return err } } - r.Website.Options = entry.GetAttributeValues("option") - r.Website.DocumentRoot = entry.GetAttributeValue("documentRoot") - r.Website.AcceptMail = s2b(entry.GetAttributeValue("acceptMail")) - return r, nil -} - -func websiteResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - // Subsites and vhosts have a different RDN. - var mainRDN, mainOC string - if strings.Contains(r.Website.DisplayName, "/") { - mainRDN = "alias" - mainOC = "subSite" - } else { - mainRDN = "cn" - mainOC = "virtualHost" - } - return []ldap.PartialAttribute{ - {Type: "objectClass", Vals: []string{"top", mainOC}}, - {Type: mainRDN, Vals: s2l(r.Name)}, - {Type: "option", Vals: r.Website.Options}, - {Type: "documentRoot", Vals: s2l(r.Website.DocumentRoot)}, - {Type: "acceptMail", Vals: s2l(b2s(r.Website.AcceptMail))}, - } -} -func newDatabaseResource(entry *ldap.Entry) (*accountserver.Resource, error) { - r := newResourceFromLDAP(entry, accountserver.ResourceTypeDatabase, "dbname") - r.Database = &accountserver.Database{ - DBUser: entry.GetAttributeValue("dbuser"), - CleartextPassword: entry.GetAttributeValue("clearPassword"), - } - - // Databases are nested below websites, so we set the ParentID by - // looking at the LDAP DN. - if dn, err := ldap.ParseDN(entry.DN); err == nil { - parentRDN := dn.RDNs[1] - r.ParentID = fmt.Sprintf("%s/%s", accountserver.ResourceTypeWebsite, parentRDN.Attributes[0].Value) - } - - return r, nil -} - -func databaseResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { - return []ldap.PartialAttribute{ - {Type: "objectClass", Vals: []string{"top", "dbMysql"}}, - {Type: "dbname", Vals: s2l(r.Name)}, - {Type: "dbuser", Vals: s2l(r.Database.DBUser)}, - {Type: "clearPassword", Vals: s2l(r.Database.CleartextPassword)}, - } -} - -type ldapUserData struct { - dn string -} - -func newUser(entry *ldap.Entry) (*accountserver.User, error) { - user := &accountserver.User{ - Name: entry.GetAttributeValue("uid"), - Lang: entry.GetAttributeValue("preferredLanguage"), - Has2FA: (entry.GetAttributeValue("totpSecret") != ""), - HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0), - //PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"), - } - if user.Lang == "" { - user.Lang = "en" - } - user.Opaque = &ldapUserData{dn: entry.DN} - return user, nil -} - -func getUserDN(user *accountserver.User) string { - lu, ok := user.Opaque.(*ldapUserData) - if !ok { - panic("no ldap user data") - } - return lu.dn + return nil } // GetUser returns a user. -func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountserver.User, error) { +func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) { // First of all, find the main user object, and just that one. vars := map[string]string{"user": username} - result, err := b.conn.Search(ctx, b.userQuery.searchRequest(vars, nil)) + result, err := tx.search(ctx, tx.backend.userQuery.searchRequest(vars, nil)) if err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return nil, nil @@ -408,8 +172,8 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser // object we just created. // TODO: parallelize. // TODO: add support for non-LDAP resource queries. - for _, query := range b.userResourceQueries { - result, err = b.conn.Search(ctx, query.searchRequest(vars, nil)) + for _, query := range tx.backend.userResourceQueries { + result, err = tx.search(ctx, query.searchRequest(vars, nil)) if err != nil { continue } @@ -419,11 +183,13 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser // 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") - user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword"))) + user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr) + user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr))) + user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "") } + // Parse the resource and add it to the User. - if r, err := parseLdapResource(entry); err == nil { + if r, err := tx.backend.resources.FromLDAP(entry); err == nil { user.Resources = append(user.Resources, r) } } @@ -434,162 +200,113 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser return user, nil } -func singleAttributeQuery(dn, attribute string) *ldap.SearchRequest { - return ldap.NewSearchRequest( - dn, - ldap.ScopeBaseObject, - ldap.NeverDerefAliases, - 0, - 0, - false, - "(objectClass=*)", - []string{attribute}, - nil, - ) -} - -func (b *LDAPBackend) readAttributeValues(ctx context.Context, dn, attribute string) []string { - req := singleAttributeQuery(dn, attribute) - result, err := b.conn.Search(ctx, req) - if err != nil { - return nil - } - if len(result.Entries) < 1 { - return nil +func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error { + 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) } - return result.Entries[0].GetAttributeValues(attribute) -} - -func (b *LDAPBackend) readAttributeValue(ctx context.Context, dn, attribute string) string { - req := singleAttributeQuery(dn, attribute) - result, err := b.conn.Search(ctx, req) - if err != nil { - return "" - } - if len(result.Entries) < 1 { - return "" - } - return result.Entries[0].GetAttributeValue(attribute) + return nil } -func (b *LDAPBackend) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error { - mod := ldap.NewModifyRequest(getUserDN(user)) - mod.Replace("userPassword", []string{encryptedPassword}) - return b.conn.Modify(ctx, mod) +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 } -func (b *LDAPBackend) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) { - rawKeys := b.readAttributeValues(ctx, getUserDN(user), "storageEncryptionKey") - return accountserver.DecodeUserEncryptionKeys(rawKeys), nil +func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) { + r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail) + dn, _ := tx.backend.resources.GetDN(r.ID) + rawKeys := tx.readAttributeValues(ctx, dn, storagePrivateKeyLDAPAttr) + return decodeUserEncryptionKeys(rawKeys), nil } -func (b *LDAPBackend) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error { - mod := ldap.NewModifyRequest(getUserDN(user)) - if user.HasEncryptionKeys { - mod.Replace("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys)) - } else { - mod.Add("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys)) +func (tx *backendTX) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error { + encKeys := encodeUserEncryptionKeys(keys) + for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) { + dn, _ := tx.backend.resources.GetDN(r.ID) + tx.setAttr(dn, storagePrivateKeyLDAPAttr, encKeys...) } - return b.conn.Modify(ctx, mod) + return nil } -func (b *LDAPBackend) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error { - mod := ldap.NewModifyRequest(getUserDN(user)) - if user.HasEncryptionKeys { - mod.Replace("storageEncryptionPublicKey", []string{string(pub)}) - } else { - mod.Add("storageEncryptionPublicKey", []string{string(pub)}) +func (tx *backendTX) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error { + for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) { + dn, _ := tx.backend.resources.GetDN(r.ID) + tx.setAttr(dn, storagePublicKeyLDAPAttr, string(pub)) } - return b.conn.Modify(ctx, mod) + return nil } -func (b *LDAPBackend) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error { - emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail) - if emailRsrc == nil { - return errors.New("no email resource") - } - emailDN := getResourceDN(emailRsrc) - - asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword")) - var outASPs []*appSpecificPassword +func excludeASPFromList(asps []*appSpecificPassword, id string) []*appSpecificPassword { + var out []*appSpecificPassword for _, asp := range asps { - if asp.ID != info.ID { - outASPs = append(outASPs, asp) + if asp.ID != id { + out = append(out, asp) } } - outASPs = append(outASPs, newAppSpecificPassword(*info, encryptedPassword)) - - mod := ldap.NewModifyRequest(emailDN) - if len(asps) > 0 { - mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs)) - } else { - mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs)) - } - return b.conn.Modify(ctx, mod) + return out } -func (b *LDAPBackend) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error { - emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail) - if emailRsrc == nil { - return errors.New("no email resource") - } - emailDN := getResourceDN(emailRsrc) +func (tx *backendTX) setASPOnResource(ctx context.Context, r *accountserver.Resource, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) { + dn, _ := tx.backend.resources.GetDN(r.ID) - asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword")) - var outASPs []*appSpecificPassword - for _, asp := range asps { - if asp.ID != id { - outASPs = append(outASPs, asp) - } - } + // Obtain the full list of ASPs from the backend and replace/append the new one. + asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, dn, aspLDAPAttr)) + asps = append(excludeASPFromList(asps, info.ID), newAppSpecificPassword(*info, encryptedPassword)) + outASPs := encodeAppSpecificPasswords(asps) + tx.setAttr(dn, aspLDAPAttr, outASPs...) +} - mod := ldap.NewModifyRequest(emailDN) - if len(outASPs) == 0 { - mod.Delete("appSpecificPassword", encodeAppSpecificPasswords(asps)) - } else if len(asps) > 0 { - mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs)) - } else { - mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs)) +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) } - return b.conn.Modify(ctx, mod) + return nil +} + +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)) + asps = excludeASPFromList(asps, id) + outASPs := encodeAppSpecificPasswords(asps) + tx.setAttr(dn, aspLDAPAttr, outASPs...) } -func (b *LDAPBackend) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error { - mod := ldap.NewModifyRequest(getUserDN(user)) - if b.readAttributeValue(ctx, getUserDN(user), "totpSecret") == "" { - mod.Add("totpSecret", []string{secret}) - } else { - mod.Replace("totpSecret", []string{secret}) +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) } - return b.conn.Modify(ctx, mod) + return nil } -func (b *LDAPBackend) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error { - mod := ldap.NewModifyRequest(getUserDN(user)) - mod.Delete("totpSecret", nil) - return b.conn.Modify(ctx, mod) +func (tx *backendTX) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error { + tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr, secret) + return nil } -func (b *LDAPBackend) SetResourcePassword(ctx context.Context, _ string, r *accountserver.Resource, encryptedPassword string) error { - mod := ldap.NewModifyRequest(getResourceDN(r)) - mod.Replace("userPassword", []string{encryptedPassword}) - return b.conn.Modify(ctx, mod) +func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error { + tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr) + return nil } -func parseResourceID(resourceID string) (string, string) { - parts := strings.SplitN(resourceID, "/", 2) - return parts[0], parts[1] +func (tx *backendTX) SetResourcePassword(ctx context.Context, r *accountserver.Resource, encryptedPassword string) error { + dn, _ := tx.backend.resources.GetDN(r.ID) + tx.setAttr(dn, passwordLDAPAttr, encryptedPassword) + return nil } -func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool, error) { - resourceType, resourceName := parseResourceID(resourceID) - query, ok := b.presenceQueries[resourceType] - if !ok { - return false, errors.New("unsupported resource type") +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 } // Make a quick LDAP search that only fetches the DN attribute. - result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{ + result, err := tx.search(ctx, query.searchRequest(map[string]string{ "resource": resourceName, "type": resourceType, }, []string{"dn"})) @@ -606,9 +323,9 @@ func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool, } // HasAnyResource returns true if any of the specified resources exists. -func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) { - for _, resourceID := range resourceIDs { - has, err := b.hasResource(ctx, resourceID) +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) if err != nil || has { return has, err } @@ -617,18 +334,28 @@ func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) } // GetResource returns a ResourceWrapper, as part of a read-modify-update transaction. -func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) { - resourceType, resourceName := parseResourceID(resourceID) - query, ok := b.resourceQueries[resourceType] - if !ok { - return nil, errors.New("unsupported resource type") +func (tx *backendTX) GetResource(ctx context.Context, rsrcID accountserver.ResourceID) (*accountserver.Resource, error) { + // 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 } - result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{ - "user": username, - "resource": resourceName, - "type": resourceType, - }, nil)) + // 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) if err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return nil, nil @@ -636,70 +363,39 @@ func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID stri return nil, err } - return parseLdapResource(result.Entries[0]) + // We know the resource type so we don't have to guess. + return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0]) } -// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call. -func (b *LDAPBackend) UpdateResource(ctx context.Context, _ string, r *accountserver.Resource) error { - lo, ok := r.Opaque.(*ldapObjectData) - if !ok || lo == nil { - return errors.New("resource did not come from GetResource") +// CreateResource creates a new LDAP-backed resource object. +func (tx *backendTX) CreateResource(ctx context.Context, r *accountserver.Resource) error { + dn, err := tx.backend.resources.GetDN(r.ID) + if err != nil { + return err } - modRequest := createModifyRequest(lo.dn, lo.original, r) - if modRequest == nil { - return nil + tx.create(dn) + for _, attr := range tx.backend.resources.ToLDAP(r) { + tx.setAttr(dn, attr.Type, attr.Vals...) } - return b.conn.Modify(ctx, modRequest) -} - -type ldapObjectData struct { - dn string - original *accountserver.Resource -} - -func getResourceDN(r *accountserver.Resource) string { - lo, ok := r.Opaque.(*ldapObjectData) - if !ok { - panic("no ldap resource data") - } - return lo.dn + return nil } -func parseLdapResource(entry *ldap.Entry) (r *accountserver.Resource, err error) { - switch { - case isObjectClass(entry, "virtualMailUser"): - r, err = newEmailResource(entry) - case isObjectClass(entry, "ftpAccount"): - r, err = newWebDAVResource(entry) - case isObjectClass(entry, "mailingList"): - r, err = newMailingListResource(entry) - case isObjectClass(entry, "dbMysql"): - r, err = newDatabaseResource(entry) - case isObjectClass(entry, "subSite") || isObjectClass(entry, "virtualHost"): - r, err = newWebsiteResource(entry) - default: - return nil, errors.New("unknown LDAP resource") - } +// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call. +func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resource) error { + dn, err := tx.backend.resources.GetDN(r.ID) if err != nil { - return - } - r.Opaque = &ldapObjectData{ - dn: entry.DN, - original: r.Copy(), + return err } - return -} -func isObjectClass(entry *ldap.Entry, class string) bool { - classes := entry.GetAttributeValues("objectClass") - for _, c := range classes { - if c == class { - return true - } + // We can simply dump all attribute/value pairs and let the + // code in ldapTX do the work of finding the differences. + for _, attr := range tx.backend.resources.ToLDAP(r) { + tx.setAttr(dn, attr.Type, attr.Vals...) } - return false + + return nil } var siteRoot = "/home/users/investici.org/" @@ -723,18 +419,18 @@ func groupWebResourcesByHomedir(resources []*accountserver.Resource) { // group for databases too, via their ParentID. webs := make(map[string]*accountserver.Resource) for _, r := range resources { - switch r.Type { - case accountserver.ResourceTypeWebsite: + switch r.ID.Type() { + case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain: r.Group = getHostingDir(r.Website.DocumentRoot) - webs[r.ID] = r + webs[r.ID.String()] = r case accountserver.ResourceTypeDAV: r.Group = getHostingDir(r.DAV.Homedir) } } // Fix databases in a second pass. for _, r := range resources { - if r.Type == accountserver.ResourceTypeDatabase && r.ParentID != "" { - r.Group = webs[r.ParentID].Group + if r.ID.Type() == accountserver.ResourceTypeDatabase && !r.ParentID.Empty() { + r.Group = webs[r.ParentID.String()].Group } } } diff --git a/backend/model_test.go b/backend/model_test.go index da98a09cd65249b8c66449f3454e34732b43b90e..c99c8f62daf6c08264db675e318712af2d502714 100644 --- a/backend/model_test.go +++ b/backend/model_test.go @@ -1,88 +1,302 @@ package backend import ( - "reflect" + "context" "testing" + "github.com/go-test/deep" + "git.autistici.org/ai3/accountserver" - ldap "gopkg.in/ldap.v2" + "git.autistici.org/ai3/accountserver/ldaptest" +) + +const ( + testLDAPPort = 42871 + testLDAPAddr = "ldap://127.0.0.1:42871" + testUser1 = "uno@investici.org" + testUser2 = "due@investici.org" ) -// Compare resources, ignoring the Opaque member. -func resourcesEqual(a, b *accountserver.Resource) bool { - aa := *a - bb := *b - aa.Opaque = nil - bb.Opaque = nil - return reflect.DeepEqual(aa, bb) +func startServerAndGetUser(t testing.TB) (func(), accountserver.Backend, *accountserver.User) { + return startServerAndGetUserWithName(t, testUser1) +} + +func startServerAndGetUser2(t testing.TB) (func(), accountserver.Backend, *accountserver.User) { + return startServerAndGetUserWithName(t, testUser2) } -func TestEmailResource_FromLDAP(t *testing.T) { - entry := ldap.NewEntry( - "mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy", - map[string][]string{ - "objectClass": []string{"top", "virtualMailUser"}, - "mail": []string{"test@investici.org"}, - "status": []string{"active"}, - "host": []string{"host1"}, - "originalHost": []string{"host1"}, - "mailAlternateAddr": []string{"test2@investici.org", "test3@investici.org"}, - "mailMessageStore": []string{"test/store"}, +func startServerAndGetUserWithName(t testing.TB, username string) (func(), accountserver.Backend, *accountserver.User) { + stop := ldaptest.StartServer(t, &ldaptest.Config{ + Dir: "../ldaptest", + Port: testLDAPPort, + Base: "dc=example,dc=com", + LDIFs: []string{ + "testdata/base.ldif", + "testdata/test1.ldif", + "testdata/test2.ldif", }, - ) + }) - r, err := parseLdapResource(entry) + b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com") if err != nil { - t.Fatal(err) - } - - expected := &accountserver.Resource{ - ID: "email/test@investici.org", - Name: "test@investici.org", - Type: accountserver.ResourceTypeEmail, - Status: "active", - Shard: "host1", - OriginalShard: "host1", - Email: &accountserver.Email{ - Aliases: []string{"test2@investici.org", "test3@investici.org"}, - Maildir: "test/store", - }, + t.Fatal("NewLDAPBackend", err) + } + + tx, _ := b.NewTransaction() + user, err := tx.GetUser(context.Background(), username) + if err != nil { + t.Fatal("GetUser", err) } - if !resourcesEqual(r, expected) { - t.Fatalf("bad result: got %+v, expected %+v", r, expected) + if user == nil { + t.Fatalf("could not find test user %s", username) } + + return stop, b, user } -func TestEmailResource_Diff(t *testing.T) { - entry := ldap.NewEntry( - "mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy", - map[string][]string{ - "objectClass": []string{"top", "virtualMailUser"}, - "mail": []string{"test@investici.org"}, - "status": []string{"active"}, - "host": []string{"host1"}, - "originalHost": []string{"host1"}, - "mailAlternateAddr": []string{"test2@investici.org", "test3@investici.org"}, - "mailMessageStore": []string{"test/store"}, +func TestModel_GetUser(t *testing.T) { + stop, _, user := startServerAndGetUser(t) + defer stop() + + if user.Name != testUser1 { + t.Fatalf("bad username: expected %s, got %s", testUser1, user.Name) + } + if len(user.Resources) != 5 { + t.Fatalf("expected 5 resources, got %d", len(user.Resources)) + } + + // Test a specific resource (the database). + db := user.GetSingleResourceByType(accountserver.ResourceTypeDatabase) + expectedDB := &accountserver.Resource{ + ID: accountserver.NewResourceID( + accountserver.ResourceTypeDatabase, + testUser1, + "alias=uno", + "unodb", + ), + ParentID: accountserver.NewResourceID( + accountserver.ResourceTypeWebsite, + testUser1, + "uno", + ), + Name: "unodb", + Shard: "host2", + OriginalShard: "host2", + Group: "uno", + Status: accountserver.ResourceStatusActive, + Database: &accountserver.Database{ + CleartextPassword: "password", + DBUser: "unodb", }, - ) + } + if err := deep.Equal(db, expectedDB); err != nil { + t.Fatalf("returned database resource differs: %v", err) + } +} + +func TestModel_GetUser_Has2FA(t *testing.T) { + stop, _, user := startServerAndGetUser2(t) + defer stop() - r, err := parseLdapResource(entry) + if !user.Has2FA { + t.Errorf("user %s does not appear to have 2FA enabled", testUser2) + } + if !user.HasEncryptionKeys { + t.Errorf("user %s does not appear to have encryption keys", testUser2) + } +} + +func TestModel_GetUser_Group(t *testing.T) { + stop, _, user := startServerAndGetUser(t) + defer stop() + + var grouped []*accountserver.Resource + for _, r := range user.Resources { + switch r.ID.Type() { + case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain, accountserver.ResourceTypeDAV, accountserver.ResourceTypeDatabase: + grouped = append(grouped, r) + } + } + + var group string + for _, r := range grouped { + if r.Group == "" { + t.Errorf("group not set on %s", r.ID) + continue + } + if group == "" { + group = r.Group + } else if group != r.Group { + t.Errorf("wrong group on %s (%s, expected %s)", r.ID, r.Group, group) + } + } +} + +func TestModel_GetUser_Resources(t *testing.T) { + stop, b, user := startServerAndGetUser(t) + defer stop() + + // Fetch individually all user resources, one by one, and + // check that they match what we have already. + tx2, _ := b.NewTransaction() + for _, r := range user.Resources { + fr, err := tx2.GetResource(context.Background(), r.ID) + if err != nil { + t.Errorf("could not fetch resource %s: %v", r.ID, err) + continue + } + if fr == nil { + t.Errorf("resource %s is missing", r.ID) + continue + } + // It's ok if Group is unset in the GetResource response. + rr := *r + rr.Group = "" + if err := deep.Equal(fr, &rr); err != nil { + t.Errorf("error in fetched resource %s: %v", r.ID, err) + continue + } + } +} + +func TestModel_SetResourceStatus(t *testing.T) { + stop := ldaptest.StartServer(t, &ldaptest.Config{ + Dir: "../ldaptest", + Port: testLDAPPort, + Base: "dc=example,dc=com", + LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"}, + }) + defer stop() + + b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com") if err != nil { - t.Fatal(err) + t.Fatal("NewLDAPBackend", err) } - r2 := new(accountserver.Resource) - *r2 = *r - r2.Shard = "host2" - mod := createModifyRequest("dn", r, r2) - if len(mod.ReplaceAttributes) != 1 { - t.Fatalf("bad ModifyRequest after changing shard: %+v", mod) + tx, _ := b.NewTransaction() + rsrcID := accountserver.NewResourceID(accountserver.ResourceTypeEmail, testUser1, testUser1) + r, err := tx.GetResource(context.Background(), rsrcID) + if err != nil { + t.Fatal("GetResource", err) + } + if r == nil { + t.Fatalf("could not find test resource %s", rsrcID) } - r2.Email.Aliases = nil - mod = createModifyRequest("dn", r, r2) - if len(mod.DeleteAttributes) != 1 { - t.Fatalf("bad ModifyRequest after deleting aliases: %+v", mod) + r.Status = accountserver.ResourceStatusInactive + if err := tx.UpdateResource(context.Background(), r); err != nil { + t.Fatal("UpdateResource", err) + } + if err := tx.Commit(context.Background()); err != nil { + t.Fatalf("commit error: %v", err) + } +} + +func TestModel_HasAnyResource(t *testing.T) { + stop := ldaptest.StartServer(t, &ldaptest.Config{ + Dir: "../ldaptest", + Port: testLDAPPort, + Base: "dc=example,dc=com", + LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"}, + }) + defer stop() + + b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com") + if err != nil { + t.Fatal("NewLDAPBackend", err) + } + + tx, _ := b.NewTransaction() + + // Request that should succeed. + ok, err := tx.HasAnyResource(context.Background(), []accountserver.FindResourceRequest{ + {Type: accountserver.ResourceTypeEmail, Name: "foo"}, + {Type: accountserver.ResourceTypeEmail, Name: testUser1}, + }) + if err != nil { + t.Fatal("HasAnyResource", err) + } + if !ok { + t.Fatal("could not find test resource") + } + + // Request that should fail (bad resource type). + ok, err = tx.HasAnyResource(context.Background(), []accountserver.FindResourceRequest{ + {Type: accountserver.ResourceTypeDatabase, Name: testUser1}, + }) + if err != nil { + t.Fatal("HasAnyResource", err) + } + if ok { + t.Fatal("oops, found non existing resource") + } +} + +func TestModel_SetUserPassword(t *testing.T) { + stop, b, user := startServerAndGetUser(t) + defer stop() + + encPass := "encrypted password" + + tx, _ := b.NewTransaction() + if err := tx.SetUserPassword(context.Background(), user, encPass); err != nil { + t.Fatal("SetUserPassword", err) + } + if err := tx.Commit(context.Background()); err != nil { + t.Fatal("Commit", err) + } + + // Verify that the new password is set. + pwattr := tx.(*backendTX).readAttributeValues( + context.Background(), + "mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com", + "userPassword", + ) + if len(pwattr) == 0 { + t.Fatalf("no userPassword attribute on mail= object") + } + if len(pwattr) > 1 { + t.Fatalf("more than one userPassword found on mail= object") + } + if pwattr[0] != encPass { + t.Fatalf("bad userPassword, got %s, expected %s", pwattr[0], encPass) + } +} + +func TestModel_SetUserEncryptionKeys_Add(t *testing.T) { + stop, b, user := startServerAndGetUser(t) + defer stop() + + tx, _ := b.NewTransaction() + keys := []*accountserver.UserEncryptionKey{ + { + ID: accountserver.UserEncryptionKeyMainID, + Key: []byte("very secret key"), + }, + } + if err := tx.SetUserEncryptionKeys(context.Background(), user, keys); err != nil { + t.Fatal("SetUserEncryptionKeys", err) + } + if err := tx.Commit(context.Background()); err != nil { + t.Fatal("Commit", err) + } +} + +func TestModel_SetUserEncryptionKeys_Replace(t *testing.T) { + stop, b, user := startServerAndGetUser2(t) + defer stop() + + tx, _ := b.NewTransaction() + keys := []*accountserver.UserEncryptionKey{ + { + ID: accountserver.UserEncryptionKeyMainID, + Key: []byte("very secret key"), + }, + } + if err := tx.SetUserEncryptionKeys(context.Background(), user, keys); err != nil { + t.Fatal("SetUserEncryptionKeys", err) + } + if err := tx.Commit(context.Background()); err != nil { + t.Fatal("Commit", err) } } diff --git a/backend/resources.go b/backend/resources.go new file mode 100644 index 0000000000000000000000000000000000000000..3da6cea0a949de7bf4c8ba6dc78c3a3d2b92cc50 --- /dev/null +++ b/backend/resources.go @@ -0,0 +1,527 @@ +package backend + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/ldap.v2" + + "git.autistici.org/ai3/accountserver" +) + +// Generic resource handler interface. One for each resource type, +// mapping to exactly one LDAP object type. +type resourceHandler interface { + GetDN(accountserver.ResourceID) (string, error) + ToLDAP(*accountserver.Resource) []ldap.PartialAttribute + FromLDAP(*ldap.Entry) (*accountserver.Resource, error) + SearchQuery() *queryConfig +} + +// Registry for demultiplexing resource handling. Has a similar +// interface to a resourceHandler, with a few exceptions. +type resourceRegistry struct { + handlers map[string]resourceHandler +} + +func newResourceRegistry() *resourceRegistry { + return &resourceRegistry{ + handlers: make(map[string]resourceHandler), + } +} + +func (reg *resourceRegistry) register(rtype string, h resourceHandler) { + if reg.handlers == nil { + reg.handlers = make(map[string]resourceHandler) + } + reg.handlers[rtype] = h +} + +func (reg *resourceRegistry) dispatch(rsrcType string, f func(resourceHandler) error) error { + h, ok := reg.handlers[rsrcType] + if !ok { + return errors.New("unknown resource type") + } + return f(h) +} + +func (reg *resourceRegistry) GetDN(id accountserver.ResourceID) (s string, err error) { + err = reg.dispatch(id.Type(), func(h resourceHandler) (herr error) { + s, herr = h.GetDN(id) + return + }) + return +} + +func (reg *resourceRegistry) ToLDAP(rsrc *accountserver.Resource) (attrs []ldap.PartialAttribute) { + if err := reg.dispatch(rsrc.ID.Type(), func(h resourceHandler) error { + attrs = h.ToLDAP(rsrc) + return nil + }); err != nil { + return nil + } + + attrs = append(attrs, []ldap.PartialAttribute{ + {Type: "status", Vals: s2l(rsrc.Status)}, + {Type: "host", Vals: s2l(rsrc.Shard)}, + {Type: "originalHost", Vals: s2l(rsrc.OriginalShard)}, + }...) + return +} + +func setCommonResourceAttrs(entry *ldap.Entry, rsrc *accountserver.Resource) { + rsrc.Status = entry.GetAttributeValue("status") + rsrc.Shard = entry.GetAttributeValue("host") + rsrc.OriginalShard = entry.GetAttributeValue("originalHost") +} + +func (reg *resourceRegistry) FromLDAP(entry *ldap.Entry) (rsrc *accountserver.Resource, err error) { + // Since we don't know what resource type to expect, we try + // all known handlers until one returns a valid Resource. + for _, h := range reg.handlers { + rsrc, err = h.FromLDAP(entry) + if err == nil { + setCommonResourceAttrs(entry, rsrc) + return + } + } + return nil, errors.New("unknown resource") +} + +func (reg *resourceRegistry) FromLDAPWithType(rsrcType string, entry *ldap.Entry) (rsrc *accountserver.Resource, err error) { + err = reg.dispatch(rsrcType, func(h resourceHandler) (rerr error) { + rsrc, rerr = h.FromLDAP(entry) + if rerr != nil { + return + } + setCommonResourceAttrs(entry, rsrc) + return + }) + return +} + +func (reg *resourceRegistry) SearchQuery(rsrcType string) (c *queryConfig, err error) { + err = reg.dispatch(rsrcType, func(h resourceHandler) error { + c = h.SearchQuery() + return nil + }) + return +} + +// Find the parent RDN, which is expected to have the specified +// attribute, and return its value. +func getParentRDN(dn, parentAttr string) (string, error) { + parsed, err := ldap.ParseDN(dn) + if err != nil { + return "", err + } + if len(parsed.RDNs) < 2 { + return "", errors.New("not enough DN components to find parent") + } + if parsed.RDNs[1].Attributes[0].Type != parentAttr { + return "", errors.New("parent RDN has unexpected type") + } + return parsed.RDNs[1].Attributes[0].Value, nil +} + +// Email resource. +type emailResourceHandler struct { + baseDN string +} + +func (h *emailResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + if id.User() == "" { + return "", errors.New("unqualified resource id") + } + dn := replaceVars("mail=${resource},uid=${user},ou=People", map[string]string{ + "user": id.User(), + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +var errWrongObjectClass = errors.New("objectClass does not match") + +func (h *emailResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "virtualMailUser") { + return nil, errWrongObjectClass + } + + email := entry.GetAttributeValue("mail") + username, err := getParentRDN(entry.DN, "uid") + if err != nil { + return nil, err + } + + return &accountserver.Resource{ + ID: accountserver.NewResourceID( + accountserver.ResourceTypeEmail, + username, + email, + ), + Name: email, + Email: &accountserver.Email{ + Aliases: entry.GetAttributeValues("mailAlternateAddr"), + Maildir: entry.GetAttributeValue("mailMessageStore"), + }, + }, nil +} + +func (h *emailResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "virtualMailUser"}}, + {Type: "mail", Vals: s2l(rsrc.ID.Name())}, + {Type: "mailAlternateAddr", Vals: rsrc.Email.Aliases}, + {Type: "mailMessageStore", Vals: s2l(rsrc.Email.Maildir)}, + } +} + +func (h *emailResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=People", h.baseDN), + Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))", + Scope: "sub", + }) +} + +// Mailing list resource. +type mailingListResourceHandler struct { + baseDN string +} + +func (h *mailingListResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + dn := replaceVars("listName=${resource},ou=Lists", map[string]string{ + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +func (h *mailingListResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "mailingList") { + return nil, errWrongObjectClass + } + + listName := entry.GetAttributeValue("listName") + return &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeMailingList, listName), + Name: listName, + List: &accountserver.MailingList{ + Public: s2b(entry.GetAttributeValue("public")), + Admins: entry.GetAttributeValues("listOwner"), + }, + }, nil +} + +func (h *mailingListResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "mailingList"}}, + {Type: "listName", Vals: s2l(rsrc.ID.Name())}, + {Type: "public", Vals: s2l(b2s(rsrc.List.Public))}, + {Type: "listOwner", Vals: rsrc.List.Admins}, + } +} + +func (h *mailingListResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=Lists", h.baseDN), + Filter: "(&(objectClass=mailingList)(listName=${resource}))", + Scope: "one", + }) +} + +// Website (subsite) resource. +type websiteResourceHandler struct { + baseDN string +} + +func (h *websiteResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + if id.User() == "" { + return "", errors.New("unqualified resource id") + } + + dn := replaceVars("alias=${resource},uid=${user},ou=People", map[string]string{ + "user": id.User(), + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +func (h *websiteResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "subSite") { + return nil, errWrongObjectClass + } + + alias := entry.GetAttributeValue("alias") + parentSite := entry.GetAttributeValue("parentSite") + name := fmt.Sprintf("%s/%s", parentSite, alias) + url := fmt.Sprintf("https://www.%s/%s/", parentSite, alias) + + username, err := getParentRDN(entry.DN, "uid") + if err != nil { + return nil, err + } + return &accountserver.Resource{ + ID: accountserver.NewResourceID( + accountserver.ResourceTypeWebsite, + username, + alias, + ), + Name: name, + Website: &accountserver.Website{ + URL: url, + ParentDomain: parentSite, + Options: entry.GetAttributeValues("option"), + DocumentRoot: entry.GetAttributeValue("documentRoot"), + AcceptMail: s2b(entry.GetAttributeValue("acceptMail")), + }, + }, nil +} + +func (h *websiteResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "subSite"}}, + {Type: "alias", Vals: s2l(rsrc.ID.Name())}, + {Type: "parentSite", Vals: s2l(rsrc.Website.ParentDomain)}, + {Type: "option", Vals: rsrc.Website.Options}, + {Type: "documentRoot", Vals: s2l(rsrc.Website.DocumentRoot)}, + {Type: "acceptMail", Vals: s2l(b2s(rsrc.Website.AcceptMail))}, + } +} + +func (h *websiteResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=People", h.baseDN), + Filter: "(&(objectClass=subSite)(alias=${resource}))", + Scope: "sub", + }) +} + +// Domain (virtual host) resource. +type domainResourceHandler struct { + baseDN string +} + +func (h *domainResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + if id.User() == "" { + return "", errors.New("unqualified resource id") + } + + dn := replaceVars("cn=${resource},uid=${user},ou=People", map[string]string{ + "user": id.User(), + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +func (h *domainResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "virtualHost") { + return nil, errWrongObjectClass + } + + cn := entry.GetAttributeValue("cn") + username, err := getParentRDN(entry.DN, "uid") + if err != nil { + return nil, err + } + return &accountserver.Resource{ + ID: accountserver.NewResourceID( + accountserver.ResourceTypeDomain, + username, + cn, + ), + Name: cn, + Website: &accountserver.Website{ + URL: fmt.Sprintf("https://%s/", cn), + Options: entry.GetAttributeValues("option"), + DocumentRoot: entry.GetAttributeValue("documentRoot"), + AcceptMail: s2b(entry.GetAttributeValue("acceptMail")), + }, + }, nil +} + +func (h *domainResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "virtualHost"}}, + {Type: "cn", Vals: s2l(rsrc.ID.Name())}, + {Type: "option", Vals: rsrc.Website.Options}, + {Type: "documentRoot", Vals: s2l(rsrc.Website.DocumentRoot)}, + {Type: "acceptMail", Vals: s2l(b2s(rsrc.Website.AcceptMail))}, + } +} + +func (h *domainResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=People", h.baseDN), + Filter: "(&(objectClass=virtualHost)(cn=${resource}))", + Scope: "sub", + }) +} + +// WebDAV (a.k.a. "ftp account") resource. +type webdavResourceHandler struct { + baseDN string +} + +func (h *webdavResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + if id.User() == "" { + return "", errors.New("unqualified resource id") + } + + dn := replaceVars("ftpname=${resource},uid=${user},ou=People", map[string]string{ + "user": id.User(), + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +func (h *webdavResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "ftpAccount") { + return nil, errWrongObjectClass + } + + name := entry.GetAttributeValue("ftpname") + username, err := getParentRDN(entry.DN, "uid") + if err != nil { + return nil, err + } + return &accountserver.Resource{ + ID: accountserver.NewResourceID( + accountserver.ResourceTypeDAV, + username, + name, + ), + Name: name, + DAV: &accountserver.WebDAV{ + Homedir: entry.GetAttributeValue("homeDirectory"), + }, + }, nil +} + +func (h *webdavResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "ftpAccount"}}, + {Type: "ftpname", Vals: s2l(rsrc.ID.Name())}, + {Type: "homeDirectory", Vals: s2l(rsrc.DAV.Homedir)}, + } +} + +func (h *webdavResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=People", h.baseDN), + Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))", + Scope: "sub", + }) +} + +// Databases are special: in LDAP, they encode their relation with a +// website using the database hierarchy. This means that, in order to +// satisfy the requirement to generate a DN directly from every +// resource ID, we need to encode the parent website in the resource +// ID itself. We do this using not just the website name (which would +// be ambiguous: Website or Domain?), but also the type, using an +// attr=name syntax. +type databaseResourceHandler struct { + baseDN string +} + +func makeDatabaseResourceID(dn string) (rsrcID, parentID accountserver.ResourceID, err error) { + parsed, perr := ldap.ParseDN(dn) + if perr != nil { + err = perr + return + } + if len(parsed.RDNs) < 3 { + err = errors.New("not enough DN components for database") + return + } + + // The database name is the first RDN. + dbname := parsed.RDNs[0].Attributes[0].Value + // The encoded parent website name is type=value of the 2nd component. + parentName := parsed.RDNs[1].Attributes[0].Value + encParent := fmt.Sprintf("%s=%s", parsed.RDNs[1].Attributes[0].Type, parentName) + // The username is the 3rd component. + username := parsed.RDNs[2].Attributes[0].Value + + rsrcID = accountserver.NewResourceID( + accountserver.ResourceTypeDatabase, + username, + encParent, + dbname, + ) + var parentType = accountserver.ResourceTypeWebsite + if parsed.RDNs[1].Attributes[0].Type == "cn" { + parentType = accountserver.ResourceTypeDomain + } + parentID = accountserver.NewResourceID( + parentType, + username, + parentName, + ) + return +} + +func (h *databaseResourceHandler) GetDN(id accountserver.ResourceID) (string, error) { + if id.User() == "" || len(id.Parts) < 4 { + return "", errors.New("unqualified resource id") + } + + // Decode the parent website as encoded in + // makeDatabaseResourceID. The parent website is the third + // path component in the ID. + parentParts := strings.SplitN(id.Parts[2], "=", 2) + if len(parentParts) != 2 { + return "", errors.New("malformed database resource id") + } + + dn := replaceVars("dbname=${resource},${parentType}=${parent},uid=${user},ou=People", map[string]string{ + "user": id.User(), + "parentType": parentParts[0], + "parent": parentParts[1], + "resource": id.Name(), + }) + return joinDN(dn, h.baseDN), nil +} + +func (h *databaseResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) { + if !isObjectClass(entry, "dbMysql") { + return nil, errWrongObjectClass + } + + name := entry.GetAttributeValue("dbname") + rsrcID, parentID, err := makeDatabaseResourceID(entry.DN) + if err != nil { + return nil, err + } + return &accountserver.Resource{ + ID: rsrcID, + ParentID: parentID, + Name: name, + Database: &accountserver.Database{ + DBUser: entry.GetAttributeValue("dbuser"), + CleartextPassword: entry.GetAttributeValue("clearPassword"), + }, + }, nil +} + +func (h *databaseResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "dbMysql"}}, + {Type: "dbname", Vals: s2l(rsrc.ID.Name())}, + {Type: "dbuser", Vals: s2l(rsrc.Database.DBUser)}, + {Type: "clearPassword", Vals: s2l(rsrc.Database.CleartextPassword)}, + } +} + +func (h *databaseResourceHandler) SearchQuery() *queryConfig { + return mustCompileQueryConfig(&queryConfig{ + Base: joinDN("ou=People", h.baseDN), + Filter: "(&(objectClass=dbMysql)(dbname=${resource}))", + Scope: "sub", + }) +} + +func joinDN(parts ...string) string { + return strings.Join(parts, ",") +} diff --git a/backend/resources_test.go b/backend/resources_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e7c5e21631da8a33f64da36139e2443ce154a4d0 --- /dev/null +++ b/backend/resources_test.go @@ -0,0 +1,48 @@ +package backend + +import ( + "testing" + + "github.com/go-test/deep" + "gopkg.in/ldap.v2" + + "git.autistici.org/ai3/accountserver" +) + +func TestEmailResource_FromLDAP(t *testing.T) { + entry := ldap.NewEntry( + "mail=test@investici.org,uid=test@investici.org,ou=People,dc=example,dc=com", + map[string][]string{ + "objectClass": []string{"top", "virtualMailUser"}, + "mail": []string{"test@investici.org"}, + "status": []string{"active"}, + "host": []string{"host1"}, + "originalHost": []string{"host1"}, + "mailAlternateAddr": []string{"test2@investici.org", "test3@investici.org"}, + "mailMessageStore": []string{"test/store"}, + }, + ) + + reg := newResourceRegistry() + reg.register(accountserver.ResourceTypeEmail, &emailResourceHandler{baseDN: "dc=example,dc=com"}) + + r, err := reg.FromLDAP(entry) + if err != nil { + t.Fatal("FromLDAP", err) + } + + expected := &accountserver.Resource{ + ID: accountserver.NewResourceID("email", "test@investici.org", "test@investici.org"), + Name: "test@investici.org", + Status: "active", + Shard: "host1", + OriginalShard: "host1", + Email: &accountserver.Email{ + Aliases: []string{"test2@investici.org", "test3@investici.org"}, + Maildir: "test/store", + }, + } + if err := deep.Equal(r, expected); err != nil { + t.Fatalf("bad result: %v", err) + } +} diff --git a/backend/testdata/base.ldif b/backend/testdata/base.ldif new file mode 100644 index 0000000000000000000000000000000000000000..fcbc2849fa34661c06ccd104a91052e2fa326dde --- /dev/null +++ b/backend/testdata/base.ldif @@ -0,0 +1,16 @@ + +dn: dc=example,dc=com +objectclass: domain +objectclass: top +dc: example + +dn: ou=People,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: People + +dn: ou=Lists,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: Lists + diff --git a/backend/testdata/test1.ldif b/backend/testdata/test1.ldif new file mode 100644 index 0000000000000000000000000000000000000000..9bf6880683b247b165f7b48ccd62866470c24af8 --- /dev/null +++ b/backend/testdata/test1.ldif @@ -0,0 +1,106 @@ +dn: uid=uno@investici.org,ou=People,dc=example,dc=com +cn: uno@investici.org +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +loginShell: /bin/false +uidNumber: 19475 +shadowMax: 99999 +gidNumber: 2000 +gecos: uno@investici.org +sn: Private +homeDirectory: /var/empty +uid: uno@investici.org +givenName: Private +shadowLastChange: 12345 +shadowWarning: 7 +preferredLanguage: it +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= + +dn: mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +recoverQuestion:: dGkgc2VpIG1haSDDuMOgdHRvIG1hbGUgY2FkZW5kbyBkYSB1biBwYWxhenpv + IGRpIG90dG8gcGlhbmk/ +objectClass: top +objectClass: virtualMailUser +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= +uidNumber: 19475 +host: host2 +mailAlternateAddress: uno@anche.no +recoverAnswer: {crypt}$1$wtEa4TKB$lxeyenkQ1yfxECn7WVQQ0/ +gidNumber: 2000 +mail: uno@investici.org +creationDate: 2002-05-07 +mailMessageStore: investici.org/uno/ +originalHost: host2 + +dn: ftpname=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +givenName: Private +cn: uno +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: ftpAccount +loginShell: /bin/false +shadowWarning: 7 +uidNumber: 19475 +host: host2 +shadowMax: 99999 +ftpname: uno +gidNumber: 33 +gecos: FTP Account for uno@investici.org +sn: Private +homeDirectory: /home/users/investici.org/uno +uid: uno +creationDate: 01-08-2013 +shadowLastChange: 12345 +originalHost: host2 +userPassword:: e2NyeXB0fSQ2JElDYkx1WTI3QWl6bC5FeEgkUDhOZHJ3VEtxZ2UwQUp3QW9oNE1 + EYlUxU3EySGtuRkF1cEx2RUI0U28waEw5NWtpZ3dIeXQuQnYxS0J5SFM2MXd6RnZuLnJsMEN4eFpx + RVgzUnVxbDE= + +dn: alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +parentSite: autistici.org +objectClass: top +objectClass: subSite +alias: uno +host: host2 +documentRoot: /home/users/investici.org/uno/html-uno +creationDate: 01-08-2013 +originalHost: host2 +statsId: 2191 + +dn: cn=example.com,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +acceptMail: true +objectClass: top +objectClass: virtualHost +cn: example.com +host: host2 +documentRoot: /home/users/investici.org/uno/html-example.com +creationDate: 02-08-2013 +originalHost: host2 +statsId: 2192 + +dn: dbname=unodb,alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +clearPassword: password +objectClass: top +objectClass: dbMysql +dbname: unodb +dbuser: unodb +host: host2 +creationDate: 01-08-2013 +originalHost: host2 diff --git a/backend/testdata/test2.ldif b/backend/testdata/test2.ldif new file mode 100644 index 0000000000000000000000000000000000000000..e4fa0dfdc52a73a516d2add396a45368fc0550ad --- /dev/null +++ b/backend/testdata/test2.ldif @@ -0,0 +1,37 @@ +dn: uid=due@investici.org,ou=People,dc=example,dc=com +cn: due@investici.org +gecos: due@investici.org +gidNumber: 2000 +givenName: Private +homeDirectory: /var/empty +loginShell: /bin/false +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +shadowLastChange: 12345 +shadowMax: 99999 +shadowWarning: 7 +sn: due@investici.org +uid: due@investici.org +uidNumber: 256799 +userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0 +totpSecret: ABCDEFGH + +dn: mail=due@investici.org,uid=due@investici.org,ou=People,dc=example,dc=com +creationDate: 01-08-2013 +gidNumber: 2000 +host: host2 +mail: due@investici.org +mailMessageStore: investici.org/due/ +objectClass: top +objectClass: virtualMailUser +originalHost: host2 +status: active +uidNumber: 256799 +userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0 +storagePublicKey:: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUU0d0VBWUhLb1pJemowQ0FRWUZLNEVFQUNFRE9nQUVTeVVyVFhaaHRTeFpreityQjYwaFM4VnhINWozM3Ftbgphb3h2WG9IeG9vYU9Sc0x5TXNnVE5RVDR1bU1XdU12U3ROamszeWdWR2FNPQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0K +storageEncryptedSecretKey:: rffWTx7AjmhqD8li78Tal+7zfIbOFSyX4sKxKFa/bj5XAlWLyq7ANWiJB/0PRC1y8JBg+ezti5DjC5Ft82f5uwb2+3vIxjrxyz5vAmSUjRYo7o9alu5vLXRapsyhiYgJGmJrBJZxkQ9rGDXsM4OfZQNlxP4AVMobQFQU9X4QBUWFo2MwKuvwQiHg359hLufUrmr2bmjzPsU5Uj+8vAeQWHsVxWuUwUuob630A2V619iO5cp5nPzk5itYMNkdl1eR3KvUonvqwz++HLRJNqwh7qn2CjdUIA5ljexFg88UbNbzrpa+6Atmd4iXieYPewYHPHtuRFV3eHHlnBbv8VMcQdVZ0sqJokWDRvFjJbg= + diff --git a/backend/tx.go b/backend/tx.go new file mode 100644 index 0000000000000000000000000000000000000000..85a8fae9dfa0e260a9af175db2d2b56d6e491ade --- /dev/null +++ b/backend/tx.go @@ -0,0 +1,225 @@ +package backend + +import ( + "context" + "log" + "strings" + + "gopkg.in/ldap.v2" +) + +// 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) + Add(context.Context, *ldap.AddRequest) error + Modify(context.Context, *ldap.ModifyRequest) error + Close() +} + +type ldapAttr struct { + dn, attr string + values []string +} + +// An LDAP "transaction" is really just a buffer of attribute changes, +// that are all executed at once at Commit() time. +// +// Unfortunately, in order to issue LDAP Modify requests properly +// (which have separate Add and Replace options, for one), we need to +// keep state about the observed data, so we cache the results of all +// Search operations and compare those with the new data at commit +// time. If you attempt to modify an object that you haven't Searched +// for previously in the same transaction, you're most likely to get a +// LDAP error in return. Which is fine because all our workflows are +// read/modify/update ones anyway. +// +// Since ordering of Modify requests is important in LDAP, this object +// will preserve the ordering of DNs and attributes when calling +// Commit(). +// +type ldapTX struct { + conn ldapConn + + cache map[string][]string + newDNs map[string]struct{} + changes []ldapAttr +} + +func newLDAPTX(conn ldapConn) *ldapTX { + return &ldapTX{ + conn: conn, + cache: make(map[string][]string), + newDNs: make(map[string]struct{}), + } +} + +func cacheKey(dn, attr string) string { + return strings.Join([]string{dn, attr}, ";") +} + +// Search wrapper that fills the cache. +func (tx *ldapTX) search(ctx context.Context, req *ldap.SearchRequest) (*ldap.SearchResult, error) { + res, err := tx.conn.Search(ctx, req) + if err != nil { + return nil, err + } + + for _, entry := range res.Entries { + for _, attr := range entry.Attributes { + tx.cache[cacheKey(entry.DN, attr.Name)] = attr.Values + } + } + + return res, nil +} + +// Announce the intention to create a new object. To be called before +// setAttr() on the new DN. +func (tx *ldapTX) create(dn string) { + tx.newDNs[dn] = struct{}{} +} + +// setAttr modifies a single attribute of an object. To delete an +// attribute, pass an empty list of values. +func (tx *ldapTX) setAttr(dn, attr string, values ...string) { + if dn == "" { + panic("empty dn in setAttr!") + } + tx.changes = append(tx.changes, ldapAttr{dn: dn, attr: attr, values: values}) +} + +// Commit the transaction, sending all changes to the LDAP server. +func (tx *ldapTX) Commit(ctx context.Context) error { + // Iterate through the changes, and generate ModifyRequest + // objects grouped by DN (while preserving the order of DNs). + adds, mods, dns := tx.aggregateChanges(ctx) + + // Now issue all Modify or Add requests, one by one, in the + // same order as we have seen them. Abort on the first error. + for _, dn := range dns { + var err error + if ar, ok := adds[dn]; ok { + if isEmptyAddRequest(ar) { + continue + } + log.Printf("issuing AddRequest: %+v", ar) + err = tx.conn.Add(ctx, ar) + } else { + mr := mods[dn] + if isEmptyModifyRequest(mr) { + continue + } + log.Printf("issuing ModifyRequest: %+v", mr) + err = tx.conn.Modify(ctx, mr) + } + if err != nil { + return err + } + } + + // Cleanup + tx.changes = nil + tx.newDNs = make(map[string]struct{}) + + return nil +} + +// Helper for Commit that aggregates changes into add and modify lists. +func (tx *ldapTX) aggregateChanges(ctx context.Context) (map[string]*ldap.AddRequest, map[string]*ldap.ModifyRequest, []string) { + var dns []string + mods := make(map[string]*ldap.ModifyRequest) + adds := make(map[string]*ldap.AddRequest) + for _, c := range tx.changes { + if _, isNew := tx.newDNs[c.dn]; isNew { + ar, ok := adds[c.dn] + if !ok { + ar = ldap.NewAddRequest(c.dn) + adds[c.dn] = ar + dns = append(dns, c.dn) + } + if len(c.values) > 0 { + ar.Attribute(c.attr, c.values) + } + } else { + mr, ok := mods[c.dn] + if !ok { + mr = ldap.NewModifyRequest(c.dn) + mods[c.dn] = mr + dns = append(dns, c.dn) + } + tx.updateModifyRequest(ctx, mr, c) + } + } + return adds, mods, dns +} + +func (tx *ldapTX) updateModifyRequest(ctx context.Context, mr *ldap.ModifyRequest, attr ldapAttr) { + old, ok := tx.cache[cacheKey(attr.dn, attr.attr)] + + // Pessimistic approach: if we haven't seen this attribute + // before, try to fetch it from LDAP so we know if we need to + // perform an Add or a Replace. + if !ok { + log.Printf("tx: pessimistic fallback for %s %s", attr.dn, attr.attr) + oldFromLDAP := tx.readAttributeValues(ctx, attr.dn, attr.attr) + if len(oldFromLDAP) > 0 { + ok = true + old = oldFromLDAP + } + } + + switch { + case ok && !stringListEquals(old, attr.values): + mr.Replace(attr.attr, attr.values) + case ok && attr.values == nil: + mr.Delete(attr.attr, old) + case !ok && len(attr.values) > 0: + mr.Add(attr.attr, attr.values) + } +} + +func (tx *ldapTX) readAttributeValues(ctx context.Context, dn, attr string) []string { + result, err := tx.search(ctx, ldap.NewSearchRequest( + dn, + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 0, + 0, + false, + "(objectClass=*)", + []string{attr}, + nil, + )) + if err == nil && len(result.Entries) > 0 { + return result.Entries[0].GetAttributeValues(attr) + } + return nil +} + +func isEmptyModifyRequest(mr *ldap.ModifyRequest) bool { + return (len(mr.AddAttributes) == 0 && + len(mr.DeleteAttributes) == 0 && + len(mr.ReplaceAttributes) == 0) +} + +func isEmptyAddRequest(ar *ldap.AddRequest) bool { + return len(ar.Attributes) == 0 +} + +// Unordered list comparison. +func stringListEquals(a, b []string) bool { + if len(a) != len(b) { + return false + } + tmp := make(map[string]struct{}) + for _, aa := range a { + tmp[aa] = struct{}{} + } + for _, bb := range b { + if _, ok := tmp[bb]; !ok { + return false + } + } + return true +} diff --git a/backend/util.go b/backend/util.go new file mode 100644 index 0000000000000000000000000000000000000000..b7037cc4d76fc9f28a70adc0b5eccb802f90dbb1 --- /dev/null +++ b/backend/util.go @@ -0,0 +1,98 @@ +package backend + +import ( + "errors" + "os" + + ldaputil "git.autistici.org/ai3/go-common/ldap" + "gopkg.in/ldap.v2" +) + +// 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 replaceVars(s string, vars map[string]string) string { + return os.Expand(s, func(k string) string { + return ldap.EscapeFilter(vars[k]) + }) +} + +func s2b(s string) bool { + switch s { + case "yes", "y", "on", "enabled", "true": + return true + default: + return false + } +} + +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} +} + +func isObjectClass(entry *ldap.Entry, class string) bool { + classes := entry.GetAttributeValues("objectClass") + for _, c := range classes { + if c == class { + return true + } + } + return false +} diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go index a1672b4bfdd0218d9c8ca04e9caf4f1c5ce79f8e..90b44bd85e73726b2b99948a8dedcef1ff603a12 100644 --- a/cmd/accountserver/main.go +++ b/cmd/accountserver/main.go @@ -113,7 +113,7 @@ func main() { log.Fatal(err) } - as := server.New(service) + as := server.New(service, be) if err := serverutil.Serve(as.Handler(), config.ServerConfig, *addr); err != nil { log.Fatal(err) diff --git a/config.go b/config.go index 42d95f3a414c215e3e7e070398393b5b211e21f3..f4e1912a254bcc60700bc42f18e6fece85b1e38e 100644 --- a/config.go +++ b/config.go @@ -6,9 +6,19 @@ import ( "git.autistici.org/id/go-sso" ) +// Config holds the configuration for the AccountService. type Config struct { - ForbiddenUsernames []string `yaml:"forbidden_usernames"` - AvailableDomains map[string][]string `yaml:"available_domains"` + ForbiddenUsernames []string `yaml:"forbidden_usernames"` + ForbiddenUsernamesFile string `yaml:"forbidden_usernames_file"` + ForbiddenPasswords []string `yaml:"forbidden_passwords"` + ForbiddenPasswordsFile string `yaml:"forbidden_passwords_file"` + AvailableDomains map[string][]string `yaml:"available_domains"` + WebsiteRootDir string `yaml:"website_root_dir"` + + Shards struct { + Available map[string][]string `yaml:"available"` + Allowed map[string][]string `yaml:"allowed"` + } `yaml:"shards"` SSO struct { PublicKeyFile string `yaml:"public_key"` @@ -27,9 +37,46 @@ func (c *Config) domainBackend() domainBackend { return b } -func (c *Config) validationConfig() *validationConfig { - return &validationConfig{ - forbiddenUsernames: newStringSetFromList(c.ForbiddenUsernames), +func (c *Config) shardBackend() shardBackend { + b := &staticShardBackend{ + available: make(map[string]stringSet), + allowed: make(map[string]stringSet), + } + loadSet := func(target map[string]stringSet, src map[string][]string) { + for kind, list := range src { + target[kind] = newStringSetFromList(list) + } + } + loadSet(b.available, c.Shards.Available) + loadSet(b.allowed, c.Shards.Allowed) + return b +} + +func (c *Config) validationContext(be Backend) (*validationContext, error) { + fu, err := newStringSetFromFileOrList(c.ForbiddenUsernames, c.ForbiddenUsernamesFile) + if err != nil { + return nil, err + } + fp, err := newStringSetFromFileOrList(c.ForbiddenPasswords, c.ForbiddenPasswordsFile) + if err != nil { + return nil, err + } + return &validationContext{ + forbiddenUsernames: fu, + forbiddenPasswords: fp, + minPasswordLength: 6, + maxPasswordLength: 128, + webroot: c.WebsiteRootDir, + domains: c.domainBackend(), + shards: c.shardBackend(), + backend: be, + }, nil +} + +func (c *Config) templateContext() *templateContext { + return &templateContext{ + shards: c.shardBackend(), + webroot: c.WebsiteRootDir, } } diff --git a/encryption.go b/encryption.go new file mode 100644 index 0000000000000000000000000000000000000000..ed6549499b7671f039c69e99323957e6ca1e5ab1 --- /dev/null +++ b/encryption.go @@ -0,0 +1,71 @@ +package accountserver + +import ( + "context" + + "git.autistici.org/id/keystore/userenckey" +) + +func keysToBytes(keys []*UserEncryptionKey) [][]byte { + var rawKeys [][]byte + for _, k := range keys { + rawKeys = append(rawKeys, k.Key) + } + return rawKeys +} + +func (s *AccountService) initializeEncryptionKeys(ctx context.Context, tx TX, user *User, password string) (keys []*UserEncryptionKey, decrypted []byte, err error) { + // Create new keys + pub, priv, err := userenckey.GenerateKey() + if err != nil { + return nil, nil, err + } + decrypted = priv + enc, err := userenckey.Encrypt(priv, []byte(password)) + if err != nil { + return nil, nil, err + } + keys = append(keys, &UserEncryptionKey{ + ID: UserEncryptionKeyMainID, + Key: enc, + }) + + // Save the new public key. + if err = tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil { + err = newBackendError(err) + } + return +} + +func (s *AccountService) readOrInitializeEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) (keys []*UserEncryptionKey, decrypted []byte, err error) { + if user.HasEncryptionKeys { + // Fetch the encryption keys from the database. + keys, err = tx.GetUserEncryptionKeys(ctx, user) + if err != nil { + return + } + decrypted, err = userenckey.Decrypt(keysToBytes(keys), []byte(oldPassword)) + return + } + return s.initializeEncryptionKeys(ctx, tx, user, newPassword) +} + +func updateEncryptionKey(keys []*UserEncryptionKey, decrypted []byte, keyID, password string) ([]*UserEncryptionKey, error) { + encrypted, err := userenckey.Encrypt(decrypted, []byte(password)) + if err != nil { + return nil, err + } + + // Replace the key with id 'keyID' in the in-memory list. + var keysOut []*UserEncryptionKey + for _, key := range keys { + if key.ID != keyID { + keysOut = append(keysOut, key) + } + } + keysOut = append(keysOut, &UserEncryptionKey{ + ID: keyID, + Key: encrypted, + }) + return keysOut, nil +} diff --git a/errors.go b/errors.go index b74704bca3f6094fce13e0f483c35a2ab35d42b8..3f7788eef4821b30d7d24ea7c0e577451463fce1 100644 --- a/errors.go +++ b/errors.go @@ -1,5 +1,18 @@ package accountserver +import "errors" + +var ( + // ErrUnauthorized means that the request failed due to lack of authorization. + ErrUnauthorized = errors.New("unauthorized") + + // ErrUserNotFound is returned when a user object is not found. + ErrUserNotFound = errors.New("user not found") + + // ErrResourceNotFound is returned when a resource object is not found. + ErrResourceNotFound = errors.New("resource not found") +) + // It is important to distinguish between different classes of errors, // so that they can be translated into distinct HTTP status codes and // transmitted back to the client. Since we also want to retain the @@ -14,6 +27,8 @@ func newAuthError(err error) error { return &authError{err} } +// IsAuthError returns true if err is an authentication / +// authorization error. func IsAuthError(err error) bool { _, ok := err.(*authError) return ok @@ -27,6 +42,8 @@ func newRequestError(err error) error { return &requestError{err} } +// IsRequestError returns true if err is a request error (bad +// request). func IsRequestError(err error) bool { _, ok := err.(*requestError) return ok @@ -40,6 +57,7 @@ func newBackendError(err error) error { return &backendError{err} } +// IsBackendError returns true if err is a backend error. func IsBackendError(err error) bool { _, ok := err.(*backendError) return ok diff --git a/integrationtest/integration_test.go b/integrationtest/integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..039b7d3890bd32f20a779b19d95f467cb0ec7952 --- /dev/null +++ b/integrationtest/integration_test.go @@ -0,0 +1,394 @@ +package integrationtest + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "git.autistici.org/ai3/accountserver" + "git.autistici.org/ai3/accountserver/backend" + "git.autistici.org/ai3/accountserver/ldaptest" + "git.autistici.org/ai3/accountserver/server" + sso "git.autistici.org/id/go-sso" + "golang.org/x/crypto/ed25519" +) + +const ( + testLDAPPort = 42872 + testLDAPAddr = "ldap://127.0.0.1:42872" + + testSSODomain = "domain" + testSSOService = "accountserver.domain/" + testAdminUser = "admin" + testAdminGroup = "admins" +) + +func withSSO(t testing.TB) (func(), sso.Signer, string) { + tmpf, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + tmpf.Write(pub) + tmpf.Close() + + signer, err := sso.NewSigner(priv) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(tmpf.Name()) + }, signer, tmpf.Name() +} + +type testClient struct { + srvURL string + signer sso.Signer +} + +func (c *testClient) ssoTicket(username string) string { + var groups []string + if username == testAdminUser { + groups = append(groups, testAdminGroup) + } + signed, err := c.signer.Sign(sso.NewTicket(username, testSSOService, testSSODomain, "", groups, 1*time.Hour)) + if err != nil { + panic(err) + } + return signed +} + +func (c *testClient) request(uri string, req, out interface{}) error { + data, _ := json.Marshal(req) + resp, err := http.Post(c.srvURL+uri, "application/json", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ = ioutil.ReadAll(resp.Body) + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + log.Printf("request error: %s", string(data)) + return errors.New(string(data)) + } + if resp.StatusCode != 200 { + log.Printf("remote error: %s", string(data)) + return fmt.Errorf("http status code %d", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != "application/json" { + return fmt.Errorf("unexpected content-type %s", resp.Header.Get("Content-Type")) + } + + log.Printf("response:\n%s\n", string(data)) + + if out == nil { + return nil + } + return json.Unmarshal(data, out) +} + +func startService(t testing.TB) (func(), *testClient) { + stop := ldaptest.StartServer(t, &ldaptest.Config{ + Dir: "../ldaptest", + Port: testLDAPPort, + Base: "dc=example,dc=com", + LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"}, + }) + + be, err := backend.NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com") + if err != nil { + t.Fatal("NewLDAPBackend", err) + } + + ssoStop, signer, ssoPubKeyFile := withSSO(t) + + var svcConfig accountserver.Config + svcConfig.SSO.PublicKeyFile = ssoPubKeyFile + svcConfig.SSO.Domain = testSSODomain + svcConfig.SSO.Service = testSSOService + svcConfig.SSO.AdminGroup = testAdminGroup + svcConfig.AvailableDomains = map[string][]string{ + accountserver.ResourceTypeEmail: []string{"example.com"}, + } + shards := []string{"host1", "host2", "host3"} + svcConfig.Shards.Available = map[string][]string{ + accountserver.ResourceTypeEmail: shards, + accountserver.ResourceTypeWebsite: shards, + accountserver.ResourceTypeDomain: shards, + accountserver.ResourceTypeDAV: shards, + accountserver.ResourceTypeDatabase: shards, + } + svcConfig.Shards.Allowed = svcConfig.Shards.Available + svcConfig.WebsiteRootDir = "/home/users/investici.org" + + service, err := accountserver.NewAccountService(be, &svcConfig) + if err != nil { + stop() + t.Fatal("NewAccountService", err) + } + + as := server.New(service, be) + srv := httptest.NewServer(as.Handler()) + + c := &testClient{ + srvURL: srv.URL, + signer: signer, + } + + return func() { + stop() + srv.Close() + ssoStop() + }, c +} + +// Verify that authentication on GetUser works as expected: +// - users can fetch their own data but not other users' +// - admins can read everything. +func TestIntegration_GetUser_Auth(t *testing.T) { + stop, c := startService(t) + defer stop() + + testdata := []struct { + authUser string + expectedOk bool + }{ + {"uno@investici.org", true}, + {"due@investici.org", false}, + {testAdminUser, true}, + } + + for _, td := range testdata { + var user accountserver.User + err := c.request("/api/user/get", &accountserver.GetUserRequest{ + RequestBase: accountserver.RequestBase{ + Username: "uno@investici.org", + SSO: c.ssoTicket(td.authUser), + }, + }, &user) + if td.expectedOk && err != nil { + t.Errorf("access error for user %s: expected ok, got error: %v", td.authUser, err) + } else if !td.expectedOk && err == nil { + t.Errorf("access error for user %s: expected error, got ok", td.authUser) + } + } +} + +// Verify that a user can change their password. +func TestIntegration_ChangeUserPassword(t *testing.T) { + stop, c := startService(t) + defer stop() + + err := c.request("/api/user/change_password", &accountserver.ChangeUserPasswordRequest{ + PrivilegedRequestBase: accountserver.PrivilegedRequestBase{ + RequestBase: accountserver.RequestBase{ + Username: "uno@investici.org", + SSO: c.ssoTicket("uno@investici.org"), + }, + // Since the user in the test fixture does + // *not* have encryption key, and + // authenticateWithPassword is currently a + // no-op, this test will pass. + CurPassword: "ha ha, really not the current password", + }, + Password: "new_password", + }, nil) + + if err != nil { + t.Fatal("ChangePassword", err) + } +} + +// Verify that changing the password sets user encryption keys. +func TestIntegration_ChangeUserPassword_SetsEncryptionKeys(t *testing.T) { + stop, c := startService(t) + defer stop() + + testdata := []struct { + password string + newPassword string + expectedOk bool + }{ + // Ordering is important as it is meant to emulate + // setting the password, failing to reset it, then + // succeeding. + {"password", "new_password", true}, + {"BADPASS", "new_password_2", false}, + {"new_password", "new_password_2", true}, + } + for _, td := range testdata { + err := c.request("/api/user/change_password", &accountserver.ChangeUserPasswordRequest{ + PrivilegedRequestBase: accountserver.PrivilegedRequestBase{ + RequestBase: accountserver.RequestBase{ + Username: "uno@investici.org", + SSO: c.ssoTicket("uno@investici.org"), + }, + CurPassword: td.password, + }, + Password: td.newPassword, + }, nil) + if err == nil && !td.expectedOk { + t.Fatalf("ChangeUserPassword(old=%s new=%s) should have failed but didn't", td.password, td.newPassword) + } else if err != nil && td.expectedOk { + t.Fatalf("ChangeUserPassword(old=%s new=%s) failed: %v", td.password, td.newPassword, err) + } + } +} + +func TestIntegration_CreateResource(t *testing.T) { + stop, c := startService(t) + defer stop() + + testdata := []struct { + resource *accountserver.Resource + expectedOk bool + }{ + // Create a domain resource. + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"), + Name: "example2.com", + Status: accountserver.ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Website: &accountserver.Website{ + URL: "https://example2.com", + DocumentRoot: "/home/users/investici.org/uno/html-example2.com", + AcceptMail: true, + }, + }, + true, + }, + + // Duplicate of the above request, should fail due to conflict. + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"), + Name: "example2.com", + Status: accountserver.ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Website: &accountserver.Website{ + URL: "https://example2.com", + DocumentRoot: "/home/users/investici.org/uno/html-example2.com", + }, + }, + false, + }, + + // Empty document root will be fixed by templating. + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"), + Name: "example3.com", + Status: accountserver.ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Website: &accountserver.Website{}, + }, + true, + }, + + // Malformed resource metadata (name fails validation). + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example$.com"), + Name: "example$.com", + Status: accountserver.ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Website: &accountserver.Website{ + URL: "https://example$.com", + DocumentRoot: "/home/users/investici.org/uno/html-example3.com", + }, + }, + false, + }, + + // Bad shard. + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"), + Name: "example4.com", + Status: accountserver.ResourceStatusActive, + Shard: "zebra", + OriginalShard: "zebra", + Website: &accountserver.Website{ + URL: "https://example4.com", + DocumentRoot: "/home/users/investici.org/uno/html-example4.com", + }, + }, + false, + }, + + // The document root has no associated DAV account. + { + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"), + Name: "example5.com", + Status: accountserver.ResourceStatusActive, + Shard: "host2", + OriginalShard: "host2", + Website: &accountserver.Website{ + URL: "https://example5.com", + DocumentRoot: "/home/users/investici.org/nonexisting", + }, + }, + false, + }, + } + + for _, td := range testdata { + err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{ + SSO: c.ssoTicket(testAdminUser), + Resources: []*accountserver.Resource{td.resource}, + }, nil) + if err == nil && !td.expectedOk { + t.Errorf("CreateResource(%s) should have failed but didn't", td.resource.ID) + } else if err != nil && td.expectedOk { + t.Errorf("CreateResource(%s) failed: %v", td.resource.ID, err) + } + } +} + +func TestIntegration_CreateMultipleResources_WithTemplate(t *testing.T) { + stop, c := startService(t) + defer stop() + + // The create request is very bare, most values will be filled + // in by the server using resource templates. + err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{ + SSO: c.ssoTicket(testAdminUser), + Resources: []*accountserver.Resource{ + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"), + Name: "example3.com", + }, + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDAV, "uno@investici.org", "example3dav"), + Name: "example3dav", + }, + &accountserver.Resource{ + ID: accountserver.NewResourceID(accountserver.ResourceTypeDatabase, "uno@investici.org", "cn=example3.com", "example3"), + ParentID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"), + Name: "example3", + }, + }, + }, nil) + if err != nil { + t.Errorf("CreateResources failed: %v", err) + } +} diff --git a/integrationtest/testdata/base.ldif b/integrationtest/testdata/base.ldif new file mode 100644 index 0000000000000000000000000000000000000000..fcbc2849fa34661c06ccd104a91052e2fa326dde --- /dev/null +++ b/integrationtest/testdata/base.ldif @@ -0,0 +1,16 @@ + +dn: dc=example,dc=com +objectclass: domain +objectclass: top +dc: example + +dn: ou=People,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: People + +dn: ou=Lists,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: Lists + diff --git a/integrationtest/testdata/test1.ldif b/integrationtest/testdata/test1.ldif new file mode 100644 index 0000000000000000000000000000000000000000..9bf6880683b247b165f7b48ccd62866470c24af8 --- /dev/null +++ b/integrationtest/testdata/test1.ldif @@ -0,0 +1,106 @@ +dn: uid=uno@investici.org,ou=People,dc=example,dc=com +cn: uno@investici.org +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +loginShell: /bin/false +uidNumber: 19475 +shadowMax: 99999 +gidNumber: 2000 +gecos: uno@investici.org +sn: Private +homeDirectory: /var/empty +uid: uno@investici.org +givenName: Private +shadowLastChange: 12345 +shadowWarning: 7 +preferredLanguage: it +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= + +dn: mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +recoverQuestion:: dGkgc2VpIG1haSDDuMOgdHRvIG1hbGUgY2FkZW5kbyBkYSB1biBwYWxhenpv + IGRpIG90dG8gcGlhbmk/ +objectClass: top +objectClass: virtualMailUser +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= +uidNumber: 19475 +host: host2 +mailAlternateAddress: uno@anche.no +recoverAnswer: {crypt}$1$wtEa4TKB$lxeyenkQ1yfxECn7WVQQ0/ +gidNumber: 2000 +mail: uno@investici.org +creationDate: 2002-05-07 +mailMessageStore: investici.org/uno/ +originalHost: host2 + +dn: ftpname=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +givenName: Private +cn: uno +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: ftpAccount +loginShell: /bin/false +shadowWarning: 7 +uidNumber: 19475 +host: host2 +shadowMax: 99999 +ftpname: uno +gidNumber: 33 +gecos: FTP Account for uno@investici.org +sn: Private +homeDirectory: /home/users/investici.org/uno +uid: uno +creationDate: 01-08-2013 +shadowLastChange: 12345 +originalHost: host2 +userPassword:: e2NyeXB0fSQ2JElDYkx1WTI3QWl6bC5FeEgkUDhOZHJ3VEtxZ2UwQUp3QW9oNE1 + EYlUxU3EySGtuRkF1cEx2RUI0U28waEw5NWtpZ3dIeXQuQnYxS0J5SFM2MXd6RnZuLnJsMEN4eFpx + RVgzUnVxbDE= + +dn: alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +parentSite: autistici.org +objectClass: top +objectClass: subSite +alias: uno +host: host2 +documentRoot: /home/users/investici.org/uno/html-uno +creationDate: 01-08-2013 +originalHost: host2 +statsId: 2191 + +dn: cn=example.com,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +acceptMail: true +objectClass: top +objectClass: virtualHost +cn: example.com +host: host2 +documentRoot: /home/users/investici.org/uno/html-example.com +creationDate: 02-08-2013 +originalHost: host2 +statsId: 2192 + +dn: dbname=unodb,alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +clearPassword: password +objectClass: top +objectClass: dbMysql +dbname: unodb +dbuser: unodb +host: host2 +creationDate: 01-08-2013 +originalHost: host2 diff --git a/ldaptest/ldap_server.go b/ldaptest/ldap_server.go new file mode 100644 index 0000000000000000000000000000000000000000..62682bac9dca0de0a9fc354d7fa739be618339c6 --- /dev/null +++ b/ldaptest/ldap_server.go @@ -0,0 +1,69 @@ +package ldaptest + +import ( + "errors" + "fmt" + "io/ioutil" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// Config for the test in-memory LDAP server. +type Config struct { + Dir string + Base string + Port int + LDIFs []string +} + +func waitForPort(port int, timeout time.Duration) error { + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(timeout) + for { + conn, err := net.Dial("tcp", addr) + if err == nil { + conn.Close() + return nil + } + time.Sleep(200 * time.Millisecond) + if time.Now().After(deadline) { + return errors.New("server did not come up within the deadline") + } + } +} + +// StartServer starts a test LDAP server with the specified configuration. +func StartServer(t testing.TB, config *Config) func() { + tmpf, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + fmt.Fprintf(tmpf, `ldap.rootDn=%s +ldap.managerDn=cn=manager,%s +ldap.managerPassword=password +ldap.port=%d +`, config.Base, config.Base, config.Port) + defer tmpf.Close() + + args := []string{tmpf.Name()} + args = append(args, config.LDIFs...) + proc := exec.Command(filepath.Join(config.Dir, "unboundid-ldap-server/bin/unboundid-ldap-server"), args...) + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + + if err := proc.Start(); err != nil { + t.Fatalf("error starting LDAP server: %v", err) + } + + waitForPort(config.Port, 5*time.Second) + + return func() { + proc.Process.Kill() + proc.Wait() + os.Remove(tmpf.Name()) + } +} diff --git a/backend/unboundid-ldap-server/bin/unboundid-ldap-server b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server similarity index 87% rename from backend/unboundid-ldap-server/bin/unboundid-ldap-server rename to ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server index a2140c60608eed0862e15c83d08b9a2793fcf781..fd117bfba6926b62da88e027eeac8240a96b9464 100755 --- a/backend/unboundid-ldap-server/bin/unboundid-ldap-server +++ b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -6,12 +6,30 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/.." >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="unboundid-ldap-server" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -30,6 +48,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,26 +59,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/.." >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - CLASSPATH=$APP_HOME/lib/unboundid-ldap-server.jar:$APP_HOME/lib/slf4j-api-1.7.12.jar:$APP_HOME/lib/unboundid-ldapsdk-3.1.1.jar:$APP_HOME/lib/ldaptive-unboundid-1.1.0.jar:$APP_HOME/lib/ldaptive-beans-1.1.0.jar:$APP_HOME/lib/commons-io-2.4.jar:$APP_HOME/lib/spring-core-4.2.1.RELEASE.jar:$APP_HOME/lib/log4j-api-2.3.jar:$APP_HOME/lib/log4j-core-2.3.jar:$APP_HOME/lib/log4j-slf4j-impl-2.3.jar:$APP_HOME/lib/log4j-jcl-2.3.jar:$APP_HOME/lib/ldaptive-1.1.0.jar:$APP_HOME/lib/commons-logging-1.2.jar:$APP_HOME/lib/commons-cli-1.3.1.jar # Determine the Java command to use to start the JVM. @@ -85,7 +89,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -150,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And UNBOUNDID_LDAP_SERVER_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $UNBOUNDID_LDAP_SERVER_OPTS +APP_ARGS=$(save "$@") +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $UNBOUNDID_LDAP_SERVER_OPTS -classpath "\"$CLASSPATH\"" com.unboundid.ldap.LdapServer "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" com.unboundid.ldap.LdapServer "$@" +exec "$JAVACMD" "$@" diff --git a/backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat similarity index 90% rename from backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat rename to ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat index 1c93278b4fb0759f55e8b28604105528fb1f3145..accd36e0de0832e4502d15b13bf9d6c823daaadb 100755 --- a/backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat +++ b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat @@ -8,14 +8,14 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME%.. +@rem Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -46,10 +46,9 @@ echo location of your Java installation. goto fail :init -@rem Get command-line arguments, handling Windowz variants +@rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args :win9xME_args @rem Slurp the command line arguments. @@ -60,11 +59,6 @@ set _SKIP=2 if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ :execute @rem Setup the command line diff --git a/backend/unboundid-ldap-server/lib/commons-cli-1.3.1.jar b/ldaptest/unboundid-ldap-server/lib/commons-cli-1.3.1.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/commons-cli-1.3.1.jar rename to ldaptest/unboundid-ldap-server/lib/commons-cli-1.3.1.jar diff --git a/backend/unboundid-ldap-server/lib/commons-io-2.4.jar b/ldaptest/unboundid-ldap-server/lib/commons-io-2.4.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/commons-io-2.4.jar rename to ldaptest/unboundid-ldap-server/lib/commons-io-2.4.jar diff --git a/backend/unboundid-ldap-server/lib/commons-logging-1.2.jar b/ldaptest/unboundid-ldap-server/lib/commons-logging-1.2.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/commons-logging-1.2.jar rename to ldaptest/unboundid-ldap-server/lib/commons-logging-1.2.jar diff --git a/backend/unboundid-ldap-server/lib/ldaptive-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-1.1.0.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/ldaptive-1.1.0.jar rename to ldaptest/unboundid-ldap-server/lib/ldaptive-1.1.0.jar diff --git a/backend/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar rename to ldaptest/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar diff --git a/backend/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar rename to ldaptest/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar diff --git a/backend/unboundid-ldap-server/lib/log4j-api-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-api-2.3.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/log4j-api-2.3.jar rename to ldaptest/unboundid-ldap-server/lib/log4j-api-2.3.jar diff --git a/backend/unboundid-ldap-server/lib/log4j-core-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-core-2.3.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/log4j-core-2.3.jar rename to ldaptest/unboundid-ldap-server/lib/log4j-core-2.3.jar diff --git a/backend/unboundid-ldap-server/lib/log4j-jcl-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-jcl-2.3.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/log4j-jcl-2.3.jar rename to ldaptest/unboundid-ldap-server/lib/log4j-jcl-2.3.jar diff --git a/backend/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar rename to ldaptest/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar diff --git a/backend/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar b/ldaptest/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar rename to ldaptest/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar diff --git a/backend/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar b/ldaptest/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar rename to ldaptest/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar diff --git a/backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar b/ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar similarity index 92% rename from backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar rename to ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar index 65162f51e5636cc8a040f154af044e94e3387c88..366895a50293c4523ba41705a046275c65d43d1e 100644 Binary files a/backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar and b/ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar differ diff --git a/backend/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar b/ldaptest/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar similarity index 100% rename from backend/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar rename to ldaptest/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar diff --git a/server/server.go b/server/server.go index de4df64512c01fb04da6dac900a0a7bd47271527..2b9a46926ed8787b13caf7bd60ffdedf6cf07e11 100644 --- a/server/server.go +++ b/server/server.go @@ -1,6 +1,9 @@ package server import ( + "context" + "encoding/json" + "errors" "log" "net/http" @@ -9,194 +12,177 @@ import ( as "git.autistici.org/ai3/accountserver" ) +type txHandler func(as.TX, http.ResponseWriter, *http.Request) (interface{}, error) + +var errBadRequest = errors.New("bad request") + type AccountServer struct { service *as.AccountService + backend as.Backend } -func New(service *as.AccountService) *AccountServer { - return &AccountServer{service} -} - -var emptyResponse = map[string]string{} - -func errToStatus(err error) int { - switch { - case err == as.ErrUserNotFound, err == as.ErrResourceNotFound: - return http.StatusNotFound - case as.IsAuthError(err): - return http.StatusUnauthorized - case as.IsRequestError(err): - return http.StatusBadRequest - default: - return http.StatusInternalServerError +func New(service *as.AccountService, backend as.Backend) *AccountServer { + return &AccountServer{ + service: service, + backend: backend, } } -func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) { - var req as.GetUserRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } +var emptyResponse struct{} - user, err := s.service.GetUser(r.Context(), &req) - if err != nil { - log.Printf("GetUser(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, user) +func (s *AccountServer) handleGetUser(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.GetUserRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.GetUser(ctx, tx, &req) + }) } -func (s *AccountServer) handleChangeUserPassword(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.ChangeUserPasswordRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.ChangeUserPassword(ctx, tx, &req) + }) +} - if err := s.service.ChangeUserPassword(r.Context(), &req); err != nil { - log.Printf("ChangeUserPassword(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } +func (s *AccountServer) handleSetPasswordRecoveryHint(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.SetPasswordRecoveryHintRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.SetPasswordRecoveryHint(ctx, tx, &req) + }) +} - serverutil.EncodeJSONResponse(w, emptyResponse) +func (s *AccountServer) handleRecoverPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.PasswordRecoveryRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.RecoverPassword(ctx, tx, &req) + }) } -func (s *AccountServer) handleCreateApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.CreateApplicationSpecificPasswordRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - resp, err := s.service.CreateApplicationSpecificPassword(r.Context(), &req) - if err != nil { - log.Printf("CreateApplicationSpecificPassword(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, resp) + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.CreateApplicationSpecificPassword(ctx, tx, &req) + }) } -func (s *AccountServer) handleDeleteApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleDeleteApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.DeleteApplicationSpecificPasswordRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - if err := s.service.DeleteApplicationSpecificPassword(r.Context(), &req); err != nil { - log.Printf("DeleteApplicationSpecificPassword(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, emptyResponse) + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.DeleteApplicationSpecificPassword(ctx, tx, &req) + }) } -func (s *AccountServer) handleEnableResource(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleEnableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.EnableResourceRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - if err := s.service.EnableResource(r.Context(), &req); err != nil { - log.Printf("EnableResource(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, emptyResponse) + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.EnableResource(ctx, tx, &req) + }) } -func (s *AccountServer) handleDisableResource(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleDisableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.DisableResourceRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - if err := s.service.DisableResource(r.Context(), &req); err != nil { - log.Printf("DisableResource(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, emptyResponse) + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.DisableResource(ctx, tx, &req) + }) } -func (s *AccountServer) handleChangeResourcePassword(w http.ResponseWriter, r *http.Request) { - var req as.ChangeResourcePasswordRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - if err := s.service.ChangeResourcePassword(r.Context(), &req); err != nil { - log.Printf("ChangeResourcePassword(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, emptyResponse) +func (s *AccountServer) handleCreateResources(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.CreateResourcesRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.CreateResources(ctx, tx, &req) + }) } -func (s *AccountServer) handleMoveResource(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleMoveResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.MoveResourceRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } - - resp, err := s.service.MoveResource(r.Context(), &req) - if err != nil { - log.Printf("MoveResource(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return - } - - serverutil.EncodeJSONResponse(w, resp) + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.MoveResource(ctx, tx, &req) + }) } -func (s *AccountServer) handleEnableOTP(w http.ResponseWriter, r *http.Request) { +func (s *AccountServer) handleEnableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.EnableOTPRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return - } + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.EnableOTP(ctx, tx, &req) + }) +} - resp, err := s.service.EnableOTP(r.Context(), &req) - if err != nil { - log.Printf("EnableOTP(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), errToStatus(err)) - return +func (s *AccountServer) handleDisableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.DisableOTPRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.DisableOTP(ctx, tx, &req) + }) +} + +func (s *AccountServer) withTx(f txHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tx, err := s.backend.NewTransaction() + if err != nil { + log.Printf("NewTransaction error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp, err := f(tx, w, r) + if err != nil { + return + } + // Automatically commit the transaction (if + // the handler didn't do this itself). + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Commit error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + serverutil.EncodeJSONResponse(w, resp) } +} - serverutil.EncodeJSONResponse(w, resp) +func (s *AccountServer) Handler() http.Handler { + h := http.NewServeMux() + h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser)) + h.HandleFunc("/api/user/change_password", s.withTx(s.handleChangeUserPassword)) + h.HandleFunc("/api/user/set_password_recovery_hint", s.withTx(s.handleSetPasswordRecoveryHint)) + h.HandleFunc("/api/user/enable_otp", s.withTx(s.handleEnableOTP)) + h.HandleFunc("/api/user/disable_otp", s.withTx(s.handleDisableOTP)) + h.HandleFunc("/api/user/create_app_specific_password", s.withTx(s.handleCreateApplicationSpecificPassword)) + h.HandleFunc("/api/user/delete_app_specific_password", s.withTx(s.handleDeleteApplicationSpecificPassword)) + h.HandleFunc("/api/resource/enable", s.withTx(s.handleEnableResource)) + h.HandleFunc("/api/resource/disable", s.withTx(s.handleDisableResource)) + h.HandleFunc("/api/resource/create", s.withTx(s.handleCreateResources)) + h.HandleFunc("/api/resource/move", s.withTx(s.handleMoveResource)) + h.HandleFunc("/api/recover_password", s.withTx(s.handleRecoverPassword)) + return h } -func (s *AccountServer) handleDisableOTP(w http.ResponseWriter, r *http.Request) { - var req as.DisableOTPRequest - if !serverutil.DecodeJSONRequest(w, r, &req) { - return +func errToStatus(err error) int { + switch { + case err == as.ErrUserNotFound, err == as.ErrResourceNotFound: + return http.StatusNotFound + case as.IsAuthError(err): + return http.StatusUnauthorized + case as.IsRequestError(err): + return http.StatusBadRequest + default: + return http.StatusInternalServerError } +} - if err := s.service.DisableOTP(r.Context(), &req); err != nil { - log.Printf("DisableOTP(%s): error: %v", req.Username, err) +func handleJSON(w http.ResponseWriter, r *http.Request, req interface{}, f func(context.Context) (interface{}, error)) (interface{}, error) { + if !serverutil.DecodeJSONRequest(w, r, req) { + return nil, errBadRequest + } + resp, err := f(r.Context()) + if err != nil { + log.Printf("error in %s: %v, request=%s", r.URL.Path, err, dumpRequest(req)) http.Error(w, err.Error(), errToStatus(err)) - return + return nil, err } - - serverutil.EncodeJSONResponse(w, emptyResponse) + return resp, nil } -func (s *AccountServer) Handler() http.Handler { - h := http.NewServeMux() - h.HandleFunc("/api/user/get", s.handleGetUser) - h.HandleFunc("/api/user/change_password", s.handleChangeUserPassword) - h.HandleFunc("/api/user/enable_otp", s.handleEnableOTP) - h.HandleFunc("/api/user/disable_otp", s.handleDisableOTP) - h.HandleFunc("/api/app_specific_password/create", s.handleCreateApplicationSpecificPassword) - h.HandleFunc("/api/app_specific_password/delete", s.handleDeleteApplicationSpecificPassword) - h.HandleFunc("/api/resource/enable", s.handleEnableResource) - h.HandleFunc("/api/resource/disable", s.handleDisableResource) - h.HandleFunc("/api/resource/change_password", s.handleChangeResourcePassword) - h.HandleFunc("/api/resource/move", s.handleMoveResource) - return h +func dumpRequest(req interface{}) string { + data, _ := json.Marshal(req) + return string(data) } diff --git a/service.go b/service.go new file mode 100644 index 0000000000000000000000000000000000000000..bfb8c10fa3b486a93185bb004b4f7f37ee797a4b --- /dev/null +++ b/service.go @@ -0,0 +1,409 @@ +package accountserver + +import ( + "context" + "encoding/json" + "log" + "reflect" + + "git.autistici.org/id/go-sso" +) + +// Backend user database interface. +// +// We are using a transactional interface even if the actual backend +// (LDAP) does not support atomic transactions, just so it is easy to +// add more backends in the future (like SQL). +type Backend interface { + NewTransaction() (TX, error) +} + +// TX represents a single transaction with the backend and offers a +// high-level data management abstraction. +// +// All methods share similar semantics: Get methods will return nil if +// the requested object is not found, and only return an error in case +// of trouble reaching the backend itself. +// +// The backend enforces strict public/private data separation by +// having Get methods return public objects (as defined in types.go), +// and using specialized methods to modify the private +// (authentication-related) attributes. +// +// We might add more sophisticated resource query methods later, as +// admin-level functionality. +// +type TX interface { + Commit(context.Context) error + + GetResource(context.Context, ResourceID) (*Resource, error) + UpdateResource(context.Context, *Resource) error + CreateResource(context.Context, *Resource) error + SetResourcePassword(context.Context, *Resource, string) error + HasAnyResource(context.Context, []FindResourceRequest) (bool, error) + + GetUser(context.Context, string) (*User, error) + CreateUser(context.Context, *User) error + SetUserPassword(context.Context, *User, string) error + SetPasswordRecoveryHint(context.Context, *User, string, string) error + GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error) + SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error + SetUserEncryptionPublicKey(context.Context, *User, []byte) error + SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error + DeleteApplicationSpecificPassword(context.Context, *User, string) error + SetUserTOTPSecret(context.Context, *User, string) error + DeleteUserTOTPSecret(context.Context, *User) error +} + +// FindResourceRequest contains parameters for searching a resource by name. +type FindResourceRequest struct { + Type string + Name string +} + +// AccountService implements the business logic and high-level +// functionality of the user accounts management service. +type AccountService struct { + *authService + + audit auditLogger + + fieldValidators *fieldValidators + resourceValidator *resourceValidator + userValidator UserValidatorFunc + resourceTemplates *templateContext +} + +// NewAccountService builds a new AccountService with the specified configuration. +func NewAccountService(backend Backend, config *Config) (*AccountService, error) { + ssoValidator, err := config.ssoValidator() + if err != nil { + return nil, err + } + + return newAccountServiceWithSSO(backend, config, ssoValidator) +} + +func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) (*AccountService, error) { + s := &AccountService{ + authService: newAuthService(config, ssoValidator), + audit: &syslogAuditLogger{}, + } + + vc, err := config.validationContext(backend) + if err != nil { + return nil, err + } + s.fieldValidators = newFieldValidators(vc) + s.resourceValidator = newResourceValidator(vc) + s.userValidator = vc.validUser() + + s.resourceTemplates = config.templateContext() + + return s, nil +} + +type authService struct { + validator sso.Validator + ssoService string + ssoGroups []string + ssoAdminGroup string +} + +func newAuthService(config *Config, v sso.Validator) *authService { + return &authService{ + validator: v, + ssoService: config.SSO.Service, + ssoGroups: config.SSO.Groups, + ssoAdminGroup: config.SSO.AdminGroup, + } +} + +func (s *authService) isAdmin(tkt *sso.Ticket) bool { + for _, g := range tkt.Groups { + if g == s.ssoAdminGroup { + return true + } + } + return false +} + +func (s *authService) validateSSO(ssoToken string) (*sso.Ticket, error) { + return s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups) +} + +func getUserOrDie(ctx context.Context, tx TX, username string) (*User, error) { + user, err := tx.GetUser(ctx, username) + if err != nil { + return nil, newBackendError(err) + } + if user == nil { + return nil, ErrUserNotFound + } + return user, nil +} + +func getResourceOrDie(ctx context.Context, tx TX, id ResourceID) (*Resource, error) { + r, err := tx.GetResource(ctx, id) + if err != nil { + return nil, newBackendError(err) + } + if r == nil { + return nil, ErrResourceNotFound + } + return r, nil +} + +type authUserCtxKeyType int + +var authUserCtxKey authUserCtxKeyType + +func authUserFromContext(ctx context.Context) string { + s, ok := ctx.Value(userCtxKey).(string) + if ok { + return s + } + return "" +} + +func (s *authService) authorizeAdminGeneric(ctx context.Context, tx TX, ssoTicket string) (context.Context, error) { + // Validate the SSO ticket. + tkt, err := s.validateSSO(ssoTicket) + if err != nil { + return nil, newAuthError(err) + } + + // Requests are allowed if the SSO ticket corresponds to an admin, or if + // it identifies the same user that we're querying. + if !s.isAdmin(tkt) { + return nil, newAuthError(ErrUnauthorized) + } + + ctx = context.WithValue(ctx, authUserCtxKey, tkt.User) + return ctx, nil +} + +func (s *authService) authorizeAdmin(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) { + // Validate the SSO ticket. + tkt, err := s.validateSSO(req.SSO) + if err != nil { + return nil, nil, newAuthError(err) + } + + // Requests are allowed if the SSO ticket corresponds to an admin, or if + // it identifies the same user that we're querying. + if !s.isAdmin(tkt) { + return nil, nil, newAuthError(ErrUnauthorized) + } + + ctx = context.WithValue(ctx, authUserCtxKey, tkt.User) + user, err := getUserOrDie(ctx, tx, req.Username) + return ctx, user, err +} + +func (s *authService) authorizeUser(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) { + // First, check that the username matches the SSO ticket + // username (or that the SSO ticket has admin permissions). + tkt, err := s.validateSSO(req.SSO) + if err != nil { + return nil, nil, newAuthError(err) + } + + // Requests are allowed if the SSO ticket corresponds to an admin, or if + // it identifies the same user that we're querying. + if !s.isAdmin(tkt) && tkt.User != req.Username { + return nil, nil, newAuthError(ErrUnauthorized) + } + + user, err := getUserOrDie(ctx, tx, req.Username) + ctx = context.WithValue(ctx, authUserCtxKey, tkt.User) + return ctx, user, err +} + +// Extended version of authorizeUser that also directly checks the +// user password. Used for account-privileged operations related to +// credential manipulation. +func (s *authService) authorizeUserWithPassword(ctx context.Context, tx TX, req PrivilegedRequestBase) (context.Context, *User, error) { + // TODO: call out to the auth-server? + return s.authorizeUser(ctx, tx, req.RequestBase) +} + +// Extended version of authorizeUser that checks access to a specific +// resource (which must belong to the specified user). To be used +// wherever we take a ResourceID in the request. +// +// Note that this access control method only works for resources that +// have explicit ownership (i.e. they have the user in their resource +// ID). For shared resources like mailing lists, we will need to +// delegate the ownership check to the Resource itself. +func (s *authService) authorizeResource(ctx context.Context, tx TX, req ResourceRequestBase) (context.Context, *Resource, error) { + tkt, err := s.validateSSO(req.SSO) + if err != nil { + return nil, nil, newAuthError(err) + } + + r, err := getResourceOrDie(ctx, tx, req.ResourceID) + if err != nil { + return nil, nil, err + } + + if !s.isAdmin(tkt) && !canAccessResource(tkt.User, r) { + return nil, nil, newAuthError(ErrUnauthorized) + } + + ctx = context.WithValue(ctx, authUserCtxKey, tkt.User) + return ctx, r, nil +} + +func canAccessResource(username string, r *Resource) bool { + switch r.ID.Type() { + case ResourceTypeMailingList: + // Check the list owners. + for _, a := range r.List.Admins { + if a == username { + return true + } + } + return false + default: + return r.ID.User() == username + } +} + +type authHandlerFunc func(context.Context, TX) (context.Context, *User, *Resource, error) + +func (s *authService) authResource(reqBase ResourceRequestBase) authHandlerFunc { + return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) { + ctx, resource, err := s.authorizeResource(ctx, tx, reqBase) + return ctx, nil, resource, err + } +} + +func (s *authService) authUser(reqBase RequestBase) authHandlerFunc { + return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) { + ctx, user, err := s.authorizeUser(ctx, tx, reqBase) + return ctx, user, nil, err + } +} + +func (s *authService) authUserWithPassword(reqBase PrivilegedRequestBase) authHandlerFunc { + return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) { + ctx, user, err := s.authorizeUserWithPassword(ctx, tx, reqBase) + return ctx, user, nil, err + } +} + +func (s *authService) authAdmin(reqBase RequestBase) authHandlerFunc { + return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) { + ctx, user, err := s.authorizeAdmin(ctx, tx, reqBase) + return ctx, user, nil, err + } +} + +type hasNewContext interface { + NewContext(context.Context) context.Context +} + +type hasApplyTemplate interface { + ApplyTemplate(context.Context, *AccountService, *User) +} + +type hasValidate interface { + Validate(context.Context, *AccountService) error +} + +type hasCompoundValidate interface { + Validate(context.Context, *AccountService, *User) error +} + +type hasValidationUser interface { + ValidationUser(context.Context, TX) *User +} + +// Wrapper for actions that validates requests and sets up some +// request-related parameters (mostly in the Context, used for later +// logging). The user parameter, if present, is passed to the Validate +// request method. +func (s *AccountService) withRequest(ctx context.Context, tx TX, req interface{}, user *User, f func(context.Context) error) error { + // If the request has a NewContext() method, call it to obtain + // a request-specific context (this step usually adds + // parameters for logging). + if rnc, ok := req.(hasNewContext); ok { + ctx = rnc.NewContext(ctx) + } + + // Fetch the user to be passed to Validate() from the request + // if one was not provided as an argument. + if user == nil { + if rvu, ok := req.(hasValidationUser); ok { + user = rvu.ValidationUser(ctx, tx) + } + } + + // 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 the request has a Validate() method, validate the + // request. We support two different fingerprints for the + // Validate() method, one without, and the other with a *User + // argument ("compound Validate"), for resource-level + // validators. + if rv, ok := req.(hasValidate); ok { + if err := rv.Validate(ctx, s); err != nil { + return newRequestError(err) + } + } else if cv, ok := req.(hasCompoundValidate); ok { + if err := cv.Validate(ctx, s, user); err != nil { + return newRequestError(err) + } + } + + err := f(ctx) + log.Printf("%s %s err=%v", reflect.TypeOf(req).String(), dumpRequest(req), err) + return err +} + +// Handle a user request. Authorize, validate, and run the given function on the User object. +func (s *AccountService) handleUserRequest(ctx context.Context, tx TX, req interface{}, auth authHandlerFunc, f func(context.Context, *User) error) (err error) { + var user *User + ctx, user, _, err = auth(ctx, tx) + if err != nil { + return + } + + return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error { + return f(ctx, user) + }) +} + +// Handle a resource request. Authorize, validate, and run the given function on the Resource object. +func (s *AccountService) handleResourceRequest(ctx context.Context, tx TX, req interface{}, auth authHandlerFunc, f func(context.Context, *Resource) error) (err error) { + var user *User + var resource *Resource + ctx, user, resource, err = auth(ctx, tx) + if err != nil { + return + } + + return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error { + return f(ctx, resource) + }) +} + +// Handle an admin request (i.e. a request with no explicit associated account or resource). +func (s *AccountService) handleAdminRequest(ctx context.Context, tx TX, req interface{}, ssoTicket string, f func(context.Context) error) (err error) { + ctx, err = s.authService.authorizeAdminGeneric(ctx, tx, ssoTicket) + if err != nil { + return + } + + return s.withRequest(ctx, tx, req, nil, f) +} + +func dumpRequest(req interface{}) string { + data, _ := json.Marshal(req) + return string(data) +} diff --git a/types.go b/types.go index bbd6cb32c59ecb275c27ee0ba6e3f31689d3ae18..447f32e23da5710ca682d1957e7834c99f390cae 100644 --- a/types.go +++ b/types.go @@ -1,7 +1,10 @@ package accountserver import ( - "fmt" + "encoding/json" + "errors" + "net/url" + "path/filepath" "strings" "time" ) @@ -29,29 +32,42 @@ type User struct { AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"` Resources []*Resource `json:"resources,omitempty"` +} - Opaque interface{} +// GetResourceByID returns the resource with the specified ID, or nil +// if not found. +func (u *User) GetResourceByID(id ResourceID) *Resource { + for _, r := range u.Resources { + if r.ID.Equal(id) { + return r + } + } + return nil } +// GetResourcesByType returns all resources with the specified type. func (u *User) GetResourcesByType(resourceType string) []*Resource { var out []*Resource for _, r := range u.Resources { - if r.Type == resourceType { + if r.ID.Type() == resourceType { out = append(out, r) } } return out } +// GetSingleResourceByType returns a single resource of the specified +// type. If there are none, returns nil. func (u *User) GetSingleResourceByType(resourceType string) *Resource { for _, r := range u.Resources { - if r.Type == resourceType { + if r.ID.Type() == resourceType { return r } } return nil } +// GetResourcesByGroup returns all resources belonging to the specified group. func (u *User) GetResourcesByGroup(group string) []*Resource { var out []*Resource for _, r := range u.Resources { @@ -70,6 +86,8 @@ type AppSpecificPasswordInfo struct { Comment string `json:"comment"` } +// Well-known user encryption key types, corresponding to primary and +// secondary passwords. const ( UserEncryptionKeyMainID = "main" UserEncryptionKeyRecoveryID = "recovery" @@ -82,60 +100,143 @@ type UserEncryptionKey struct { Key []byte `json:"key"` } -func DecodeUserEncryptionKeys(values []string) []*UserEncryptionKey { - var out []*UserEncryptionKey - for _, value := range values { - idx := strings.IndexByte(value, ':') - if idx < 0 { - continue - } - out = append(out, &UserEncryptionKey{ - ID: value[:idx], - Key: []byte(value[idx+1:]), - }) - } - return out -} - -func EncodeUserEncryptionKeys(keys []*UserEncryptionKey) []string { - var out []string - for _, key := range keys { - out = append(out, fmt.Sprintf("%s:%s", key.ID, string(key.Key))) - } - return out -} - +// Resource types. const ( ResourceTypeEmail = "email" ResourceTypeMailingList = "list" ResourceTypeWebsite = "web" + ResourceTypeDomain = "domain" ResourceTypeDAV = "dav" ResourceTypeDatabase = "db" ) +// Resource status values. const ( ResourceStatusActive = "active" ResourceStatusInactive = "inactive" + ResourceStatusReadonly = "readonly" ) +// ResourceID is a a unique primary key in the resources space, with a +// path-like representation. It must make sense to the database +// backend and be reversible (i.e. there must be a bidirectional +// mapping between database objects and resource IDs). +type ResourceID struct { + Parts []string +} + +// NewResourceID builds a ResourceID out of a list of path components. +func NewResourceID(p ...string) ResourceID { + return ResourceID{Parts: p} +} + +// Equal returns true if the two IDs are the same. +func (i ResourceID) Equal(other ResourceID) bool { + if len(i.Parts) != len(other.Parts) { + return false + } + for idx := 0; idx < len(i.Parts); idx++ { + if i.Parts[idx] != other.Parts[idx] { + return false + } + } + return true +} + +// Empty returns true if the ResourceID has the nil value. +func (i ResourceID) Empty() bool { + return len(i.Parts) == 0 +} + +// Type of the resource, the first component. +func (i ResourceID) Type() string { + return i.Parts[0] +} + +// Name of the resource, without path. This is the last component of +// the composite ID. +func (i ResourceID) Name() string { + return i.Parts[len(i.Parts)-1] +} + +// User that owns the resource. This may be missing for some resources +// (those that share a single global namespace), in which case this +// function will return an empty string. +func (i ResourceID) User() string { + if len(i.Parts) > 2 { + return i.Parts[1] + } + return "" +} + +// Path for the resource, i.e. the ID components after type and +// (optionally) user. +func (i ResourceID) Path() []string { + if len(i.Parts) > 2 { + return i.Parts[2:] + } + return i.Parts[1:] +} + +func (i ResourceID) String() string { + var tmp []string + for _, s := range i.Parts { + tmp = append(tmp, url.PathEscape(s)) + } + return filepath.Join(tmp...) +} + +// MarshalJSON serializes a resource ID to JSON. +func (i ResourceID) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON deserializes a resource ID from JSON. +func (i *ResourceID) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + if s != "" { + *i, err = ParseResourceID(s) + } + return err +} + +// ParseResourceID parses a string representation of a ResourceID. +func ParseResourceID(s string) (ResourceID, error) { + var id ResourceID + for _, e := range strings.Split(s, "/") { + u, err := url.PathUnescape(e) + if err != nil { + return ResourceID{}, err + } + id.Parts = append(id.Parts, u) + } + if len(id.Parts) < 2 { + return ResourceID{}, errors.New("malformed resource ID") + } + return id, nil +} + // Resource represents a somewhat arbitrary resource, identified by a // unique name/type combination (a.k.a. its ID). A resource contains // some common properties related to sharding and state, plus // type-specific attributes. type Resource struct { - // ID is a unique primary key in the resources space, - // consisting of 'type/name'. - ID string `json:"id"` - - // Name of the resource, unique to the resource type namespace - // for this user. + // ID is a unique primary key in the resources space, with a + // path-like representation. It must make sense to the + // database backend and be reversible (i.e. there must be a + // bidirectional mapping between database objects and resource + // IDs). + ID ResourceID `json:"id"` + + // Name of the resource, used for display purposes. Name string `json:"name"` - // Type of the resource. - Type string `json:"type"` - // Optional attribute for hierarchical resources. - ParentID string `json:"parent_id,omitempty"` + ParentID ResourceID `json:"parent_id,omitempty"` // Optional attribute for resources that have a status. Status string `json:"status,omitempty"` @@ -157,11 +258,6 @@ type Resource struct { Website *Website `json:"website,omitempty"` DAV *WebDAV `json:"dav,omitempty"` Database *Database `json:"database,omitempty"` - - // When the resource is used internally in the accountserver, - // it needs a reference to backend-specific data. This is not - // part of the public interface, and it is not serialized. - Opaque interface{} `json:"-"` } // Copy the resource (makes a deep copy). @@ -208,10 +304,10 @@ type WebDAV struct { // Website resource attributes. type Website struct { - URL string `json:"url"` - DisplayName string `json:"display_name"` + URL string `json:"url,omitempty"` + ParentDomain string `json:"parent_domain,omitempty"` AcceptMail bool `json:"accept_mail"` - Options []string `json:"options"` + Options []string `json:"options,omitempty"` Categories []string `json:"categories,omitempty"` Description map[string]string `json:"description,omitempty"` QuotaUsage int `json:"quota_usage"` diff --git a/validators.go b/validators.go index 224b8d2a4b8ac201e15e00768365acf65641866b..e128da69dd1f401d8a6646a37b6d2627f09c4ada 100644 --- a/validators.go +++ b/validators.go @@ -5,7 +5,10 @@ import ( "context" "errors" "fmt" + "log" + "math/rand" "os" + "path/filepath" "regexp" "strings" @@ -14,17 +17,31 @@ import ( // A domainBackend manages the list of domains users are allowed to request services on. type domainBackend interface { - GetAvailableDomains(context.Context, string) []string - IsAvailableDomain(context.Context, string, string) bool + GetAllowedDomains(context.Context, string) []string + IsAllowedDomain(context.Context, string, string) bool } -// The checkBackend verifies if specific resources already exist or are available. -type checkBackend interface { - HasAnyResource(context.Context, []string) (bool, error) +// A shardBackend can return information about available / allowed service shards. +type shardBackend interface { + GetAllowedShards(context.Context, string) []string + GetAvailableShards(context.Context, string) []string + IsAllowedShard(context.Context, string, string) bool } -type validationConfig struct { +// The validationContext contains all configuration and backends that +// the various validation functions will need. Most methods on this +// object return functions themselves (ValidatorFunc or variations +// thereof) that can later be called multiple times at will and +// combined with operators like 'allOf'. +type validationContext struct { forbiddenUsernames stringSet + forbiddenPasswords stringSet + minPasswordLength int + maxPasswordLength int + webroot string + domains domainBackend + shards shardBackend + backend Backend } // A stringSet is just a list of strings with a quick membership test. @@ -41,6 +58,13 @@ func newStringSetFromList(list []string) stringSet { return stringSet{set: set, list: list} } +func newStringSetFromFileOrList(list []string, path string) (stringSet, error) { + if path != "" { + return loadStringSetFromFile(path) + } + return newStringSetFromList(list), nil +} + func (s stringSet) Contains(needle string) bool { _, ok := s.set[needle] return ok @@ -55,14 +79,31 @@ type staticDomainBackend struct { sets map[string]stringSet } -func (d *staticDomainBackend) GetAvailableDomains(_ context.Context, kind string) []string { +func (d *staticDomainBackend) GetAllowedDomains(_ context.Context, kind string) []string { return d.sets[kind].List() } -func (d *staticDomainBackend) IsAvailableDomain(_ context.Context, kind, domain string) bool { +func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain string) bool { return d.sets[kind].Contains(domain) } +type staticShardBackend struct { + available map[string]stringSet + allowed map[string]stringSet +} + +func (d *staticShardBackend) GetAllowedShards(_ context.Context, kind string) []string { + return d.allowed[kind].List() +} + +func (d *staticShardBackend) GetAvailableShards(_ context.Context, kind string) []string { + return d.available[kind].List() +} + +func (d *staticShardBackend) IsAllowedShard(_ context.Context, kind, shard string) bool { + return d.allowed[kind].Contains(shard) +} + func loadStringSetFromFile(path string) (stringSet, error) { f, err := os.Open(path) if err != nil { @@ -157,7 +198,7 @@ func isRegistered(domain string) bool { return true } -func validDomainName(value string) error { +func validDomainName(_ context.Context, value string) error { if !domainRx.MatchString(value) { return errors.New("invalid domain name") } @@ -183,85 +224,127 @@ func splitEmailAddr(addr string) (string, string) { // Returns all the possible resources in the email and mailing list // namespaces that might conflict with the given email address. -func relatedEmails(ctx context.Context, be domainBackend, addr string) []string { - resourceIDs := []string{fmt.Sprintf("%s/%s", ResourceTypeEmail, addr)} +func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindResourceRequest { + rel := []FindResourceRequest{ + {Type: ResourceTypeEmail, Name: addr}, + } user, _ := splitEmailAddr(addr) // Mailing lists must have unique names regardless of the domain, so we // add potential conflicts for mailing lists with the same name over all // list-enabled domains. - for _, d := range be.GetAvailableDomains(ctx, ResourceTypeMailingList) { - resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s@%s", ResourceTypeMailingList, user, d)) + for _, d := range be.GetAllowedDomains(ctx, ResourceTypeMailingList) { + rel = append(rel, FindResourceRequest{ + Type: ResourceTypeMailingList, + Name: fmt.Sprintf("%s@%s", user, d), + }) } - return resourceIDs + return rel } -func splitSubsite(value string) (string, string) { - parts := strings.SplitN(value, "/", 2) - return parts[0], parts[1] +func relatedWebsites(ctx context.Context, be domainBackend, value string) []FindResourceRequest { + // Ignore the parent domain (websites share a global namespace). + return []FindResourceRequest{ + { + Type: ResourceTypeWebsite, + Name: value, + }, + } } -func isSubsite(value string) bool { - return strings.Contains(value, "/") +func relatedDomains(ctx context.Context, be domainBackend, value string) []FindResourceRequest { + return []FindResourceRequest{ + { + Type: ResourceTypeDomain, + Name: value, + }, + } } -func relatedWebsites(ctx context.Context, be domainBackend, value string) []string { - var resourceIDs []string - if isSubsite(value) { - _, path := splitSubsite(value) - for _, d := range be.GetAvailableDomains(ctx, ResourceTypeWebsite) { - resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s/%s", ResourceTypeWebsite, d, path)) +func (v *validationContext) isAllowedDomain(rtype string) ValidatorFunc { + return func(ctx context.Context, value string) error { + if !v.domains.IsAllowedDomain(ctx, rtype, value) { + return errors.New("unavailable domain") } - } else { - resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s", ResourceTypeWebsite, value)) + return nil } - return resourceIDs } -func isAvailableEmailHostingDomain(be domainBackend) ValidatorFunc { +func (v *validationContext) isAvailableEmailAddr() ValidatorFunc { return func(ctx context.Context, value string) error { - if !be.IsAvailableDomain(ctx, ResourceTypeEmail, value) { - return errors.New("unavailable domain") + rel := relatedEmails(ctx, v.domains, value) + + // Run the presence check in a new transaction. Unavailability + // of the server results in a validation error (fail close). + tx, err := v.backend.NewTransaction() + if err != nil { + return err + } + if ok, _ := tx.HasAnyResource(ctx, rel); ok { + return errors.New("address unavailable") } return nil } } -func isAvailableMailingListDomain(be domainBackend) ValidatorFunc { +func (v *validationContext) isAvailableDomain() ValidatorFunc { return func(ctx context.Context, value string) error { - if !be.IsAvailableDomain(ctx, ResourceTypeMailingList, value) { - return errors.New("unavailable domain") + rel := relatedDomains(ctx, v.domains, value) + + // Run the presence check in a new transaction. Unavailability + // of the server results in a validation error (fail close). + tx, err := v.backend.NewTransaction() + if err != nil { + return err + } + if ok, _ := tx.HasAnyResource(ctx, rel); ok { + return errors.New("address unavailable") } return nil } } -func isAvailableEmailAddr(be domainBackend, cb checkBackend) ValidatorFunc { +func (v *validationContext) isAvailableWebsite() ValidatorFunc { return func(ctx context.Context, value string) error { - rel := relatedEmails(ctx, be, value) - if ok, _ := cb.HasAnyResource(ctx, rel); ok { + rel := relatedWebsites(ctx, v.domains, value) + + // Run the presence check in a new transaction. Unavailability + // of the server results in a validation error (fail close). + tx, err := v.backend.NewTransaction() + if err != nil { + return err + } + if ok, _ := tx.HasAnyResource(ctx, rel); ok { return errors.New("address unavailable") } return nil } } -func validHostedEmail(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc { +func (v *validationContext) validHostedEmail() ValidatorFunc { return allOf( validateUsernameAndDomain( - allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)), - allOf(isAvailableEmailHostingDomain(be)), + allOf(matchUsernameRx(), minLength(4), maxLength(64), notInSet(v.forbiddenUsernames)), + allOf(v.isAllowedDomain(ResourceTypeEmail)), ), - isAvailableEmailAddr(be, cb), + v.isAvailableEmailAddr(), ) } -func validHostedMailingList(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc { +func (v *validationContext) validHostedMailingList() ValidatorFunc { return allOf( validateUsernameAndDomain( - allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)), - allOf(isAvailableMailingListDomain(be)), + allOf(matchUsernameRx(), minLength(4), maxLength(64), notInSet(v.forbiddenUsernames)), + allOf(v.isAllowedDomain(ResourceTypeMailingList)), ), - isAvailableEmailAddr(be, cb), + v.isAvailableEmailAddr(), + ) +} + +func (v *validationContext) validPassword() ValidatorFunc { + return allOf( + minLength(v.minPasswordLength), + maxLength(v.maxPasswordLength), + notInSet(v.forbiddenPasswords), ) } @@ -275,3 +358,423 @@ func allOf(funcs ...ValidatorFunc) ValidatorFunc { return nil } } + +// ResourceValidatorFunc is a composite type validator that checks +// various fields in a Resource, depending on its type. +type ResourceValidatorFunc func(context.Context, *Resource, *User) error + +func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error { + // Resource name must match the name in the resource ID + // (until we get rid of the Name field). + if r.Name != r.ID.Name() { + return errors.New("mismatched ID and name") + } + + // Validate the status enum. + switch r.Status { + case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly: + default: + return errors.New("unknown resource status") + } + + // Ensure that, if the resource has an user, it is the given user. + u := r.ID.User() + switch { + case u == "" && user != nil: + return fmt.Errorf("attempt to modify global resource in user context (user=%s)", user.Name) + case u != "" && user == nil: + return errors.New("resource ID has user but no user in context") + case user != nil && u != user.Name: + return errors.New("can't modify resource owned by another user") + } + + // If the resource has a ParentID, it must reference another + // resource owned by the user. + if !r.ParentID.Empty() { + if user == nil { + return errors.New("resource can't have parent without user context") + } + if p := user.GetResourceByID(r.ParentID); p == nil { + return errors.New("parent references unknown resource") + } + } + + return nil +} + +func (v *validationContext) validateShardedResource(ctx context.Context, r *Resource, user *User) error { + if err := v.validateResource(ctx, r, user); err != nil { + return err + } + if !v.shards.IsAllowedShard(ctx, r.ID.Type(), r.Shard) { + return fmt.Errorf( + "invalid shard %s for resource type %s (allowed: %v)", + r.Shard, + r.ID.Type(), + v.shards.GetAllowedShards(ctx, r.ID.Type()), + ) + } + if r.OriginalShard == "" { + return errors.New("empty original_shard") + } + return nil +} + +func (v *validationContext) validEmailResource() ResourceValidatorFunc { + emailValidator := v.validHostedEmail() + + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + + // Email resources aren't nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.Email == nil { + return errors.New("resource has no email metadata") + } + if err := emailValidator(ctx, r.ID.Name()); err != nil { + return err + } + if r.Email.Maildir == "" { + return errors.New("empty maildir") + } + return nil + } +} + +func (v *validationContext) validListResource() ResourceValidatorFunc { + listValidator := v.validHostedMailingList() + + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + if r.List == nil { + return errors.New("resource has no list metadata") + } + if err := listValidator(ctx, r.ID.Name()); err != nil { + return err + } + if len(r.List.Admins) < 1 { + return errors.New("can't create a list without admins") + } + return nil + } +} + +func findMatchingDAVAccount(user *User, r *Resource) *Resource { + for _, dav := range user.GetResourcesByType(ResourceTypeDAV) { + if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) { + return r + } + } + return nil +} + +func hasMatchingDAVAccount(user *User, r *Resource) bool { + return findMatchingDAVAccount(user, r) != nil +} + +func (v *validationContext) validDomainResource() ResourceValidatorFunc { + domainValidator := allOf( + minLength(6), + validDomainName, + v.isAvailableDomain(), + ) + + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + + // Web resources aren't nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.Website == nil { + return errors.New("resource has no website metadata") + } + if err := domainValidator(ctx, r.ID.Name()); err != nil { + return err + } + if r.Website.ParentDomain != "" { + 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 + } +} + +func (v *validationContext) validWebsiteResource() ResourceValidatorFunc { + nameValidator := allOf( + minLength(6), + matchSitenameRx(), + v.isAvailableWebsite(), + ) + parentValidator := v.isAllowedDomain(ResourceTypeWebsite) + + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + + // Web resources aren't nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.Website == nil { + return errors.New("resource has no website metadata") + } + if err := nameValidator(ctx, r.ID.Name()); err != nil { + return err + } + if err := parentValidator(ctx, r.Website.ParentDomain); err != nil { + 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 + } +} + +func (v *validationContext) validDAVResource() ResourceValidatorFunc { + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + + // DAV resources aren't nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.DAV == nil { + return errors.New("resource has no dav metadata") + } + if !isSubdir(v.webroot, r.DAV.Homedir) { + return errors.New("homedir outside of web root") + } + return nil + } +} + +func (v *validationContext) validDatabaseResource() ResourceValidatorFunc { + return func(ctx context.Context, r *Resource, user *User) error { + if err := v.validateShardedResource(ctx, r, user); err != nil { + return err + } + + // Database resources must be nested below a website. + if r.ParentID.Empty() { + return errors.New("database resources should be nested") + } + switch r.ParentID.Type() { + case ResourceTypeWebsite, ResourceTypeDomain: + default: + return errors.New("database parent is not a website resource") + } + + if r.Database == nil { + return errors.New("resource has no database metadata") + } + return nil + } +} + +// Validator for arbitrary resource types. +type resourceValidator struct { + rvs map[string]ResourceValidatorFunc +} + +func newResourceValidator(v *validationContext) *resourceValidator { + return &resourceValidator{ + rvs: map[string]ResourceValidatorFunc{ + ResourceTypeEmail: v.validEmailResource(), + ResourceTypeMailingList: v.validListResource(), + ResourceTypeDomain: v.validDomainResource(), + ResourceTypeWebsite: v.validWebsiteResource(), + ResourceTypeDAV: v.validDAVResource(), + ResourceTypeDatabase: v.validDatabaseResource(), + }, + } +} + +func (v *resourceValidator) validateResource(ctx context.Context, r *Resource, user *User) error { + return v.rvs[r.ID.Type()](ctx, r, user) +} + +// Common validators for specific field types. +type fieldValidators struct { + password ValidatorFunc + email ValidatorFunc +} + +func newFieldValidators(v *validationContext) *fieldValidators { + return &fieldValidators{ + password: v.validPassword(), + email: v.validHostedEmail(), + } +} + +// UserValidatorFunc is a compound validator for User objects. +type UserValidatorFunc func(context.Context, *User) error + +// A custom validator for User objects. +func (v *validationContext) validUser() UserValidatorFunc { + nameValidator := v.validHostedEmail() + return func(ctx context.Context, user *User) error { + return nameValidator(ctx, user.Name) + } +} + +// A ResourceTemplateFunc fills up server-generated fields with +// defaults for newly created resources. Called before validation. +type ResourceTemplateFunc func(context.Context, *Resource, *User) + +type templateContext struct { + shards shardBackend + webroot string +} + +func (c *templateContext) pickShard(ctx context.Context, r *Resource) string { + avail := c.shards.GetAvailableShards(ctx, r.ID.Type()) + if len(avail) == 0 { + return "" + } + return avail[rand.Intn(len(avail))] +} + +func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) { + if r.Shard == "" { + if ref != nil { + // If we are evaluating templates out of + // order, the reference resource may not have + // a shard yet. Assign it now. + if ref.Shard == "" { + ref.Shard = c.pickShard(ctx, ref) + } + r.Shard = ref.Shard + } else { + r.Shard = c.pickShard(ctx, r) + } + } + if r.OriginalShard == "" { + r.OriginalShard = r.Shard + } +} + +func (c *templateContext) setResourceStatus(r *Resource) { + if r.Status == "" { + r.Status = ResourceStatusActive + } +} + +func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, user *User) { + if r.Email == nil { + r.Email = new(Email) + } + addrParts := strings.Split(r.ID.Name(), "@") + r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0]) + r.Email.QuotaLimit = 4096 + c.setResourceShard(ctx, r, nil) + c.setResourceStatus(r) +} + +func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) { + if r.Website == nil { + r.Website = new(Website) + } + if r.Website.DocumentRoot == "" { + if dav := user.GetSingleResourceByType(ResourceTypeDAV); dav != nil { + // The DAV resource may not have been templatized yet. + if dav.DAV == nil || dav.DAV.Homedir == "" { + c.davResourceTemplate(ctx, dav, user) + } + r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.ID.Name()) + } + } + r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot) + + if len(r.Website.Options) == 0 { + r.Website.Options = []string{"nomail"} + } + + dav := findMatchingDAVAccount(user, r) + c.setResourceShard(ctx, r, dav) + c.setResourceStatus(r) + + log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Website) +} + +func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) { + if r.DAV == nil { + r.DAV = new(WebDAV) + } + if r.DAV.Homedir == "" { + r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name()) + } + r.DAV.Homedir = filepath.Clean(r.DAV.Homedir) + + c.setResourceShard(ctx, r, nil) + c.setResourceStatus(r) + + log.Printf("applyTemplate(%s) -> %+v", r.ID, r.DAV) +} + +func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) { + if r.Database == nil { + r.Database = new(Database) + } + if r.Database.DBUser == "" { + r.Database.DBUser = r.ID.Name() + } + + c.setResourceShard(ctx, r, user.GetResourceByID(r.ParentID)) + c.setResourceStatus(r) + + log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Database) +} + +func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) { + switch r.ID.Type() { + case ResourceTypeEmail: + c.emailResourceTemplate(ctx, r, user) + case ResourceTypeWebsite, ResourceTypeDomain: + c.websiteResourceTemplate(ctx, r, user) + case ResourceTypeDAV: + c.davResourceTemplate(ctx, r, user) + case ResourceTypeDatabase: + c.databaseResourceTemplate(ctx, r, user) + } +} + +func isSubdir(root, dir string) bool { + return strings.HasPrefix(dir, root+"/") +} diff --git a/validators_test.go b/validators_test.go index 6dbbae6be6f34ef6b2281cd7f78fd11bc8840efb..95845bb80ae02d3146d41393f09372acb36546ff 100644 --- a/validators_test.go +++ b/validators_test.go @@ -2,6 +2,7 @@ package accountserver import ( "context" + "fmt" "testing" ) @@ -59,27 +60,45 @@ func TestValidator_MatchUsername(t *testing.T) { runValidationTest(t, matchUsernameRx(), td) } -type fakeCheckBackend map[string]struct{} +type fakeCheckBackend struct { + // We need this just to satisfy the Backend interface. + *fakeBackend -func newFakeCheckBackend(rids ...string) fakeCheckBackend { - f := make(fakeCheckBackend) - for _, rid := range rids { - f[rid] = struct{}{} + m map[string]struct{} +} + +type fakeCheckTX struct { + TX + m map[string]struct{} +} + +func newFakeCheckBackend(seeds []FindResourceRequest) *fakeCheckBackend { + f := &fakeCheckBackend{ + fakeBackend: createFakeBackend(), + m: make(map[string]struct{}), + } + for _, s := range seeds { + f.m[fmt.Sprintf("%s/%s", s.Type, s.Name)] = struct{}{} } return f } -func (f fakeCheckBackend) HasAnyResource(_ context.Context, ids []string) (bool, error) { - for _, id := range ids { - if _, ok := f[id]; ok { +func (f *fakeCheckBackend) NewTransaction() (TX, error) { + tx, _ := f.fakeBackend.NewTransaction() + return &fakeCheckTX{TX: tx, m: f.m}, nil +} + +func (f *fakeCheckTX) HasAnyResource(_ context.Context, reqs []FindResourceRequest) (bool, error) { + for _, req := range reqs { + if _, ok := f.m[fmt.Sprintf("%s/%s", req.Type, req.Name)]; ok { return true, nil } } return false, nil } -func newTestValidationConfig(entries ...string) *validationConfig { - return &validationConfig{ +func newTestValidationConfig(entries ...string) *validationContext { + return &validationContext{ forbiddenUsernames: newStringSetFromList(entries), } } @@ -105,9 +124,11 @@ func TestValidator_HostedEmail(t *testing.T) { {"existing@example.com", false}, } vc := newTestValidationConfig("forbidden") - cb := newFakeCheckBackend("email/existing@example.com") - db := newFakeDomainBackend("example.com") - runValidationTest(t, validHostedEmail(vc, db, cb), td) + vc.backend = newFakeCheckBackend([]FindResourceRequest{ + {Type: ResourceTypeEmail, Name: "existing@example.com"}, + }) + vc.domains = newFakeDomainBackend("example.com") + runValidationTest(t, vc.validHostedEmail(), td) } func TestValidator_HostedMailingList(t *testing.T) { @@ -121,7 +142,9 @@ func TestValidator_HostedMailingList(t *testing.T) { {"existing@domain2.com", false}, } vc := newTestValidationConfig("forbidden") - cb := newFakeCheckBackend("list/existing@domain2.com") - db := newFakeDomainBackend("domain1.com", "domain2.com") - runValidationTest(t, validHostedMailingList(vc, db, cb), td) + vc.backend = newFakeCheckBackend([]FindResourceRequest{ + {Type: ResourceTypeMailingList, Name: "existing@domain2.com"}, + }) + vc.domains = newFakeDomainBackend("domain1.com", "domain2.com") + runValidationTest(t, vc.validHostedMailingList(), td) }