diff --git a/backend/model.go b/backend/model.go index 1c53ba583a9e086746077383649cfd0b755e4681..e8947eaba4c0b89f3d9100da7ccc074ff8143cc0 100644 --- a/backend/model.go +++ b/backend/model.go @@ -3,7 +3,6 @@ package backend import ( "context" "errors" - "fmt" "os" "strings" @@ -30,6 +29,7 @@ type ldapConn interface { // all users. type backend struct { conn ldapConn + baseDN string userQuery *queryConfig userResourceQueries []*queryConfig resources *resourceRegistry @@ -71,7 +71,8 @@ func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) { rsrc.register(accountserver.ResourceTypeDatabase, &databaseResourceHandler{baseDN: base}) return &backend{ - conn: conn, + conn: conn, + baseDN: base, userQuery: mustCompileQueryConfig(&queryConfig{ Base: "uid=${user},ou=People," + base, Scope: "base", @@ -186,9 +187,8 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) { return user, nil } -func getUserDN(user *accountserver.User) string { - // TODO: fix this - return fmt.Sprintf("uid=%s,ou=People,", user.Name) +func (tx *backendTX) getUserDN(user *accountserver.User) string { + return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN) } // GetUser returns a user. @@ -266,23 +266,23 @@ func (tx *backendTX) readAttributeValues(ctx context.Context, dn, attribute stri } func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error { - tx.setAttr(getUserDN(user), "userPassword", encryptedPassword) + tx.setAttr(tx.getUserDN(user), "userPassword", encryptedPassword) return nil } func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) { - rawKeys := tx.readAttributeValues(ctx, getUserDN(user), "storageEncryptionKey") + rawKeys := tx.readAttributeValues(ctx, tx.getUserDN(user), "storageEncryptionKey") return decodeUserEncryptionKeys(rawKeys), nil } func (tx *backendTX) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error { encKeys := encodeUserEncryptionKeys(keys) - tx.setAttr(getUserDN(user), "storageEncryptionKey", encKeys...) + tx.setAttr(tx.getUserDN(user), "storageEncryptionKey", encKeys...) return nil } func (tx *backendTX) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error { - tx.setAttr(getUserDN(user), "storageEncryptionPublicKey", string(pub)) + tx.setAttr(tx.getUserDN(user), "storageEncryptionPublicKey", string(pub)) return nil } @@ -328,12 +328,12 @@ func (tx *backendTX) DeleteApplicationSpecificPassword(ctx context.Context, user } func (tx *backendTX) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error { - tx.setAttr(getUserDN(user), "totpSecret", secret) + tx.setAttr(tx.getUserDN(user), "totpSecret", secret) return nil } func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error { - tx.setAttr(getUserDN(user), "totpSecret") + tx.setAttr(tx.getUserDN(user), "totpSecret") return nil } diff --git a/integrationtest/integration_test.go b/integrationtest/integration_test.go new file mode 100644 index 0000000000000000000000000000000000000000..131021f034e35448acc5239c36810f11211b14d6 --- /dev/null +++ b/integrationtest/integration_test.go @@ -0,0 +1,194 @@ +package integrationtest + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "git.autistici.org/ai3/accountserver" + "git.autistici.org/ai3/accountserver/backend" + "git.autistici.org/ai3/accountserver/ldaptest" + "git.autistici.org/ai3/accountserver/server" + sso "git.autistici.org/id/go-sso" + "golang.org/x/crypto/ed25519" +) + +const ( + testLDAPPort = 42872 + testLDAPAddr = "ldap://127.0.0.1:42872" + + testSSODomain = "domain" + testSSOService = "accountserver.domain/" + testAdminUser = "admin" + testAdminGroup = "admins" +) + +func withSSO(t testing.TB) (func(), sso.Signer, string) { + tmpf, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + tmpf.Write(pub) + tmpf.Close() + + signer, err := sso.NewSigner(priv) + if err != nil { + t.Fatal(err) + } + + return func() { + os.Remove(tmpf.Name()) + }, signer, tmpf.Name() +} + +type testClient struct { + srvURL string + signer sso.Signer +} + +func (c *testClient) ssoTicket(username string) string { + var groups []string + if username == testAdminUser { + groups = append(groups, testAdminGroup) + } + signed, err := c.signer.Sign(sso.NewTicket(username, testSSOService, testSSODomain, "", groups, 1*time.Hour)) + if err != nil { + panic(err) + } + return signed +} + +func (c *testClient) request(uri string, req, out interface{}) error { + data, _ := json.Marshal(req) + resp, err := http.Post(c.srvURL+uri, "application/json", bytes.NewReader(data)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("http status code %d", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != "application/json" { + return fmt.Errorf("unexpected content-type %s", resp.Header.Get("Content-Type")) + } + data, _ = ioutil.ReadAll(resp.Body) + + log.Printf("response:\n%s\n", string(data)) + + if out == nil { + return nil + } + return json.Unmarshal(data, out) +} + +func startService(t testing.TB) (func(), *testClient) { + stop := ldaptest.StartServer(t, &ldaptest.Config{ + Dir: "../ldaptest", + Port: testLDAPPort, + Base: "dc=example,dc=com", + LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"}, + }) + + be, err := backend.NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com") + if err != nil { + t.Fatal("NewLDAPBackend", err) + } + + ssoStop, signer, ssoPubKeyFile := withSSO(t) + + var svcConfig accountserver.Config + svcConfig.SSO.PublicKeyFile = ssoPubKeyFile + svcConfig.SSO.Domain = testSSODomain + svcConfig.SSO.Service = testSSOService + svcConfig.SSO.AdminGroup = testAdminGroup + svcConfig.AvailableDomains = map[string][]string{ + accountserver.ResourceTypeEmail: []string{"example.com"}, + } + + service, err := accountserver.NewAccountService(be, &svcConfig) + if err != nil { + stop() + t.Fatal("NewAccountService", err) + } + + as := server.New(service, be) + srv := httptest.NewServer(as.Handler()) + + c := &testClient{ + srvURL: srv.URL, + signer: signer, + } + + return func() { + stop() + srv.Close() + ssoStop() + }, c +} + +// Verify that authentication on GetUser works as expected: +// - users can fetch their own data but not other users' +// - admins can read everything. +func TestIntegration_GetUser_Auth(t *testing.T) { + stop, c := startService(t) + defer stop() + + testdata := []struct { + authUser string + expectedOk bool + }{ + {"uno@investici.org", true}, + {"due@investici.org", false}, + {testAdminUser, true}, + } + + for _, td := range testdata { + var user accountserver.User + err := c.request("/api/user/get", &accountserver.GetUserRequest{ + RequestBase: accountserver.RequestBase{ + Username: "uno@investici.org", + SSO: c.ssoTicket(td.authUser), + }, + }, &user) + if td.expectedOk && err != nil { + t.Errorf("access error for user %s: expected ok, got error: %v", td.authUser, err) + } else if !td.expectedOk && err == nil { + t.Errorf("access error for user %s: expected error, got ok", td.authUser) + } + } +} + +// Verify that authentication on GetResource works as expected: +// - users can fetch their own data but not other users' +// - admins can read everything. +func TestIntegration_ChangeUserPassword(t *testing.T) { + stop, c := startService(t) + defer stop() + + err := c.request("/api/user/change_password", &accountserver.ChangeUserPasswordRequest{ + PrivilegedRequestBase: accountserver.PrivilegedRequestBase{ + RequestBase: accountserver.RequestBase{ + Username: "uno@investici.org", + SSO: c.ssoTicket("uno@investici.org"), + }, + CurPassword: "password", + }, + Password: "new_password", + }, nil) + + if err != nil { + t.Fatal("ChangePassword", err) + } +} diff --git a/integrationtest/testdata/base.ldif b/integrationtest/testdata/base.ldif new file mode 100644 index 0000000000000000000000000000000000000000..fcbc2849fa34661c06ccd104a91052e2fa326dde --- /dev/null +++ b/integrationtest/testdata/base.ldif @@ -0,0 +1,16 @@ + +dn: dc=example,dc=com +objectclass: domain +objectclass: top +dc: example + +dn: ou=People,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: People + +dn: ou=Lists,dc=example,dc=com +objectclass: top +objectclass: organizationalUnit +ou: Lists + diff --git a/integrationtest/testdata/test1.ldif b/integrationtest/testdata/test1.ldif new file mode 100644 index 0000000000000000000000000000000000000000..9bf6880683b247b165f7b48ccd62866470c24af8 --- /dev/null +++ b/integrationtest/testdata/test1.ldif @@ -0,0 +1,106 @@ +dn: uid=uno@investici.org,ou=People,dc=example,dc=com +cn: uno@investici.org +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +loginShell: /bin/false +uidNumber: 19475 +shadowMax: 99999 +gidNumber: 2000 +gecos: uno@investici.org +sn: Private +homeDirectory: /var/empty +uid: uno@investici.org +givenName: Private +shadowLastChange: 12345 +shadowWarning: 7 +preferredLanguage: it +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= + +dn: mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +recoverQuestion:: dGkgc2VpIG1haSDDuMOgdHRvIG1hbGUgY2FkZW5kbyBkYSB1biBwYWxhenpv + IGRpIG90dG8gcGlhbmk/ +objectClass: top +objectClass: virtualMailUser +userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ + FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5 + WEtkeDV0QTE= +uidNumber: 19475 +host: host2 +mailAlternateAddress: uno@anche.no +recoverAnswer: {crypt}$1$wtEa4TKB$lxeyenkQ1yfxECn7WVQQ0/ +gidNumber: 2000 +mail: uno@investici.org +creationDate: 2002-05-07 +mailMessageStore: investici.org/uno/ +originalHost: host2 + +dn: ftpname=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +givenName: Private +cn: uno +objectClass: top +objectClass: person +objectClass: posixAccount +objectClass: shadowAccount +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: ftpAccount +loginShell: /bin/false +shadowWarning: 7 +uidNumber: 19475 +host: host2 +shadowMax: 99999 +ftpname: uno +gidNumber: 33 +gecos: FTP Account for uno@investici.org +sn: Private +homeDirectory: /home/users/investici.org/uno +uid: uno +creationDate: 01-08-2013 +shadowLastChange: 12345 +originalHost: host2 +userPassword:: e2NyeXB0fSQ2JElDYkx1WTI3QWl6bC5FeEgkUDhOZHJ3VEtxZ2UwQUp3QW9oNE1 + EYlUxU3EySGtuRkF1cEx2RUI0U28waEw5NWtpZ3dIeXQuQnYxS0J5SFM2MXd6RnZuLnJsMEN4eFpx + RVgzUnVxbDE= + +dn: alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +parentSite: autistici.org +objectClass: top +objectClass: subSite +alias: uno +host: host2 +documentRoot: /home/users/investici.org/uno/html-uno +creationDate: 01-08-2013 +originalHost: host2 +statsId: 2191 + +dn: cn=example.com,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +acceptMail: true +objectClass: top +objectClass: virtualHost +cn: example.com +host: host2 +documentRoot: /home/users/investici.org/uno/html-example.com +creationDate: 02-08-2013 +originalHost: host2 +statsId: 2192 + +dn: dbname=unodb,alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com +status: active +clearPassword: password +objectClass: top +objectClass: dbMysql +dbname: unodb +dbuser: unodb +host: host2 +creationDate: 01-08-2013 +originalHost: host2 diff --git a/server/server.go b/server/server.go index 0d84166800d70e10db72e176b5d2b99dcd7b4fee..eff6995eed45fc1cf99b7d0550a86e7ea4e6e187 100644 --- a/server/server.go +++ b/server/server.go @@ -10,7 +10,7 @@ import ( as "git.autistici.org/ai3/accountserver" ) -type txHandler func(as.TX, http.ResponseWriter, *http.Request) error +type txHandler func(as.TX, http.ResponseWriter, *http.Request) (interface{}, error) var errBadRequest = errors.New("bad request") @@ -26,7 +26,7 @@ func New(service *as.AccountService, backend as.Backend) *AccountServer { } } -var emptyResponse = map[string]string{} +var emptyResponse struct{} func errToStatus(err error) int { switch { @@ -41,168 +41,158 @@ func errToStatus(err error) int { } } -func (s *AccountServer) handleGetUser(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleGetUser(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.GetUserRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } user, err := s.service.GetUser(r.Context(), tx, &req) if err != nil { log.Printf("GetUser(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, user) - return nil + return user, nil } -func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.ChangeUserPasswordRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.ChangeUserPassword(r.Context(), tx, &req); err != nil { log.Printf("ChangeUserPassword(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } -func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.CreateApplicationSpecificPasswordRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } resp, err := s.service.CreateApplicationSpecificPassword(r.Context(), tx, &req) if err != nil { log.Printf("CreateApplicationSpecificPassword(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, resp) - return nil + return resp, nil } -func (s *AccountServer) handleDeleteApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleDeleteApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.DeleteApplicationSpecificPasswordRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.DeleteApplicationSpecificPassword(r.Context(), tx, &req); err != nil { log.Printf("DeleteApplicationSpecificPassword(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } -func (s *AccountServer) handleEnableResource(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleEnableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.EnableResourceRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.EnableResource(r.Context(), tx, &req); err != nil { log.Printf("EnableResource(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } -func (s *AccountServer) handleDisableResource(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleDisableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.DisableResourceRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.DisableResource(r.Context(), tx, &req); err != nil { log.Printf("DisableResource(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } -func (s *AccountServer) handleChangeResourcePassword(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleChangeResourcePassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.ChangeResourcePasswordRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.ChangeResourcePassword(r.Context(), tx, &req); err != nil { log.Printf("ChangeResourcePassword(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } -func (s *AccountServer) handleMoveResource(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleMoveResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.MoveResourceRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } resp, err := s.service.MoveResource(r.Context(), tx, &req) if err != nil { log.Printf("MoveResource(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, resp) - return nil + return resp, nil } -func (s *AccountServer) handleEnableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleEnableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.EnableOTPRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } resp, err := s.service.EnableOTP(r.Context(), tx, &req) if err != nil { log.Printf("EnableOTP(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, resp) - return nil + return resp, nil } -func (s *AccountServer) handleDisableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) error { +func (s *AccountServer) handleDisableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.DisableOTPRequest if !serverutil.DecodeJSONRequest(w, r, &req) { - return errBadRequest + return nil, errBadRequest } if err := s.service.DisableOTP(r.Context(), tx, &req); err != nil { log.Printf("DisableOTP(%s): error: %v", req.Username, err) http.Error(w, err.Error(), errToStatus(err)) - return err + return nil, err } - serverutil.EncodeJSONResponse(w, emptyResponse) - return nil + return &emptyResponse, nil } func (s *AccountServer) withTx(f txHandler) http.HandlerFunc { @@ -214,15 +204,19 @@ func (s *AccountServer) withTx(f txHandler) http.HandlerFunc { return } - if err := f(tx, w, r); err == nil { - // Automatically commit the transaction (if - // the handler didn't do this itself). - if err := tx.Commit(r.Context()); err != nil { - log.Printf("Commit error: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + resp, err := f(tx, w, r) + if err != nil { + return + } + // Automatically commit the transaction (if + // the handler didn't do this itself). + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Commit error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } + + serverutil.EncodeJSONResponse(w, resp) } } diff --git a/types.go b/types.go index a544ed9dceda9a70485afa4f9f847d3af497eba4..3aea54d9cb7a7260fee2bb0915acc45473599907 100644 --- a/types.go +++ b/types.go @@ -161,7 +161,9 @@ func (i *ResourceID) UnmarshalJSON(data []byte) error { if err != nil { return err } - *i, err = ParseResourceID(s) + if s != "" { + *i, err = ParseResourceID(s) + } return err }