| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569 |
- package server
- import (
- "bytes"
- "context"
- "crypto/subtle"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "log/slog"
- "mime"
- "net/http"
- "path"
- "strings"
- "sync"
- "time"
- "vocat/internal/auth"
- "vocat/internal/loghub"
- "vocat/internal/store"
- "vocat/internal/vowifi"
- )
- const (
- sessionCookieName = "vocat_session"
- csrfCookieName = "vocat_csrf"
- csrfHeaderName = "X-CSRF-Token"
- )
- type Options struct {
- Store *store.Store
- Auth *auth.Service
- Devices DeviceController
- VoWiFi VoWiFiController
- Logs *loghub.Hub
- Assets fs.FS
- Logger *slog.Logger
- SecureCookies bool
- MaxRequestBodyBytes int64
- }
- // Server is the single HTTP handler for the JSON API and embedded SPA.
- type Server struct {
- store *store.Store
- auth *auth.Service
- devices DeviceController
- vowifi VoWiFiController
- logs *loghub.Hub
- assets fs.FS
- indexHTML []byte
- fileServer http.Handler
- logger *slog.Logger
- secureCookies bool
- maxRequestBodyBytes int64
- startedAt time.Time
- handler http.Handler
- websheets *websheetManager
- accessMu sync.RWMutex
- access parsedAccessConfig
- loginLimiter *loginRateLimiter
- }
- func New(options Options) (*Server, error) {
- if options.Store == nil {
- return nil, errors.New("server: store is required")
- }
- if options.Auth == nil {
- return nil, errors.New("server: auth service is required")
- }
- if options.Assets == nil {
- return nil, errors.New("server: SPA assets are required")
- }
- indexHTML, err := fs.ReadFile(options.Assets, "index.html")
- if err != nil {
- return nil, fmt.Errorf("server: read embedded index.html: %w", err)
- }
- if options.Logger == nil {
- options.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
- }
- if options.MaxRequestBodyBytes <= 0 {
- options.MaxRequestBodyBytes = 1 << 20
- }
- server := &Server{
- store: options.Store,
- auth: options.Auth,
- devices: options.Devices,
- vowifi: options.VoWiFi,
- logs: options.Logs,
- assets: options.Assets,
- indexHTML: indexHTML,
- fileServer: http.FileServer(http.FS(options.Assets)),
- logger: options.Logger,
- secureCookies: options.SecureCookies,
- maxRequestBodyBytes: options.MaxRequestBodyBytes,
- startedAt: time.Now().UTC(),
- websheets: newWebsheetManager(),
- loginLimiter: newLoginRateLimiter(),
- }
- server.loadAccessConfig(context.Background())
- server.loadUILanguage(context.Background())
- mux := http.NewServeMux()
- mux.HandleFunc("/api/health", server.handleHealth)
- mux.HandleFunc("/api/auth/login", server.handleLogin)
- mux.HandleFunc("/api/auth/session", server.handleSession)
- mux.HandleFunc("/api/auth/logout", server.handleLogout)
- mux.HandleFunc("/api", server.handleAPI)
- mux.HandleFunc("/api/", server.handleAPI)
- mux.HandleFunc("/websheets/", server.handleWebsheet)
- mux.HandleFunc("/", server.handleSPA)
- server.handler = server.recoverPanics(
- server.securityHeaders(server.accessControl(server.logRequests(mux))),
- )
- return server, nil
- }
- // VoWiFiController is the asynchronous runtime boundary used by the HTTP
- // layer. State transitions continue after the request completes and are
- // surfaced by the normal device status endpoints.
- type VoWiFiController interface {
- State(string) (vowifi.State, error)
- RequestEnabled(string, bool) (vowifi.State, error)
- RequestReconnect(string) (vowifi.State, error)
- }
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- s.handler.ServeHTTP(w, r)
- }
- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
- defer cancel()
- if err := s.store.Ready(ctx); err != nil {
- s.logger.Error("health check failed", "error", err)
- writeError(w, http.StatusServiceUnavailable, "unavailable", "service is not ready")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "status": "ok",
- "database": "ok",
- "time": time.Now().UTC().Format(time.RFC3339),
- },
- })
- }
- func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Cache-Control", "no-store")
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- var request struct {
- Username string `json:"username"`
- Password string `json:"password"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- limiterKey := s.loginKey(r, request.Username)
- if retryAfter, locked := s.loginLimiter.checkLocked(limiterKey); locked {
- s.auditAuth(r, request.Username, "locked")
- w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())+1))
- writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
- return
- }
- credentials, err := s.auth.Login(r.Context(), request.Username, request.Password)
- if errors.Is(err, auth.ErrInvalidCredentials) {
- lockout, newlyLocked := s.loginLimiter.recordFailure(limiterKey)
- s.auditAuth(r, request.Username, "failure")
- if newlyLocked {
- w.Header().Set("Retry-After", fmt.Sprintf("%d", int(lockout.Seconds())))
- writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
- return
- }
- writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
- return
- }
- if err != nil {
- s.logger.Error("login failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- return
- }
- s.loginLimiter.recordSuccess(limiterKey)
- s.auditAuth(r, credentials.Principal.Username, "success")
- s.setAuthCookies(w, credentials.SessionToken, credentials.CSRFToken, credentials.ExpiresAt)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "user": credentials.Principal,
- "csrf_token": credentials.CSRFToken,
- "expires_at": credentials.ExpiresAt.Format(time.RFC3339),
- "authenticated": true,
- "status": "ok",
- },
- })
- }
- // loginKey builds the rate-limit key from the client address and username so
- // brute-force attempts against one account from one source are throttled.
- func (s *Server) loginKey(r *http.Request, username string) string {
- address := s.currentAccessConfig().clientIP(r)
- return address.String() + "|" + strings.ToLower(strings.TrimSpace(username))
- }
- func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Cache-Control", "no-store")
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- sessionToken, ok := s.sessionToken(w, r)
- if !ok {
- return
- }
- existingCSRF := ""
- if cookie, cookieErr := r.Cookie(csrfCookieName); cookieErr == nil {
- existingCSRF = cookie.Value
- }
- session, csrfToken, err := s.auth.CSRFToken(r.Context(), sessionToken, existingCSRF)
- if errors.Is(err, auth.ErrUnauthorized) {
- s.clearAuthCookies(w)
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- return
- }
- if err != nil {
- s.logger.Error("load session failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- return
- }
- s.setCSRFCookie(w, csrfToken, session.ExpiresAt)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "user": session.Principal,
- "csrf_token": csrfToken,
- "expires_at": session.ExpiresAt.Format(time.RFC3339),
- "authenticated": true,
- },
- })
- }
- func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Cache-Control", "no-store")
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- sessionToken, ok := s.sessionToken(w, r)
- if !ok {
- return
- }
- csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
- if !ok {
- return
- }
- if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
- switch {
- case errors.Is(err, auth.ErrUnauthorized):
- s.clearAuthCookies(w)
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- case errors.Is(err, auth.ErrInvalidCSRF):
- writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
- default:
- s.logger.Error("logout validation failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- }
- return
- }
- if err := s.auth.Logout(r.Context(), sessionToken); err != nil {
- s.logger.Error("logout failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- return
- }
- s.clearAuthCookies(w)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]bool{"logged_out": true},
- })
- }
- func (s *Server) handleAPINotFound(w http.ResponseWriter, r *http.Request) {
- writeError(w, http.StatusNotFound, "not_found", "API endpoint not found")
- }
- func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
- // The UI language preference is not sensitive; exposing the read side lets
- // the login page render in the persisted language before authentication.
- if r.Method == http.MethodGet &&
- strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") == "settings/preferences" {
- s.writeUIPreferences(w, r)
- return
- }
- if !s.requireAuthenticated(w, r) {
- return
- }
- if r.Method != http.MethodGet &&
- r.Method != http.MethodHead &&
- r.Method != http.MethodOptions {
- sessionToken, ok := s.sessionToken(w, r)
- if !ok {
- return
- }
- csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
- if !ok {
- return
- }
- if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
- switch {
- case errors.Is(err, auth.ErrUnauthorized):
- s.clearAuthCookies(w)
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- case errors.Is(err, auth.ErrInvalidCSRF):
- writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
- default:
- s.logger.Error("API CSRF validation failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- }
- return
- }
- }
- if s.routeDeviceAPI(w, r) {
- return
- }
- if s.routeGeneralAPI(w, r) {
- return
- }
- s.handleAPINotFound(w, r)
- }
- func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet && r.Method != http.MethodHead {
- w.Header().Set("Allow", "GET, HEAD")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- return
- }
- name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
- if name != "." && fs.ValidPath(name) {
- if info, err := fs.Stat(s.assets, name); err == nil && info.Mode().IsRegular() {
- if strings.HasPrefix(name, "assets/") {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
- }
- s.fileServer.ServeHTTP(w, r)
- return
- }
- }
- w.Header().Set("Cache-Control", "no-cache")
- w.Header().Set("Content-Type", mime.TypeByExtension(".html"))
- http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(s.indexHTML))
- }
- func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
- contentType := r.Header.Get("Content-Type")
- if contentType != "" {
- mediaType, _, err := mime.ParseMediaType(contentType)
- if err != nil ||
- (mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json")) {
- return errors.New("Content-Type must be application/json")
- }
- }
- r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
- decoder := json.NewDecoder(r.Body)
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(destination); err != nil {
- var maxBytesError *http.MaxBytesError
- if errors.As(err, &maxBytesError) {
- return fmt.Errorf("request body exceeds %d bytes", s.maxRequestBodyBytes)
- }
- return errors.New("request body must contain one valid JSON object")
- }
- var trailing any
- if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
- return errors.New("request body must contain one valid JSON object")
- }
- return nil
- }
- func (s *Server) sessionToken(w http.ResponseWriter, r *http.Request) (string, bool) {
- cookie, err := r.Cookie(sessionCookieName)
- if err != nil || cookie.Value == "" {
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- return "", false
- }
- return cookie.Value, true
- }
- func (s *Server) requireAuthenticated(w http.ResponseWriter, r *http.Request) bool {
- sessionToken, ok := s.sessionToken(w, r)
- if !ok {
- return false
- }
- if _, err := s.auth.Authenticate(r.Context(), sessionToken); err != nil {
- if errors.Is(err, auth.ErrUnauthorized) {
- s.clearAuthCookies(w)
- writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
- } else {
- s.logger.Error("request authentication failed", "error", err)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- }
- return false
- }
- return true
- }
- func (s *Server) validateDoubleSubmitCSRF(w http.ResponseWriter, r *http.Request) (string, bool) {
- headerToken := r.Header.Get(csrfHeaderName)
- cookie, err := r.Cookie(csrfCookieName)
- if err != nil || headerToken == "" || cookie.Value == "" ||
- subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie.Value)) != 1 {
- writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
- return "", false
- }
- return headerToken, true
- }
- func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken string, csrfToken string, expiresAt time.Time) {
- maxAge := int(time.Until(expiresAt).Seconds())
- if maxAge < 1 {
- maxAge = 1
- }
- http.SetCookie(w, &http.Cookie{
- Name: sessionCookieName,
- Value: sessionToken,
- Path: "/",
- Expires: expiresAt,
- MaxAge: maxAge,
- HttpOnly: true,
- Secure: s.secureCookies,
- SameSite: http.SameSiteStrictMode,
- })
- s.setCSRFCookie(w, csrfToken, expiresAt)
- }
- func (s *Server) setCSRFCookie(w http.ResponseWriter, csrfToken string, expiresAt time.Time) {
- maxAge := int(time.Until(expiresAt).Seconds())
- if maxAge < 1 {
- maxAge = 1
- }
- http.SetCookie(w, &http.Cookie{
- Name: csrfCookieName,
- Value: csrfToken,
- Path: "/",
- Expires: expiresAt,
- MaxAge: maxAge,
- HttpOnly: false,
- Secure: s.secureCookies,
- SameSite: http.SameSiteStrictMode,
- })
- }
- func (s *Server) clearAuthCookies(w http.ResponseWriter) {
- for _, name := range []string{sessionCookieName, csrfCookieName} {
- http.SetCookie(w, &http.Cookie{
- Name: name,
- Value: "",
- Path: "/",
- Expires: time.Unix(1, 0),
- MaxAge: -1,
- HttpOnly: name == sessionCookieName,
- Secure: s.secureCookies,
- SameSite: http.SameSiteStrictMode,
- })
- }
- }
- func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool {
- if r.Method == allowed {
- return true
- }
- w.Header().Set("Allow", allowed)
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- return false
- }
- type statusWriter struct {
- http.ResponseWriter
- status int
- }
- func (w *statusWriter) Unwrap() http.ResponseWriter {
- return w.ResponseWriter
- }
- func (w *statusWriter) WriteHeader(status int) {
- if w.status != 0 {
- return
- }
- w.status = status
- w.ResponseWriter.WriteHeader(status)
- }
- func (w *statusWriter) Write(data []byte) (int, error) {
- if w.status == 0 {
- w.WriteHeader(http.StatusOK)
- }
- return w.ResponseWriter.Write(data)
- }
- func (s *Server) logRequests(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- startedAt := time.Now()
- writer := &statusWriter{ResponseWriter: w}
- next.ServeHTTP(writer, r)
- status := writer.status
- if status == 0 {
- status = http.StatusOK
- }
- s.logger.Info(
- "http request",
- "method", r.Method,
- "path", r.URL.Path,
- "status", status,
- "duration", time.Since(startedAt),
- )
- })
- }
- func (s *Server) securityHeaders(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Referrer-Policy", "same-origin")
- w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
- if strings.HasPrefix(r.URL.Path, "/websheets/") {
- // The self-hosted E911 websheet is embedded in an iframe by the SPA, so
- // it must be frameable same-origin. Every other route stays DENY.
- w.Header().Set("X-Frame-Options", "SAMEORIGIN")
- w.Header().Set(
- "Content-Security-Policy",
- "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; "+
- "object-src 'none'; form-action 'self'; "+
- "script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
- "img-src 'self' data:; connect-src 'self'",
- )
- } else {
- w.Header().Set("X-Frame-Options", "DENY")
- w.Header().Set(
- "Content-Security-Policy",
- "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; "+
- "object-src 'none'; form-action 'self'; "+
- "script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
- "img-src 'self' data:; connect-src 'self'",
- )
- }
- if s.secureCookies {
- w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
- }
- next.ServeHTTP(w, r)
- })
- }
- func (s *Server) recoverPanics(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- defer func() {
- if recovered := recover(); recovered != nil {
- s.logger.Error("panic while serving request", "panic", recovered)
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- }
- }()
- next.ServeHTTP(w, r)
- })
- }
|