Files
openfsd/web/users.go
Reese Norris 57d54d6705 v0.1.0-alpha
Changes:
- Implement bootstrapping library for managing several concurrent internal services
- Refactor concurrency model for connections/logical clients and their associated I/O
- Refactor server context singleton
- Refactor error handling
    - Most errors are now gracefully sent to the FSD client directly encoded as an $ER packet,
      enhancing visibility and debugging
    - Most errors are now rightfully treated as non-fatal
- Refactor package/dependency graph
- Refactor calling conventions/interfaces for many packages
- Refactor database package
- Refactor post office

Features:
- Add VATSIM-esque HTTP/JSON "data feed"
- Add ephemeral in-memory database option
- Add user management REST API
- Add improved web interface
- Add MySQL support (drop SQLite support)
2024-10-07 12:50:39 -07:00

281 lines
7.8 KiB
Go

package web
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"github.com/golang-jwt/jwt/v5"
auth2 "github.com/renorris/openfsd/auth"
"github.com/renorris/openfsd/database"
"github.com/renorris/openfsd/protocol"
"github.com/renorris/openfsd/servercontext"
"io"
"net/http"
"path"
"slices"
"strconv"
"strings"
)
type APIV1UsersRequest struct {
CID int `json:"cid,omitempty"`
User database.FSDUserRecord `json:"user,omitempty"`
}
type APIV1UsersResponse struct {
StatusMessage string `json:"msg"`
User *database.FSDUserRecord `json:"user,omitempty"`
}
// APIV1UsersHandler handles all /api/v1/users calls
func APIV1UsersHandler(w http.ResponseWriter, r *http.Request, verifier auth2.JWTVerifier) {
r.Body = http.MaxBytesReader(w, r.Body, 8192)
w.Header().Set("Content-Type", "application/json")
resp := APIV1UsersResponse{}
// Verify authorization
var tokenStr string
if tokenStr = r.Header.Get("Authorization"); tokenStr == "" {
resp.StatusMessage = "authorization header missing"
writeResponseError(w, http.StatusBadRequest, &resp)
return
}
if split := strings.Split(tokenStr, "Bearer "); len(split) != 2 {
resp.StatusMessage = "invalid authorization header format"
writeResponseError(w, http.StatusBadRequest, &resp)
return
} else {
tokenStr = split[1]
}
// Verify JWT signature and expiry times
var token *jwt.Token
var err error
if token, err = verifier.VerifyJWT(tokenStr); err != nil {
resp.StatusMessage = "invalid token"
writeResponseError(w, http.StatusForbidden, &resp)
return
}
// Verify JWT claims
claims := auth2.FSDJWTClaims{}
if err = claims.Parse(token); err != nil {
resp.StatusMessage = "invalid token claims"
writeResponseError(w, http.StatusForbidden, &resp)
return
}
if !slices.Contains(claims.Audience(), "dashboard") {
resp.StatusMessage = "invalid token audience"
writeResponseError(w, http.StatusForbidden, &resp)
return
}
// Read body
var body []byte
if body, err = io.ReadAll(r.Body); err != nil {
resp.StatusMessage = "error reading request body"
writeResponseError(w, http.StatusInternalServerError, &resp)
return
}
var status int
req := APIV1UsersRequest{}
// GET method doesn't have a body, handle it separately
if r.Method == "GET" {
if !strings.HasPrefix(r.URL.Path, "/api/v1/users/") {
resp.StatusMessage = "invalid request path"
writeResponseError(w, http.StatusBadRequest, &resp)
return
}
var cid int
if cid, err = strconv.Atoi(path.Base(r.URL.Path)); err != nil {
resp.StatusMessage = "invalid CID"
writeResponseError(w, http.StatusBadRequest, &resp)
return
}
req.CID = cid
status = getUserHandler(&claims, &req, &resp)
} else {
if err = json.Unmarshal(body, &req); err != nil {
resp.StatusMessage = "error parsing request body"
writeResponseError(w, http.StatusBadRequest, &resp)
return
}
switch r.Method {
case "POST":
status = createUserHandler(&claims, &req, &resp)
case "PUT":
status = updateUserHandler(&claims, &req, &resp)
case "DELETE":
status = deleteUserHandler(&claims, &req, &resp)
default:
resp.StatusMessage = "method not allowed"
writeResponseError(w, http.StatusMethodNotAllowed, &resp)
return
}
}
// Serialize response
var resBody []byte
if resBody, err = json.Marshal(&resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
resp.StatusMessage = "error serializing response body"
if respBytes, err := json.Marshal(&resp); err == nil {
io.Copy(w, bytes.NewReader(respBytes))
}
return
}
w.WriteHeader(status)
io.Copy(w, bytes.NewReader(resBody))
}
func createUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
// User must be an administrator, or a supervisor with the limitation of only creating users of lower rating
if claims.ControllerRating() != protocol.NetworkRatingADM {
if claims.ControllerRating() < protocol.NetworkRatingSUP {
res.StatusMessage = "must be at least Supervisor to create user"
return http.StatusForbidden
}
if req.User.NetworkRating >= protocol.NetworkRatingSUP {
res.StatusMessage = "created user must be below supervisor rating"
return http.StatusForbidden
}
}
var err error
var autoPassword, fsdAutoPassword bool
if req.User.Password == "" {
if req.User.Password, err = generateRandomPassword(); err != nil {
res.StatusMessage = "error generating random password"
return http.StatusInternalServerError
}
autoPassword = true
}
if req.User.FSDPassword == "" {
if req.User.FSDPassword, err = generateRandomPassword(); err != nil {
res.StatusMessage = "error generating random password"
return http.StatusInternalServerError
}
fsdAutoPassword = true
}
if req.User.CID, err = req.User.Insert(servercontext.DB()); err != nil {
res.StatusMessage = "error inserting user into database"
return http.StatusInternalServerError
}
res.StatusMessage = "success"
// omit passwords for response if they weren't auto generated
if !autoPassword {
req.User.Password = ""
}
if !fsdAutoPassword {
req.User.FSDPassword = ""
}
// copy user into response
res.User = &req.User
return http.StatusOK
}
func getUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
if claims.ControllerRating() < protocol.NetworkRatingSUP {
res.StatusMessage = "must be at least Supervisor to read users"
return http.StatusForbidden
}
userRecord := database.FSDUserRecord{}
if err := userRecord.LoadByCID(servercontext.DB(), req.CID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
res.StatusMessage = "no user found"
return http.StatusNotFound
}
res.StatusMessage = "error loading user from database"
return http.StatusInternalServerError
}
// omit passwords
userRecord.Password = ""
userRecord.FSDPassword = ""
res.StatusMessage = "success"
// copy into response
res.User = &userRecord
return http.StatusOK
}
func updateUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
// User must be an administrator, or a supervisor with the limitation of only updating users of lower rating
if claims.ControllerRating() != protocol.NetworkRatingADM {
if claims.ControllerRating() < protocol.NetworkRatingSUP {
res.StatusMessage = "must be at least Supervisor to update user"
return http.StatusForbidden
}
if req.User.NetworkRating >= protocol.NetworkRatingSUP {
res.StatusMessage = "user to update must be below supervisor rating"
return http.StatusForbidden
}
}
var err error
if err = req.User.Update(servercontext.DB()); err != nil {
if errors.Is(err, database.NoRowsChangedError) {
res.StatusMessage = "user not found"
return http.StatusNotFound
}
res.StatusMessage = "error updating user"
return http.StatusInternalServerError
}
if err = req.User.LoadByCID(servercontext.DB(), req.User.CID); err != nil {
res.StatusMessage = "error loading user for response"
return http.StatusInternalServerError
}
res.StatusMessage = "success"
// omit passwords for response
req.User.Password = ""
req.User.FSDPassword = ""
// copy user into response
res.User = &req.User
return http.StatusOK
}
func deleteUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
// User must be an administrator
if claims.ControllerRating() != protocol.NetworkRatingADM {
res.StatusMessage = "must be at Administrator to delete user"
return http.StatusForbidden
}
var err error
if err = req.User.Delete(servercontext.DB(), req.CID); err != nil {
if errors.Is(err, database.NoRowsChangedError) {
res.StatusMessage = "user not found"
return http.StatusNotFound
}
res.StatusMessage = "error deleting user"
return http.StatusInternalServerError
}
res.StatusMessage = "success"
res.User = nil
return http.StatusOK
}