// 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" "encoding/json" "fmt" "log" "net/http" "sort" "sync" "time" ) // 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), } } // 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{}), } } // 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 { 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) } // 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"`) 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 { 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) return } 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.mu.Unlock() log.Printf("whitelisted %s for %ds (%s)", req.IP, req.TTLSeconds, 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 { 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 if ip == "" { http.Error(w, "ip is required", http.StatusBadRequest) return } tw.mu.Lock() delete(tw.entries, ip) tw.mu.Unlock() log.Printf("removed temporary whitelist for %s", 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, } } // 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 }