2026-01-11 17:16:59 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
2026-01-11 20:24:50 -07:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 20:24:50 -07:00
|
|
|
// SaveSession persists a session to the database
|
2026-01-11 17:16:59 -07:00
|
|
|
func SaveSession(session *Session) error {
|
2026-01-11 20:24:50 -07:00
|
|
|
_, 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
|
|
|
}
|
|
|
|
|
|
2026-01-11 20:24:50 -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
|
2026-01-11 20:24:50 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 20:24:50 -07:00
|
|
|
// DeleteSession removes a session from the database
|
2026-01-11 17:16:59 -07:00
|
|
|
func DeleteSession(token string) error {
|
2026-01-11 20:24:50 -07:00
|
|
|
_, 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"
|
|
|
|
|
}
|