diff --git a/lib/db_auth.go b/lib/db_auth.go index 5b5ba56..fa376a6 100644 --- a/lib/db_auth.go +++ b/lib/db_auth.go @@ -12,6 +12,7 @@ import ( "fmt" "reflect" "strings" + "time" _ "github.com/mattn/go-sqlite3" ) @@ -177,3 +178,63 @@ func authDelete(table, pkCol string, pkVal interface{}) error { _, err := authDB.Exec(query, pkVal) return err } + +// ============================================================================ +// Session Management +// ============================================================================ + +// SessionCreate creates a new session with token and expiry +type Session struct { + Token string `db:"token,pk"` + DossierID string `db:"dossier_id"` + CreatedAt int64 `db:"created_at"` + ExpiresAt int64 `db:"expires_at"` +} + +func SessionCreate(token, dossierID string, maxAgeSeconds int) error { + now := time.Now().Unix() + s := &Session{ + Token: token, + DossierID: dossierID, + CreatedAt: now, + ExpiresAt: now + int64(maxAgeSeconds), + } + return authSave("sessions", s) +} + +func SessionDelete(token string) error { + return authDelete("sessions", "token", token) +} + +// FIX TASK-018: Delete all sessions for a dossier (session rotation on login) +func SessionDeleteByDossier(dossierID string) error { + _, err := authDB.Exec("DELETE FROM sessions WHERE dossier_id = ?", dossierID) + return err +} + +func SessionGetDossier(token string) string { + var s Session + if err := authLoad("sessions", token, &s); err != nil { + return "" + } + // Check expiry + if time.Now().Unix() > s.ExpiresAt { + SessionDelete(token) + return "" + } + return s.DossierID +} + +func SessionCleanup() { + authDB.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix()) +} + +// DossierGetBySessionToken gets dossier by session token (for API auth) +func DossierGetBySessionToken(token string) *Dossier { + dossierID := SessionGetDossier(token) + if dossierID == "" { + return nil + } + d, _ := DossierGet(dossierID, dossierID) + return d +} diff --git a/portal/api_mobile.go b/portal/api_mobile.go index a94bd21..73dbf25 100644 --- a/portal/api_mobile.go +++ b/portal/api_mobile.go @@ -45,6 +45,7 @@ func cors(w http.ResponseWriter, r *http.Request) bool { func initMobileAPI(mux *http.ServeMux) { mux.HandleFunc("/api/v1/auth/send", handleAPISend) mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) + mux.HandleFunc("/api/v1/auth/logout", handleAPILogout) // FIX TASK-018: Server-side logout mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond) @@ -98,6 +99,11 @@ func handleAPISend(w http.ResponseWriter, r *http.Request) { return } + // FIX TASK-018: Prevent auth response caching + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + jsonOK(w, map[string]bool{"success": true}) } @@ -124,9 +130,16 @@ func handleAPIVerify(w http.ResponseWriter, r *http.Request) { return } - // Generate session token + // Generate session token and store it token := generateSessionToken() - // TODO: store session token when session management is implemented + + // FIX TASK-018: Store session server-side with 30-day expiry + // Session rotation: Delete any existing sessions for this dossier to prevent fixation + lib.SessionDeleteByDossier(id) // Delete old sessions + if err := lib.SessionCreate(token, id, 30*24*60*60); err != nil { + jsonError(w, "Session creation failed", 500) + return + } d, _ := lib.DossierGet("", id) needsOnboard := d == nil || d.Name == "" @@ -134,9 +147,37 @@ func handleAPIVerify(w http.ResponseWriter, r *http.Request) { lib.SendSignal(fmt.Sprintf("Mobile login: %s", d.Name)) } + // FIX TASK-018: Prevent session token caching in browser/proxy + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + jsonOK(w, APIVerifyResponse{Token: token, NeedsOnboard: needsOnboard}) } +// FIX TASK-018: Server-side logout endpoint +func handleAPILogout(w http.ResponseWriter, r *http.Request) { + if cors(w, r) { return } + if r.Method != "POST" { + jsonError(w, "Method not allowed", 405) + return + } + + // Delete session from server-side store + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + token := strings.TrimPrefix(auth, "Bearer ") + lib.SessionDelete(token) + } + + // FIX TASK-018: Cache-Control headers to prevent session token caching + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + jsonOK(w, map[string]bool{"success": true}) +} + // --- Dashboard --- type APIDossierEntry struct {