diff --git a/static/stats.js b/static/stats.js index 271decb..7be63a9 100644 --- a/static/stats.js +++ b/static/stats.js @@ -25,7 +25,7 @@ async function listStats() { let l; try { - let r = await fetch('/galene-api/.stats'); + let r = await fetch('/galene-api/0/.stats'); if(!r.ok) throw new Error(`${r.status} ${r.statusText}`); l = await r.json(); diff --git a/webserver/api.go b/webserver/api.go index d12cb1d..439ead9 100644 --- a/webserver/api.go +++ b/webserver/api.go @@ -1,11 +1,19 @@ package webserver import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" - "log" + "fmt" + "io" "net/http" + "os" "strings" + "golang.org/x/crypto/pbkdf2" + + "github.com/jech/galene/group" "github.com/jech/galene/stats" ) @@ -13,36 +21,60 @@ func parseContentType(ctype string) string { return strings.Trim(strings.Split(ctype, ";")[0], " ") } -func apiHandler(w http.ResponseWriter, r *http.Request) { +// checkAdmin checks whether the client authentifies as an administrator +func checkAdmin(w http.ResponseWriter, r *http.Request) bool { username, password, ok := r.BasicAuth() + if ok { + ok, _ = adminMatch(username, password) + } if !ok { - failAuthentication(w, "galene-api") - return + failAuthentication(w, "/galene-api/") + return false } + return true +} - if ok, err := adminMatch(username, password); !ok { - if err != nil { - log.Printf("Administrator password: %v", err) +// checkPasswordAdmin checks whether the client authentifies as either an +// administrator or the given user. It is used to check whether the +// client has the right to change user's password. +func checkPasswordAdmin(w http.ResponseWriter, r *http.Request, groupname, user string) bool { + username, password, ok := r.BasicAuth() + if ok { + ok, _ := adminMatch(username, password) + if ok { + return true } - failAuthentication(w, "galene-api") - return } + if ok && username == user { + desc, err := group.GetDescription(groupname) + if err == nil && desc.Users != nil { + u, ok := desc.Users[user] + if ok { + ok, _ := u.Password.Match(password) + if ok { + return true + } + } + } + } + failAuthentication(w, "/galene-api/") + return false +} +func apiHandler(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/galene-api/") { http.NotFound(w, r) return } - first, kind, rest := splitPath(r.URL.Path[len("/galene/api"):]) - if first != "" { - http.NotFound(w, r) - return - } - - if kind == ".stats" && rest == "" { + first, kind, rest := splitPath(r.URL.Path[len("/galene-api"):]) + if first == "/0" && kind == ".stats" && rest == "" { + if !checkAdmin(w, r) { + return + } if r.Method != "HEAD" && r.Method != "GET" { - http.Error(w, "method not allowed", - http.StatusMethodNotAllowed) + methodNotAllowed(w, "HEAD", "GET") + return } w.Header().Set("content-type", "application/json") w.Header().Set("cache-control", "no-cache") @@ -54,8 +86,346 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { e := json.NewEncoder(w) e.Encode(ss) return + } else if first == "/0" && kind == ".groups" { + apiGroupHandler(w, r, rest) + return } http.NotFound(w, r) return } + +func apiGroupHandler(w http.ResponseWriter, r *http.Request, pth string) { + first, kind, rest := splitPath(pth) + if first == "" { + notFound(w) + return + } + g := first[1:] + if g == "" && kind == "" { + if !checkAdmin(w, r) { + return + } + if r.Method != "HEAD" && r.Method != "GET" { + methodNotAllowed(w, "HEAD", "GET") + return + } + groups, err := group.GetDescriptionNames() + if err != nil { + httpError(w, err) + return + } + w.Header().Set("content-type", "text/plain; charset=utf-8") + if r.Method == "HEAD" { + return + } + for _, g := range groups { + fmt.Fprintln(w, g) + } + return + } + + if kind == ".users" { + apiUserHandler(w, r, g, rest) + return + } else if kind != "" { + if !checkAdmin(w, r) { + return + } + notFound(w) + return + } + + if !checkAdmin(w, r) { + return + } + + if r.Method == "HEAD" || r.Method == "GET" { + desc, etag, err := group.GetSanitisedDescription(g) + if err != nil { + httpError(w, err) + return + } + + w.Header().Set("etag", etag) + + done := checkPreconditions(w, r, etag) + if done { + return + } + + w.Header().Set("content-type", "application/json") + if r.Method == "HEAD" { + return + } + + e := json.NewEncoder(w) + e.Encode(desc) + return + } else if r.Method == "PUT" { + etag, err := group.GetDescriptionTag(g) + if os.IsNotExist(err) { + err = nil + etag = "" + } else if err != nil { + httpError(w, err) + return + } + + done := checkPreconditions(w, r, etag) + if done { + return + } + + ctype := parseContentType( + r.Header.Get("Content-Type"), + ) + if !strings.EqualFold(ctype, "application/json") { + http.Error(w, "unsupported content type", + http.StatusUnsupportedMediaType) + return + } + d := json.NewDecoder(r.Body) + var newdesc group.Description + err = d.Decode(&newdesc) + if err != nil { + httpError(w, err) + return + } + err = group.UpdateDescription(g, etag, &newdesc) + if err != nil { + httpError(w, err) + return + } + if etag == "" { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusNoContent) + } + return + } else if r.Method == "DELETE" { + etag, err := group.GetDescriptionTag(g) + if err != nil { + httpError(w, err) + return + } + + done := checkPreconditions(w, r, etag) + if done { + return + } + err = group.DeleteDescription(g, etag) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + methodNotAllowed(w, "HEAD", "GET", "PUT", "DELETE") + return +} + +func apiUserHandler(w http.ResponseWriter, r *http.Request, g, pth string) { + if pth == "/" { + if !checkAdmin(w, r) { + return + } + if r.Method != "HEAD" && r.Method != "GET" { + http.Error(w, "method not allowed", + http.StatusMethodNotAllowed) + return + } + users, etag, err := group.GetUsers(g) + if err != nil { + httpError(w, err) + return + } + w.Header().Set("content-type", "text/plain; charset=utf-8") + w.Header().Set("etag", etag) + done := checkPreconditions(w, r, etag) + if done { + return + } + if r.Method == "HEAD" { + return + } + for _, u := range users { + fmt.Fprintln(w, u) + } + return + } + + first2, kind2, rest2 := splitPath(pth) + if kind2 == ".password" && first2 != "" && rest2 == "" { + passwordHandler(w, r, g, first2[1:]) + return + } else if kind2 != "" || first2 == "" { + if !checkAdmin(w, r) { + return + } + notFound(w) + return + } + + if !checkAdmin(w, r) { + return + } + + username := first2[1:] + if r.Method == "HEAD" || r.Method == "GET" { + w.Header().Set("content-type", "application/json") + user, etag, err := group.GetSanitisedUser(g, username) + if err != nil { + httpError(w, err) + return + } + w.Header().Set("content-type", "application/json") + w.Header().Set("etag", etag) + done := checkPreconditions(w, r, etag) + if done { + return + } + if r.Method == "HEAD" { + return + } + e := json.NewEncoder(w) + e.Encode(user) + return + } else if r.Method == "PUT" { + etag, err := group.GetUserTag(g, username) + if os.IsNotExist(err) { + etag = "" + err = nil + } else if err != nil { + httpError(w, err) + return + } + + done := checkPreconditions(w, r, etag) + if done { + return + } + + ctype := parseContentType(r.Header.Get("Content-Type")) + if !strings.EqualFold(ctype, "application/json") { + http.Error(w, "unsupported content type", + http.StatusUnsupportedMediaType) + return + } + d := json.NewDecoder(r.Body) + var newdesc group.UserDescription + err = d.Decode(&newdesc) + if err != nil { + httpError(w, err) + return + } + err = group.UpdateUser(g, username, etag, &newdesc) + if err != nil { + httpError(w, err) + return + } + if etag == "" { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusNoContent) + } + return + } else if r.Method == "DELETE" { + etag, err := group.GetUserTag(g, username) + if err != nil { + httpError(w, err) + return + } + + done := checkPreconditions(w, r, etag) + if done { + return + } + + err = group.DeleteUser(g, username, etag) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + methodNotAllowed(w, "HEAD", "GET", "PUT", "DELETE") + return +} + +func passwordHandler(w http.ResponseWriter, r *http.Request, g, user string) { + if !checkPasswordAdmin(w, r, g, user) { + return + } + + if r.Method == "PUT" { + ctype := parseContentType(r.Header.Get("Content-Type")) + if !strings.EqualFold(ctype, "application/json") { + http.Error(w, "unsupported content type", + http.StatusUnsupportedMediaType) + return + } + d := json.NewDecoder(r.Body) + var pw group.Password + err := d.Decode(&pw) + if err != nil { + httpError(w, err) + return + } + err = group.SetUserPassword(g, user, pw) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } else if r.Method == "POST" { + ctype := parseContentType(r.Header.Get("Content-Type")) + if !strings.EqualFold(ctype, "text/plain") { + http.Error(w, "unsupported content type", + http.StatusUnsupportedMediaType) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096)) + if err != nil { + httpError(w, err) + return + } + salt := make([]byte, 8) + _, err = rand.Read(salt) + if err != nil { + httpError(w, err) + return + } + iterations := 4096 + key := pbkdf2.Key(body, salt, iterations, 32, sha256.New) + pw := group.Password{ + Type: "pbkdf2", + Hash: "sha-256", + Key: hex.EncodeToString(key), + Salt: hex.EncodeToString(salt), + Iterations: iterations, + } + err = group.SetUserPassword(g, user, pw) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } else if r.Method == "DELETE" { + err := group.SetUserPassword(g, user, group.Password{}) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + + methodNotAllowed(w, "PUT", "POST", "DELETE") + return +} diff --git a/webserver/api_test.go b/webserver/api_test.go new file mode 100644 index 0000000..5a7073f --- /dev/null +++ b/webserver/api_test.go @@ -0,0 +1,317 @@ +package webserver + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "encoding/json" + "net/http" + "path/filepath" + "testing" + + "github.com/jech/galene/group" +) + +var setupOnce = sync.OnceFunc(func() { + Insecure = true + go func() { + err := Serve("localhost:1234", "") + if err != nil { + panic("could not start server") + } + }() + time.Sleep(100 * time.Millisecond) +}) + +func setupTest(dir, datadir string) error { + setupOnce() + + group.Directory = dir + group.DataDirectory = datadir + config := `{ + "writableGroups": true, + "users": { + "root": { + "password": "pw", + "permissions": "admin" + } + } +}` + f, err := os.Create(filepath.Join(group.DataDirectory, "config.json")) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(config) + if err != nil { + return err + } + return nil +} + +func TestApi(t *testing.T) { + err := setupTest(t.TempDir(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + + client := http.Client{} + + do := func(method, path, ctype, im, inm, body string) (*http.Response, error) { + req, err := http.NewRequest(method, + "http://localhost:1234"+path, + strings.NewReader(body), + ) + if err != nil { + return nil, err + } + if ctype != "" { + req.Header.Set("Content-Type", ctype) + } + if im != "" { + req.Header.Set("If-Match", im) + } + if inm != "" { + req.Header.Set("If-None-Match", inm) + } + req.SetBasicAuth("root", "pw") + return client.Do(req) + } + + getString := func(path string) (string, error) { + resp, err := do("GET", path, "", "", "", "") + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Status is %v", resp.StatusCode) + } + ctype := parseContentType(resp.Header.Get("Content-Type")) + if !strings.EqualFold(ctype, "text/plain") { + return "", errors.New("Unexpected Content-Type") + } + b, err := io.ReadAll(resp.Body) + return string(b), err + } + + getJSON := func(path string, value any) error { + resp, err := do("GET", path, "", "", "", "") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Status is %v", resp.StatusCode) + } + ctype := parseContentType(resp.Header.Get("Content-Type")) + if !strings.EqualFold(ctype, "application/json") { + return errors.New("Unexpected") + } + d := json.NewDecoder(resp.Body) + return d.Decode(value) + } + + s, err := getString("/galene-api/0/.groups/") + if err != nil || s != "" { + t.Errorf("Get groups: %v", err) + } + + resp, err := do("PUT", "/galene-api/0/.groups/test/", + "application/json", "\"foo\"", "", + "{}") + if err != nil || resp.StatusCode != http.StatusPreconditionFailed { + t.Errorf("Create group (bad ETag): %v %v", err, resp.StatusCode) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/", + "text/plain", "", "", + "Hello, world!") + if err != nil || resp.StatusCode != http.StatusUnsupportedMediaType { + t.Errorf("Create group (bad content-type): %v %v", + err, resp.StatusCode) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/", + "application/json", "", "*", + "{}") + if err != nil || resp.StatusCode != http.StatusCreated { + t.Errorf("Create group: %v %v", err, resp.StatusCode) + } + + var desc *group.Description + err = getJSON("/galene-api/0/.groups/test/", &desc) + if err != nil || len(desc.Users) != 0 { + t.Errorf("Get group: %v", err) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/", + "application/json", "", "*", + "{}") + if err != nil || resp.StatusCode != http.StatusPreconditionFailed { + t.Errorf("Create group (bad ETag): %v %v", err, resp.StatusCode) + } + + resp, err = do("DELETE", "/galene-api/0/.groups/test/", + "", "", "*", "") + if err != nil || resp.StatusCode != http.StatusPreconditionFailed { + t.Errorf("Delete group (bad ETag): %v %v", err, resp.StatusCode) + } + + s, err = getString("/galene-api/0/.groups/") + if err != nil || s != "test\n" { + t.Errorf("Get groups: %v %#v", err, s) + } + + s, err = getString("/galene-api/0/.groups/test/.users/") + if err != nil || s != "" { + t.Errorf("Get users: %v", err) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/.users/jch", + "text/plain", "", "*", + `hello, world!`) + if err != nil || resp.StatusCode != http.StatusUnsupportedMediaType { + t.Errorf("Create user (bad content-type): %v %v", + err, resp.StatusCode) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/.users/jch", + "application/json", "", "*", + `{"permissions": "present"}`) + if err != nil || resp.StatusCode != http.StatusCreated { + t.Errorf("Create user: %v %v", err, resp.StatusCode) + } + + s, err = getString("/galene-api/0/.groups/test/.users/") + if err != nil || s != "jch\n" { + t.Errorf("Get users: %v", err) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/.users/jch", + "application/json", "", "*", + `{"permissions": "present"}`) + if err != nil || resp.StatusCode != http.StatusPreconditionFailed { + t.Errorf("Create user (bad ETag): %v %v", err, resp.StatusCode) + } + + resp, err = do("PUT", "/galene-api/0/.groups/test/.users/jch/.password", + "application/json", "", "", + `"toto"`) + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Set password (PUT): %v %v", err, resp.StatusCode) + } + + resp, err = do("POST", "/galene-api/0/.groups/test/.users/jch/.password", + "text/plain", "", "", + `toto`) + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Set password (POST): %v %v", err, resp.StatusCode) + } + + var user group.UserDescription + err = getJSON("/galene-api/0/.groups/test/.users/jch", &user) + if err != nil { + t.Errorf("Get user: %v", err) + } + if user.Password.Type != "" && user.Password.Key != "" { + t.Errorf("User not sanitised properly") + } + + desc, err = group.GetDescription("test") + if err != nil { + t.Errorf("GetDescription: %v", err) + } + + if len(desc.Users) != 1 { + t.Errorf("Users: %#v", desc.Users) + } + + if desc.Users["jch"].Password.Type != "pbkdf2" { + t.Errorf("Password.Type: %v", desc.Users["jch"].Password.Type) + } + + resp, err = do("DELETE", "/galene-api/0/.groups/test/.users/jch", + "", "", "", "") + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Delete group: %v %v", err, resp.StatusCode) + } + + desc, err = group.GetDescription("test") + if err != nil { + t.Errorf("GetDescription: %v", err) + } + + if len(desc.Users) != 0 { + t.Errorf("Users (after delete): %#v", desc.Users) + } + + resp, err = do("DELETE", "/galene-api/0/.groups/test/", + "", "", "", "") + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Delete group: %v %v", err, resp.StatusCode) + } + + _, err = group.GetDescription("test") + if !os.IsNotExist(err) { + t.Errorf("Group exists after delete") + } +} + +func TestApiBadAuth(t *testing.T) { + err := setupTest(t.TempDir(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + + client := http.Client{} + + do := func(method, path string) { + req, err := http.NewRequest(method, + "http://localhost:1234"+path, + nil) + if err != nil { + t.Errorf("New request: %v", err) + return + } + req.SetBasicAuth("root", "badpw") + resp, err := client.Do(req) + if err != nil { + t.Errorf("%v %v: %v", method, path, err) + return + } + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("%v %v: %v", method, path, resp.StatusCode) + } + } + + do("GET", "/galene-api/0/.stats") + do("GET", "/galene-api/0/.groups/") + do("PUT", "/galene-api/0/.groups/test/") + + f, err := os.Create(filepath.Join(group.Directory, "test.json")) + if err != nil { + t.Fatalf("Create(test.json): %v", err) + } + f.WriteString(`{ + "users": {"jch": {"permissions": "present", "password": "pw"}} + }\n`) + f.Close() + + do("PUT", "/galene-api/0/.groups/test/") + do("DELETE", "/galene-api/0/.groups/test/") + do("GET", "/galene-api/0/.groups/test/.users/") + do("GET", "/galene-api/0/.groups/test/.users/jch") + do("GET", "/galene-api/0/.groups/test/.users/jch") + do("PUT", "/galene-api/0/.groups/test/.users/jch") + do("DELETE", "/galene-api/0/.groups/test/.users/jch") + do("GET", "/galene-api/0/.groups/test/.users/not-jch") + do("PUT", "/galene-api/0/.groups/test/.users/not-jch") + do("PUT", "/galene-api/0/.groups/test/.users/jch/.password") + do("POST", "/galene-api/0/.groups/test/.users/jch/.password") +}