- 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
138 lines
4.4 KiB
Go
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
|
|
}
|
|
|