98 lines
3 KiB
Go
98 lines
3 KiB
Go
|
|
// Package sessions implements the Session → Preview → Confirm pipeline on
|
||
|
|
// top of a Store. Keeping it one layer above the HTTP handlers lets the
|
||
|
|
// unit tests exercise business logic without an httptest server.
|
||
|
|
package sessions
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/base64"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// IDGenerator returns opaque collision-resistant IDs. The default
|
||
|
|
// implementation uses crypto/rand; tests inject a deterministic one.
|
||
|
|
type IDGenerator func(prefix string) (string, error)
|
||
|
|
|
||
|
|
// DefaultIDGenerator produces "<prefix>_" + 24 random base64url chars.
|
||
|
|
func DefaultIDGenerator(prefix string) (string, error) {
|
||
|
|
b := make([]byte, 18)
|
||
|
|
if _, err := rand.Read(b); err != nil {
|
||
|
|
return "", fmt.Errorf("id: %w", err)
|
||
|
|
}
|
||
|
|
return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Manager orchestrates sessions on top of a Store. Its only state is the
|
||
|
|
// clock and ID generator; all persistence happens through Store.
|
||
|
|
type Manager struct {
|
||
|
|
Store store.Store
|
||
|
|
Now func() time.Time
|
||
|
|
GenID IDGenerator
|
||
|
|
SessionTTL time.Duration
|
||
|
|
ChallengeTTL time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
// New constructs a Manager with safe defaults for Now, GenID, SessionTTL
|
||
|
|
// (15m) and ChallengeTTL (2m) when the caller leaves them zero.
|
||
|
|
func New(s store.Store) *Manager {
|
||
|
|
return &Manager{
|
||
|
|
Store: s,
|
||
|
|
Now: time.Now,
|
||
|
|
GenID: DefaultIDGenerator,
|
||
|
|
SessionTTL: 15 * time.Minute,
|
||
|
|
ChallengeTTL: 2 * time.Minute,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// StartSession creates a fresh approval_session for userSub.
|
||
|
|
func (m *Manager) StartSession(ctx context.Context, userSub string) (store.Session, error) {
|
||
|
|
if userSub == "" {
|
||
|
|
return store.Session{}, errors.New("sessions: userSub is empty")
|
||
|
|
}
|
||
|
|
id, err := m.GenID("sess")
|
||
|
|
if err != nil {
|
||
|
|
return store.Session{}, err
|
||
|
|
}
|
||
|
|
now := m.Now()
|
||
|
|
s := store.Session{
|
||
|
|
ID: id,
|
||
|
|
UserSub: userSub,
|
||
|
|
ActiveUntil: now.Add(m.SessionTTL),
|
||
|
|
CreatedAt: now,
|
||
|
|
}
|
||
|
|
if err := m.Store.CreateSession(ctx, s); err != nil {
|
||
|
|
return store.Session{}, err
|
||
|
|
}
|
||
|
|
return s, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ErrSessionExpired is returned by GetActiveSession when the session
|
||
|
|
// row exists but active_until is in the past.
|
||
|
|
var ErrSessionExpired = errors.New("sessions: session expired")
|
||
|
|
|
||
|
|
// GetActiveSession returns the session when it is still within
|
||
|
|
// active_until. Enforces session_id ↔ user_sub binding (the caller passes
|
||
|
|
// the authenticated userSub so a leaked session_id cannot be used from
|
||
|
|
// another account).
|
||
|
|
func (m *Manager) GetActiveSession(ctx context.Context, sessionID, userSub string) (store.Session, error) {
|
||
|
|
s, err := m.Store.GetSession(ctx, sessionID)
|
||
|
|
if err != nil {
|
||
|
|
return store.Session{}, err
|
||
|
|
}
|
||
|
|
if s.UserSub != userSub {
|
||
|
|
// Explicit NotFound so handlers return 404 instead of leaking the
|
||
|
|
// mismatch — a probing attacker cannot distinguish "wrong user"
|
||
|
|
// from "unknown session".
|
||
|
|
return store.Session{}, store.ErrNotFound
|
||
|
|
}
|
||
|
|
if !m.Now().Before(s.ActiveUntil) {
|
||
|
|
return store.Session{}, ErrSessionExpired
|
||
|
|
}
|
||
|
|
return s, nil
|
||
|
|
}
|