diff options
Diffstat (limited to 'cmd/mirumd/server_web.go')
| -rw-r--r-- | cmd/mirumd/server_web.go | 315 | +224 −91 |
1 files changed, 224 insertions, 91 deletions
diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go index 2ecdea4..fc4a6a6 100644 --- a/cmd/mirumd/server_web.go +++ b/cmd/mirumd/server_web.go @@ -15,6 +15,14 @@ import ( "io" "net" "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" @@ -28,106 +36,47 @@ var ( loginTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/login.html")) ) -func NewWebServer(ctx context.Context, srv *server) *http.Server { - mux := http.NewServeMux() - - mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - var data struct{ Email, CSRF string } - if c, err := r.Cookie("session"); err == nil { - if sess, err := srv.db.UserGetSession(r.Context(), c.Value); err == nil { - data.Email = sess.Email - data.CSRF = csrfToken(w, r) - } - } - indexTmpl.ExecuteTemplate(w, "layout", data) - }) - - mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "read body", http.StatusBadRequest) - return - } - - ev, err := srv.forge.Webhook(r, body) - if errors.Is(err, forges.ErrInvalidSignature) { - http.Error(w, "invalid signature", http.StatusUnauthorized) - return - } - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if ev == nil { - w.WriteHeader(http.StatusNoContent) - return - } - - srv.enqueue(ev) - w.WriteHeader(http.StatusAccepted) - }) +func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandler http.Handler) *http.Server { + h := &webHandler{srv: srv} - mux.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - loginTmpl.ExecuteTemplate(w, "layout", map[string]string{"CSRF": csrfToken(w, r)}) - }) + r := chi.NewRouter() - mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4096) - if !csrfOK(r) { - clearCookie(w, "csrf") - http.Error(w, "invalid csrf token", http.StatusForbidden) - return - } + r.Use(middleware.CleanPath) + r.Use(middleware.StripSlashes) + r.Use(middleware.RequestID) + r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Compress(5)) + r.Use(middleware.Heartbeat("/ping")) + r.Use(middleware.Timeout(30 * time.Second)) + r.Use(middleware.RequestSize(64 << 20)) // 64 MiB global body limit - email := r.FormValue("email") - password := r.FormValue("password") + // The authorization session sets the user to ctx + r.Use(h.SessionMiddleware) - userID, err := srv.db.UserVerifyPassword(r.Context(), email, password, []byte(srv.cfg.Pepper)) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - loginTmpl.ExecuteTemplate(w, "layout", map[string]string{ - "Error": "Invalid credentials", - "CSRF": csrfToken(w, r), - }) - return - } + r.Get("/", h.index) + r.Post("/webhook", h.webhook) - token, err := srv.db.UserCreateSession(r.Context(), userID) - if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } + r.Route("/auth", func(r chi.Router) { + r.Use(middleware.NoCache) + r.Use(httprate.LimitByIP(10, time.Minute)) + r.Use(middleware.RequestSize(4096)) - http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(database.SessionTTL.Seconds()), - }) - http.Redirect(w, r, "/", http.StatusSeeOther) + r.Get("/login", h.loginPage) + r.Post("/login", h.login) + r.Post("/logout", h.logout) }) - mux.HandleFunc("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) { - if !csrfOK(r) { - http.Error(w, "invalid csrf token", http.StatusForbidden) - return - } - if c, err := r.Cookie("session"); err == nil { - srv.db.UserDeleteSession(r.Context(), c.Value) - } - clearCookie(w, "session") - clearCookie(w, "csrf") - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.NoCache) + r.Use(httprate.LimitByIP(300, time.Minute)) + r.Mount(adminPath, adminHandler) }) - var tls_config *tls.Config = nil + var tlsCfg *tls.Config if srv.cfg.WebTls != nil { - tls_config = &tls.Config{ + tlsCfg = &tls.Config{ MinVersion: tls.VersionTLS13, GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { cert, err := tls.LoadX509KeyPair(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) @@ -137,14 +86,142 @@ func NewWebServer(ctx context.Context, srv *server) *http.Server { } return &http.Server{ - Handler: mux, - TLSConfig: tls_config, + Handler: r, + TLSConfig: tlsCfg, BaseContext: func(_ net.Listener) context.Context { return ctx }, } } +type callerKey struct{} + +type callerInfo struct { + UserID uuid.UUID + Email string + Superuser bool +} + +type webHandler struct { + srv *server +} + +// CallerFromContext returns the authenticated caller, or nil. +func CallerFromContext(ctx context.Context) *callerInfo { + if v, ok := ctx.Value(callerKey{}).(*callerInfo); ok { + return v + } + return nil +} + +// SessionMiddleware resolves the session cookie and puts callerInfo in context. +func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("session"); err == nil { + if sess, err := h.srv.db.UserGetSession(r.Context(), uuid.Nil, c.Value); err == nil { + caller := &callerInfo{ + UserID: sess.UserID, + Email: sess.Email, + } + ctx := context.WithValue(r.Context(), callerKey{}, caller) + r = r.WithContext(ctx) + } + } + next.ServeHTTP(w, r) + }) +} + +func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + var data struct{ Email, CSRF string } + if caller := CallerFromContext(r.Context()); caller != nil { + data.Email = caller.Email + data.CSRF = csrfToken(w, r) + } + indexTmpl.ExecuteTemplate(w, "layout", data) +} + +func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + + ev, err := h.srv.forge.Webhook(r, body) + if errors.Is(err, forges.ErrInvalidSignature) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if ev == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + h.srv.enqueue(ev) + w.WriteHeader(http.StatusAccepted) +} + +func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + loginTmpl.ExecuteTemplate(w, "layout", map[string]string{"CSRF": csrfToken(w, r)}) +} + +func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { + if !csrfOK(r) { + clearCookie(w, "csrf") + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + userID, err := h.srv.db.UserVerifyPassword(r.Context(), uuid.Nil, email, password, []byte(h.srv.cfg.Pepper)) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + loginTmpl.ExecuteTemplate(w, "layout", map[string]string{ + "Error": "Invalid credentials", + "CSRF": csrfToken(w, r), + }) + return + } + + token, err := h.srv.db.UserCreateSession(r.Context(), uuid.Nil, userID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(database.SessionTTL.Seconds()), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { + if !csrfOK(r) { + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + if c, err := r.Cookie("session"); err == nil { + h.srv.db.UserDeleteSession(r.Context(), uuid.Nil, c.Value) + } + clearCookie(w, "session") + clearCookie(w, "csrf") + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) +} + // csrfToken returns the current CSRF token, setting a cookie if absent. func csrfToken(w http.ResponseWriter, r *http.Request) string { if c, err := r.Cookie("csrf"); err == nil && c.Value != "" { @@ -184,3 +261,59 @@ func clearCookie(w http.ResponseWriter, name string) { MaxAge: -1, }) } + +// trustedProxyMiddleware resolves the real client IP from X-Forwarded-For, +// walking right-to-left and stopping at the first untrusted hop. +// Empty cidrs = trust RemoteAddr only (safe default). +func trustedProxyMiddleware(cidrs []string) func(http.Handler) http.Handler { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + _, n, err := net.ParseCIDR(c) + if err != nil { + panic("invalid trusted_proxies CIDR: " + c) + } + nets = append(nets, n) + } + + isTrusted := func(ip net.IP) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(nets) == 0 { + next.ServeHTTP(w, r) + return + } + + host, _, _ := net.SplitHostPort(r.RemoteAddr) + ip := net.ParseIP(host) + if ip == nil || !isTrusted(ip) { + // RemoteAddr is not a trusted proxy — use as-is. + next.ServeHTTP(w, r) + return + } + + // Walk X-Forwarded-For right to left. + xff := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(xff) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(xff[i]) + ip = net.ParseIP(candidate) + if ip == nil { + break // garbage — stop, don't trust anything further left + } + if !isTrusted(ip) { + r.RemoteAddr = candidate + ":0" + break + } + } + + next.ServeHTTP(w, r) + }) + } +} |
