1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-22 08:35:57 +01:00

Token API.

This commit is contained in:
Juliusz Chroboczek 2024-05-01 22:12:48 +02:00
parent 2f5c21d161
commit b7f9ef00b6
3 changed files with 276 additions and 0 deletions

View file

@ -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. 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 Accepted content-types are `application/json` for PUT and `text/plain` for
POST. 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.

View file

@ -3,6 +3,7 @@ package webserver
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@ -16,6 +17,7 @@ import (
"github.com/jech/galene/group" "github.com/jech/galene/group"
"github.com/jech/galene/stats" "github.com/jech/galene/stats"
"github.com/jech/galene/token"
) )
func parseContentType(ctype string) string { func parseContentType(ctype string) string {
@ -135,6 +137,9 @@ func apiGroupHandler(w http.ResponseWriter, r *http.Request, pth string) {
} else if kind == ".keys" && rest == "" { } else if kind == ".keys" && rest == "" {
keysHandler(w, r, g) keysHandler(w, r, g)
return return
} else if kind == ".tokens" {
tokensHandler(w, r, g, rest)
return
} else if kind != "" { } else if kind != "" {
if !checkAdmin(w, r) { if !checkAdmin(w, r) {
return return
@ -524,3 +529,172 @@ func keysHandler(w http.ResponseWriter, r *http.Request, g string) {
methodNotAllowed(w, "PUT", "DELETE") methodNotAllowed(w, "PUT", "DELETE")
return 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
}

View file

@ -7,6 +7,7 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"time"
"encoding/json" "encoding/json"
"net/http" "net/http"
@ -14,6 +15,7 @@ import (
"testing" "testing"
"github.com/jech/galene/group" "github.com/jech/galene/group"
"github.com/jech/galene/token"
) )
var setupOnce = sync.OnceFunc(func() { var setupOnce = sync.OnceFunc(func() {
@ -47,9 +49,19 @@ func setupTest(dir, datadir string) error {
if err != nil { if err != nil {
return err return err
} }
token.SetStatefulFilename(filepath.Join(datadir, "tokens.jsonl"))
return nil return nil
} }
func marshalToString(v any) string {
buf, err := json.Marshal(v)
if err != nil {
return ""
}
return string(buf)
}
func TestApi(t *testing.T) { func TestApi(t *testing.T) {
err := setupTest(t.TempDir(), t.TempDir()) err := setupTest(t.TempDir(), t.TempDir())
if err != nil { if err != nil {
@ -272,6 +284,74 @@ func TestApi(t *testing.T) {
t.Errorf("Keys: %v", len(desc.AuthKeys)) 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", resp, err = do("DELETE", "/galene-api/0/.groups/test/.fallback-users",
"", "", "", "") "", "", "", "")
if err != nil || resp.StatusCode != http.StatusNoContent { 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/not-jch")
do("PUT", "/galene-api/0/.groups/test/.users/jch/.password") do("PUT", "/galene-api/0/.groups/test/.users/jch/.password")
do("POST", "/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")
} }