package app import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "net/http" "time" ) const ( cookieName = "spread_session" sessionTTL = 30 * 24 * time.Hour ) type ctxKey int const ( userKey ctxKey = iota subKey csrfKey ) func hashToken(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // randomToken returns a 256-bit URL-safe random string. func randomToken() string { buf := make([]byte, 32) _, _ = rand.Read(buf) return base64.RawURLEncoding.EncodeToString(buf) } func (a *App) createSession(ctx context.Context, userID int64) (string, error) { raw := randomToken() _, err := a.pool.Exec(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, hashToken(raw), userID, time.Now().Add(sessionTTL)) if err != nil { return "", err } return raw, nil } func (a *App) lookupSession(ctx context.Context, raw string) (User, Subscriber, error) { var u User err := a.pool.QueryRow(ctx, `SELECT u.id, u.subscriber_id, u.email, u.name FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now()`, hashToken(raw)).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name) if err != nil { return User{}, Subscriber{}, err } sub, err := a.subscriber(ctx, u.SubscriberID) return u, sub, err } func (a *App) destroySession(ctx context.Context, raw string) { _, _ = a.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hashToken(raw)) } func (a *App) setSessionCookie(w http.ResponseWriter, raw string) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: raw, Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(sessionTTL), }) } func (a *App) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } // current returns the logged-in user and subscriber from the request context. func current(r *http.Request) (User, Subscriber, bool) { u, ok := r.Context().Value(userKey).(User) if !ok { return User{}, Subscriber{}, false } s, _ := r.Context().Value(subKey).(Subscriber) return u, s, true } // load attaches the session user/subscriber to the request context if a valid // session cookie is present. It never redirects. func (a *App) load(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { if u, s, err := a.lookupSession(r.Context(), c.Value); err == nil { ctx := context.WithValue(r.Context(), userKey, u) ctx = context.WithValue(ctx, subKey, s) r = r.WithContext(ctx) } } next.ServeHTTP(w, r) }) } // requireAuth redirects to /login when there is no session. func (a *App) requireAuth(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := current(r); !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } fn(w, r) } } // requireInternal requires a session belonging to the internal subscriber. func (a *App) requireInternal(fn http.HandlerFunc) http.HandlerFunc { return a.requireAuth(func(w http.ResponseWriter, r *http.Request) { if _, s, _ := current(r); !s.IsInternal { http.Redirect(w, r, "/app", http.StatusSeeOther) return } fn(w, r) }) }