This commit is contained in:
159
server/session.go
Normal file
159
server/session.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 disk
|
||||
func SaveSession(session *Session) error {
|
||||
sessionDir := "data/sessions"
|
||||
if err := os.MkdirAll(sessionDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create sessions directory: %w", err)
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(sessionDir, session.Token+".json")
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(sessionPath, data, 0600)
|
||||
}
|
||||
|
||||
// LoadSession loads a session from disk by token
|
||||
func LoadSession(token string) (*Session, error) {
|
||||
sessionPath := filepath.Join("data/sessions", token+".json")
|
||||
data, err := os.ReadFile(sessionPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
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 disk
|
||||
func DeleteSession(token string) error {
|
||||
sessionPath := filepath.Join("data/sessions", token+".json")
|
||||
return os.Remove(sessionPath)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user