diff --git a/README.API b/README.API index abb90ef..df4fe87 100644 --- a/README.API +++ b/README.API @@ -96,3 +96,20 @@ field of the on-disk format, while the POST method takes a string which will be hashed on the server. Allowed methods are PUT, POST and DELETE. Accepted content-types are `application/json` for PUT and `text/plain` for POST. + +### List of stateful tokens + + /galene-api/0/.groups/groupname/.users/username/.tokens/ + +GET returns the list of stateful tokens, as plain text, one token per +line. POST creates a new token, and returns its name in the `Location` +header. Allowed methods are HEAD, GET and POST. + +### Stateful token + + /galene-api/0/.groups/groupname/.users/username/.tokens/token + +The full contents of a single token, in JSON. The exact format may change +between versions, so a client should first GET a token, update one or more +fields, then PUT the resulting token. Allowed methods are HEAD, GET and +PUT. diff --git a/webserver/api.go b/webserver/api.go index cdc7b8d..574045b 100644 --- a/webserver/api.go +++ b/webserver/api.go @@ -3,6 +3,7 @@ package webserver import ( "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -16,6 +17,7 @@ import ( "github.com/jech/galene/group" "github.com/jech/galene/stats" + "github.com/jech/galene/token" ) func parseContentType(ctype string) string { @@ -135,6 +137,9 @@ func apiGroupHandler(w http.ResponseWriter, r *http.Request, pth string) { } else if kind == ".keys" && rest == "" { keysHandler(w, r, g) return + } else if kind == ".tokens" { + tokensHandler(w, r, g, rest) + return } else if kind != "" { if !checkAdmin(w, r) { return @@ -524,3 +529,172 @@ func keysHandler(w http.ResponseWriter, r *http.Request, g string) { methodNotAllowed(w, "PUT", "DELETE") return } + +func tokensHandler(w http.ResponseWriter, r *http.Request, g, pth string) { + if pth == "" { + http.NotFound(w, r) + return + } + if !checkAdmin(w, r) { + return + } + if pth == "/" { + if r.Method == "HEAD" || r.Method == "GET" { + tokens, etag, err := token.List(g) + if err != nil { + httpError(w, err) + return + } + w.Header().Set("content-type", + "text/plain; charset=utf-8") + if etag != "" { + w.Header().Set("etag", etag) + } + if r.Method == "HEAD" { + return + } + for _, t := range tokens { + fmt.Fprintln(w, t.Token) + } + return + } else if r.Method == "POST" { + 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 newtoken token.Stateful + err := d.Decode(&newtoken) + if err != nil { + httpError(w, err) + return + } + if newtoken.Token != "" || newtoken.Group != "" { + http.Error(w, "overspecified token", + http.StatusBadRequest) + return + } + buf := make([]byte, 8) + rand.Read(buf) + newtoken.Token = + base64.RawURLEncoding.EncodeToString(buf) + newtoken.Group = g + t, err := token.Update(&newtoken, "") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("content-type", + "text/plain; charset=utf-8") + w.Header().Set("location", t.Token) + w.WriteHeader(http.StatusCreated) + return + } + methodNotAllowed(w, "HEAD", "GET", "POST") + return + } + + if pth[0] != '/' { + http.NotFound(w, r) + return + } + t := pth[1:] + if r.Method == "HEAD" || r.Method == "GET" { + tok, etag, err := token.Get(t) + if err != nil { + httpError(w, err) + return + } + if tok.Group != g { + http.NotFound(w, r) + 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(t) + return + } else if r.Method == "PUT" { + old, etag, err := token.Get(t) + if errors.Is(err, os.ErrNotExist) { + etag = "" + err = nil + } else if err != nil { + httpError(w, err) + return + } + if old.Group != g { + http.Error(w, "token exists in different group", + http.StatusConflict) + 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 newtoken token.Stateful + err = d.Decode(&newtoken) + if err != nil { + httpError(w, err) + return + } + if newtoken.Group != g { + http.Error(w, "wrong group", http.StatusBadRequest) + return + } + _, err = token.Update(&newtoken, etag) + if err != nil { + httpError(w, err) + return + } + if etag == "" { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusNoContent) + } + return + } else if r.Method == "DELETE" { + old, etag, err := token.Get(t) + if err != nil { + httpError(w, err) + return + } + if old.Group != g { + http.NotFound(w, r) + return + } + + done := checkPreconditions(w, r, etag) + if done { + return + } + + err = token.Delete(t, etag) + if err != nil { + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + methodNotAllowed(w, "HEAD", "GET", "PUT", "DELETE") + return +} diff --git a/webserver/api_test.go b/webserver/api_test.go index a4d18e3..ceb5c02 100644 --- a/webserver/api_test.go +++ b/webserver/api_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "sync" + "time" "encoding/json" "net/http" @@ -14,6 +15,7 @@ import ( "testing" "github.com/jech/galene/group" + "github.com/jech/galene/token" ) var setupOnce = sync.OnceFunc(func() { @@ -47,9 +49,19 @@ func setupTest(dir, datadir string) error { if err != nil { return err } + + token.SetStatefulFilename(filepath.Join(datadir, "tokens.jsonl")) return nil } +func marshalToString(v any) string { + buf, err := json.Marshal(v) + if err != nil { + return "" + } + return string(buf) +} + func TestApi(t *testing.T) { err := setupTest(t.TempDir(), t.TempDir()) if err != nil { @@ -272,6 +284,74 @@ func TestApi(t *testing.T) { t.Errorf("Keys: %v", len(desc.AuthKeys)) } + resp, err = do("POST", "/galene-api/0/.groups/test/.tokens/", + "application/json", "", "", `{"group":"bad"}`) + if err != nil || resp.StatusCode != http.StatusBadRequest { + t.Errorf("Create token (bad group): %v %v", err, resp.StatusCode) + } + + resp, err = do("POST", "/galene-api/0/.groups/test/.tokens/", + "application/json", "", "", "{}") + if err != nil || resp.StatusCode != http.StatusCreated { + t.Errorf("Create token: %v %v", err, resp.StatusCode) + } + + tokname, err := getString("/galene-api/0/.groups/test/.tokens/") + if err != nil { + t.Errorf("Get tokens: %v", err) + } + tokname = tokname[:len(tokname)-1] + + tokens, etag, err := token.List("test") + if err != nil || len(tokens) != 1 || tokens[0].Token != tokname { + t.Errorf("token.List: %v %v", tokens, err) + } + + tokenpath := "/galene-api/0/.groups/test/.tokens/" + tokname + resp, err = do("GET", tokenpath, + "", "", "", "") + if err != nil || resp.StatusCode != http.StatusOK { + t.Errorf("Get token: %v %v", err, resp.StatusCode) + } + + tok := tokens[0].Clone() + e := time.Now().Add(time.Hour) + tok.Expires = &e + resp, err = do("PUT", tokenpath, + "application/json", etag, "", marshalToString(tok)) + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Update token: %v %v", err, resp.StatusCode) + } + + tok.Group = "bad" + resp, err = do("PUT", tokenpath, + "application/json", "", "", marshalToString(tok)) + if err != nil || resp.StatusCode != http.StatusBadRequest { + t.Errorf("Update token (bad group): %v %v", err, resp.StatusCode) + } + + tokens, etag, err = token.List("test") + if err != nil || len(tokens) != 1 { + t.Errorf("Token list: %v %v", tokens, err) + } + if !tokens[0].Expires.Equal(e) { + t.Errorf("Got %v, expected %v", tokens[0].Expires, e) + } + + resp, err = do("GET", tokenpath, "", "", "", "") + if err != nil || resp.StatusCode != http.StatusOK { + t.Errorf("Get token: %v %v", err, resp.StatusCode) + } + + resp, err = do("DELETE", tokenpath, "", "", "", "") + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Errorf("Update token: %v %v", err, resp.StatusCode) + } + tokens, etag, err = token.List("test") + if err != nil || len(tokens) != 0 { + t.Errorf("Token list: %v %v", tokens, err) + } + resp, err = do("DELETE", "/galene-api/0/.groups/test/.fallback-users", "", "", "", "") if err != nil || resp.StatusCode != http.StatusNoContent { @@ -347,4 +427,9 @@ func TestApiBadAuth(t *testing.T) { 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") + do("GET", "/galene-api/0/.groups/test/.tokens/") + do("POST", "/galene-api/0/.groups/test/.tokens/") + do("GET", "/galene-api/0/.groups/test/.tokens/token") + do("PUT", "/galene-api/0/.groups/test/.tokens/token") + do("DELETE", "/galene-api/0/.groups/test/.tokens/token") }