Files
pedestrian-simulator/server/session.go

150 lines
3.8 KiB
Go
Raw Normal View History

2026-01-11 17:16:59 -07:00
package main
import (
"crypto/rand"
"database/sql"
2026-01-11 17:16:59 -07:00
"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
2026-01-11 17:16:59 -07:00
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
2026-01-11 17:16:59 -07:00
}
// LoadSession loads a session from the database by token
2026-01-11 17:16:59 -07:00
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")
}
2026-01-11 17:16:59 -07:00
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
2026-01-11 17:16:59 -07:00
func DeleteSession(token string) error {
_, err := db.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
2026-01-11 17:16:59 -07:00
}
// 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"
}