homelab/apps/finance/services/api/main/handler_auth.go
Gonçalo Rodrigues fb6c839352 feat: public landing page + split personal/business nav (#26)
* 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 <noreply@anthropic.com>

* 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>

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

* fix: homepage sign-in links point to /auth/login instead of auth.homelab.local

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>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:18:09 +01:00

405 lines
11 KiB
Go

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
}