Files
openfsd/http_server.go
Reese Norris 05ed593a4b initial commit
2024-04-04 19:40:43 -07:00

510 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
_ "github.com/mattn/go-sqlite3"
"github.com/renorris/openfsd/protocol"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"strconv"
"time"
)
//go:embed dashboard.html
var dashboardHtml []byte
type JwtRequest struct {
CID string `json:"cid"`
Password string `json:"password"`
}
type JwtResponse struct {
Success bool `json:"success"`
Token string `json:"token,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
}
type CustomClaims struct {
jwt.RegisteredClaims
ControllerRating int `json:"controller_rating"`
PilotRating int `json:"pilot_rating"`
}
type UserApiResponse struct {
Success bool `json:"success"`
Message string `json:"msg"`
User *FSDUserRecord `json:"user_record,omitempty"`
}
func fsdJwtApiHandler(w http.ResponseWriter, r *http.Request) {
buf := new(bytes.Buffer)
_, err := buf.ReadFrom(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
var jwtRequest JwtRequest
if err = json.Unmarshal(buf.Bytes(), &jwtRequest); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
cid, err := strconv.Atoi(jwtRequest.CID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
userRecord, userRecordErr := GetUserRecord(DB, cid)
if userRecordErr != nil && !errors.Is(userRecordErr, sql.ErrNoRows) {
w.WriteHeader(http.StatusInternalServerError)
return
}
// If user not found
if errors.Is(userRecordErr, sql.ErrNoRows) {
jwtResponse := JwtResponse{
Success: false,
Token: "",
ErrorMsg: "User not found",
}
body, marshalErr := json.Marshal(jwtResponse)
if marshalErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, writeErr := w.Write(body)
if writeErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
return
}
// Verify password
userRecordErr = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(jwtRequest.Password))
if userRecordErr != nil { // Password didn't match
jwtResponse := JwtResponse{
Success: false,
Token: "",
ErrorMsg: "Password is incorrect",
}
body, err := json.Marshal(jwtResponse)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, writeErr := w.Write(body)
if writeErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
return
}
// Else send a login token
idBytes := make([]byte, 16)
_, userRecordErr = rand.Read(idBytes)
if userRecordErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
idStr := base64.StdEncoding.EncodeToString(idBytes)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "openfsd",
Subject: jwtRequest.CID,
Audience: []string{"fsd-live"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(420 * time.Second)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-120 * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: idStr,
},
ControllerRating: userRecord.Rating,
PilotRating: 0,
})
tokenString, userRecordErr := token.SignedString(JWTKey)
if userRecordErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
jwtResponse := JwtResponse{
Success: true,
Token: tokenString,
ErrorMsg: "",
}
body, userRecordErr := json.Marshal(jwtResponse)
if userRecordErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, writeErr := w.Write(body)
if writeErr != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func userAPIHandler(w http.ResponseWriter, r *http.Request) {
// Check for a valid token cookie
cookie, err := r.Cookie("token")
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
// Validate token
claims := jwt.RegisteredClaims{}
token, err := jwt.ParseWithClaims(cookie.Value, &claims, func(token *jwt.Token) (interface{}, error) {
return JWTKey, nil
})
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
audience, err := token.Claims.GetAudience()
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
audienceValid := false
for _, audClaim := range audience {
if audClaim == "administrator-dashboard" {
audienceValid = true
break
}
}
if !audienceValid {
w.WriteHeader(http.StatusForbidden)
return
}
// Handle the request
switch r.Method {
case "GET":
cidStr := r.FormValue("cid")
cid, err := strconv.Atoi(cidStr)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
userRecord, err := GetUserRecord(DB, cid)
if err != nil {
res := UserApiResponse{
Success: false,
Message: "Error: user not found",
}
resBytes, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(resBytes)
return
}
// omit password
userRecord.Password = ""
res := UserApiResponse{
Success: true,
Message: "Success",
User: userRecord,
}
resBytes, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(resBytes)
return
case "POST":
buf := new(bytes.Buffer)
_, err := buf.ReadFrom(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
req := FSDUserRecord{}
err = json.Unmarshal(buf.Bytes(), &req)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
bcryptBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
passwordHash := string(bcryptBytes)
record, err := AddUserRecordSequential(DB, passwordHash, req.Rating, req.RealName)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
record.Password = ""
res := UserApiResponse{
Success: true,
Message: "Success: added user with CID " + fmt.Sprintf("%d", record.CID),
User: record,
}
resBytes, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(resBytes)
return
case "PATCH":
buf := new(bytes.Buffer)
_, err := buf.ReadFrom(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
req := FSDUserRecord{}
err = json.Unmarshal(buf.Bytes(), &req)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(req.Password) > 0 {
bcryptBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
req.Password = string(bcryptBytes)
}
err = UpdateUserRecord(DB, &req)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
req.Password = ""
res := UserApiResponse{
Success: true,
Message: "Success: updated user with CID " + fmt.Sprintf("%d", req.CID),
User: &req,
}
resBytes, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(resBytes)
return
}
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
// If we have a valid cookie containing a valid JWT, send the dashboard page
cookie, err := r.Cookie("token")
if err == nil {
claims := jwt.RegisteredClaims{}
token, err := jwt.ParseWithClaims(cookie.Value, &claims, func(token *jwt.Token) (interface{}, error) {
return JWTKey, nil
})
// If the token is invalid, remove the cookie and send back basic auth
if err != nil {
cookie.Expires = time.Unix(0, 0)
cookie.Value = ""
http.SetCookie(w, cookie)
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
audience, err := token.Claims.GetAudience()
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
audienceValid := false
for _, audClaim := range audience {
if audClaim == "administrator-dashboard" {
audienceValid = true
break
}
}
if !audienceValid {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
w.Write(dashboardHtml)
return
}
// If we don't have a cookie, check if we were sent basic auth information
username, pwd, ok := r.BasicAuth()
// If not, send the basic auth header
if !ok {
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
cid, err := strconv.Atoi(username)
if err != nil {
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
userRecord, err := GetUserRecord(DB, cid)
if err != nil {
w.Header().Add("Content-Type", "text/plain")
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("user not found"))
return
}
if userRecord.Rating < protocol.NetworkRatingSUP {
w.Header().Add("Content-Type", "text/plain")
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("rating too low"))
return
}
err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(pwd))
if err != nil {
w.Header().Add("Content-Type", "text/plain")
w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("user not found"))
return
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: "openfsd",
Subject: string(rune(userRecord.CID)),
Audience: []string{"administrator-dashboard"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
})
tokenStr, err := token.SignedString(JWTKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: tokenStr,
Expires: time.Now().Add(12 * time.Hour),
})
w.WriteHeader(http.StatusOK)
w.Write(dashboardHtml)
}
func defaultHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
func StartHttpServer(ctx context.Context) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/fsd-jwt", fsdJwtApiHandler)
mux.HandleFunc("GET /dashboard", dashboardHandler)
mux.HandleFunc("/user", userAPIHandler)
mux.HandleFunc("/", defaultHandler)
server := &http.Server{Addr: SC.HttpListenAddr, Handler: mux}
go func() {
if SC.HttpsEnabled {
if err := server.ListenAndServeTLS(SC.TLSCertFile, SC.TLSKeyFile); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal("https server error:\n" + err.Error())
}
}
} else {
if err := server.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal("http server error:\n" + err.Error())
}
}
}
}()
log.Println("HTTP listening")
// Wait for context done signal
<-ctx.Done()
// Shutdown server
if err := server.Shutdown(context.Background()); err != nil {
log.Fatal("http server shutdown error:\n" + err.Error())
}
}