TASK-018: Fix session management vulnerabilities

- Store session tokens server-side on login (was: generated but not stored)
- Add /api/v1/auth/logout endpoint for server-side session invalidation
- Delete old sessions on login to prevent session fixation attacks
- Add Cache-Control: no-store headers to all auth responses

Security fixes:
1. Session identifiers now rotated on login (old sessions deleted)
2. Logout properly invalidates server-side session
3. Auth responses include anti-caching headers
This commit is contained in:
James 2026-03-23 00:35:36 -04:00
parent 20795e1ea8
commit 5ebf9925ed
2 changed files with 104 additions and 2 deletions

View File

@ -12,6 +12,7 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"strings" "strings"
"time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@ -177,3 +178,63 @@ func authDelete(table, pkCol string, pkVal interface{}) error {
_, err := authDB.Exec(query, pkVal) _, err := authDB.Exec(query, pkVal)
return err 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
}

View File

@ -45,6 +45,7 @@ func cors(w http.ResponseWriter, r *http.Request) bool {
func initMobileAPI(mux *http.ServeMux) { func initMobileAPI(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/send", handleAPISend) mux.HandleFunc("/api/v1/auth/send", handleAPISend)
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) 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/dashboard", handleAPIDashboard)
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond) mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond)
@ -98,6 +99,11 @@ func handleAPISend(w http.ResponseWriter, r *http.Request) {
return 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}) jsonOK(w, map[string]bool{"success": true})
} }
@ -124,9 +130,16 @@ func handleAPIVerify(w http.ResponseWriter, r *http.Request) {
return return
} }
// Generate session token // Generate session token and store it
token := generateSessionToken() 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) d, _ := lib.DossierGet("", id)
needsOnboard := d == nil || d.Name == "" 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)) 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}) 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 --- // --- Dashboard ---
type APIDossierEntry struct { type APIDossierEntry struct {