Files
skald/webserver/api.go
T

715 lines
14 KiB
Go

package webserver
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
"git.stormux.org/storm/skald/hall"
"git.stormux.org/storm/skald/stats"
"git.stormux.org/storm/skald/token"
)
// 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, "/skald-api/",
"Authentication required. Provide valid administrator credentials.")
return false
}
return true
}
// 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, hallname, user string, wildcard bool) bool {
username, password, ok := r.BasicAuth()
if ok {
ok, err := adminMatch(username, password)
if err != nil {
internalError(w, "Admin match: %v", err)
return false
}
if ok {
return true
}
}
if ok && !wildcard && username == user {
desc, err := hall.GetDescription(hallname)
if err != nil {
internalError(w,
"Get description for hall %v: %v",
hallname, err,
)
return false
}
if desc.Users != nil {
u, ok := desc.Users[user]
if ok {
ok, err := u.Password.Match(password)
if err != nil {
internalError(w,
"Password match: %v", err,
)
return false
}
if ok {
return true
}
}
}
}
failAuthentication(w, "/skald-api/",
"Authentication required. Provide valid administrator credentials.")
return false
}
func sendJSON(w http.ResponseWriter, r *http.Request, v any) {
w.Header().Set("content-type", "application/json")
if r.Method == "HEAD" {
return
}
e := json.NewEncoder(w)
e.Encode(v)
}
const maxAPIMessageSize = 1024 * 1024
func getText(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(ctype, "text/plain") {
w.Header().Set("Accept", "text/plain")
http.Error(w, "unsupported content type",
http.StatusUnsupportedMediaType)
return nil, true
}
body, err := io.ReadAll(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
if err != nil {
httpError(w, err)
return nil, true
}
return body, false
}
func getJSON(w http.ResponseWriter, r *http.Request, v any) bool {
ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(ctype, "application/json") {
w.Header().Set("Accept", "application/json")
http.Error(w, "unsupported content type",
http.StatusUnsupportedMediaType)
return true
}
d := json.NewDecoder(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
err = d.Decode(v)
if err != nil {
httpError(w, err)
return true
}
return false
}
func apiCORS(w http.ResponseWriter, r *http.Request, methods string) bool {
CheckOrigin(w, r, true)
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods",
"OPTIONS, "+methods)
w.Header().Set("Access-Control-Allow-Headers",
"Authorization, Content-Type",
)
return true
}
return false
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/skald-api/") {
http.NotFound(w, r)
return
}
first, kind, rest := splitPath(r.URL.Path[len("/skald-api"):])
if first != "/v0" {
http.NotFound(w, r)
return
}
switch kind {
case ".stats":
if rest != "" {
http.NotFound(w, r)
return
}
if apiCORS(w, r, "HEAD, GET") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method != "HEAD" && r.Method != "GET" {
methodNotAllowed(w, "HEAD, GET")
return
}
w.Header().Set("cache-control", "no-cache")
sendJSON(w, r, stats.GetHalls())
case ".halls":
apiHallHandler(w, r, rest)
default:
http.NotFound(w, r)
}
}
func apiHallHandler(w http.ResponseWriter, r *http.Request, pth string) {
first, kind, rest := splitPath(pth)
g := ""
if first != "" {
g = strings.TrimSuffix(first[1:], "/")
}
if g == "" && kind == "" {
if apiCORS(w, r, "HEAD, GET") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method != "HEAD" && r.Method != "GET" {
methodNotAllowed(w, "HEAD, GET")
return
}
halls, err := hall.GetDescriptionNames()
if err != nil {
httpError(w, err)
return
}
sendJSON(w, r, halls)
return
}
if kind == ".users" {
usersHandler(w, r, g, rest)
return
} else if kind == ".empty-user" {
specialUserHandler(w, r, g, rest, false)
return
} else if kind == ".wildcard-user" {
specialUserHandler(w, r, g, rest, true)
return
} 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
}
notFound(w)
return
}
if apiCORS(w, r, "HEAD, GET, PUT, DELETE") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method == "HEAD" || r.Method == "GET" {
desc, etag, err := hall.GetSanitisedDescription(g)
if err != nil {
httpError(w, err)
return
}
w.Header().Set("etag", etag)
done := checkPreconditions(w, r, etag)
if done {
return
}
sendJSON(w, r, desc)
return
} else if r.Method == "PUT" {
etag, err := hall.GetDescriptionTag(g)
if errors.Is(err, os.ErrNotExist) {
err = nil
etag = ""
} else if err != nil {
httpError(w, err)
return
}
done := checkPreconditions(w, r, etag)
if done {
return
}
var newdesc hall.Description
done = getJSON(w, r, &newdesc)
if done {
return
}
err = hall.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 := hall.GetDescriptionTag(g)
if err != nil {
httpError(w, err)
return
}
done := checkPreconditions(w, r, etag)
if done {
return
}
err = hall.DeleteDescription(g, etag)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
methodNotAllowed(w, "HEAD, GET, PUT, DELETE")
return
}
func usersHandler(w http.ResponseWriter, r *http.Request, g, pth string) {
if pth == "" {
http.NotFound(w, r)
return
}
if pth == "/" {
if apiCORS(w, r, "HEAD, GET") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method != "HEAD" && r.Method != "GET" {
methodNotAllowed(w, "HEAD, GET")
return
}
users, etag, err := hall.GetUsers(g)
if err != nil {
httpError(w, err)
return
}
w.Header().Set("etag", etag)
done := checkPreconditions(w, r, etag)
if done {
return
}
sendJSON(w, r, users)
return
}
first2, kind2, rest2 := splitPath(pth)
if first2 != "" && kind2 == "" {
userHandler(w, r, g, first2[1:], false)
return
} else if first2 != "" && kind2 == ".password" && rest2 == "" {
passwordHandler(w, r, g, first2[1:], false)
return
}
if !checkAdmin(w, r) {
return
}
notFound(w)
return
}
func specialUserHandler(w http.ResponseWriter, r *http.Request, g, pth string, wildcard bool) {
if pth == "" {
userHandler(w, r, g, "", wildcard)
return
} else if pth == "/.password" {
passwordHandler(w, r, g, "", wildcard)
return
}
if !checkAdmin(w, r) {
return
}
notFound(w)
return
}
func userHandler(w http.ResponseWriter, r *http.Request, g, user string, wildcard bool) {
if apiCORS(w, r, "HEAD, GET, PUT, DELETE") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method == "HEAD" || r.Method == "GET" {
user, etag, err := hall.GetSanitisedUser(g, user, wildcard)
if err != nil {
httpError(w, err)
return
}
w.Header().Set("etag", etag)
done := checkPreconditions(w, r, etag)
if done {
return
}
sendJSON(w, r, user)
return
} else if r.Method == "PUT" {
etag, err := hall.GetUserTag(g, user, wildcard)
if errors.Is(err, os.ErrNotExist) {
etag = ""
err = nil
} else if err != nil {
httpError(w, err)
return
}
done := checkPreconditions(w, r, etag)
if done {
return
}
var newdesc hall.UserDescription
done = getJSON(w, r, &newdesc)
if done {
return
}
err = hall.UpdateUser(g, user, wildcard, 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 := hall.GetUserTag(g, user, wildcard)
if err != nil {
httpError(w, err)
return
}
done := checkPreconditions(w, r, etag)
if done {
return
}
err = hall.DeleteUser(g, user, wildcard, 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, wildcard bool) {
if apiCORS(w, r, "PUT, POST, DELETE") {
return
}
if !checkPasswordAdmin(w, r, g, user, wildcard) {
return
}
if r.Method == "PUT" {
var pw hall.Password
done := getJSON(w, r, &pw)
if done {
return
}
err := hall.SetUserPassword(g, user, wildcard, pw)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
} else if r.Method == "POST" {
body, done := getText(w, r)
if done {
return
}
key, err := bcrypt.GenerateFromPassword(body, 8)
if err != nil {
httpError(w, err)
return
}
k := string(key)
pw := hall.Password{
Type: "bcrypt",
Key: &k,
}
err = hall.SetUserPassword(g, user, wildcard, pw)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
} else if r.Method == "DELETE" {
err := hall.SetUserPassword(g, user, wildcard, hall.Password{})
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
methodNotAllowed(w, "PUT, POST, DELETE")
return
}
type jwkset = struct {
Keys []map[string]any `json:"keys"`
}
func keysHandler(w http.ResponseWriter, r *http.Request, g string) {
if apiCORS(w, r, "PUT, DELETE") {
return
}
if !checkAdmin(w, r) {
return
}
if r.Method == "PUT" {
// cannot use getJSON due to the weird content-type
ctype, _, err :=
mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil ||
!strings.EqualFold(ctype, "application/jwk-set+json") {
w.Header().Set("Accept", "application/jwk-set+json")
http.Error(w, "unsupported content type",
http.StatusUnsupportedMediaType)
return
}
d := json.NewDecoder(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
var keys jwkset
err = d.Decode(&keys)
if err != nil {
httpError(w, err)
return
}
err = hall.SetKeys(g, keys.Keys)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
} else if r.Method == "DELETE" {
err := hall.SetKeys(g, nil)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
methodNotAllowed(w, "PUT, DELETE")
return
}
func tokensHandler(w http.ResponseWriter, r *http.Request, g, pth string) {
if pth == "" {
http.NotFound(w, r)
return
}
if apiCORS(w, r, "HEAD, GET, POST, PUT, DELETE") {
return
}
if !checkAdmin(w, r) {
return
}
if g != "" {
// check that the hall exists
_, err := hall.GetDescription(g)
if err != nil {
httpError(w, err)
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", "application/json")
if etag != "" {
w.Header().Set("etag", etag)
}
toknames := make([]string, len(tokens))
for i, t := range tokens {
toknames[i] = t.Token
}
sendJSON(w, r, toknames)
return
} else if r.Method == "POST" {
var newtoken token.Stateful
done := getJSON(w, r, &newtoken)
if done {
return
}
if newtoken.Token != "" || newtoken.Hall != "" {
http.Error(w, "overspecified token",
http.StatusBadRequest)
return
}
buf := make([]byte, 8)
rand.Read(buf)
newtoken.Token =
base64.RawURLEncoding.EncodeToString(buf)
newtoken.Hall = g
t, err := token.Update(&newtoken, "")
if err != nil {
httpError(w, err)
return
}
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" {
old, etag, err := token.Get(t)
if err != nil {
httpError(w, err)
return
}
if old.Hall != g {
http.NotFound(w, r)
return
}
tok := old.Clone()
tok.Token = ""
tok.Hall = ""
w.Header().Set("etag", etag)
done := checkPreconditions(w, r, etag)
if done {
return
}
sendJSON(w, r, tok)
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.Hall != g {
http.Error(w, "token exists in different hall",
http.StatusConflict)
return
}
done := checkPreconditions(w, r, etag)
if done {
return
}
var newtoken token.Stateful
done = getJSON(w, r, &newtoken)
if done {
return
}
if newtoken.Hall != "" || newtoken.Token != "" {
http.Error(w, "overspecified token",
http.StatusBadRequest)
return
}
newtoken.Hall = g
newtoken.Token = t
_, 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.Hall != 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
}