1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-22 16:45:58 +01:00
galene/webserver/api.go

705 lines
14 KiB
Go
Raw Normal View History

package webserver
import (
2024-04-09 16:53:03 +02:00
"crypto/rand"
"crypto/sha256"
2024-05-01 22:12:48 +02:00
"encoding/base64"
2024-04-09 16:53:03 +02:00
"encoding/hex"
"encoding/json"
"errors"
2024-04-09 16:53:03 +02:00
"fmt"
"io"
"net/http"
2024-04-09 16:53:03 +02:00
"os"
"strings"
2024-04-09 16:53:03 +02:00
"golang.org/x/crypto/pbkdf2"
"github.com/jech/galene/group"
"github.com/jech/galene/stats"
2024-05-01 22:12:48 +02:00
"github.com/jech/galene/token"
)
2024-04-09 15:30:11 +02:00
func parseContentType(ctype string) string {
return strings.Trim(strings.Split(ctype, ";")[0], " ")
}
2024-04-09 16:53:03 +02:00
// checkAdmin checks whether the client authentifies as an administrator
func checkAdmin(w http.ResponseWriter, r *http.Request) bool {
username, password, ok := r.BasicAuth()
2024-04-09 16:53:03 +02:00
if ok {
ok, _ = adminMatch(username, password)
}
if !ok {
2024-04-09 16:53:03 +02:00
failAuthentication(w, "/galene-api/")
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, groupname, user string) bool {
username, password, ok := r.BasicAuth()
if ok {
ok, _ := adminMatch(username, password)
if ok {
return true
}
}
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 == "/0" && kind == ".stats" && rest == "" {
if !checkAdmin(w, r) {
return
}
if r.Method != "HEAD" && r.Method != "GET" {
methodNotAllowed(w, "HEAD", "GET")
return
}
w.Header().Set("content-type", "application/json")
w.Header().Set("cache-control", "no-cache")
if r.Method == "HEAD" {
return
}
ss := stats.GetGroups()
e := json.NewEncoder(w)
e.Encode(ss)
return
} else if first == "/0" && kind == ".groups" {
apiGroupHandler(w, r, rest)
return
}
2024-04-09 16:53:03 +02:00
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 {
2024-04-09 16:53:03 +02:00
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
}
2024-04-09 16:53:03 +02:00
if kind == ".users" {
2024-04-14 01:04:44 +02:00
usersHandler(w, r, g, rest)
2024-04-09 16:53:03 +02:00
return
2024-04-11 13:45:30 +02:00
} else if kind == ".fallback-users" && rest == "" {
fallbackUsersHandler(w, r, g)
return
2024-04-11 13:25:59 +02:00
} else if kind == ".keys" && rest == "" {
keysHandler(w, r, g)
return
2024-05-01 22:12:48 +02:00
} else if kind == ".tokens" {
tokensHandler(w, r, g, rest)
return
2024-04-09 16:53:03 +02:00
} else if kind != "" {
if !checkAdmin(w, r) {
return
}
notFound(w)
return
}
2024-04-09 16:53:03 +02:00
if !checkAdmin(w, r) {
return
}
2024-04-09 16:53:03 +02:00
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 errors.Is(err, os.ErrNotExist) {
2024-04-09 16:53:03 +02:00
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
}
2024-04-14 01:04:44 +02:00
func usersHandler(w http.ResponseWriter, r *http.Request, g, pth string) {
2024-05-01 22:20:02 +02:00
if pth == "" {
http.NotFound(w, r)
return
}
2024-04-09 16:53:03 +02:00
if pth == "/" {
if !checkAdmin(w, r) {
return
}
if r.Method != "HEAD" && r.Method != "GET" {
http.Error(w, "method not allowed",
http.StatusMethodNotAllowed)
2024-04-09 16:53:03 +02:00
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
}
2024-04-09 16:53:03 +02:00
for _, u := range users {
fmt.Fprintln(w, u)
}
return
}
2024-04-09 16:53:03 +02:00
first2, kind2, rest2 := splitPath(pth)
2024-04-11 13:25:59 +02:00
if first2 != "" && kind2 == ".password" && rest2 == "" {
2024-04-09 16:53:03 +02:00
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" {
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)
2024-04-09 16:53:03 +02:00
e.Encode(user)
return
} else if r.Method == "PUT" {
etag, err := group.GetUserTag(g, username)
if errors.Is(err, os.ErrNotExist) {
2024-04-09 16:53:03 +02:00
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
}
2024-04-09 16:53:03 +02:00
methodNotAllowed(w, "HEAD", "GET", "PUT", "DELETE")
return
}
2024-04-09 16:53:03 +02:00
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)
encoded := hex.EncodeToString(key)
2024-04-09 16:53:03 +02:00
pw := group.Password{
Type: "pbkdf2",
Hash: "sha-256",
Key: &encoded,
2024-04-09 16:53:03 +02:00
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
}
2024-04-11 13:25:59 +02:00
2024-04-11 13:45:30 +02:00
func fallbackUsersHandler(w http.ResponseWriter, r *http.Request, g string) {
if !checkAdmin(w, r) {
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 users []group.UserDescription
err := d.Decode(&users)
if err != nil {
httpError(w, err)
return
}
err = group.SetFallbackUsers(g, users)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
} else if r.Method == "DELETE" {
err := group.SetFallbackUsers(g, nil)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
methodNotAllowed(w, "PUT", "DELETE")
return
}
2024-04-11 13:25:59 +02:00
type jwkset = struct {
Keys []map[string]any `json:"keys"`
}
func keysHandler(w http.ResponseWriter, r *http.Request, g string) {
if !checkAdmin(w, r) {
return
}
if r.Method == "PUT" {
ctype := parseContentType(r.Header.Get("Content-Type"))
if !strings.EqualFold(ctype, "application/jwk-set+json") {
http.Error(w, "unsupported content type",
http.StatusUnsupportedMediaType)
return
}
d := json.NewDecoder(r.Body)
var keys jwkset
err := d.Decode(&keys)
if err != nil {
httpError(w, err)
return
}
err = group.SetKeys(g, keys.Keys)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
} else if r.Method == "DELETE" {
err := group.SetKeys(g, nil)
if err != nil {
httpError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
methodNotAllowed(w, "PUT", "DELETE")
return
}
2024-05-01 22:12:48 +02:00
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
}
2024-05-02 18:14:51 +02:00
if newtoken.Token != t {
http.Error(w, "token mismatch", http.StatusBadRequest)
return
}
2024-05-01 22:12:48 +02:00
_, 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
}