Vellum/internal/auth/session.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

138 lines
4.5 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
)
// ErrNoSession means no session exists for the presented token, or it expired.
var ErrNoSession = errors.New("auth: no such session")
// Session is a logged-in browser or device.
//
// The token itself is NEVER stored. Only its SHA-256 is, so a database leak
// does not hand out live sessions.
type Session struct {
ID string
UserID int64
DeviceLabel string
IP string
UserAgent string
CreatedAt time.Time
LastSeen time.Time
ExpiresAt time.Time
}
// newSessionToken returns a fresh opaque token and its storage hash.
//
// The token is 32 bytes of CSPRNG output, base64url-encoded. Hashing with
// SHA-256 (not argon2) is correct here: the token already has full entropy, so
// there is nothing to brute-force and lookups must stay cheap.
func newSessionToken() (token string, hash string, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", fmt.Errorf("auth: read session token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(raw)
return token, hashToken(token), nil
}
// hashToken is the deterministic mapping from token to stored value.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// CreateSession stores a new session for userID and returns the raw token.
// The raw token is returned exactly once and never persisted.
func CreateSession(ctx context.Context, db *sql.DB, userID int64, ttl time.Duration, deviceLabel, ip, userAgent string) (string, error) {
token, hash, err := newSessionToken()
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, token_hash, device_label, ip, user_agent, created_at, last_seen, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hash[:16], userID, hash, deviceLabel, ip, userAgent,
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
now.Add(ttl).Format(time.RFC3339Nano),
)
if err != nil {
return "", fmt.Errorf("auth: create session: %w", err)
}
return token, nil
}
// SessionByToken resolves a raw token to its session, or ErrNoSession if the
// token is unknown or expired.
func SessionByToken(ctx context.Context, db *sql.DB, token string) (Session, error) {
var (
s Session
created string
lastSeen string
expiresAt string
)
err := db.QueryRowContext(ctx, `
SELECT id, user_id, device_label, ip, user_agent, created_at, last_seen, expires_at
FROM sessions WHERE token_hash = ?`, hashToken(token),
).Scan(&s.ID, &s.UserID, &s.DeviceLabel, &s.IP, &s.UserAgent, &created, &lastSeen, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrNoSession
}
if err != nil {
return Session{}, fmt.Errorf("auth: load session: %w", err)
}
if s.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
return Session{}, fmt.Errorf("auth: parse created_at: %w", err)
}
if s.LastSeen, err = time.Parse(time.RFC3339Nano, lastSeen); err != nil {
return Session{}, fmt.Errorf("auth: parse last_seen: %w", err)
}
if s.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt); err != nil {
return Session{}, fmt.Errorf("auth: parse expires_at: %w", err)
}
if time.Now().UTC().After(s.ExpiresAt) {
return Session{}, ErrNoSession
}
return s, nil
}
// TouchSession updates last_seen. Best-effort: a failure here must never fail
// the request that triggered it, so callers log rather than propagate.
func TouchSession(ctx context.Context, db *sql.DB, sessionID string) error {
_, err := db.ExecContext(ctx,
`UPDATE sessions SET last_seen = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339Nano), sessionID)
return err
}
// DeleteSession logs out a single session by its raw token.
func DeleteSession(ctx context.Context, db *sql.DB, token string) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token))
return err
}
// DeleteUserSessions logs a user out everywhere.
func DeleteUserSessions(ctx context.Context, db *sql.DB, userID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// DeleteExpiredSessions is housekeeping, run periodically.
func DeleteExpiredSessions(ctx context.Context, db *sql.DB) (int64, error) {
res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`,
time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
return res.RowsAffected()
}