diff --git a/actions.go b/actions.go new file mode 100644 index 0000000000000000000000000000000000000000..ed2c61297c030a0f3a71910f67ccd39a0bae4fe1 --- /dev/null +++ b/actions.go @@ -0,0 +1,368 @@ +package accountserver + +import ( + "context" + "errors" + + "git.autistici.org/ai3/go-common/pwhash" + sso "git.autistici.org/id/go-sso" + "git.autistici.org/id/keystore/userenckey" +) + +// 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 + SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error + DeleteApplicationSpecificPassword(context.Context, *User, string) error +} + +// AccountService contains the business logic and functionality of the +// user accounts service. +type AccountService struct { + backend Backend + + validator sso.Validator + ssoService string + ssoGroups []string + ssoAdminGroup string +} + +func NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService { + return &AccountService{ + backend: backend, + validator: ssoValidator, + ssoService: ssoService, + ssoGroups: ssoGroups, + ssoAdminGroup: ssoAdminGroup, + } +} + +func (s *AccountService) isAdmin(tkt *sso.Ticket) bool { + for _, g := range tkt.Groups { + if g == s.ssoAdminGroup { + return true + } + } + return false +} + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrUserNotFound = errors.New("user not found") + ErrResourceNotFound = errors.New("resource not found") +) + +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, ErrUnauthorized + } + + // 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, ErrUnauthorized + } + + user, err := s.backend.GetUser(ctx, username) + if err != nil { + return nil, err + } + if user == nil { + return nil, ErrUserNotFound + } + return user, nil +} + +type RequestBase struct { + Username string `json:"username"` + SSO string `json:"sso"` +} + +type GetUserRequest struct { + RequestBase +} + +func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) { + return s.authorizeUser(ctx, req.Username, req.SSO) +} + +var errResourceNotFound = errors.New("resource not found") + +func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error { + r, err := s.backend.GetResource(ctx, username, resourceID) + if err != nil { + return errResourceNotFound + } + r.Status = status + return s.backend.UpdateResource(ctx, username, r) +} + +type DisableResourceRequest struct { + RequestBase + ResourceID string `json:"resource_id"` +} + +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) +} + +type EnableResourceRequest struct { + RequestBase + ResourceID string `json:"resource_id"` +} + +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) +} + +type ChangeUserPasswordRequest struct { + RequestBase + OldPassword string `json:"old_password"` + Password string `json:"password"` +} + +func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error { + user, err := s.authorizeUser(ctx, req.Username, req.SSO) + if err != nil { + return err + } + + if err := s.updateUserEncryptionKeys(ctx, user, req.OldPassword, req.Password, UserEncryptionKeyMainID); err != nil { + return err + } + + // 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 err + } + for _, r := range user.GetResourcesByType(ResourceTypeEmail) { + if err := s.backend.SetResourcePassword(ctx, user.Name, r, encPass); err != nil { + return err + } + } + + return nil +} + +func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, user *User, curPassword, newPassword, keyID string) error { + // Re-encrypt the user encryption key with the new password. + keys, err := s.backend.GetUserEncryptionKeys(ctx, user) + if err != nil { + return err + } + + keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID) + if err != nil { + return err + } + return s.backend.SetUserEncryptionKeys(ctx, user, keys) +} + +// 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 + } + + 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 +} + +type CreateApplicationSpecificPasswordRequest struct { + RequestBase + Password string `json:"password"` // User password + Service string `json:"service"` + Comment string `json:"comment"` +} + +type CreateApplicationSpecificPasswordResponse struct { + Password string `json:"password"` +} + +func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) { + user, err := s.authorizeUser(ctx, req.Username, req.SSO) + if err != nil { + return nil, 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.Comment, + } + password := randomAppSpecificPassword() + encPass := pwhash.Encrypt(password) + if err := s.backend.SetApplicationSpecificPassword(ctx, user, asp, encPass); 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. + keyID := "asp_" + asp.ID + if err := s.updateUserEncryptionKeys(ctx, user, req.Password, password, keyID); err != nil { + return nil, err + } + + return &CreateApplicationSpecificPasswordResponse{ + Password: password, + }, nil +} + +type DeleteApplicationSpecificPasswordRequest struct { + RequestBase + AspID string `json:"asp_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) + } + } + return s.backend.SetUserEncryptionKeys(ctx, user, newKeys) +} + +type ChangeResourcePasswordRequest struct { + RequestBase + ResourceID string `json:"resource_id"` + Password string `json:"password"` +} + +func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error { + _, err := s.authorizeUser(ctx, req.Username, req.SSO) + if err != nil { + return err + } + + r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID) + if err != nil { + return err + } + + encPass := pwhash.Encrypt(req.Password) + return s.backend.SetResourcePassword(ctx, req.Username, r, encPass) +} + +type MoveResourceRequest struct { + RequestBase + ResourceID string `json:"resource_id"` + Shard string `json:"shard"` +} + +type MoveResourceResponse struct { + MovedIDs []string `json:"moved_ids"` +} + +func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) { + user, err := s.authorizeUser(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 != "" { + for _, r := range user.GetResourcesByGroup(r.Group) { + resources = append(resources, r) + } + } else { + resources = []*Resource{r} + } + + 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 + } + resp.MovedIDs = append(resp.MovedIDs, r.ID) + } + + return &resp, nil +} + +func randomAppSpecificPassword() string { + return "haha" +} + +func randomAppSpecificPasswordID() string { + return "1234" +} diff --git a/backend/composite_values.go b/backend/composite_values.go new file mode 100644 index 0000000000000000000000000000000000000000..e6287f54d3bca02e9309a0165221210ddaa151c0 --- /dev/null +++ b/backend/composite_values.go @@ -0,0 +1,65 @@ +package backend + +import ( + "errors" + "strings" + + "git.autistici.org/ai3/accountserver" +) + +type appSpecificPassword struct { + accountserver.AppSpecificPasswordInfo + Password string +} + +func (p *appSpecificPassword) Encode() string { + return strings.Join([]string{ + p.Service, + p.Password, + p.Comment, + }, ":") +} + +func newAppSpecificPassword(info accountserver.AppSpecificPasswordInfo, pw string) *appSpecificPassword { + return &appSpecificPassword{ + AppSpecificPasswordInfo: info, + Password: pw, + } +} + +func parseAppSpecificPassword(asp string) (*appSpecificPassword, error) { + parts := strings.Split(asp, ":") + if len(parts) != 3 { + return nil, errors.New("badly encoded app-specific password") + } + return newAppSpecificPassword(accountserver.AppSpecificPasswordInfo{ + Service: parts[0], + Comment: parts[2], + }, parts[1]), nil +} + +func decodeAppSpecificPasswords(values []string) []*appSpecificPassword { + var out []*appSpecificPassword + for _, value := range values { + if asp, err := parseAppSpecificPassword(value); err == nil { + out = append(out, asp) + } + } + return out +} + +func encodeAppSpecificPasswords(asps []*appSpecificPassword) []string { + var out []string + for _, asp := range asps { + out = append(out, asp.Encode()) + } + return out +} + +func getASPInfo(asps []*appSpecificPassword) []*accountserver.AppSpecificPasswordInfo { + var out []*accountserver.AppSpecificPasswordInfo + for _, asp := range asps { + out = append(out, &asp.AppSpecificPasswordInfo) + } + return out +} diff --git a/backend/model.go b/backend/model.go index 793186e718d95760603d0ed35a0d2ccfb0154b7e..8baf77aab515886aaa7012f4b2832baf9e13f982 100644 --- a/backend/model.go +++ b/backend/model.go @@ -30,9 +30,16 @@ type LDAPBackend struct { resourceQueries map[string]*queryConfig } +const ldapPoolSize = 20 + // NewLDAPBackend initializes an LDAPBackend object with the given LDAP // connection pool. -func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend { +func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) { + pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize) + if err != nil { + return nil, err + } + return &LDAPBackend{ conn: pool, userQuery: mustCompileQueryConfig(&queryConfig{ @@ -79,7 +86,7 @@ func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend { Scope: "one", }), }, - } + }, nil } func replaceVars(s string, vars map[string]string) string { @@ -320,6 +327,10 @@ func databaseResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { } } +type ldapUserData struct { + dn string +} + func newUser(entry *ldap.Entry) (*accountserver.User, error) { user := &accountserver.User{ Name: entry.GetAttributeValue("uid"), @@ -330,9 +341,18 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) { 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 +} + // GetUser returns a user. func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountserver.User, error) { // First of all, find the main user object, and just that one. @@ -366,7 +386,7 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser // them on the main User object. if isObjectClass(entry, "virtualMailUser") { user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion") - setAppSpecificPasswords(user, entry.GetAttributeValues("appSpecificPassword")) + user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword"))) } // Parse the resource and add it to the User. if r, err := parseLdapResource(entry); err == nil { @@ -380,6 +400,122 @@ 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 + } + 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) +} + +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 (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 (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)) + } + return b.conn.Modify(ctx, mod) +} + +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 + for _, asp := range asps { + if asp.ID != info.ID { + outASPs = append(outASPs, 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) +} + +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) + + asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword")) + var outASPs []*appSpecificPassword + for _, asp := range asps { + if asp.ID != id { + outASPs = append(outASPs, asp) + } + } + + 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)) + } + return b.conn.Modify(ctx, mod) +} + +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 parseResourceID(resourceID string) (string, string) { parts := strings.SplitN(resourceID, "/", 2) return parts[0], parts[1] @@ -405,22 +541,12 @@ func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID stri return nil, err } - r, err := parseLdapResource(result.Entries[0]) - if err != nil { - return nil, err - } - - r.SetBackendHandle(&ldapObjectData{ - dn: result.Entries[0].DN, - original: r.Copy(), - }) - - return r, nil + return parseLdapResource(result.Entries[0]) } // UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call. -func (b *LDAPBackend) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error { - lo, ok := r.GetBackendHandle().(*ldapObjectData) +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") } @@ -438,20 +564,37 @@ type ldapObjectData struct { original *accountserver.Resource } -func parseLdapResource(entry *ldap.Entry) (*accountserver.Resource, error) { +func getResourceDN(r *accountserver.Resource) string { + lo, ok := r.Opaque.(*ldapObjectData) + if !ok { + panic("no ldap resource data") + } + return lo.dn +} + +func parseLdapResource(entry *ldap.Entry) (r *accountserver.Resource, err error) { switch { case isObjectClass(entry, "virtualMailUser"): - return newEmailResource(entry) + r, err = newEmailResource(entry) case isObjectClass(entry, "ftpAccount"): - return newWebDAVResource(entry) + r, err = newWebDAVResource(entry) case isObjectClass(entry, "mailingList"): - return newMailingListResource(entry) + r, err = newMailingListResource(entry) case isObjectClass(entry, "dbMysql"): - return newDatabaseResource(entry) + r, err = newDatabaseResource(entry) case isObjectClass(entry, "subSite") || isObjectClass(entry, "virtualHost"): - return newWebsiteResource(entry) + r, err = newWebsiteResource(entry) + default: + return nil, errors.New("unknown LDAP resource") + } + if err != nil { + return } - return nil, errors.New("unknown LDAP resource") + r.Opaque = &ldapObjectData{ + dn: entry.DN, + original: r.Copy(), + } + return } func isObjectClass(entry *ldap.Entry, class string) bool { @@ -464,25 +607,6 @@ func isObjectClass(entry *ldap.Entry, class string) bool { return false } -func parseAppSpecificPassword(asp string) (*accountserver.AppSpecificPasswordInfo, error) { - parts := strings.Split(asp, ":") - if len(parts) != 3 { - return nil, errors.New("badly encoded app-specific password") - } - return &accountserver.AppSpecificPasswordInfo{ - Service: parts[0], - Comment: parts[2], - }, nil -} - -func setAppSpecificPasswords(user *accountserver.User, asps []string) { - for _, asp := range asps { - if ainfo, err := parseAppSpecificPassword(asp); err == nil { - user.AppSpecificPasswords = append(user.AppSpecificPasswords, ainfo) - } - } -} - var siteRoot = "/home/users/investici.org/" // The hosting directory for a website is the path component immediately after diff --git a/backend/model_test.go b/backend/model_test.go index 664170428cc039345083631a35d241277a44870f..da98a09cd65249b8c66449f3454e34732b43b90e 100644 --- a/backend/model_test.go +++ b/backend/model_test.go @@ -8,6 +8,15 @@ import ( ldap "gopkg.in/ldap.v2" ) +// 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 TestEmailResource_FromLDAP(t *testing.T) { entry := ldap.NewEntry( "mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy", @@ -39,7 +48,7 @@ func TestEmailResource_FromLDAP(t *testing.T) { Maildir: "test/store", }, } - if !reflect.DeepEqual(r, expected) { + if !resourcesEqual(r, expected) { t.Fatalf("bad result: got %+v, expected %+v", r, expected) } } diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go index 9b7fef416a57451c94783ce2f2cccd1a0094d65e..b9d116614171660d8df6a1e585c177c26be1bc3f 100644 --- a/cmd/accountserver/main.go +++ b/cmd/accountserver/main.go @@ -7,9 +7,10 @@ import ( "log" "strings" - ldaputil "git.autistici.org/ai3/go-common/ldap" + "git.autistici.org/ai3/accountserver" "git.autistici.org/ai3/go-common/serverutil" - "gopkg.in/yaml.v1" + "git.autistici.org/id/go-sso" + "gopkg.in/yaml.v2" "git.autistici.org/ai3/accountserver/backend" "git.autistici.org/ai3/accountserver/server" @@ -28,6 +29,13 @@ 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"` } @@ -41,6 +49,15 @@ 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 == "" { + return errors.New("empty sso.public_key") + } + if c.SSO.Domain == "" { + return errors.New("empty sso.domain") + } + if c.SSO.Service == "" { + return errors.New("empty sso.service") + } return nil } @@ -60,18 +77,26 @@ func loadConfig(path string) (*Config, error) { return &config, nil } -func connectLDAP(config *Config) (*ldaputil.ConnectionPool, error) { +func getBindPw(config *Config) (string, error) { // Read the bind password. bindPw := config.LDAP.BindPw if config.LDAP.BindPwFile != "" { pwData, err := ioutil.ReadFile(config.LDAP.BindPwFile) if err != nil { - return nil, err + return "", err } bindPw = strings.TrimSpace(string(pwData)) } - return ldaputil.NewConnectionPool(config.LDAP.URI, config.LDAP.BindDN, bindPw, 5) + 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() { @@ -83,13 +108,34 @@ func main() { log.Fatal(err) } - pool, err := connectLDAP(config) + bindPw, err := getBindPw(config) + if err != nil { + log.Fatal(err) + } + + be, err := backend.NewLDAPBackend( + config.LDAP.URI, + config.LDAP.BindDN, + bindPw, + config.LDAP.BaseDN, + ) + if err != nil { + log.Fatal(err) + } + + validator, err := initSSO(config) if err != nil { log.Fatal(err) } + service := accountserver.NewAccountService( + be, + validator, + config.SSO.Service, + config.SSO.Groups, + config.SSO.AdminGroup, + ) - be := backend.NewLDAPBackend(pool, config.LDAP.BaseDN) - as := server.New(be) + as := server.New(service) if err := serverutil.Serve(as.Handler(), config.ServerConfig, *addr); err != nil { log.Fatal(err) diff --git a/server/server.go b/server/server.go index 66eb927f9635fe8d77c5d27a5810203016af48bb..643482700b48e34abed6893b4b0c9d7f7ac58619 100644 --- a/server/server.go +++ b/server/server.go @@ -1,88 +1,167 @@ package server import ( - "context" "log" "net/http" - "git.autistici.org/ai/go-sso" "git.autistici.org/ai3/go-common/serverutil" - "git.autistici.org/ai3/accountserver" + as "git.autistici.org/ai3/accountserver" ) -type Backend interface { - GetUser(context.Context, string) (*accountserver.User, error) - //GetResource(context.Context, string, string) (*accountserver.Resource, error) +type AccountServer struct { + service *as.AccountService } -type AccountServer struct { - backend Backend - validator sso.Validator +func New(service *as.AccountService) *AccountServer { + return &AccountServer{service} +} + +var emptyResponse = map[string]string{} + +func errToStatus(err error) int { + switch err { + case as.ErrUserNotFound, as.ErrResourceNotFound: + return http.StatusNotFound + case as.ErrUnauthorized: + return http.StatusUnauthorized + default: + return http.StatusInternalServerError + } } -func New(backend Backend, ssoPublicKey []byte, ssoDomain string) (*AccountServer, error) { - v, err := sso.NewValidator(ssoPublicKey, ssoDomain) +func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) { + var req as.GetUserRequest + if !serverutil.DecodeJSONRequest(w, r, &req) { + return + } + + user, err := s.service.GetUser(r.Context(), &req) if err != nil { - return nil, err + log.Printf("GetUser(%s): error: %v", req.Username, err) + http.Error(w, err.Error(), errToStatus(err)) + return } - return &AccountServer{ - backend: backend, - validator: v, - }, nil + + serverutil.EncodeJSONResponse(w, user) } -var adminGroup = "admins" +func (s *AccountServer) handleChangeUserPassword(w http.ResponseWriter, r *http.Request) { + var req as.ChangeUserPasswordRequest + if !serverutil.DecodeJSONRequest(w, r, &req) { + return + } -func isAdmin(tkt *sso.Ticket) bool { - for _, g := range tkt.Groups { - if g == adminGroup { - return true - } + 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 } - return false + + serverutil.EncodeJSONResponse(w, emptyResponse) } -func (s *AccountServer) authorize(w http.ResponseWriter, ssoToken, username string) bool { - tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups) +func (s *AccountServer) handleCreateApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) { + var req as.CreateApplicationSpecificPasswordRequest + if !serverutil.DecodeJSONRequest(w, r, &req) { + return + } + + resp, err := s.service.CreateApplicationSpecificPassword(r.Context(), &req) if err != nil { - log.Printf("authentication error: %v", err) - http.Error(w, err.Error(), http.StatusUnauthorized) - return false + log.Printf("CreateApplicationSpecificPassword(%s): error: %v", req.Username, err) + http.Error(w, err.Error(), errToStatus(err)) + return + } + + serverutil.EncodeJSONResponse(w, resp) +} + +func (s *AccountServer) handleDeleteApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) { + var req as.DeleteApplicationSpecificPasswordRequest + if !serverutil.DecodeJSONRequest(w, r, &req) { + return } - // Requests are allowed if the SSO ticket corresponds to an admin, or if - // it identifies the same user that we're querying. - if !isAdmin(tkt) && tkt.User != username { - log.Printf("unauthorized access to %s from %s", username, tkt.User) - http.Error(w, err.Error(), http.StatusUnauthorized) - return false + 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 } - return true + serverutil.EncodeJSONResponse(w, emptyResponse) } -func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) { - var req accountserver.GetUserRequest +func (s *AccountServer) handleEnableResource(w http.ResponseWriter, r *http.Request) { + var req as.EnableResourceRequest if !serverutil.DecodeJSONRequest(w, r, &req) { return } - if !s.authorize(w, req.SSO, req.Username) { + + 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 } - user, err := s.backend.GetUser(r.Context(), req.Username) + serverutil.EncodeJSONResponse(w, emptyResponse) +} + +func (s *AccountServer) handleDisableResource(w http.ResponseWriter, r *http.Request) { + 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) +} + +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) handleMoveResource(w http.ResponseWriter, r *http.Request) { + var req as.MoveResourceRequest + if !serverutil.DecodeJSONRequest(w, r, &req) { + return + } + + resp, err := s.service.MoveResource(r.Context(), &req) if err != nil { - log.Printf("GetUser(%s): error: %v", req.Username, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + log.Printf("MoveResource(%s): error: %v", req.Username, err) + http.Error(w, err.Error(), errToStatus(err)) return } - serverutil.EncodeJSONResponse(w, &accountserver.GetUserResponse{User: user}) + serverutil.EncodeJSONResponse(w, resp) } func (s *AccountServer) Handler() http.Handler { h := http.NewServeMux() - h.HandleFunc("/api/get_user", s.handleGetUser) + h.HandleFunc("/api/user/get", s.handleGetUser) + h.HandleFunc("/api/user/change_password", s.handleChangeUserPassword) + 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 } diff --git a/types.go b/types.go index 01211a6f3ea668e7c890b353e7b1a6d217cd8cc9..9b132dccb724d0e6273dede381fa19c499b9e425 100644 --- a/types.go +++ b/types.go @@ -1,6 +1,10 @@ package accountserver -import "time" +import ( + "fmt" + "strings" + "time" +) const ( StatusActive = "active" @@ -27,22 +31,89 @@ type User struct { // Preferred language. Lang string `json:"lang"` - // Whether 2FA is enabled. Has2FA bool `json:"has_2fa"` + HasEncryptionKeys bool `json:"has_encryption_keys"` PasswordRecoveryHint string `json:"password_recovery_hint"` AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"` Resources []*Resource `json:"resources,omitempty"` + + Opaque interface{} +} + +func (u *User) GetResourcesByType(resourceType string) []*Resource { + var out []*Resource + for _, r := range u.Resources { + if r.Type == resourceType { + out = append(out, r) + } + } + return out +} + +func (u *User) GetSingleResourceByType(resourceType string) *Resource { + for _, r := range u.Resources { + if r.Type == resourceType { + return r + } + } + return nil +} + +func (u *User) GetResourcesByGroup(group string) []*Resource { + var out []*Resource + for _, r := range u.Resources { + if r.Group == group { + out = append(out, r) + } + } + return out } // AppSpecificPasswordInfo stores public information about an // app-specific password. type AppSpecificPasswordInfo struct { + ID string `json:"id"` Service string `json:"service"` Comment string `json:"comment"` } +const ( + UserEncryptionKeyMainID = "main" + UserEncryptionKeyRecoveryID = "recovery" +) + +// UserEncryptionKey stores a password-encrypted secret key for the +// user's encrypted storage. +type UserEncryptionKey struct { + ID string `json:"id"` + 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 +} + const ( ResourceTypeEmail = "email" ResourceTypeMailingList = "list" @@ -51,6 +122,11 @@ const ( ResourceTypeDatabase = "db" ) +const ( + ResourceStatusActive = "active" + ResourceStatusInactive = "inactive" +) + // 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 @@ -78,7 +154,8 @@ type Resource struct { OriginalShard string `json:"original_shard,omitempty"` // Resources can be 'grouped' together, for various reasons - // (display purposes, service integrity). Group names can be + // (display purposes, service integrity). All resources in the + // same group should have the same Shard. Group names can be // arbitrary strings. Group string `json:"group,omitempty"` @@ -93,19 +170,7 @@ type Resource struct { // 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{} -} - -// SetBackendHandle associates some backend-specific data at runtime -// with this resource. -func (r *Resource) SetBackendHandle(h interface{}) { - r.opaque = h -} - -// GetBackendHandle returns the backend-specific data associated with -// the resource. -func (r *Resource) GetBackendHandle() interface{} { - return r.opaque + Opaque interface{} `json:"-"` } // Copy the resource (makes a deep copy). @@ -191,14 +256,3 @@ type Blog struct { Name string `json:"name"` URL string `json:"url"` } - -// RPC requests. - -type GetUserRequest struct { - SSO string `json:"sso"` - Username string `json:"username"` -} - -type GetUserResponse struct { - User *User `json:"user"` -}