diff --git a/actions.go b/actions.go index 852fa017f92a319fc095eda194497110035eae8b..3914cca5dfae5062fd329dc41ef8af0b840adb2e 100644 --- a/actions.go +++ b/actions.go @@ -39,6 +39,8 @@ type Backend interface { 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 @@ -50,16 +52,37 @@ type AccountService struct { 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 + } + + return newAccountServiceWithSSO(backend, config, ssoValidator), nil } -func NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService { - return &AccountService{ +func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService { + s := &AccountService{ backend: backend, validator: ssoValidator, - ssoService: ssoService, - ssoGroups: ssoGroups, - ssoAdminGroup: ssoAdminGroup, + ssoService: config.SSO.Service, + ssoGroups: config.SSO.Groups, + ssoAdminGroup: config.SSO.AdminGroup, } + + validationConfig := config.validationConfig() + domainBackend := config.domainBackend() + s.dataValidators = map[string]ValidatorFunc{ + ResourceTypeEmail: validHostedEmail(validationConfig, domainBackend, backend), + ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend), + } + + return s } func (s *AccountService) isAdmin(tkt *sso.Ticket) bool { @@ -77,6 +100,29 @@ var ( 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) + } + + // 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) + } + + user, err := s.backend.GetUser(ctx, username) + if err != nil { + return nil, newBackendError(err) + } + if user == nil { + return nil, ErrUserNotFound + } + return user, nil +} + 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). @@ -101,19 +147,38 @@ func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken s 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"` } +// PrivilegedRequestBase extends RequestBase with the user password, +// for privileged endpoints. +type PrivilegedRequestBase struct { + RequestBase + CurPassword string `json:"cur_password"` +} + 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) } +// 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 { @@ -134,6 +199,7 @@ type DisableResourceRequest struct { ResourceID string `json:"resource_id"` } +// 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 @@ -146,6 +212,7 @@ type EnableResourceRequest struct { ResourceID string `json:"resource_id"` } +// 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 @@ -154,23 +221,21 @@ func (s *AccountService) EnableResource(ctx context.Context, req *EnableResource } type ChangeUserPasswordRequest struct { - RequestBase - CurPassword string `json:"cur_password"` - Password string `json:"password"` + PrivilegedRequestBase + Password string `json:"password"` } func (r *ChangeUserPasswordRequest) Validate() error { - if r.CurPassword == "" { - return errors.New("empty 'cur_password' attribute") - } if r.Password == "" { return errors.New("empty 'password' attribute") } return nil } +// 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.authorizeUser(ctx, req.Username, req.SSO) + user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword) if err != nil { return err } @@ -286,16 +351,12 @@ func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyI } type CreateApplicationSpecificPasswordRequest struct { - RequestBase - CurPassword string `json:"cur_password"` // User password. - Service string `json:"service"` - Comment string `json:"comment"` + PrivilegedRequestBase + Service string `json:"service"` + Comment string `json:"comment"` } func (r *CreateApplicationSpecificPasswordRequest) Validate() error { - if r.CurPassword == "" { - return errors.New("empty 'cur_password' attribute") - } if r.Service == "" { return errors.New("empty 'service' attribute") } @@ -306,8 +367,10 @@ 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.authorizeUser(ctx, req.Username, req.SSO) + user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword) if err != nil { return nil, err } @@ -355,6 +418,8 @@ type DeleteApplicationSpecificPasswordRequest struct { AspID string `json:"asp_id"` } +// 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 { @@ -397,6 +462,9 @@ func (r *ChangeResourcePasswordRequest) Validate() error { 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 { @@ -432,8 +500,12 @@ 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, req *MoveResourceRequest) (*MoveResourceResponse, error) { - user, err := s.authorizeUser(ctx, req.Username, req.SSO) + user, err := s.authorizeAdmin(ctx, req.Username, req.SSO) if err != nil { return nil, err } @@ -464,29 +536,49 @@ func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequ type EnableOTPRequest struct { RequestBase + TOTPSecret string `json:"totp_secret"` +} + +func (r *EnableOTPRequest) Validate() 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 } type EnableOTPResponse struct { TOTPSecret string `json:"totp_secret"` } +// 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, req *EnableOTPRequest) (*EnableOTPResponse, error) { user, err := s.authorizeUser(ctx, req.Username, req.SSO) if err != nil { return nil, err } + if err = req.Validate(); err != nil { + return nil, newRequestError(err) + } + // Replace or initialize the TOTP secret. - totpSecret, err := generateTOTPSecret() - if err != nil { - return nil, err + if req.TOTPSecret == "" { + req.TOTPSecret, err = generateTOTPSecret() + if err != nil { + return nil, err + } } - if err := s.backend.SetUserTOTPSecret(ctx, user, totpSecret); err != nil { + if err := s.backend.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil { return nil, newBackendError(err) } return &EnableOTPResponse{ - TOTPSecret: totpSecret, + TOTPSecret: req.TOTPSecret, }, nil } @@ -494,6 +586,7 @@ type DisableOTPRequest struct { RequestBase } +// 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 { diff --git a/actions_test.go b/actions_test.go index e875b36517c28840a6740727d2df48e534ba6092..4c9443da21cb1657efb952163b96cb27442dc657 100644 --- a/actions_test.go +++ b/actions_test.go @@ -66,6 +66,10 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error return nil } +func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []string) (bool, error) { + return false, nil +} + const testAdminGroupName = "admins" type fakeValidator struct { @@ -110,8 +114,16 @@ func createFakeBackend() *fakeBackend { return fb } +func testConfig() *Config { + var c Config + c.SSO.Domain = "mydomain" + c.SSO.Service = "service/" + c.SSO.AdminGroup = testAdminGroupName + return &c +} + func TestService_GetUser(t *testing.T) { - svc := NewAccountService(createFakeBackend(), &fakeValidator{}, "service/", nil, testAdminGroupName) + svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{}) req := &GetUserRequest{ RequestBase: RequestBase{ @@ -129,7 +141,7 @@ func TestService_GetUser(t *testing.T) { } func TestService_Auth(t *testing.T) { - svc := NewAccountService(createFakeBackend(), &fakeValidator{"adminuser"}, "service/", nil, "admins") + svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{"adminuser"}) for _, td := range []struct { sso string @@ -160,15 +172,17 @@ func TestService_Auth(t *testing.T) { func TestService_ChangePassword(t *testing.T) { fb := createFakeBackend() - svc := NewAccountService(fb, &fakeValidator{}, "service/", nil, testAdminGroupName) + svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{}) req := &ChangeUserPasswordRequest{ - RequestBase: RequestBase{ - Username: "testuser", - SSO: "testuser", + PrivilegedRequestBase: PrivilegedRequestBase{ + RequestBase: RequestBase{ + Username: "testuser", + SSO: "testuser", + }, + CurPassword: "cur", }, - CurPassword: "cur", - Password: "password", + Password: "password", } err := svc.ChangeUserPassword(context.TODO(), req) if err != nil { @@ -176,6 +190,9 @@ func TestService_ChangePassword(t *testing.T) { } if _, ok := fb.passwords["testuser"]; !ok { - t.Fatal("password was not set on the backend") + t.Error("password was not set on the backend") + } + if len(fb.encryptionKeys["testuser"]) != 1 { + t.Errorf("no encryption keys were set") } } diff --git a/backend/model.go b/backend/model.go index 8537fd9197a39040ca6a1aaa332e8f0b587756a6..d876ad2352271c30132f4788f022b16066c67392 100644 --- a/backend/model.go +++ b/backend/model.go @@ -23,11 +23,17 @@ type ldapConn interface { } // LDAPBackend 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 { conn ldapConn userQuery *queryConfig userResourceQueries []*queryConfig resourceQueries map[string]*queryConfig + presenceQueries map[string]*queryConfig } const ldapPoolSize = 20 @@ -86,6 +92,33 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) { 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", + }), + }, }, nil } @@ -548,6 +581,41 @@ func parseResourceID(resourceID string) (string, string) { return parts[0], parts[1] } +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") + } + + // Make a quick LDAP search that only fetches the DN attribute. + result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{ + "resource": resourceName, + "type": resourceType, + }, []string{"dn"})) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return false, nil + } + return false, err + } + if len(result.Entries) == 0 { + return false, nil + } + return true, nil +} + +// HasAnyResource returns true if any of the specified resources exists. +func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) { + for _, resourceID := range resourceIDs { + has, err := b.hasResource(ctx, resourceID) + if err != nil || has { + return has, err + } + } + return false, nil +} + // 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) diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go index b9d116614171660d8df6a1e585c177c26be1bc3f..a1672b4bfdd0218d9c8ca04e9caf4f1c5ce79f8e 100644 --- a/cmd/accountserver/main.go +++ b/cmd/accountserver/main.go @@ -9,7 +9,6 @@ import ( "git.autistici.org/ai3/accountserver" "git.autistici.org/ai3/go-common/serverutil" - "git.autistici.org/id/go-sso" "gopkg.in/yaml.v2" "git.autistici.org/ai3/accountserver/backend" @@ -18,10 +17,10 @@ import ( var ( addr = flag.String("addr", ":4040", "tcp `address` to listen on") - configFile = flag.String("config", "/etc/authserver/config.yml", "configuration `file`") + configFile = flag.String("config", "/etc/accountserver/config.yml", "configuration `file`") ) -type Config struct { +type config struct { LDAP struct { URI string `yaml:"uri"` BindDN string `yaml:"bind_dn"` @@ -29,17 +28,11 @@ type Config struct { BindPwFile string `yaml:"bind_pw_file"` BaseDN string `yaml:"base_dn"` } `yaml:"ldap"` - SSO struct { - PublicKeyFile string `yaml:"public_key"` - Domain string `yaml:"domain"` - Service string `yaml:"service"` - Groups []string `yaml:"groups"` - AdminGroup string `yaml:"admin_group"` - } `yaml:"sso"` - ServerConfig *serverutil.ServerConfig `yaml:"http_server"` + AccountServerConfig *accountserver.Config `yaml:",inline"` + ServerConfig *serverutil.ServerConfig `yaml:"http_server"` } -func (c *Config) Validate() error { +func (c *config) validate() error { if c.LDAP.URI == "" { return errors.New("empty ldap.uri") } @@ -49,39 +42,39 @@ func (c *Config) Validate() error { if (c.LDAP.BindPwFile == "" && c.LDAP.BindPw == "") || (c.LDAP.BindPwFile != "" && c.LDAP.BindPw != "") { return errors.New("only one of ldap.bind_pw_file or ldap.bind_pw must be set") } - if c.SSO.PublicKeyFile == "" { + if c.AccountServerConfig.SSO.PublicKeyFile == "" { return errors.New("empty sso.public_key") } - if c.SSO.Domain == "" { + if c.AccountServerConfig.SSO.Domain == "" { return errors.New("empty sso.domain") } - if c.SSO.Service == "" { + if c.AccountServerConfig.SSO.Service == "" { return errors.New("empty sso.service") } return nil } -func loadConfig(path string) (*Config, error) { +func loadConfig(path string) (*config, error) { // Read YAML config. data, err := ioutil.ReadFile(path) if err != nil { return nil, err } - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { + var c config + if err := yaml.Unmarshal(data, &c); err != nil { return nil, err } - if err := config.Validate(); err != nil { + if err := c.validate(); err != nil { return nil, err } - return &config, nil + return &c, nil } -func getBindPw(config *Config) (string, error) { +func getBindPw(c *config) (string, error) { // Read the bind password. - bindPw := config.LDAP.BindPw - if config.LDAP.BindPwFile != "" { - pwData, err := ioutil.ReadFile(config.LDAP.BindPwFile) + bindPw := c.LDAP.BindPw + if c.LDAP.BindPwFile != "" { + pwData, err := ioutil.ReadFile(c.LDAP.BindPwFile) if err != nil { return "", err } @@ -91,14 +84,6 @@ func getBindPw(config *Config) (string, error) { return bindPw, nil } -func initSSO(config *Config) (sso.Validator, error) { - pkey, err := ioutil.ReadFile(config.SSO.PublicKeyFile) - if err != nil { - return nil, err - } - return sso.NewValidator(pkey, config.SSO.Domain) -} - func main() { log.SetFlags(0) flag.Parse() @@ -123,17 +108,10 @@ func main() { log.Fatal(err) } - validator, err := initSSO(config) + service, err := accountserver.NewAccountService(be, config.AccountServerConfig) if err != nil { log.Fatal(err) } - service := accountserver.NewAccountService( - be, - validator, - config.SSO.Service, - config.SSO.Groups, - config.SSO.AdminGroup, - ) as := server.New(service) diff --git a/config.go b/config.go new file mode 100644 index 0000000000000000000000000000000000000000..42d95f3a414c215e3e7e070398393b5b211e21f3 --- /dev/null +++ b/config.go @@ -0,0 +1,42 @@ +package accountserver + +import ( + "io/ioutil" + + "git.autistici.org/id/go-sso" +) + +type Config struct { + ForbiddenUsernames []string `yaml:"forbidden_usernames"` + AvailableDomains map[string][]string `yaml:"available_domains"` + + SSO struct { + PublicKeyFile string `yaml:"public_key"` + Domain string `yaml:"domain"` + Service string `yaml:"service"` + Groups []string `yaml:"groups"` + AdminGroup string `yaml:"admin_group"` + } `yaml:"sso"` +} + +func (c *Config) domainBackend() domainBackend { + b := &staticDomainBackend{sets: make(map[string]stringSet)} + for kind, list := range c.AvailableDomains { + b.sets[kind] = newStringSetFromList(list) + } + return b +} + +func (c *Config) validationConfig() *validationConfig { + return &validationConfig{ + forbiddenUsernames: newStringSetFromList(c.ForbiddenUsernames), + } +} + +func (c *Config) ssoValidator() (sso.Validator, error) { + pkey, err := ioutil.ReadFile(c.SSO.PublicKeyFile) + if err != nil { + return nil, err + } + return sso.NewValidator(pkey, c.SSO.Domain) +} diff --git a/validators.go b/validators.go new file mode 100644 index 0000000000000000000000000000000000000000..224b8d2a4b8ac201e15e00768365acf65641866b --- /dev/null +++ b/validators.go @@ -0,0 +1,277 @@ +package accountserver + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// 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 +} + +// The checkBackend verifies if specific resources already exist or are available. +type checkBackend interface { + HasAnyResource(context.Context, []string) (bool, error) +} + +type validationConfig struct { + forbiddenUsernames stringSet +} + +// A stringSet is just a list of strings with a quick membership test. +type stringSet struct { + set map[string]struct{} + list []string +} + +func newStringSetFromList(list []string) stringSet { + set := make(map[string]struct{}) + for _, s := range list { + set[s] = struct{}{} + } + return stringSet{set: set, list: list} +} + +func (s stringSet) Contains(needle string) bool { + _, ok := s.set[needle] + return ok +} + +func (s stringSet) List() []string { + return s.list +} + +// A domainBackend that works with a static list of type-specific allowed domains. +type staticDomainBackend struct { + sets map[string]stringSet +} + +func (d *staticDomainBackend) GetAvailableDomains(_ context.Context, kind string) []string { + return d.sets[kind].List() +} + +func (d *staticDomainBackend) IsAvailableDomain(_ context.Context, kind, domain string) bool { + return d.sets[kind].Contains(domain) +} + +func loadStringSetFromFile(path string) (stringSet, error) { + f, err := os.Open(path) + if err != nil { + return stringSet{}, err + } + defer f.Close() + + var list []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + list = append(list, line) + } + if err := scanner.Err(); err != nil { + return stringSet{}, err + } + return newStringSetFromList(list), nil +} + +// ValidatorFunc is the generic interface for unstructured data field +// (string) validators. +type ValidatorFunc func(context.Context, string) error + +func notInSet(set stringSet) ValidatorFunc { + return func(_ context.Context, value string) error { + if set.Contains(value) { + return errors.New("reserved value") + } + return nil + } +} + +func minLength(minLen int) ValidatorFunc { + return func(_ context.Context, value string) error { + if len(value) < minLen { + return fmt.Errorf("value must be at least %d characters", minLen) + } + return nil + } +} + +func maxLength(maxLen int) ValidatorFunc { + return func(_ context.Context, value string) error { + if len(value) > maxLen { + return fmt.Errorf("value must be at most %d characters", maxLen) + } + return nil + } +} + +func matchRegexp(rx *regexp.Regexp, errmsg string) ValidatorFunc { + return func(_ context.Context, value string) error { + if !rx.MatchString(value) { + return errors.New(errmsg) + } + return nil + } +} + +var usernameRx = regexp.MustCompile(`^([a-z0-9]+[.-]?)+[a-z0-9]$`) + +func matchUsernameRx() ValidatorFunc { + return matchRegexp(usernameRx, "value must be an alphanumeric sequence, including . and - characters but not starting or ending with those") +} + +func matchSitenameRx() ValidatorFunc { + return matchUsernameRx() +} + +func validateUsernameAndDomain(validateUsername, validateDomain ValidatorFunc) ValidatorFunc { + return func(ctx context.Context, value string) error { + parts := strings.SplitN(value, "@", 2) + if len(parts) != 2 { + return errors.New("malformed email address") + } + if err := validateUsername(ctx, parts[0]); err != nil { + return err + } + return validateDomain(ctx, parts[1]) + } +} + +// This is not a prescriptive domain name validation regex, it's just used to +// give useful feedback to the user if the domain name looks visibly wrong. +var domainRx = regexp.MustCompile(`^(?:[a-zA-Z0-9_\-]{1,63}\.)+(?:[a-zA-Z]{2,})$`) + +func isRegistered(domain string) bool { + // TODO: ha ha, there isn't really a good way to check. + return true +} + +func validDomainName(value string) error { + if !domainRx.MatchString(value) { + return errors.New("invalid domain name") + } + + // Extract the top-level domain and check that it is registered + // (which works also if we're hosting the top-level domain + // ourselves already). + domain, err := publicsuffix.EffectiveTLDPlusOne(value) + if err != nil { + return errors.New("invalid TLD") + } + if !isRegistered(domain) { + return fmt.Errorf("the domain %s does not seem to be registered", domain) + } + return nil +} + +// Split a (correctly formed) email address into username/domain. +func splitEmailAddr(addr string) (string, string) { + parts := strings.SplitN(addr, "@", 2) + return parts[0], parts[1] +} + +// 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)} + 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)) + } + return resourceIDs +} + +func splitSubsite(value string) (string, string) { + parts := strings.SplitN(value, "/", 2) + return parts[0], parts[1] +} + +func isSubsite(value string) bool { + return strings.Contains(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)) + } + } else { + resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s", ResourceTypeWebsite, value)) + } + return resourceIDs +} + +func isAvailableEmailHostingDomain(be domainBackend) ValidatorFunc { + return func(ctx context.Context, value string) error { + if !be.IsAvailableDomain(ctx, ResourceTypeEmail, value) { + return errors.New("unavailable domain") + } + return nil + } +} + +func isAvailableMailingListDomain(be domainBackend) ValidatorFunc { + return func(ctx context.Context, value string) error { + if !be.IsAvailableDomain(ctx, ResourceTypeMailingList, value) { + return errors.New("unavailable domain") + } + return nil + } +} + +func isAvailableEmailAddr(be domainBackend, cb checkBackend) ValidatorFunc { + return func(ctx context.Context, value string) error { + rel := relatedEmails(ctx, be, value) + if ok, _ := cb.HasAnyResource(ctx, rel); ok { + return errors.New("address unavailable") + } + return nil + } +} + +func validHostedEmail(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc { + return allOf( + validateUsernameAndDomain( + allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)), + allOf(isAvailableEmailHostingDomain(be)), + ), + isAvailableEmailAddr(be, cb), + ) +} + +func validHostedMailingList(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc { + return allOf( + validateUsernameAndDomain( + allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)), + allOf(isAvailableMailingListDomain(be)), + ), + isAvailableEmailAddr(be, cb), + ) +} + +func allOf(funcs ...ValidatorFunc) ValidatorFunc { + return func(ctx context.Context, value string) error { + for _, f := range funcs { + if err := f(ctx, value); err != nil { + return err + } + } + return nil + } +} diff --git a/validators_test.go b/validators_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6dbbae6be6f34ef6b2281cd7f78fd11bc8840efb --- /dev/null +++ b/validators_test.go @@ -0,0 +1,127 @@ +package accountserver + +import ( + "context" + "testing" +) + +type validationTestData struct { + value string + ok bool +} + +func runValidationTest(t testing.TB, v ValidatorFunc, testData []validationTestData) { + for _, td := range testData { + err := v(context.Background(), td.value) + if (err == nil && !td.ok) || (err != nil && td.ok) { + t.Errorf("test for '%s' failed: expected %v, got error %v", td.value, td.ok, err) + } + } +} + +func TestValidator_NotInSet(t *testing.T) { + td := []validationTestData{ + {"apple", true}, + {"pear", false}, + {"", true}, + } + runValidationTest(t, notInSet(newStringSetFromList([]string{"pear", "banana"})), td) +} + +func TestValidator_MinMaxLength(t *testing.T) { + td := []validationTestData{ + {"a", false}, + {"aaa", true}, + {"aaaaa", true}, + {"aaaaaaa", false}, + {"aaaaaaaaa", false}, + } + runValidationTest(t, allOf(minLength(2), maxLength(5)), td) +} + +func TestValidator_MatchUsername(t *testing.T) { + td := []validationTestData{ + {"a", false}, + {"aa", true}, + {"abc", true}, + {"123", true}, + {"a-b", true}, + {"a_b", false}, + {"a-b-c-d", true}, + {"-ab", false}, + {"ab-", false}, + {"a--b", false}, + {"...", false}, + {"a.b", true}, + {"a.-b", false}, + {"a-.-b", false}, + } + runValidationTest(t, matchUsernameRx(), td) +} + +type fakeCheckBackend map[string]struct{} + +func newFakeCheckBackend(rids ...string) fakeCheckBackend { + f := make(fakeCheckBackend) + for _, rid := range rids { + f[rid] = struct{}{} + } + return f +} + +func (f fakeCheckBackend) HasAnyResource(_ context.Context, ids []string) (bool, error) { + for _, id := range ids { + if _, ok := f[id]; ok { + return true, nil + } + } + return false, nil +} + +func newTestValidationConfig(entries ...string) *validationConfig { + return &validationConfig{ + forbiddenUsernames: newStringSetFromList(entries), + } +} + +func newFakeDomainBackend(domains ...string) domainBackend { + set := newStringSetFromList(domains) + return &staticDomainBackend{ + sets: map[string]stringSet{ + ResourceTypeEmail: set, + ResourceTypeMailingList: set, + ResourceTypeWebsite: set, + }, + } +} + +func TestValidator_HostedEmail(t *testing.T) { + td := []validationTestData{ + {"user1@example.com", true}, + {"user2@example.com", true}, + {".@example.com", false}, + {"user@other-domain.com", false}, + {"forbidden@example.com", false}, + {"existing@example.com", false}, + } + vc := newTestValidationConfig("forbidden") + cb := newFakeCheckBackend("email/existing@example.com") + db := newFakeDomainBackend("example.com") + runValidationTest(t, validHostedEmail(vc, db, cb), td) +} + +func TestValidator_HostedMailingList(t *testing.T) { + td := []validationTestData{ + {"list1@domain1.com", true}, + {"list2@domain2.com", true}, + {".@domain1.com", false}, + {"list@other-domain.com", false}, + {"forbidden@domain1.com", false}, + {"existing@domain1.com", false}, + {"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) +}