Files
openfsd/web/frontend_handler.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

311 lines
8.3 KiB
Go

package web
import (
"database/sql"
"embed"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/renorris/openfsd/auth"
"github.com/renorris/openfsd/database"
"github.com/renorris/openfsd/protocol"
"github.com/renorris/openfsd/servercontext"
"golang.org/x/crypto/bcrypt"
"net/http"
"slices"
"strconv"
"time"
)
//go:embed static
var StaticFS embed.FS
// FrontendHandler handles UI-related HTTP calls
func FrontendHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1024)
switch r.URL.Path {
case "/login":
loginHandler(w, r)
case "/logout":
logoutHandler(w, r)
case "/dashboard":
dashboardHandler(w, r)
case "/admin_dashboard":
adminDashboardHandler(w, r)
case "/changepassword":
changePasswordHandler(w, r)
case "/":
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
default:
http.NotFound(w, r)
}
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
// Load login page
if err := RenderTemplate(w, "login.html", nil); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
case "POST":
// Handle login
if err := r.ParseForm(); err != nil {
http.Error(w, "unable to parse form values", http.StatusBadRequest)
}
var cid, password string
if cid = r.PostForm.Get("cid"); cid == "" {
http.Error(w, "CID query parameter not found", http.StatusBadRequest)
return
}
if password = r.PostForm.Get("password"); password == "" {
http.Error(w, "password query parameter not found", http.StatusBadRequest)
return
}
var cidInt int
var err error
if cidInt, err = strconv.Atoi(cid); err != nil {
http.Error(w, "invalid CID", http.StatusBadRequest)
return
}
// Load user record from database
userRecord := database.FSDUserRecord{}
if err = userRecord.LoadByCID(servercontext.DB(), cidInt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "invalid login", http.StatusUnauthorized)
} else {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
// Verify password
if err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(password)); err != nil {
http.Error(w, "invalid login", http.StatusUnauthorized)
return
}
// Verify account standing
if userRecord.NetworkRating <= protocol.NetworkRatingSUS {
http.Error(w, "account suspended/inactive", http.StatusForbidden)
return
}
// Administer a token
// Use "dashboard" audience to specify that this is a web frontend token; not for connecting to FSD.
claims := auth.NewFSDJWTClaims(
userRecord.CID, userRecord.NetworkRating,
userRecord.PilotRating, []string{"dashboard"})
now := time.Now()
expires := now.Add(24 * time.Hour)
var token string
if token, err = claims.MakeToken(expires); err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Set-Cookie", fmt.Sprintf("token=%s; Expires=%s", token, expires.Format(http.TimeFormat)))
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
func logoutHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
deleteCookie(w, "token")
http.Redirect(w, r, "/login", http.StatusSeeOther)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
var userRecord *database.FSDUserRecord
var err error
if userRecord, _, err = frontendSessionMiddleware(w, r); err != nil {
return
}
if err = RenderTemplate(w, "dashboard.html", DashboardPageData{UserRecord: userRecord}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
func changePasswordHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
var userRecord *database.FSDUserRecord
var err error
if userRecord, _, err = frontendSessionMiddleware(w, r); err != nil {
return
}
// Handle form parameters
if err := r.ParseForm(); err != nil {
http.Error(w, "unable to parse form values", http.StatusBadRequest)
}
var oldPassword, newPassword string
var changeFSDPassword bool
oldPassword = r.PostForm.Get("old_password")
if newPassword = r.PostForm.Get("new_password"); newPassword == "" {
http.Error(w, "new password query parameter not found", http.StatusBadRequest)
return
}
if changeFSDPasswordStr := r.PostForm.Get("change_fsd_password"); changeFSDPasswordStr == "" {
http.Error(w, "change fsd password query parameter not found", http.StatusBadRequest)
return
} else {
switch changeFSDPasswordStr {
case "true":
changeFSDPassword = true
case "false":
changeFSDPassword = false
default:
http.Error(w, "change fsd password query parameter must be true or false", http.StatusBadRequest)
return
}
}
if len(newPassword) < 8 {
http.Error(w, "password must be 8 or more characters", http.StatusBadRequest)
return
}
if changeFSDPassword {
userRecord.Password = ""
userRecord.FSDPassword = newPassword
} else {
if err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(oldPassword)); err != nil {
http.Error(w, "old password is incorrect", http.StatusUnauthorized)
return
}
userRecord.FSDPassword = ""
userRecord.Password = newPassword
}
if err = userRecord.Update(servercontext.DB()); err != nil {
http.Error(w, "unable to update user record", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
func adminDashboardHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
var claims *auth.FSDJWTClaims
var err error
if _, claims, err = frontendSessionMiddleware(w, r); err != nil {
return
}
if claims.ControllerRating() < protocol.NetworkRatingSUP {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err = RenderTemplate(w, "admin_dashboard.html", nil); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
func deleteCookie(w http.ResponseWriter, name string) {
w.Header().Set("Set-Cookie", fmt.Sprintf("%s=; Expires=%s", name, time.Unix(0, 0).Format(http.TimeFormat)))
}
func frontendSessionMiddleware(w http.ResponseWriter, r *http.Request) (userRecord *database.FSDUserRecord, claims *auth.FSDJWTClaims, err error) {
// Get token cookie
var tokenStr string
if cookies := r.CookiesNamed("token"); len(cookies) != 1 {
deleteCookie(w, "token")
http.Redirect(w, r, "/login", http.StatusSeeOther)
err = errors.New("invalid cookie length")
return
} else {
tokenStr = cookies[0].Value
}
// Validate token
var token *jwt.Token
if token, err = (auth.DefaultVerifier{}).VerifyJWT(tokenStr); err != nil {
deleteCookie(w, "token")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Parse claims
claims = &auth.FSDJWTClaims{}
if err = claims.Parse(token); err != nil {
http.Error(w, "invalid token claims", http.StatusBadRequest)
return
}
if !slices.Contains(claims.Audience(), "dashboard") {
http.Error(w, "invalid token audience", http.StatusBadRequest)
err = errors.New("token claims does not include 'dashboard'")
return
}
// Load user record
userRecord = &database.FSDUserRecord{}
if err = userRecord.LoadByCID(servercontext.DB(), claims.CID()); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "invalid CID", http.StatusForbidden)
} else {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
// Verify claims match database record
if userRecord.CID != claims.CID() {
http.Error(w, "claimed CID does not match CID on record", http.StatusForbidden)
return
}
if userRecord.NetworkRating != claims.ControllerRating() {
http.Error(w, "claimed network rating does not match rating on record", http.StatusForbidden)
return
}
if userRecord.PilotRating != claims.PilotRating() {
http.Error(w, "claimed pilot rating does not match rating on record", http.StatusForbidden)
return
}
return userRecord, claims, nil
}