mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
510 lines
12 KiB
Go
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: fmt.Sprintf("%d", 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())
|
|
}
|
|
}
|