mirror of
https://github.com/jech/galene.git
synced 2024-11-09 18:25:58 +01:00
Token API.
This commit is contained in:
parent
2f5c21d161
commit
b7f9ef00b6
3 changed files with 276 additions and 0 deletions
17
README.API
17
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.
|
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.
|
||||||
|
|
174
webserver/api.go
174
webserver/api.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue