From fb6c8393528a7efe6c454e1bd71cea18e076378c 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:18:09 +0100 Subject: [PATCH] feat: public landing page + split personal/business nav (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: public landing page with auth-conditional state Rewrites homepage.html as a full marketing landing page serving both unauthenticated visitors (Sign In CTA) and authenticated users (Personal + Business portal links). Fixes handler to pass UserID so auth-conditional rendering activates correctly. Co-Authored-By: Claude Sonnet 4.6 * 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 * feat: self-contained auth — email/password + Google OAuth, HMAC session cookies Embeds a full authentication system into the finance API so it can be deployed as a standalone container without any external auth dependency. - Email/password registration and login with bcrypt hashing - Google OAuth 2.0 (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET env vars) - HMAC-SHA256 signed session cookies (SESSION_SECRET env var, 30-day TTL) - Sessions stored in MongoDB finance_sessions with TTL index auto-expiry - Users stored in MongoDB finance_users with unique email index - /auth/login, /auth/register, /auth/logout, /auth/oauth/google routes - authMW now redirects to /auth/login?next=... instead of auth.homelab.local - getAuth() resolves session cookie first, falls back to X-Auth-* headers - Default categories seeded automatically on new account creation - seed.go checks finance_users before the shared legacy users collection Co-Authored-By: Claude Sonnet 4.6 * fix: homepage sign-in links point to /auth/login instead of auth.homelab.local 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 --------- Co-authored-by: Gonçalo Rodrigues Co-authored-by: Claude Sonnet 4.6 --- apps/finance/services/api/k8s/ingress.yaml | 2 - apps/finance/services/api/main/handler.go | 117 +- .../finance/services/api/main/handler_auth.go | 404 +++++ apps/finance/services/api/main/handler_org.go | 18 +- .../finance/services/api/main/handler_test.go | 17 +- apps/finance/services/api/main/main.go | 12 +- apps/finance/services/api/main/models_auth.go | 25 + apps/finance/services/api/main/seed.go | 21 +- apps/finance/services/api/main/store_auth.go | 78 + .../api/main/templates/auth_login.html | 255 ++++ .../api/main/templates/auth_register.html | 216 +++ .../services/api/main/templates/homepage.html | 1337 +++++++++-------- 12 files changed, 1846 insertions(+), 656 deletions(-) create mode 100644 apps/finance/services/api/main/handler_auth.go create mode 100644 apps/finance/services/api/main/models_auth.go create mode 100644 apps/finance/services/api/main/store_auth.go create mode 100644 apps/finance/services/api/main/templates/auth_login.html create mode 100644 apps/finance/services/api/main/templates/auth_register.html diff --git a/apps/finance/services/api/k8s/ingress.yaml b/apps/finance/services/api/k8s/ingress.yaml index 985243e..8d37aad 100644 --- a/apps/finance/services/api/k8s/ingress.yaml +++ b/apps/finance/services/api/k8s/ingress.yaml @@ -3,8 +3,6 @@ kind: Ingress metadata: name: api namespace: finance - annotations: - traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd spec: ingressClassName: traefik rules: diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 2820ed1..4ea0eb6 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -12,6 +12,7 @@ import ( "log/slog" "math" "net/http" + "net/url" "sort" "strconv" "strings" @@ -150,8 +151,19 @@ func parseTmpl(files ...string) *template.Template { }).ParseFS(templateFS, files...)) } +// parseStandalone parses a single template file that has no {{define}} blocks. +// parseTmpl roots on "", but ParseFS stores content under the base filename, +// so Execute() would run the empty root. Here we root on the base filename so +// Execute() runs the actual content. +func parseStandalone(file string) *template.Template { + name := file[strings.LastIndex(file, "/")+1:] + return template.Must(template.New(name).ParseFS(templateFS, file)) +} + var ( - homepageTmpl = parseTmpl("templates/homepage.html") + homepageTmpl = parseStandalone("templates/homepage.html") + authLoginTmpl = parseStandalone("templates/auth_login.html") + authRegisterTmpl = parseStandalone("templates/auth_register.html") baseTmpl = parseTmpl("templates/base.html") dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html") txnsTmpl = parseTmpl("templates/base.html", "templates/transactions.html") @@ -196,7 +208,12 @@ type authInfo struct { Roles string } -func getAuth(r *http.Request) authInfo { +// getAuth resolves the current user from the session cookie first, then falls +// back to X-Auth-* headers (Traefik forward-auth / tests). +func (h *Handler) getAuth(r *http.Request) authInfo { + if a, ok := h.authFromSession(r); ok { + return a + } return authInfo{ UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), @@ -318,21 +335,39 @@ type storeIface interface { updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error) createAttachment(ctx context.Context, a *OrgAttachment) error + + // Auth + createAuthUser(ctx context.Context, u *AuthUser) error + findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) + findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) + createAuthSession(ctx context.Context, sess *AuthSession) error + getAuthSession(ctx context.Context, id string) (*AuthSession, error) + deleteAuthSession(ctx context.Context, id string) error } type Handler struct { - store storeIface + store storeIface + secret string // HMAC key for session tokens + googleID string + googleSecret string + baseURL string // used to build OAuth redirect URLs } -func NewHandler(store *Store) *Handler { - return &Handler{store: store} +func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *Handler { + return &Handler{ + store: store, + secret: secret, + googleID: googleID, + googleSecret: googleSecret, + baseURL: baseURL, + } } func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) if a.UserID == "" { - http.Redirect(w, r, "https://auth.homelab.local/login", http.StatusFound) + http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) return } next(w, r) @@ -341,7 +376,7 @@ func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc { func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc { return h.authMW(func(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) ownerID := r.PathValue("user_id") if ownerID == "" { ownerID = a.UserID @@ -371,15 +406,16 @@ func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc { } func (h *Handler) Homepage(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) renderRaw(w, homepageTmpl, map[string]interface{}{ - "Email": a.Email, + "Email": a.Email, + "UserID": a.UserID, }) } func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) now := time.Now() thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) @@ -725,7 +761,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) var filter bson.M cat := r.URL.Query().Get("category") @@ -796,7 +832,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) var body struct { AccountID string `json:"account_id"` @@ -838,7 +874,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { } func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) accounts, _ := h.store.getAccounts(r.Context(), a.UserID) render(w, importTmpl, map[string]interface{}{ "UserID": a.UserID, @@ -853,7 +889,7 @@ func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) if err := r.ParseMultipartForm(32 << 20); err != nil { slog.Error("import preview multipart", @@ -987,7 +1023,7 @@ func GenericMapping(data []byte) CSVColumnMapping { func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -1123,7 +1159,7 @@ func autoCategorize(desc string, catMap map[string]string) string { func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) id := r.PathValue("id") var body struct { @@ -1153,7 +1189,7 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) id := r.PathValue("id") if err := h.store.deleteTransaction(ctx, id, a.UserID); err != nil { slog.Error("delete transaction", "err", err) @@ -1164,7 +1200,7 @@ func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { } func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) switch r.Method { case http.MethodGet: @@ -1213,7 +1249,7 @@ func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) { } func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) switch r.Method { case http.MethodGet: @@ -1288,7 +1324,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) { func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) if err != nil { @@ -1344,7 +1380,7 @@ func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) { func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) if err != nil { @@ -1443,7 +1479,7 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) trades, err := h.store.getTrades(ctx, a.UserID) if err != nil { @@ -1502,7 +1538,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) isin := strings.TrimSpace(r.FormValue("isin")) ticker := strings.TrimSpace(r.FormValue("ticker")) if isin == "" || ticker == "" { @@ -1519,7 +1555,7 @@ func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -1574,7 +1610,7 @@ func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) { func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) switch r.Method { case http.MethodGet: @@ -1654,7 +1690,7 @@ func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) { } func (h *Handler) SearchUsers(w http.ResponseWriter, r *http.Request) { - a := getAuth(r) + a := h.getAuth(r) q := r.URL.Query().Get("q") if q == "" || len(q) < 2 { json.NewEncoder(w).Encode([]map[string]string{}) @@ -1693,7 +1729,7 @@ func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) { func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) if r.Method == http.MethodPost { if err := r.ParseForm(); err != nil { @@ -1878,7 +1914,7 @@ func parseFloat(s string) float64 { func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{}) goals, _ := h.store.getGoals(ctx, a.UserID) @@ -2008,7 +2044,7 @@ func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) { func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) if err != nil { @@ -2097,7 +2133,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) { // ── Tax Summary ─────────────────────────────────────────────────────────────── func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) { - auth := getAuth(r) + auth := h.getAuth(r) ctx := r.Context() // year selector @@ -2277,7 +2313,7 @@ func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) { func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) { // Reuse Tax logic output as CSV — redirect with same year param - auth := getAuth(r) + auth := h.getAuth(r) ctx := r.Context() yearStr := r.URL.Query().Get("year") @@ -2312,7 +2348,7 @@ func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) { // ── Household ───────────────────────────────────────────────────────────────── func (h *Handler) Household(w http.ResponseWriter, r *http.Request) { - auth := getAuth(r) + auth := h.getAuth(r) ctx := r.Context() now := time.Now() @@ -2409,7 +2445,7 @@ func (h *Handler) Household(w http.ResponseWriter, r *http.Request) { // ── Auto Import ─────────────────────────────────────────────────────────────── func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) { - auth := getAuth(r) + auth := h.getAuth(r) accounts, _ := h.store.getAccounts(r.Context(), auth.UserID) render(w, autoImportTmpl, &AutoImportData{ UserID: auth.UserID, @@ -2424,7 +2460,7 @@ func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) People(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) tab := r.URL.Query().Get("tab") if tab == "" { tab = "sharing" @@ -2553,7 +2589,7 @@ func (h *Handler) People(w http.ResponseWriter, r *http.Request) { func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) tab := r.URL.Query().Get("tab") if tab == "" { tab = "accounts" @@ -2574,6 +2610,15 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // Auth (no authMW — these are public by definition) + mux.HandleFunc("GET /auth/login", h.AuthLogin) + mux.HandleFunc("POST /auth/login", h.AuthLogin) + mux.HandleFunc("GET /auth/register", h.AuthRegister) + mux.HandleFunc("POST /auth/register", h.AuthRegister) + mux.HandleFunc("POST /auth/logout", h.AuthLogout) + mux.HandleFunc("GET /auth/oauth/google", h.AuthGoogleStart) + mux.HandleFunc("GET /auth/oauth/google/callback", h.AuthGoogleCallback) + mux.HandleFunc("GET /{$}", h.Homepage) mux.HandleFunc("GET /dashboard", h.authMW(h.Dashboard)) mux.HandleFunc("GET /transactions", h.Transactions) diff --git a/apps/finance/services/api/main/handler_auth.go b/apps/finance/services/api/main/handler_auth.go new file mode 100644 index 0000000..364bfaf --- /dev/null +++ b/apps/finance/services/api/main/handler_auth.go @@ -0,0 +1,404 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/crypto/bcrypt" +) + +const ( + cookieName = "finsession" + sessionTTL = 30 * 24 * time.Hour +) + +// ── session token ───────────────────────────────────────────────────────────── + +func (h *Handler) signSessionID(id string) string { + mac := hmac.New(sha256.New, []byte(h.secret)) + mac.Write([]byte(id)) + return id + "." + hex.EncodeToString(mac.Sum(nil)) +} + +func (h *Handler) verifySessionToken(token string) (string, bool) { + i := strings.LastIndex(token, ".") + if i < 0 { + return "", false + } + id := token[:i] + expected := h.signSessionID(id) + if !hmac.Equal([]byte(token), []byte(expected)) { + return "", false + } + return id, true +} + +func (h *Handler) authFromSession(r *http.Request) (authInfo, bool) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return authInfo{}, false + } + id, ok := h.verifySessionToken(cookie.Value) + if !ok { + return authInfo{}, false + } + sess, err := h.store.getAuthSession(r.Context(), id) + if err != nil || sess == nil || time.Now().After(sess.ExpiresAt) { + return authInfo{}, false + } + 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 { + sess := &AuthSession{ + UserID: userID, + Email: email, + ExpiresAt: time.Now().Add(sessionTTL), + } + if err := h.store.createAuthSession(r.Context(), sess); err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: h.signSessionID(sess.ID.Hex()), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) + return nil +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) +} + +// ── 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 + } + if r.Method == http.MethodPost { + h.authLoginPost(w, r) + return + } + renderRaw(w, authLoginTmpl, map[string]any{ + "GoogleEnabled": h.googleID != "", + }) +} + +func (h *Handler) authLoginPost(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.FormValue("email")) + password := r.FormValue("password") + + fail := func(msg string) { + renderRaw(w, authLoginTmpl, map[string]any{ + "Error": msg, + "Email": email, + "GoogleEnabled": h.googleID != "", + }) + } + + if email == "" || password == "" { + fail("Email and password are required.") + return + } + user, err := h.store.findAuthUserByEmail(r.Context(), email) + if err != nil || user == nil || user.PasswordHash == "" { + fail("Invalid email or password.") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + fail("Invalid email or password.") + return + } + if err := h.startSession(w, r, user.ID, user.Email); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + next := r.URL.Query().Get("next") + if next == "" || !strings.HasPrefix(next, "/") { + next = "/dashboard" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +// ── register ────────────────────────────────────────────────────────────────── + +func (h *Handler) AuthRegister(w http.ResponseWriter, r *http.Request) { + if a, ok := h.authFromSession(r); ok && a.UserID != "" { + http.Redirect(w, r, "/dashboard", http.StatusFound) + return + } + if r.Method == http.MethodPost { + h.authRegisterPost(w, r) + return + } + renderRaw(w, authRegisterTmpl, map[string]any{ + "GoogleEnabled": h.googleID != "", + }) +} + +func (h *Handler) authRegisterPost(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.FormValue("email")) + name := strings.TrimSpace(r.FormValue("name")) + password := r.FormValue("password") + confirm := r.FormValue("confirm") + + fail := func(msg string) { + renderRaw(w, authRegisterTmpl, map[string]any{ + "Error": msg, + "Email": email, + "Name": name, + "GoogleEnabled": h.googleID != "", + }) + } + + if email == "" || password == "" { + fail("Email and password are required.") + return + } + if password != confirm { + fail("Passwords do not match.") + return + } + if len(password) < 8 { + fail("Password must be at least 8 characters.") + return + } + existing, err := h.store.findAuthUserByEmail(r.Context(), email) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if existing != nil { + fail("An account with that email already exists.") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user := &AuthUser{Email: email, Name: name, PasswordHash: string(hash)} + if err := h.store.createAuthUser(r.Context(), user); err != nil { + fail("Could not create account. Please try again.") + return + } + if err := h.store.seedCategories(r.Context(), user.ID.Hex()); err != nil { + slog.Warn("seed categories on register", "err", err) + } + if err := h.startSession(w, r, user.ID, user.Email); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +// ── logout ──────────────────────────────────────────────────────────────────── + +func (h *Handler) AuthLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(cookieName); err == nil { + if id, ok := h.verifySessionToken(cookie.Value); ok { + _ = h.store.deleteAuthSession(r.Context(), id) + } + } + clearSessionCookie(w) + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) +} + +// ── Google OAuth ────────────────────────────────────────────────────────────── + +const ( + googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth" + googleTokenURL = "https://oauth2.googleapis.com/token" + googleUserURL = "https://www.googleapis.com/oauth2/v3/userinfo" +) + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func (h *Handler) googleRedirectURL() string { + base := strings.TrimRight(h.baseURL, "/") + if base == "" { + base = "http://localhost:8080" + } + return base + "/auth/oauth/google/callback" +} + +func (h *Handler) AuthGoogleStart(w http.ResponseWriter, r *http.Request) { + if h.googleID == "" { + http.NotFound(w, r) + return + } + state := randomHex(16) + http.SetCookie(w, &http.Cookie{ + Name: "oauthstate", + Value: state, + Path: "/auth", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, + }) + params := url.Values{ + "client_id": {h.googleID}, + "redirect_uri": {h.googleRedirectURL()}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {state}, + "access_type": {"offline"}, + "prompt": {"select_account"}, + } + http.Redirect(w, r, googleAuthURL+"?"+params.Encode(), http.StatusFound) +} + +func (h *Handler) AuthGoogleCallback(w http.ResponseWriter, r *http.Request) { + if h.googleID == "" { + http.NotFound(w, r) + return + } + stateCookie, err := r.Cookie("oauthstate") + if err != nil || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "invalid OAuth state", http.StatusBadRequest) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + token, err := h.googleExchangeCode(r.Context(), code) + if err != nil { + slog.Error("google token exchange", "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) + 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) + return + } + if user == nil { + user, err = h.store.findAuthUserByEmail(r.Context(), gUser.Email) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + if user == nil { + 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) + http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound) + return + } + _ = h.store.seedCategories(r.Context(), user.ID.Hex()) + } + + if err := h.startSession(w, r, user.ID, user.Email); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +type googleTokenResp struct { + AccessToken string `json:"access_token"` +} + +type googleUserResp struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` +} + +func (h *Handler) googleExchangeCode(ctx context.Context, code string) (string, error) { + form := url.Values{ + "code": {code}, + "client_id": {h.googleID}, + "client_secret": {h.googleSecret}, + "redirect_uri": {h.googleRedirectURL()}, + "grant_type": {"authorization_code"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("google token error %d: %s", resp.StatusCode, body) + } + var tr googleTokenResp + if err := json.Unmarshal(body, &tr); err != nil { + return "", err + } + return tr.AccessToken, nil +} + +func (h *Handler) googleUserInfo(ctx context.Context, accessToken string) (*googleUserResp, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("google userinfo error %d: %s", resp.StatusCode, body) + } + var u googleUserResp + if err := json.Unmarshal(body, &u); err != nil { + return nil, err + } + return &u, nil +} diff --git a/apps/finance/services/api/main/handler_org.go b/apps/finance/services/api/main/handler_org.go index b3ba3b4..f589877 100644 --- a/apps/finance/services/api/main/handler_org.go +++ b/apps/finance/services/api/main/handler_org.go @@ -1,9 +1,7 @@ package main import ( - "crypto/rand" "encoding/csv" - "encoding/hex" "fmt" "io" "log/slog" @@ -31,7 +29,7 @@ var slugRe = regexp.MustCompile(`^[a-z0-9-]{2,40}$`) func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc { return h.authMW(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) slug := r.PathValue("slug") org, err := h.store.getOrgBySlug(ctx, slug) @@ -71,7 +69,7 @@ func canManageOrg(role OrgRole) bool { func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) orgs, err := h.store.getOrgsForUser(ctx, a.UserID) if err != nil { @@ -90,7 +88,7 @@ func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) if r.Method == http.MethodGet { render(w, orgCreateTmpl, map[string]any{ @@ -309,7 +307,7 @@ func (h *Handler) OrgMemberRemove(w http.ResponseWriter, r *http.Request) { h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { memberID := r.PathValue("member_id") // prevent removing yourself - a := getAuth(r) + a := h.getAuth(r) if me.UserID == a.UserID && memberID == me.ID { http.Error(w, "cannot remove yourself", http.StatusBadRequest) return @@ -417,7 +415,7 @@ func (h *Handler) OrgInviteRevoke(w http.ResponseWriter, r *http.Request) { // OrgJoin handles the invite link: GET shows a confirmation page, POST accepts. func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - a := getAuth(r) + a := h.getAuth(r) token := r.PathValue("token") inv, err := h.store.getInviteByToken(ctx, token) @@ -1967,12 +1965,6 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) { // ── Helpers ─────────────────────────────────────────────────────────────────── -func randomHex(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - // parseEuroAmount converts a user-entered decimal string (e.g. "12.50") to cents. func parseEuroAmount(s string) (int64, error) { s = strings.TrimSpace(s) diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index 854b293..093dd8c 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -232,10 +232,23 @@ func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachm } func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil } +func (m *mockStore) createAuthUser(_ context.Context, _ *AuthUser) error { return nil } +func (m *mockStore) findAuthUserByEmail(_ context.Context, _ string) (*AuthUser, error) { + return nil, nil +} +func (m *mockStore) findAuthUserByProvider(_ context.Context, _, _ string) (*AuthUser, error) { + return nil, nil +} +func (m *mockStore) createAuthSession(_ context.Context, _ *AuthSession) error { return nil } +func (m *mockStore) getAuthSession(_ context.Context, _ string) (*AuthSession, error) { + return nil, nil +} +func (m *mockStore) deleteAuthSession(_ context.Context, _ string) error { return nil } + // ── helpers ─────────────────────────────────────────────────────────────────── func newHandler(store *mockStore) *Handler { - return &Handler{store: store} + return &Handler{store: store, secret: "test-secret"} } func authReq(method, path string, body url.Values) *http.Request { @@ -1487,7 +1500,7 @@ func TestProjections_WithTransactions(t *testing.T) { func TestNewHandler(t *testing.T) { // NewHandler wraps a *Store into a Handler. // Pass a nil *Store — the function just assigns; no methods are called. - h := NewHandler((*Store)(nil)) + h := NewHandler((*Store)(nil), "test-secret", "", "", "") if h == nil { t.Fatal("NewHandler returned nil") } diff --git a/apps/finance/services/api/main/main.go b/apps/finance/services/api/main/main.go index bb3fce4..7326370 100644 --- a/apps/finance/services/api/main/main.go +++ b/apps/finance/services/api/main/main.go @@ -32,10 +32,20 @@ func main() { defer db.Close(ctx) store := NewStore(db) + store.ensureAuthIndexes(ctx) go SeedAdmin(ctx, store) - handler := NewHandler(store) + secret := os.Getenv("SESSION_SECRET") + if secret == "" { + secret = "dev-secret-change-in-production-32x" + slog.Warn("SESSION_SECRET not set — using insecure default, set it before deploying") + } + handler := NewHandler(store, secret, + os.Getenv("GOOGLE_CLIENT_ID"), + os.Getenv("GOOGLE_CLIENT_SECRET"), + os.Getenv("BASE_URL"), + ) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/apps/finance/services/api/main/models_auth.go b/apps/finance/services/api/main/models_auth.go new file mode 100644 index 0000000..e80b484 --- /dev/null +++ b/apps/finance/services/api/main/models_auth.go @@ -0,0 +1,25 @@ +package main + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AuthUser struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Email string `bson:"email"` + Name string `bson:"name,omitempty"` + PasswordHash string `bson:"password_hash,omitempty"` + Provider string `bson:"provider,omitempty"` // "google" or "" + ProviderID string `bson:"provider_id,omitempty"` // provider's user sub/id + CreatedAt time.Time `bson:"created_at"` +} + +type AuthSession struct { + ID bson.ObjectID `bson:"_id,omitempty"` + UserID bson.ObjectID `bson:"user_id"` + Email string `bson:"email"` + ExpiresAt time.Time `bson:"expires_at"` + CreatedAt time.Time `bson:"created_at"` +} diff --git a/apps/finance/services/api/main/seed.go b/apps/finance/services/api/main/seed.go index 9a24253..fd77c6f 100644 --- a/apps/finance/services/api/main/seed.go +++ b/apps/finance/services/api/main/seed.go @@ -41,19 +41,26 @@ func SeedAdmin(ctx context.Context, store *Store) { } } -// lookupUserByEmailMongo queries the shared "users" collection directly, -// avoiding any cross-service HTTP dependency. +// lookupUserByEmailMongo resolves a user ID from email. +// Checks finance_users first (standalone/cloud deployment), then the shared +// "users" collection (Traefik forward-auth deployment). func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (string, error) { - coll := store.db.Collection("users") - var result struct { + // standalone auth + var finUser struct { + ID bson.ObjectID `bson:"_id"` + } + if err := store.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&finUser); err == nil { + return finUser.ID.Hex(), nil + } + // legacy shared-auth fallback + var legacy struct { ID string `bson:"_id"` Email string `bson:"email"` } - err := coll.FindOne(ctx, bson.M{"email": email}).Decode(&result) - if err != nil { + if err := store.db.Collection("users").FindOne(ctx, bson.M{"email": email}).Decode(&legacy); err != nil { return "", fmt.Errorf("user %q not found in mongo: %w", email, err) } - return result.ID, nil + return legacy.ID, nil } func seedAll(ctx context.Context, store *Store, userID string) error { diff --git a/apps/finance/services/api/main/store_auth.go b/apps/finance/services/api/main/store_auth.go new file mode 100644 index 0000000..68ced60 --- /dev/null +++ b/apps/finance/services/api/main/store_auth.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func (s *Store) createAuthUser(ctx context.Context, u *AuthUser) error { + u.ID = bson.NewObjectID() + u.CreatedAt = time.Now() + _, err := s.db.Collection("finance_users").InsertOne(ctx, u) + return err +} + +func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) { + var u AuthUser + err := s.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&u) + if err == mongo.ErrNoDocuments { + return nil, nil + } + return &u, err +} + +func (s *Store) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) { + var u AuthUser + err := s.db.Collection("finance_users").FindOne(ctx, bson.M{ + "provider": provider, + "provider_id": providerID, + }).Decode(&u) + if err == mongo.ErrNoDocuments { + return nil, nil + } + return &u, err +} + +func (s *Store) createAuthSession(ctx context.Context, sess *AuthSession) error { + sess.ID = bson.NewObjectID() + sess.CreatedAt = time.Now() + _, err := s.db.Collection("finance_sessions").InsertOne(ctx, sess) + return err +} + +func (s *Store) getAuthSession(ctx context.Context, id string) (*AuthSession, error) { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil, nil + } + var sess AuthSession + err = s.db.Collection("finance_sessions").FindOne(ctx, bson.M{"_id": oid}).Decode(&sess) + if err == mongo.ErrNoDocuments { + return nil, nil + } + return &sess, err +} + +func (s *Store) deleteAuthSession(ctx context.Context, id string) error { + oid, err := bson.ObjectIDFromHex(id) + if err != nil { + return nil + } + _, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": oid}) + return err +} + +func (s *Store) ensureAuthIndexes(ctx context.Context) { + s.db.Collection("finance_users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true).SetSparse(true), + }) + s.db.Collection("finance_sessions").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }) +} diff --git a/apps/finance/services/api/main/templates/auth_login.html b/apps/finance/services/api/main/templates/auth_login.html new file mode 100644 index 0000000..3fdea03 --- /dev/null +++ b/apps/finance/services/api/main/templates/auth_login.html @@ -0,0 +1,255 @@ + + + + + + Sign in — Finance Hub + + + + + +
+
+
+ Finance Hub +
+ +
+

Welcome back

+

Sign in to your account. No account? Create one →

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if eq (index . "Error") "oauth"}} +
Google sign-in failed. Please try again.
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ + {{if .GoogleEnabled}} +
or
+ + + + + + + + Continue with Google + + {{end}} +
+ + +
+ + + + diff --git a/apps/finance/services/api/main/templates/auth_register.html b/apps/finance/services/api/main/templates/auth_register.html new file mode 100644 index 0000000..d3596c9 --- /dev/null +++ b/apps/finance/services/api/main/templates/auth_register.html @@ -0,0 +1,216 @@ + + + + + + Create account — Finance Hub + + + + + +
+
+
+ Finance Hub +
+ +
+

Create your account

+

Already have an account? Sign in →

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .GoogleEnabled}} + + + + + + + + Continue with Google + +
or sign up with email
+ {{end}} + +
+
+ + +
+
+ + +
+
+ + +
At least 8 characters
+
+
+ + +
+ +
+
+ + +
+ + + + diff --git a/apps/finance/services/api/main/templates/homepage.html b/apps/finance/services/api/main/templates/homepage.html index 5e9c79b..6852015 100644 --- a/apps/finance/services/api/main/templates/homepage.html +++ b/apps/finance/services/api/main/templates/homepage.html @@ -3,23 +3,32 @@ - Finance Hub + Finance Hub — Personal & Business Finance + - - - -
-
-
+ +
+
+
-
+
- -
- - {{if .Email}}{{.Email}}{{end}} + + - -
-
Your Financial Universe
-

Finance Hub

-

One platform for managing personal wealth and business finances — beautifully unified.

-
- - -
-
-
2
-
Products
-
-
-
-
Organisations
-
-
-
100%
-
Self-hosted
-
+ +
+
Self-hosted · Private · Open
+

+ Personal wealth. + Business finances. +

+

+ One self-hosted platform to track your personal wealth and manage your organisation's budget — without handing your data to anyone. +

+
+ {{if .UserID}} + Open personal + Open business + {{else}} + Get started + See what's inside + {{end}}
+
- - - -
- - -
-
🔒 Self-hosted & private
-
🌙 Dark & light themes
-
📊 Real-time analytics
-
🏦 Multi-bank support
-
👥 Role-based access
-
📅 Fiscal year lifecycle
-
- - + +
+
🔒 Your data, your server
+
+
🏦 Multi-bank CSV import
+
+
🌙 Dark & light mode
+
+
👥 Role-based access
+
+
📊 Real-time analytics
- + /* ── Scroll reveal ────────────────────────────────────────────── */ + const io = new IntersectionObserver(entries => { + entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); }}); + }, { threshold: 0.1 }); + document.querySelectorAll('.reveal').forEach(el => io.observe(el)); + + /* ── Rotating rings ───────────────────────────────────────────── */ + (function() { + const s = document.createElement('style'); + s.textContent = ` + @keyframes ring-spin { from{transform:rotateX(72deg) rotateZ(0)} to{transform:rotateX(72deg) rotateZ(360deg)} } + .bg-ring { position:fixed; border-radius:50%; border:1px solid rgba(0,201,184,0.04); + pointer-events:none; z-index:0; animation:ring-spin linear infinite; } + `; + document.head.appendChild(s); + [[380,'16s','rgba(0,201,184,0.04)'],[600,'26s','rgba(124,58,237,0.03)'],[220,'11s','rgba(168,85,247,0.05)']].forEach(([sz,dur,col]) => { + const d = document.createElement('div'); + d.className = 'bg-ring'; + d.style.cssText = `width:${sz}px;height:${sz}px;top:50%;left:50%;margin:${-sz/2}px 0 0 ${-sz/2}px;animation-duration:${dur};border-color:${col}`; + document.body.appendChild(d); + }); + })(); +