feat: Google OAuth login + server sessions (user_key u:<sub>)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -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:<sub>`), 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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) 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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- FlowSight schema v5: Google-authenticated users + server sessions.
|
||||
-- user_key for a login user is `u:<google_sub>`; 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);
|
||||
Reference in New Issue
Block a user