package main import ( "crypto/rand" "database/sql" "encoding/base64" "fmt" "net/http" "time" ) const ( sessionCookieName = "pedestrian_simulator_session" sessionDuration = 30 * 24 * time.Hour // 30 days ) type Session struct { Token string `json:"token"` FitbitUserID string `json:"fitbit_user_id"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } // GenerateSessionToken creates a cryptographically secure random token func GenerateSessionToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // CreateSession generates a new session for a user func CreateSession(fitbitUserID string) (*Session, error) { token, err := GenerateSessionToken() if err != nil { return nil, err } now := time.Now() session := &Session{ Token: token, FitbitUserID: fitbitUserID, CreatedAt: now, ExpiresAt: now.Add(sessionDuration), } if err := SaveSession(session); err != nil { return nil, err } return session, nil } // SaveSession persists a session to the database func SaveSession(session *Session) error { _, err := db.Exec(` INSERT INTO sessions (token, fitbit_user_id, created_at, expires_at) VALUES (?, ?, ?, ?) `, session.Token, session.FitbitUserID, session.CreatedAt, session.ExpiresAt) return err } // LoadSession loads a session from the database by token func LoadSession(token string) (*Session, error) { var session Session err := db.QueryRow("SELECT token, fitbit_user_id, created_at, expires_at FROM sessions WHERE token = ?", token). Scan(&session.Token, &session.FitbitUserID, &session.CreatedAt, &session.ExpiresAt) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("session not found") } return nil, err } // Check if expired if time.Now().After(session.ExpiresAt) { DeleteSession(token) return nil, fmt.Errorf("session expired") } return &session, nil } // DeleteSession removes a session from the database func DeleteSession(token string) error { _, err := db.Exec("DELETE FROM sessions WHERE token = ?", token) return err } // GetSessionFromRequest extracts the session from the request cookie func GetSessionFromRequest(r *http.Request) (*Session, error) { cookie, err := r.Cookie(sessionCookieName) if err != nil { return nil, err } return LoadSession(cookie.Value) } // SetSessionCookie sets the session cookie on the response func SetSessionCookie(w http.ResponseWriter, session *Session) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: session.Token, Path: "/", Expires: session.ExpiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode, // Secure: true, // Enable in production with HTTPS }) } // ClearSessionCookie removes the session cookie func ClearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) } // RequireAuth is middleware that ensures the user is authenticated func RequireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session, err := GetSessionFromRequest(r) if err != nil { // Not authenticated - for API calls return 401, for pages redirect if isAPIRequest(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) } else { http.Redirect(w, r, "/auth/fitbit", http.StatusTemporaryRedirect) } return } // Store user ID in request context for handlers to use ctx := r.Context() ctx = withUserID(ctx, session.FitbitUserID) next(w, r.WithContext(ctx)) } } // isAPIRequest checks if the request is to an API endpoint func isAPIRequest(r *http.Request) bool { return len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" }