feat(18-01): implement chi server, auth middleware, overview handler
- Server struct with chi router, embedded template parsing, static file serving - AuthMiddleware supports Basic and Bearer token with constant-time comparison - Overview handler renders stats from providers/recon/storage when available - Nil-safe: works with zero config (shows zeroes, no DB required) - All 7 tests pass
This commit is contained in:
52
pkg/web/auth.go
Normal file
52
pkg/web/auth.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware returns HTTP middleware that enforces authentication when
|
||||||
|
// at least one of user/pass or token is configured. If all auth fields are
|
||||||
|
// empty, the middleware is a no-op passthrough.
|
||||||
|
//
|
||||||
|
// Supported schemes:
|
||||||
|
// - Bearer <token> — matches the configured token
|
||||||
|
// - Basic <base64> — matches the configured user:pass
|
||||||
|
func AuthMiddleware(user, pass, token string) func(http.Handler) http.Handler {
|
||||||
|
authEnabled := user != "" || token != ""
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !authEnabled {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
// Check bearer token first.
|
||||||
|
if token != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
provided := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check basic auth.
|
||||||
|
if user != "" {
|
||||||
|
u, p, ok := r.BasicAuth()
|
||||||
|
if ok &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="keyhunter"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
58
pkg/web/handlers.go
Normal file
58
pkg/web/handlers.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OverviewData holds template data for the overview dashboard page.
|
||||||
|
type OverviewData struct {
|
||||||
|
TotalKeys int
|
||||||
|
TotalProviders int
|
||||||
|
ReconSources int
|
||||||
|
LastScan string
|
||||||
|
RecentFindings []storage.Finding
|
||||||
|
PageTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOverview renders the overview dashboard page with aggregated stats.
|
||||||
|
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := OverviewData{
|
||||||
|
PageTitle: "Overview",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider count.
|
||||||
|
if s.cfg.Providers != nil {
|
||||||
|
data.TotalProviders = s.cfg.Providers.Stats().Total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recon source count.
|
||||||
|
if s.cfg.ReconEngine != nil {
|
||||||
|
data.ReconSources = len(s.cfg.ReconEngine.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent findings + total count from DB.
|
||||||
|
if s.cfg.DB != nil && s.cfg.EncKey != nil {
|
||||||
|
recent, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("web: listing recent findings: %v", err)
|
||||||
|
} else {
|
||||||
|
data.RecentFindings = recent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total count via a broader query.
|
||||||
|
all, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, storage.Filters{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("web: counting findings: %v", err)
|
||||||
|
} else {
|
||||||
|
data.TotalKeys = len(all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
log.Printf("web: rendering overview: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
pkg/web/server.go
Normal file
84
pkg/web/server.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/salvacybersec/keyhunter/pkg/dorks"
|
||||||
|
"github.com/salvacybersec/keyhunter/pkg/providers"
|
||||||
|
"github.com/salvacybersec/keyhunter/pkg/recon"
|
||||||
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all dependencies and settings needed by the web server.
|
||||||
|
type Config struct {
|
||||||
|
DB *storage.DB
|
||||||
|
EncKey []byte
|
||||||
|
Providers *providers.Registry
|
||||||
|
Dorks *dorks.Registry
|
||||||
|
ReconEngine *recon.Engine
|
||||||
|
Port int
|
||||||
|
AuthUser string
|
||||||
|
AuthPass string
|
||||||
|
AuthToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the KeyHunter web dashboard backed by chi.
|
||||||
|
type Server struct {
|
||||||
|
router chi.Router
|
||||||
|
cfg Config
|
||||||
|
tmpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a Server, parsing embedded templates and building routes.
|
||||||
|
func NewServer(cfg Config) (*Server, error) {
|
||||||
|
// Parse all templates from the embedded filesystem.
|
||||||
|
tmpl, err := template.ParseFS(templateFiles, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
tmpl: tmpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Middleware stack.
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// Auth middleware (no-op when auth fields are empty).
|
||||||
|
r.Use(AuthMiddleware(cfg.AuthUser, cfg.AuthPass, cfg.AuthToken))
|
||||||
|
|
||||||
|
// Static file serving from embedded FS.
|
||||||
|
staticSub, err := fs.Sub(staticFiles, "static")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating static sub-filesystem: %w", err)
|
||||||
|
}
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
|
|
||||||
|
// Page routes.
|
||||||
|
r.Get("/", s.handleOverview)
|
||||||
|
|
||||||
|
s.router = r
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router returns the chi router for testing.
|
||||||
|
func (s *Server) Router() chi.Router {
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe starts the HTTP server on the configured port.
|
||||||
|
func (s *Server) ListenAndServe() error {
|
||||||
|
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
||||||
|
return http.ListenAndServe(addr, s.router)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user