Commit 4e34034b authored by ale's avatar ale

First stage of refactor targeting simplicity

Structure flow around requests themselves and composition rather than
handlers and wrappers, the results are likely more readable (and
shorter).

Move all the user auth management business logic to a smart RawUser
object, to separate it from details of API handling. The result should
be more understandable: all critical changes are contained within a
single type.

Also, with all the workflow driven by Requests, we can get rid of the
boilerplate in the HTTP API server and replace it with a tiny tiny
layer of reflection.
parent 6f16cef4
......@@ -4,945 +4,232 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log"
"git.autistici.org/ai3/go-common/pwhash"
umdb "git.autistici.org/id/usermetadb"
"git.autistici.org/id/go-sso"
"github.com/pquerna/otp/totp"
"github.com/sethvargo/go-password/password"
)
// RequestBase contains parameters shared by all request types.
// Request is the generic interface for request types. Each request
// type defines its own handler, built of composable objects that
// define its behavior with respect to validation, authentication and
// execution.
type Request interface {
PopulateContext(*RequestContext) error
Validate(*RequestContext) error
Authorize(*RequestContext) error
Serve(*RequestContext) (interface{}, error)
}
// The RequestContext holds a large number of request-scoped variables
// populated at different stages of the action workflow. This is
// simpler than managing lots of custom Context vars and the
// associated boilerplate, but it's still a bit of an antipattern due
// to the loss of generality.
type RequestContext struct {
// Link to the infra services.
*AccountService
// Request-scoped read-only values.
Context context.Context
//HTTPRequest *http.Request
TX TX
// Request-scoped read-write parameters.
SSO *sso.Ticket
User *RawUser
Resource *Resource
Comment string
}
// Value that we put in place of private fields when sanitizing.
const sanitizedValue = "XXXXXX"
// RequestBase contains parameters shared by all authenticated request types.
type RequestBase struct {
Username string `json:"username"`
SSO string `json:"sso"`
// Optional comment, will end up in audit logs.
Comment string `json:"comment,omitempty"`
}
type userCtxKeyType int
var userCtxKey userCtxKeyType
func userFromContext(ctx context.Context) string {
s, ok := ctx.Value(userCtxKey).(string)
if ok {
return s
}
return ""
}
type commentCtxKeyType int
var commentCtxKey commentCtxKeyType
func commentFromContext(ctx context.Context) string {
s, ok := ctx.Value(commentCtxKey).(string)
if ok {
return s
}
return ""
}
// 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 ctx
}
// PrivilegedRequestBase extends RequestBase with the user password,
// for privileged endpoints.
type PrivilegedRequestBase struct {
RequestBase
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"`
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, 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, tx TX, r *Resource, status string) error {
r.Status = status
if err := tx.UpdateResource(ctx, r); err != nil {
return newBackendError(err)
// Sanitize the request.
func (r *RequestBase) Sanitize() {
if r.SSO != "" {
r.SSO = sanitizedValue
}
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 {
ResourceRequestBase
}
// DisableResource disables a resource belonging to the user.
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 {
ResourceRequestBase
}
// EnableResource enables a resource belonging to the user.
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"`
}
// 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, 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, "password changed (user request)")
})
}
// 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"`
}
// NewContext adds logging data to the current request context.
func (r *PasswordRecoveryRequest) NewContext(ctx context.Context) context.Context {
return context.WithValue(ctx, userCtxKey, r.Username)
}
// Validate the request.
func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error {
return s.fieldValidators.password(ctx, r.Password)
}
// PasswordRecoveryResponse is the response type for AccountService.RecoverPassword().
type PasswordRecoveryResponse struct {
Hint string `json:"hint"`
}
// RecoverPassword lets users reset their password by providing
// secondary credentials, which we authenticate ourselves.
//
// Two-factor authentication is disabled on successful recovery.
//
// TODO: call out to auth-server for secondary authentication?
func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) (*PasswordRecoveryResponse, error) {
user, err := getUserOrDie(ctx, tx, req.Username)
if err != nil {
return nil, err
}
resp := PasswordRecoveryResponse{
Hint: user.PasswordRecoveryHint,
}
// Only attempt to authenticate if the recovery password is
// set in the request, otherwise just return the hint.
if req.RecoveryPassword == "" {
return &resp, nil
}
// Authenticate the secret recovery password.
if !pwhash.ComparePassword(tx.GetUserRecoveryEncryptedPassword(ctx, user), req.RecoveryPassword) {
return nil, ErrUnauthorized
}
ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
err = s.withRequest(ctx, tx, req, user, func(ctx context.Context) error {
// Change the user password (the recovery password does not change).
if err := s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password, "password changed (account recovery)"); err != nil {
return err
}
// Disable 2FA.
return s.disable2FA(ctx, tx, user)
})
return &resp, err
}
// 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, "password reset (admin)")
})
}
// 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)
}
encResponse := pwhash.Encrypt(req.Response)
return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse)
})
}
// 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, logmsg 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
}
keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword)
if err != nil {
return err
}
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
// 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{}, logmsg)
s.logUserAction(user, umdb.LogTypePasswordChange, logmsg)
func (r *RequestBase) Validate(rctx *RequestContext) error {
return nil
}
// 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, logmsg string) error {
// Calling initialize will wipe the current keys and replace
// them with a new one.
keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword)
// PopulateContext extracts information from the request and stores it
// into the RequestContext.
func (r *RequestBase) PopulateContext(rctx *RequestContext) error {
tkt, err := rctx.validateSSO(r.SSO)
if err != nil {
return err
}
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
// 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)
}
if logmsg != "" {
s.audit.Log(ctx, ResourceID{}, logmsg)
s.logUserAction(user, umdb.LogTypePasswordReset, logmsg)
}
rctx.SSO = tkt
rctx.Comment = r.Comment
return nil
}
// 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)
}
// Disable U2F.
if len(user.U2FRegistrations) > 0 {
user.U2FRegistrations = nil
if err := tx.UpdateUser(ctx, user); err != nil {
return newBackendError(err)
}
}
// Wipe all app-specific passwords.
for _, asp := range user.AppSpecificPasswords {
if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil {
return newBackendError(err)
}
}
return nil
}
// commented out so subtypes *must* implement authorization, checked at compile time.
//func (r *RequestBase) Authorize(rctx *RequestContext) error {
// return nil
//}
// CreateApplicationSpecificPasswordRequest is the request type for
// AccountService.CreateApplicationSpecificPassword().
type CreateApplicationSpecificPasswordRequest struct {
PrivilegedRequestBase
Service string `json:"service"`
Notes string `json:"notes"`
}
// 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, 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.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 {
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)
}
}
resp.Password = password
return nil
})
return &resp, err
}
// DeleteApplicationSpecificPasswordRequest is the request type for
// AccountService.DeleteApplicationSpecificPassword().
type DeleteApplicationSpecificPasswordRequest struct {
// AdminRequestBase is a generic admin request.
type AdminRequestBase struct {
RequestBase
AspID string `json:"asp_id"`
}
// DeleteApplicationSpecificPassword destroys an application-specific
// password, identified by its unique ID.
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
}
// 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 newBackendError(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)
})
}
// ResetResourcePasswordRequest is the request type for AccountService.ResetResourcePassword().
type ResetResourcePasswordRequest struct {
ResourceRequestBase
}
func resourceHasPassword(r *Resource) bool {
switch r.ID.Type() {
case ResourceTypeDAV, ResourceTypeDatabase, ResourceTypeMailingList:
return true
default:
return false
}
}
// Validate the request.
func (r *ResetResourcePasswordRequest) Validate(ctx context.Context, s *AccountService) error {
switch r.ResourceID.Type() {
case ResourceTypeDAV, ResourceTypeDatabase, ResourceTypeMailingList:
case ResourceTypeEmail:
return errors.New("can't reset email passwords independently")
default:
return errors.New("can't reset password on this resource type")
// Authorize the request.
func (r *AdminRequestBase) Authorize(rctx *RequestContext) error {
if !rctx.isAdmin(rctx.SSO) {
return fmt.Errorf("user %s is not an admin", rctx.SSO.User)
}
return nil
}
// ResetResourcePasswordResponse is the response type for AccountService.ResetResourcePassword().
type ResetResourcePasswordResponse struct {
Password string `json:"password"`
}
// ResetResourcePassword can reset the password associated with a
// resource (if the resource type supports it). It will generate a
// random password and return it to the caller.
func (s *AccountService) ResetResourcePassword(ctx context.Context, tx TX, req *ResetResourcePasswordRequest) (*ResetResourcePasswordResponse, error) {
var resp ResetResourcePasswordResponse
err := s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error {
newPassword, err := s.doResetResourcePassword(ctx, tx, r)
if err != nil {
return err
}
// Return the password to the caller, in cleartext.
resp.Password = newPassword
// TODO: This is where we may want to call out to
// other backends in order to reset credentials for
// certain resources that have their own secondary
// authentication databases (lists, mysql).
return nil
})
return &resp, err
}
func (s *AccountService) doResetResourcePassword(ctx context.Context, tx TX, r *Resource) (string, error) {
newPassword := randomPassword()
encPassword := pwhash.Encrypt(newPassword)
// TODO: this needs a resource type-switch.
if err := tx.SetResourcePassword(ctx, r, encPassword); err != nil {
return "", err
}
return newPassword, nil
}
// MoveResourceRequest is the request type for AccountService.MoveResource().
type MoveResourceRequest struct {
RequestBase
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"`
}
// MoveResource is an administrative operation to move resources
// 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, tx TX, req *MoveResourceRequest) (*MoveResourceResponse, error) {
var resp MoveResourceResponse
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 newBackendError(err)
}
var resources []*Resource
if r.Group != "" {
resources = append(resources, user.GetResourcesByGroup(r.Group)...)
} else {
resources = []*Resource{r}
}
for _, r := range resources {
r.Shard = req.Shard
if err := tx.UpdateResource(ctx, r); err != nil {
return newBackendError(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 {
// UserRequestBase is a generic request about a specific user.
type UserRequestBase struct {
RequestBase
TOTPSecret string `json:"totp_secret"`
Username string `json:"username"`
}
// 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")
}
return nil
}
// EnableOTPResponse is the response type for AccountService.EnableOTP().
type EnableOTPResponse struct {
TOTPSecret string `json:"totp_secret"`
func (r *UserRequestBase) Validate(rctx *RequestContext) error {
return r.RequestBase.Validate(rctx)
}
// EnableOTP enables OTP-based two-factor authentication for a
// user. The caller can generate the TOTP secret itself if needed
// (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, 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
s.audit.Log(ctx, ResourceID{}, "totp enabled")
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)
}
s.audit.Log(ctx, ResourceID{}, "totp disabled")
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")
// PopulateContext extracts information from the request and stores it
// into the RequestContext.
func (r *UserRequestBase) PopulateContext(rctx *RequestContext) error {
user, err := getUserOrDie(rctx.Context, rctx.TX, r.Username)
if err != nil {
return err
}
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 {