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:
parent
fb6c839352
commit
cedc0c2192
@ -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)
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@ -22,9 +23,110 @@ import (
|
||||
const (
|
||||
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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user