xtablo-source/backend/internal/auth/session.go
Arthur Belleville fd2301decf
feat(02-03): session store + cookie helpers (real-DB TDD)
- Store.Create: 32-byte crypto/rand token, SHA-256 hex as DB id (D-05)
- Store.Lookup: hashes cookie, maps pgx.ErrNoRows to ErrSessionNotFound (D-07)
- Store.Delete: hard-deletes session row (D-06)
- Store.Rotate: deletes old row before creating new one (D-10, T-2-04)
- Store.MaybeExtend: extends only when remaining < 7 days (D-09)
- SetSessionCookie: HttpOnly + Secure (env-gated) + SameSite=Lax (D-12)
- ClearSessionCookie: MaxAge=-1 not 0 (RESEARCH Pattern 3 / D-06)
- 10 tests: 7 real-DB (skip without TEST_DATABASE_URL) + 3 cookie unit tests
2026-05-14 22:08:04 +02:00

138 lines
4.4 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"backend/internal/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// Store manages session lifecycle: create, lookup, delete, rotate, and
// lazy-extend. It uses the sqlc-generated Queries for all DB operations.
//
// The raw 32-byte token is held only in memory (as the cookie value);
// the DB row stores hex(sha256(token)) so a DB-read leak does not expose
// live sessions (D-05).
type Store struct {
q *sqlc.Queries
now func() time.Time // injectable for testing (D-09 MaybeExtend)
}
// NewStore returns a Store backed by q. The store's clock defaults to
// time.Now; tests may override store.now after construction.
func NewStore(q *sqlc.Queries) *Store {
return &Store{q: q, now: time.Now}
}
// Create generates a fresh 32-byte crypto/rand token, stores its SHA-256 hash
// in the sessions table, and returns the base64url-encoded cookie value plus
// the session expiry time.
//
// This is the only place tokens are generated. The cookie value is the raw
// token (base64url); the DB stores hex(sha256(rawToken)) only (D-05).
func (s *Store) Create(ctx context.Context, userID uuid.UUID) (cookieValue string, expiresAt time.Time, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", time.Time{}, fmt.Errorf("auth: generate session token: %w", err)
}
cookieValue = base64.RawURLEncoding.EncodeToString(raw)
sum := sha256.Sum256(raw) // D-05: store hash, never raw token
id := hex.EncodeToString(sum[:])
expiresAt = s.now().Add(SessionTTL)
pgExpires := pgtype.Timestamptz{Time: expiresAt, Valid: true}
if err = s.q.InsertSession(ctx, sqlc.InsertSessionParams{
ID: id,
UserID: userID,
ExpiresAt: pgExpires,
}); err != nil {
return "", time.Time{}, fmt.Errorf("auth: insert session: %w", err)
}
return cookieValue, expiresAt, nil
}
// Lookup decodes the cookie value, hashes it, and retrieves the matching
// live session + user from the DB. Returns ErrSessionNotFound if the cookie
// is malformed, the session does not exist, or it has expired (D-07).
func (s *Store) Lookup(ctx context.Context, cookieValue string) (*Session, *User, error) {
raw, err := base64.RawURLEncoding.DecodeString(cookieValue)
if err != nil || len(raw) != 32 {
return nil, nil, ErrSessionNotFound
}
sum := sha256.Sum256(raw) // D-05: hash on every lookup, never store raw token
id := hex.EncodeToString(sum[:])
row, err := s.q.GetSessionWithUser(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionNotFound
}
return nil, nil, fmt.Errorf("auth: lookup session: %w", err)
}
sess := &Session{
ID: row.ID,
UserID: row.UserID,
CreatedAt: row.CreatedAt.Time,
ExpiresAt: row.ExpiresAt.Time,
}
user := &User{
ID: row.UID,
Email: row.Email,
PasswordHash: row.PasswordHash,
CreatedAt: row.UCreatedAt.Time,
UpdatedAt: row.UUpdatedAt.Time,
}
return sess, user, nil
}
// Delete hard-deletes the session row by its hashed ID (D-06).
func (s *Store) Delete(ctx context.Context, id string) error {
if err := s.q.DeleteSession(ctx, id); err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
return nil
}
// Rotate deletes the old session row (best-effort) and creates a new one.
// This mitigates session fixation on every login and signup (D-10, T-2-04).
// If oldID is empty the delete step is skipped.
func (s *Store) Rotate(ctx context.Context, oldID string, userID uuid.UUID) (string, time.Time, error) {
if oldID != "" {
// Best-effort: ignore delete error; even if it fails the new session is safe.
_ = s.q.DeleteSession(ctx, oldID)
}
return s.Create(ctx, userID)
}
// MaybeExtend updates expires_at only when the remaining session lifetime drops
// below SessionExtendThreshold (~7 days). This provides a sliding-window TTL
// with at most one DB write per ~23 days (D-09).
func (s *Store) MaybeExtend(ctx context.Context, id string, expiresAt time.Time) error {
remaining := expiresAt.Sub(s.now())
if remaining >= SessionExtendThreshold {
// Plenty of time left — no update needed.
return nil
}
newExpiry := s.now().Add(SessionTTL)
if err := s.q.ExtendSession(ctx, sqlc.ExtendSessionParams{
ID: id,
ExpiresAt: pgtype.Timestamptz{Time: newExpiry, Valid: true},
}); err != nil {
return fmt.Errorf("auth: extend session: %w", err)
}
return nil
}