diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index acc5ffd..303d88f 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -271,3 +271,51 @@ func TestDestinations(t *testing.T) { t.Fatalf("code = %d, want 404", rec.Code) } } + +// Auth endpoints 404 when Google is unconfigured; /me 401. +func TestAuthUnconfigured(t *testing.T) { + s := testServer(t) + rec := do(s, "GET", "/api/auth/start", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("start code = %d", rec.Code) + } + rec = do(s, "GET", "/api/auth/me", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("me code = %d", rec.Code) + } + rec = do(s, "POST", "/api/auth/logout", nil) + if rec.Code != http.StatusOK { + t.Fatalf("logout code = %d", rec.Code) + } +} + +// Session round-trip: upsert user -> create session -> resolve -> revoke. +func TestSessionRoundTrip(t *testing.T) { + s := testServer(t) + u, err := s.DB.UpsertUser("sub-123", "a@x.id", "A", "") + if err != nil { + t.Fatal(err) + } + if u.UserKey != "u:sub-123" { + t.Fatalf("userkey = %s", u.UserKey) + } + tok, err := s.DB.CreateSession(u.ID, u.UserKey, 3600000000000) + if err != nil { + t.Fatal(err) + } + got, ok := s.DB.SessionUser(tok) + if !ok || got.Email != "a@x.id" { + t.Fatalf("resolve ok=%v got=%v", ok, got) + } + req := httptest.NewRequest("GET", "/api/watchlist", nil) + req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok}) + if key := s.userKey(req); key != "u:sub-123" { + t.Fatalf("userKey = %s", key) + } + if err := s.DB.DeleteSession(tok); err != nil { + t.Fatal(err) + } + if _, ok := s.DB.SessionUser(tok); ok { + t.Fatal("revoked session still valid") + } +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..d4e8f57 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,210 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "flowsight/internal/store" +) + +// Session lifetime for Google logins. +const sessionTTL = 30 * 24 * time.Hour + +// googleAuthURL builds the Google consent URL (state carries platform=web). +func (s *Server) googleAuthURL() string { + q := url.Values{ + "client_id": {s.Cfg.GoogleClientID}, + "redirect_uri": {s.Cfg.GoogleRedirectURL}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "access_type": {"offline"}, + "prompt": {"select_account"}, + "state": {base64.RawURLEncoding.EncodeToString([]byte(`{"platform":"web"}`))}, + } + return "https://accounts.google.com/o/oauth2/v2/auth?" + q.Encode() +} + +// AuthStart serves GET /api/auth/start: 302 to Google, or 404 unconfigured. +func (s *Server) AuthStart(w http.ResponseWriter, r *http.Request) { + if !s.Cfg.HasGoogle() { + writeErr(w, http.StatusNotFound, "google login not configured") + return + } + http.Redirect(w, r, s.googleAuthURL(), http.StatusFound) +} + +// AuthCallback serves GET /api/auth/callback: exchanges code for tokens, +// upserts the user, mints a session cookie, redirects to /. +// Error cases redirect to /login?error=... (web client shows the message). +func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) { + if !s.Cfg.HasGoogle() { + writeErr(w, http.StatusNotFound, "google login not configured") + return + } + fail := func(msg string) { + http.Redirect(w, r, "/login?error="+url.QueryEscape(msg), http.StatusFound) + } + q := r.URL.Query() + if q.Get("error") != "" || q.Get("code") == "" { + fail(q.Get("error")) + if q.Get("error") == "" { + fail("missing_code") + } + return + } + tokens, err := exchangeGoogleCode(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret, + s.Cfg.GoogleRedirectURL, q.Get("code")) + if err != nil { + fail("google token exchange failed") + return + } + claims, err := decodeGoogleIDToken(tokens.IDToken) + if err != nil || !claims.EmailVerified || claims.Email == "" { + fail("email not verified by google") + return + } + name := strings.TrimSpace(claims.Name) + if name == "" { + name = strings.Split(claims.Email, "@")[0] + } + user, err := s.DB.UpsertUser(claims.Sub, strings.ToLower(strings.TrimSpace(claims.Email)), + name, claims.Picture) + if err != nil { + fail("user store unavailable") + return + } + // Ensure the login user owns the default watchlist on first login. + if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 { + for _, t := range s.Cfg.Watchlist { + _ = s.DB.AddWatch(user.UserKey, t) + } + } + tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL) + if err != nil { + fail("session store unavailable") + return + } + http.SetCookie(w, &http.Cookie{ + Name: "fs_session", Value: tok, Path: "/", HttpOnly: true, + Secure: true, SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionTTL), + }) + http.Redirect(w, r, "/", http.StatusFound) +} + +// AuthMe serves GET /api/auth/me: 200 user when logged in, else 401. +func (s *Server) AuthMe(w http.ResponseWriter, r *http.Request) { + u, ok := s.sessionUser(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "not logged in") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{ + "id": u.ID, "email": u.Email, "name": u.Name, + "avatar_url": u.AvatarURL, "user_key": u.UserKey, + "google_configured": s.Cfg.HasGoogle(), + }, + }) +} + +// AuthLogout serves POST /api/auth/logout: revokes the session cookie. +func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("fs_session"); err == nil { + _ = s.DB.DeleteSession(c.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "fs_session", Value: "", Path: "/", HttpOnly: true, + MaxAge: -1, + }) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// sessionUser resolves the request's session cookie to its user. +func (s *Server) sessionUser(r *http.Request) (*store.User, bool) { + c, err := r.Cookie("fs_session") + if err != nil { + return nil, false + } + return s.DB.SessionUser(c.Value) +} + +// googleTokenResp is the subset of oauth2.googleapis.com/token we need. +type googleTokenResp struct { + IDToken string `json:"id_token"` +} + +// googleClaims is the verified-identity subset of the id_token payload. +type googleClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +// exchangeGoogleCode swaps an authorization code for tokens. +func exchangeGoogleCode(clientID, secret, redirectURL, code string) (*googleTokenResp, error) { + form := url.Values{ + "code": {code}, + "client_id": {clientID}, + "client_secret": {secret}, + "redirect_uri": {redirectURL}, + "grant_type": {"authorization_code"}, + } + resp, err := http.PostForm("https://oauth2.googleapis.com/token", form) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(resp.StatusCode)} + } + var out googleTokenResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if out.IDToken == "" { + return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(0)} + } + return &out, nil +} + +// decodeGoogleIDToken decodes (not signature-verifies) the id_token payload. +// Full verification happens implicitly: the token arrives over TLS directly +// from Google's token endpoint in exchange for our client_secret. +func decodeGoogleIDToken(idToken string) (*googleClaims, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return nil, errInvalidToken{} + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + var c googleClaims + if err := json.Unmarshal(raw, &c); err != nil { + return nil, err + } + if c.Sub == "" { + return nil, errInvalidToken{} + } + return &c, nil +} + +type errString int + +func (e errString) Error() string { + if e == 0 { + return "empty id_token" + } + return http.StatusText(int(e)) +} + +type errInvalidToken struct{} + +func (errInvalidToken) Error() string { return "invalid id_token" } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index d0556da..2cffe39 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -62,6 +62,10 @@ func (s *Server) Router() http.Handler { r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping")) r.Route("/api", func(r chi.Router) { r.Get("/health", s.Health) + r.Get("/auth/start", s.AuthStart) + r.Get("/auth/callback", s.AuthCallback) + r.Get("/auth/me", s.AuthMe) + r.Post("/auth/logout", s.AuthLogout) r.Get("/stream", s.Stream) r.Get("/flow/summary", s.FlowSummary) r.Get("/flow/broker", s.FlowBroker) @@ -96,8 +100,12 @@ func (s *Server) Router() http.Handler { return r } -// userKey resolves the demo auth header (single demo key for hackathon). +// userKey resolves the request owner: session cookie first (Google login -> +// `u:`), then the demo header, then the shared demo key. func (s *Server) userKey(r *http.Request) string { + if u, ok := s.sessionUser(r); ok { + return u.UserKey + } if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" { return k } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 45ae05e..b07fc9b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -29,6 +29,17 @@ type Config struct { Watchlist []string // StaticDir serves the prebuilt web dist (WEB_DIST_DIR). Empty = API only. StaticDir string + // Google OAuth (login). Empty client ID = auth endpoints 404. + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string +} + +// HasGoogle reports whether Google OAuth login is configured. +func (c Config) HasGoogle() bool { + return strings.TrimSpace(c.GoogleClientID) != "" && + strings.TrimSpace(c.GoogleClientSecret) != "" && + strings.TrimSpace(c.GoogleRedirectURL) != "" } // HasSectorsKey reports whether live Sectors API calls are possible. @@ -86,5 +97,8 @@ func Load() Config { CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120), Watchlist: tickers, StaticDir: os.Getenv("WEB_DIST_DIR"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"), } } diff --git a/backend/internal/store/auth.go b/backend/internal/store/auth.go new file mode 100644 index 0000000..cca5893 --- /dev/null +++ b/backend/internal/store/auth.go @@ -0,0 +1,110 @@ +package store + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "time" +) + +// User is one Google-authenticated account. UserKey (`u:`) is +// the scoping key used by every user-owned table. +type User struct { + ID int64 + GoogleSub string + Email string + Name string + AvatarURL string + CreatedAt string + UserKey string +} + +// UserKeyForSub maps a Google subject to its scoping key. +func UserKeyForSub(sub string) string { return "u:" + sub } + +// UpsertUser inserts a first-time login or refreshes profile on return. +func (db *DB) UpsertUser(sub, email, name, avatar string) (*User, error) { + now := time.Now().UTC().Format(time.RFC3339) + if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,avatar_url,created_at) + VALUES(?,?,?,?,?) + ON CONFLICT(google_sub) DO UPDATE SET email=excluded.email, + name=excluded.name, avatar_url=excluded.avatar_url`, + sub, email, name, avatar, now); err != nil { + return nil, err + } + return db.UserBySub(sub) +} + +// UserBySub returns the user for a Google subject. +func (db *DB) UserBySub(sub string) (*User, error) { + var u User + if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at + FROM users WHERE google_sub=?`, sub). + Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil { + return nil, err + } + u.UserKey = UserKeyForSub(u.GoogleSub) + return &u, nil +} + +// NewSessionToken mints one opaque 256-bit session token. +func NewSessionToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// CreateSession stores a session valid for ttl. +func (db *DB) CreateSession(userID int64, userKey string, ttl time.Duration) (string, error) { + tok, err := NewSessionToken() + if err != nil { + return "", err + } + now := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO sessions(token,user_key,user_id,expires_at,created_at) + VALUES(?,?,?,?,?)`, tok, userKey, userID, + now.Add(ttl).Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { + return "", err + } + return tok, nil +} + +// SessionUser resolves a session token to its user. Expired tokens are +// deleted lazily and reported invalid. +func (db *DB) SessionUser(token string) (*User, bool) { + if strings.TrimSpace(token) == "" { + return nil, false + } + var u User + var exp string + if err := db.QueryRow(`SELECT u.id,u.google_sub,u.email,u.name,u.avatar_url, + u.created_at,s.expires_at FROM sessions s + JOIN users u ON u.id=s.user_id WHERE s.token=?`, token). + Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &exp); err != nil { + return nil, false + } + t, err := time.Parse(time.RFC3339, exp) + if err != nil || time.Now().UTC().After(t) { + _, _ = db.Exec(`DELETE FROM sessions WHERE token=?`, token) + return nil, false + } + u.UserKey = UserKeyForSub(u.GoogleSub) + return &u, true +} + +// UserKeyBySession returns the scoping key for a valid session token. +func (db *DB) UserKeyBySession(token string) (string, bool) { + u, ok := db.SessionUser(token) + if !ok { + return "", false + } + return u.UserKey, true +} + +// DeleteSession revokes one session token. +func (db *DB) DeleteSession(token string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token) + return err +} diff --git a/backend/internal/store/migrations/0005_auth.sql b/backend/internal/store/migrations/0005_auth.sql new file mode 100644 index 0000000..68d5c5c --- /dev/null +++ b/backend/internal/store/migrations/0005_auth.sql @@ -0,0 +1,19 @@ +-- FlowSight schema v5: Google-authenticated users + server sessions. +-- user_key for a login user is `u:`; all user-scoped tables +-- (watchlists, routines, alerts, destinations, reports) work unchanged. +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + google_sub TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '' +); +CREATE TABLE IF NOT EXISTS sessions( + token TEXT PRIMARY KEY, + user_key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);