diff --git a/auth.go b/auth.go index 7511dc5..d00d93d 100644 --- a/auth.go +++ b/auth.go @@ -1,332 +1,188 @@ -// auth.go — core authentication logic and HTTP handlers -// -// The authentication flow is: -// -// 1. Extract the client's real IP (see NGINX config for how we set X-Real-IP). -// 2. Check the permanent whitelist. If the IP is whitelisted, allow. -// 3. Check the temporary whitelist. If the IP is whitelisted AND not expired, allow. -// 4. Fall back to HTTP Basic Auth. -// -// Why this order? -// -// - Permanent whitelist: checked first for speed (O(1) map lookup). -// - Temporary whitelist: checked second. The expiry check is also O(1). -// - Basic Auth: checked last because it requires hashing (bcrypt). -// -// Security note: we use X-Real-IP (set by NGINX from the TCP peer IP) rather -// than X-Forwarded-For. X-Forwarded-For is client-controlled and can be -// spoofed. If you need a CDN, configure the realip module instead of trusting -// the header. - package main import ( - "encoding/base64" + "database/sql" "encoding/json" - "fmt" - "log" + "log/slog" "net/http" "sort" "sync" "time" + + _ "modernc.org/sqlite" ) -// tempEntry represents a temporary whitelisted IP with a TTL. type tempEntry struct { IP string `json:"ip"` Reason string `json:"reason,omitempty"` Expires time.Time `json:"expires"` } -// tempWhitelist is the in-memory store for temporary whitelisted IPs. -// -// Why in-memory instead of a database? -// -// - The TTL-based expiry pattern is simple to implement with a map. -// - No external dependencies. -// - If you need persistence, add a SQLite/Redis layer later. -// -// The mutex protects concurrent reads and writes. type tempWhitelist struct { mu sync.RWMutex entries map[string]tempEntry } func newTempWhitelist() *tempWhitelist { - return &tempWhitelist{ - entries: make(map[string]tempEntry), - } + return &tempWhitelist{entries: make(map[string]tempEntry)} } -// PermanentWhitelist is the in-memory store for the permanent whitelist -// loaded from disk (see watcher.go). type PermanentWhitelist struct { mu sync.RWMutex entries map[string]struct{} } func newPermanentWhitelist() *PermanentWhitelist { - return &PermanentWhitelist{ - entries: make(map[string]struct{}), - } + return &PermanentWhitelist{entries: make(map[string]struct{})} } -// CheckAuth returns true if the request's IP is whitelisted or the -// Authorization header contains valid Basic Auth credentials. -// -// The function is stateless: it reads from the two whitelist maps -// and compares credentials. No session state. -// -// Why does this function return only a bool? -// -// - It keeps the function testable: you can feed it any Request -// and verify the result without spinning up a server. -// - The HTTP handler (authHandler) is responsible for translating -// the bool into the correct HTTP response. -func (tw *tempWhitelist) CheckAuth(r *http.Request, perm *PermanentWhitelist, user, pass string) bool { +func (tw *tempWhitelist) CheckAuth(r *http.Request, perm *PermanentWhitelist) bool { ip := r.Header.Get("X-Real-IP") if ip == "" { return false } - - // Check permanent whitelist (fast path). perm.mu.RLock() _, ok := perm.entries[ip] perm.mu.RUnlock() if ok { return true } - - // Check temporary whitelist. tw.mu.RLock() if e, exists := tw.entries[ip]; exists && e.Expires.After(time.Now()) { tw.mu.RUnlock() return true } tw.mu.RUnlock() - - // Fall back to Basic Auth. - return checkBasicAuth(r, user, pass) + return false } -// checkBasicAuth validates the HTTP Basic Auth header. -// -// Why not use a library like "golang.org/x/crypto/bcrypt"? -// -// - The standard library only supports plaintext comparison, which is -// what we need for a simple auth gateway. If you want password hashing, -// add bcrypt later. -// -// The implementation follows the RFC 7617 spec for Basic Auth. -func checkBasicAuth(r *http.Request, user, pass string) bool { - const prefix = "Basic " - auth := r.Header.Get("Authorization") - if auth == "" { - return false - } - - if len(auth) < len(prefix) || auth[:len(prefix)] != prefix { - return false - } - - decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) - if err != nil { - return false - } - - parts := splitColon(string(decoded)) - if len(parts) != 2 { - return false - } - - return parts[0] == user && parts[1] == pass -} - -// splitColon splits a string by the first colon, returning at most two parts. -// -// Why not strings.SplitN? -// -// - It works, but splitColon is more explicit about the intent and -// avoids the allocation of the full parts slice. -func splitColon(s string) []string { - i := 0 - for i < len(s) && s[i] != ':' { - i++ - } - return []string{s[:i], s[i+1:]} -} - -// --- API endpoints --- - -// authHandler is the NGINX auth_request endpoint. -// -// NGINX sends a subrequest to this endpoint. On 200 the original request -// proceeds. On 401/403 NGINX returns the response to the client. -// -// We set the WWW-Authenticate header on 401 so that browsers show the -// login dialog automatically. This is the standard HTTP Basic Auth flow. func authHandler(cfg Config, tw *tempWhitelist, perm *PermanentWhitelist) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !tw.CheckAuth(r, perm, cfg.BasicAuthUser, cfg.BasicAuthPass) { - // 401 Unauthorized — the browser should show a login dialog. - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + if !tw.CheckAuth(r, perm) { w.WriteHeader(http.StatusUnauthorized) return } - // 200 — allow the request. w.WriteHeader(http.StatusNoContent) } } -// whitelistTempHandler creates a new temporary whitelisted IP. -// -// Expected request: -// -// POST /api/whitelist/temp -// Authorization: Bearer -// -// Body (JSON): -// -// { -// "ip": "1.2.3.4", -// "ttl_seconds": 300, -// "reason": "my laptop" -// } -func (tw *tempWhitelist) whitelistTempHandler(cfg Config) http.HandlerFunc { +func (tw *tempWhitelist) whitelistTempHandler(cfg Config, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !verifyAPIKey(r, cfg.APIToken) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - type request struct { IP string `json:"ip"` TTLSeconds int `json:"ttl_seconds"` Reason string `json:"reason,omitempty"` } - var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } - if req.IP == "" || req.TTLSeconds <= 0 { - http.Error(w, "ip and ttl_seconds are required", http.StatusBadRequest) + http.Error(w, "ip and ttl_seconds required", http.StatusBadRequest) return } + expires := time.Now().Add(time.Duration(req.TTLSeconds) * time.Second) tw.mu.Lock() - tw.entries[req.IP] = tempEntry{ - IP: req.IP, - Reason: req.Reason, - Expires: time.Now().Add(time.Duration(req.TTLSeconds) * time.Second), - } + tw.entries[req.IP] = tempEntry{IP: req.IP, Reason: req.Reason, Expires: expires} tw.mu.Unlock() - log.Printf("whitelisted %s for %ds (%s)", req.IP, req.TTLSeconds, req.Reason) + go logWhitelistEvent(db, "add_temp", req.IP, req.TTLSeconds, req.Reason) + + slog.Info("whitelist added", "ip", req.IP, "ttl", req.TTLSeconds, "reason", req.Reason) w.WriteHeader(http.StatusNoContent) } } -// whitelistListHandler lists all currently active temporary whitelisted IPs. func (tw *tempWhitelist) whitelistListHandler(cfg Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !verifyAPIKey(r, cfg.APIToken) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - tw.mu.RLock() entries := make([]tempEntry, 0, len(tw.entries)) for _, e := range tw.entries { entries = append(entries, e) } tw.mu.RUnlock() - - // Sort by expiry time for predictable output. sort.Slice(entries, func(i, j int) bool { return entries[i].Expires.Before(entries[j].Expires) }) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } } -// whitelistDeleteHandler removes a temporary whitelisted IP. -func (tw *tempWhitelist) whitelistDeleteHandler(cfg Config) http.HandlerFunc { +func (tw *tempWhitelist) whitelistDeleteHandler(cfg Config, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !verifyAPIKey(r, cfg.APIToken) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - - ip := r.PathValue("ip") // or r.URL.Query()["ip"][0] for older Go + ip := r.PathValue("ip") if ip == "" { - http.Error(w, "ip is required", http.StatusBadRequest) + http.Error(w, "ip required", http.StatusBadRequest) return } - tw.mu.Lock() delete(tw.entries, ip) tw.mu.Unlock() - log.Printf("removed temporary whitelist for %s", ip) + go logWhitelistEvent(db, "delete_temp", ip, 0, "", getAPIUser(r)) + slog.Info("whitelist deleted", "ip", ip) w.WriteHeader(http.StatusNoContent) } } -// statusHandler is a health-check endpoint. -func statusHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - fmt.Fprintln(w, "ok") -} - -// newAuthServer creates the HTTP server with all routes. -// -// We use a single server instance for all endpoints so that the listener -// is created once and the route table is built once. This is simpler than -// multiple servers and avoids the complexity of a reverse-proxy setup -// within the service itself. -func newAuthServer(cfg Config, addr string) *http.Server { - mux := http.NewServeMux() - tw := newTempWhitelist() - perm := newPermanentWhitelist() - - // Auth endpoint (called by NGINX). - mux.HandleFunc("/auth", authHandler(cfg, tw, perm)) - - // API endpoints (admin-only, requires API key). - mux.HandleFunc("/api/whitelist/temp", tw.whitelistTempHandler(cfg)) - mux.HandleFunc("/api/whitelist", tw.whitelistListHandler(cfg)) - mux.HandleFunc("/api/whitelist/{ip}", tw.whitelistDeleteHandler(cfg)) - - // Health check. - mux.HandleFunc("/status", statusHandler) - - return &http.Server{ - Addr: addr, - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, +func logsHandler(cfg Config, db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !verifyAPIKey(r, cfg.APIToken) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + rows, err := db.Query(`SELECT timestamp, action, ip, ttl_seconds, reason, api_user + FROM whitelist_audit ORDER BY timestamp DESC LIMIT 100`) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + defer rows.Close() + var logs []map[string]interface{} + for rows.Next() { + var ts time.Time + var action, ip, reason, apiUser string + var ttl int + if err := rows.Scan(&ts, &action, &ip, &ttl, &reason); err != nil { + continue + } + logs = append(logs, map[string]interface{}{ + "timestamp": ts, + "action": action, + "ip": ip, + "ttl": ttl, + "reason": reason, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(logs) } } -// verifyAPIKey checks that the Authorization header contains the expected -// bearer token. -// -// Why bearer token for the API? -// -// - It's the standard convention for REST APIs. -// - Easy to test with curl: -H "Authorization: Bearer xxx". -// - Doesn't require username/password in the URL. -func verifyAPIKey(r *http.Request, expectedToken string) bool { - auth := r.Header.Get("Authorization") - return auth == "Bearer "+expectedToken +func statusHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("ok\n")) } + +func verifyAPIKey(r *http.Request, expectedToken string) bool { + return r.Header.Get("Authorization") == "Bearer "+expectedToken +} \ No newline at end of file