feat: self-contained auth system for standalone cloud deployment (#27)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-15 18:30:19 +01:00 committed by GitHub
parent fb6c839352
commit cedc0c2192
4 changed files with 172 additions and 28 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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 {

View File

@ -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)