From cedc0c219211620a9383568da3963a6769d8b616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= <95761178+GoncaloRodri@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:30:19 +0100 Subject: [PATCH] feat: self-contained auth system for standalone cloud deployment (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(k8s): expose / without auth so homepage is publicly reachable Adds a second Ingress (api-public) for the exact path / with no forward-auth middleware. Traefik prefers the Exact match for the root, while the Prefix ingress (with auth) still protects all other routes. Co-Authored-By: Claude Sonnet 4.6 * fix: homepage renders correctly at / for unauthenticated visitors Two fixes: 1. Added parseStandalone() helper — parseTmpl() roots on "" but ParseFS() stores standalone (no {{define}}) files under their base filename, so Execute() ran the empty root and returned Content-Length: 0. 2. Added router.priority: 100 annotation to api-public ingress so Traefik picks the Exact / rule over the Prefix / rule (Traefik ranks by rule string length by default, which made PathPrefix beat Path). Co-Authored-By: Claude Sonnet 4.6 * fix(k8s): remove forward-auth middleware from finance ingress The app now handles its own auth at /auth/login — Traefik no longer needs to forward-auth requests, which was causing redirects to auth.homelab.local instead of finance.homelab.local. Co-Authored-By: Claude Sonnet 4.6 * feat(auth): harden authentication for cloud deployment 1. Secure cookie flag — set when BASE_URL starts with https:// 2. SameSite=Strict on session cookie (was Lax) 3. Rate limiter — per-IP, 10 failures → 15-min lockout, auto-cleanup goroutine 4. Session rotation on login — old session deleted before issuing new one (prevents session fixation attacks) 5. bcrypt cost 12 (was DefaultCost/10, OWASP minimum for cloud) 6. Security headers middleware on all responses: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy, HSTS (when HTTPS) 7. Structured audit logging — login success/failure/lockout with IP + email 8. Google OAuth state cookie gets Secure flag too Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Gonçalo Rodrigues Co-authored-by: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 28 ++- .../finance/services/api/main/handler_auth.go | 168 +++++++++++++++--- .../finance/services/api/main/handler_test.go | 2 +- apps/finance/services/api/main/main.go | 2 +- 4 files changed, 172 insertions(+), 28 deletions(-) diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 4ea0eb6..13b37fe 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -350,7 +350,8 @@ type Handler struct { secret string // HMAC key for session tokens googleID string googleSecret string - baseURL string // used to build OAuth redirect URLs + baseURL string // used to build OAuth redirect URLs and detect HTTPS + loginRL *loginRateLimiter } func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *Handler { @@ -360,9 +361,34 @@ func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *H googleID: googleID, googleSecret: googleSecret, baseURL: baseURL, + loginRL: newLoginRateLimiter(), } } +// securityHeaders adds defence-in-depth HTTP headers to every response. +func (h *Handler) securityHeaders(next http.Handler) http.Handler { + csp := strings.Join([]string{ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net", // Chart.js + inline scripts in templates + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + }, "; ") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", csp) + if h.isSecure() { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + } + next.ServeHTTP(w, r) + }) +} + func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a := h.getAuth(r) diff --git a/apps/finance/services/api/main/handler_auth.go b/apps/finance/services/api/main/handler_auth.go index 364bfaf..2d4c2b6 100644 --- a/apps/finance/services/api/main/handler_auth.go +++ b/apps/finance/services/api/main/handler_auth.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -20,11 +21,112 @@ import ( ) const ( - cookieName = "finsession" - sessionTTL = 30 * 24 * time.Hour + cookieName = "finsession" + sessionTTL = 30 * 24 * time.Hour + bcryptCost = 12 // OWASP minimum for cloud deployments ) -// ── session token ───────────────────────────────────────────────────────────── +// ── rate limiter ────────────────────────────────────────────────────────────── + +const ( + rlMaxFailures = 10 + rlWindow = 15 * time.Minute + rlLockout = 15 * time.Minute +) + +type rlEntry struct { + mu sync.Mutex + failures int + windowStart time.Time + lockedUntil time.Time +} + +type loginRateLimiter struct { + entries sync.Map +} + +func newLoginRateLimiter() *loginRateLimiter { + rl := &loginRateLimiter{} + go func() { + for range time.Tick(10 * time.Minute) { + rl.cleanup() + } + }() + return rl +} + +func (l *loginRateLimiter) entry(ip string) *rlEntry { + v, _ := l.entries.LoadOrStore(ip, &rlEntry{windowStart: time.Now()}) + return v.(*rlEntry) +} + +// allow returns true if the IP may attempt a login right now. +func (l *loginRateLimiter) allow(ip string) bool { + e := l.entry(ip) + e.mu.Lock() + defer e.mu.Unlock() + now := time.Now() + if now.Before(e.lockedUntil) { + return false + } + if now.After(e.windowStart.Add(rlWindow)) { + e.failures = 0 + e.windowStart = now + } + return true +} + +func (l *loginRateLimiter) failure(ip string) { + e := l.entry(ip) + e.mu.Lock() + defer e.mu.Unlock() + e.failures++ + if e.failures >= rlMaxFailures { + e.lockedUntil = time.Now().Add(rlLockout) + slog.Warn("auth: IP locked out after repeated failures", "ip", ip, "failures", e.failures) + } +} + +func (l *loginRateLimiter) success(ip string) { + l.entries.Delete(ip) +} + +func (l *loginRateLimiter) cleanup() { + now := time.Now() + l.entries.Range(func(k, v any) bool { + e := v.(*rlEntry) + e.mu.Lock() + stale := now.After(e.lockedUntil) && now.After(e.windowStart.Add(rlWindow)) + e.mu.Unlock() + if stale { + l.entries.Delete(k) + } + return true + }) +} + +// clientIP extracts the real client IP, honouring X-Forwarded-For from a +// trusted proxy (Traefik / cloud load balancer). +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.Index(xff, ","); i > 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if ip, _, _ := strings.Cut(r.RemoteAddr, ":"); ip != "" { + return ip + } + return r.RemoteAddr +} + +// ── session helpers ─────────────────────────────────────────────────────────── + +// isSecure reports whether the deployment is behind HTTPS, which controls the +// Secure cookie flag and HSTS header. +func (h *Handler) isSecure() bool { + return strings.HasPrefix(h.baseURL, "https://") +} func (h *Handler) signSessionID(id string) string { mac := hmac.New(sha256.New, []byte(h.secret)) @@ -58,13 +160,16 @@ func (h *Handler) authFromSession(r *http.Request) (authInfo, bool) { if err != nil || sess == nil || time.Now().After(sess.ExpiresAt) { return authInfo{}, false } - return authInfo{ - UserID: sess.UserID.Hex(), - Email: sess.Email, - }, true + return authInfo{UserID: sess.UserID.Hex(), Email: sess.Email}, true } func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bson.ObjectID, email string) error { + // Rotate: delete any existing session to prevent session fixation. + if cookie, err := r.Cookie(cookieName); err == nil { + if id, ok := h.verifySessionToken(cookie.Value); ok { + _ = h.store.deleteAuthSession(r.Context(), id) + } + } sess := &AuthSession{ UserID: userID, Email: email, @@ -78,7 +183,8 @@ func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bs Value: h.signSessionID(sess.ID.Hex()), Path: "/", HttpOnly: true, - SameSite: http.SameSiteLaxMode, + Secure: h.isSecure(), + SameSite: http.SameSiteStrictMode, MaxAge: int(sessionTTL.Seconds()), }) return nil @@ -97,7 +203,6 @@ func clearSessionCookie(w http.ResponseWriter) { // ── login ───────────────────────────────────────────────────────────────────── func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) { - // Already signed in → go to dashboard if a, ok := h.authFromSession(r); ok && a.UserID != "" { http.Redirect(w, r, "/dashboard", http.StatusFound) return @@ -106,16 +211,23 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) { h.authLoginPost(w, r) return } + errMsg := "" + if r.URL.Query().Get("error") == "oauth" { + errMsg = "Google sign-in failed. Please try again or use email and password." + } renderRaw(w, authLoginTmpl, map[string]any{ "GoogleEnabled": h.googleID != "", + "Error": errMsg, }) } func (h *Handler) authLoginPost(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) email := strings.TrimSpace(r.FormValue("email")) - password := r.FormValue("password") fail := func(msg string) { + h.loginRL.failure(ip) + slog.Warn("auth: login failed", "ip", ip, "email", email) renderRaw(w, authLoginTmpl, map[string]any{ "Error": msg, "Email": email, @@ -123,6 +235,13 @@ func (h *Handler) authLoginPost(w http.ResponseWriter, r *http.Request) { }) } + if !h.loginRL.allow(ip) { + slog.Warn("auth: login blocked by rate limiter", "ip", ip) + http.Error(w, "Too many failed attempts. Try again in 15 minutes.", http.StatusTooManyRequests) + return + } + + password := r.FormValue("password") if email == "" || password == "" { fail("Email and password are required.") return @@ -136,10 +255,14 @@ func (h *Handler) authLoginPost(w http.ResponseWriter, r *http.Request) { fail("Invalid email or password.") return } + + h.loginRL.success(ip) if err := h.startSession(w, r, user.ID, user.Email); err != nil { http.Error(w, "session error", http.StatusInternalServerError) return } + slog.Info("auth: login successful", "user_id", user.ID.Hex(), "email", user.Email, "ip", ip) + next := r.URL.Query().Get("next") if next == "" || !strings.HasPrefix(next, "/") { next = "/dashboard" @@ -158,9 +281,7 @@ func (h *Handler) AuthRegister(w http.ResponseWriter, r *http.Request) { h.authRegisterPost(w, r) return } - renderRaw(w, authRegisterTmpl, map[string]any{ - "GoogleEnabled": h.googleID != "", - }) + renderRaw(w, authRegisterTmpl, map[string]any{"GoogleEnabled": h.googleID != ""}) } func (h *Handler) authRegisterPost(w http.ResponseWriter, r *http.Request) { @@ -199,7 +320,7 @@ func (h *Handler) authRegisterPost(w http.ResponseWriter, r *http.Request) { fail("An account with that email already exists.") return } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return @@ -216,6 +337,7 @@ func (h *Handler) authRegisterPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "session error", http.StatusInternalServerError) return } + slog.Info("auth: new account registered", "user_id", user.ID.Hex(), "email", user.Email) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } @@ -264,7 +386,8 @@ func (h *Handler) AuthGoogleStart(w http.ResponseWriter, r *http.Request) { Value: state, Path: "/auth", HttpOnly: true, - SameSite: http.SameSiteLaxMode, + Secure: h.isSecure(), + SameSite: http.SameSiteLaxMode, // Lax required — the OAuth redirect is cross-site MaxAge: 600, }) params := url.Values{ @@ -296,18 +419,17 @@ func (h *Handler) AuthGoogleCallback(w http.ResponseWriter, r *http.Request) { } token, err := h.googleExchangeCode(r.Context(), code) if err != nil { - slog.Error("google token exchange", "err", err) + slog.Error("auth: google token exchange failed", "err", err) http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound) return } gUser, err := h.googleUserInfo(r.Context(), token) if err != nil { - slog.Error("google userinfo", "err", err) + slog.Error("auth: google userinfo failed", "err", err) http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound) return } - // Find by OAuth provider first, then fall back to matching email user, err := h.store.findAuthUserByProvider(r.Context(), "google", gUser.Sub) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) @@ -321,18 +443,14 @@ func (h *Handler) AuthGoogleCallback(w http.ResponseWriter, r *http.Request) { } } if user == nil { - user = &AuthUser{ - Email: gUser.Email, - Name: gUser.Name, - Provider: "google", - ProviderID: gUser.Sub, - } + user = &AuthUser{Email: gUser.Email, Name: gUser.Name, Provider: "google", ProviderID: gUser.Sub} if err := h.store.createAuthUser(r.Context(), user); err != nil { - slog.Error("create oauth user", "err", err) + slog.Error("auth: create oauth user", "err", err) http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound) return } _ = h.store.seedCategories(r.Context(), user.ID.Hex()) + slog.Info("auth: new OAuth account", "provider", "google", "user_id", user.ID.Hex(), "email", user.Email) } if err := h.startSession(w, r, user.ID, user.Email); err != nil { diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index 093dd8c..eb6810d 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -248,7 +248,7 @@ func (m *mockStore) deleteAuthSession(_ context.Context, _ string) error { retur // ── helpers ─────────────────────────────────────────────────────────────────── func newHandler(store *mockStore) *Handler { - return &Handler{store: store, secret: "test-secret"} + return &Handler{store: store, secret: "test-secret", loginRL: newLoginRateLimiter()} } func authReq(method, path string, body url.Values) *http.Request { diff --git a/apps/finance/services/api/main/main.go b/apps/finance/services/api/main/main.go index 7326370..a4cad70 100644 --- a/apps/finance/services/api/main/main.go +++ b/apps/finance/services/api/main/main.go @@ -50,7 +50,7 @@ func main() { mux := http.NewServeMux() handler.RegisterRoutes(mux) - srv := setup.Default("finance-api", mux) + srv := setup.Default("finance-api", handler.securityHeaders(mux)) go func() { sigCh := make(chan os.Signal, 1)