mitbringsl/backend/internal/auth/session.go
Tronax 7b1c18590e
Backend Phase B (1/2): password auth + sessions
Argon2id password hashing (PHC format, self-encoded/decoded without an
external lib) with constant-time verification, UserStore (create/get by
email and id) and SessionStore (opaque crypto/rand tokens, SHA-256 hashed
in DB, create/lookup/revoke, last_seen_at bump on lookup).

HTTP layer: Register/Login/Logout handlers + RequireAuth middleware.
Login uses a dummy-hash path so unknown-email and wrong-password yield the
same timing/shape, narrowing user enumeration. Tokens accepted via Bearer
header (native clients) or session cookie (HttpOnly, SameSite=Lax).

Routes wired in api.go: POST /auth/register, /auth/login, /auth/logout.
Verified with go test, go vet and an end-to-end smoke test against a real
PostgreSQL container (register/login/logout/duplicate/short-pw/wrong-pw).

OIDC (Phase B part 2) follows next; the issueSession helper is reused.
2026-08-05 19:05:07 +02:00

117 lines
3.7 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Session is the application-side view of a row in the sessions table.
type Session struct {
ID uuid.UUID
UserID uuid.UUID
ExpiresAt time.Time
RevokedAt *time.Time
}
// ErrSessionNotFound is returned when no (valid, non-expired, non-revoked)
// session matches the given token.
var ErrSessionNotFound = errors.New("session not found")
// SessionConfig controls token generation and persistence.
type SessionConfig struct {
TokenBytes int // entropy of the raw token before base64url encoding
TTL time.Duration // validity window from creation
}
// SessionStore wraps database access for the sessions table and handles opaque
// token generation. Only the SHA-256 hash of a token is ever stored.
type SessionStore struct {
pool *pgxpool.Pool
cfg SessionConfig
}
// NewSessionStore constructs a SessionStore. tokenBytes must be >= 16.
func NewSessionStore(pool *pgxpool.Pool, cfg SessionConfig) *SessionStore {
if cfg.TokenBytes < 16 {
cfg.TokenBytes = 32
}
return &SessionStore{pool: pool, cfg: cfg}
}
// Create issues a new session for the user and returns the raw token (to send to
// the client exactly once) together with the persisted Session row.
func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, userAgent, ip string) (token string, sess Session, err error) {
raw := make([]byte, s.cfg.TokenBytes)
if _, err = rand.Read(raw); err != nil {
return "", Session{}, fmt.Errorf("generate session token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(raw)
tokenHash := hashToken(token)
expiresAt := time.Now().Add(s.cfg.TTL)
const q = `
INSERT INTO sessions (user_id, token_hash, expires_at, user_agent, ip)
VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''))
RETURNING id, user_id, expires_at, revoked_at`
err = s.pool.QueryRow(ctx, q, userID, tokenHash, expiresAt, userAgent, ip).
Scan(&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt)
if err != nil {
return "", Session{}, fmt.Errorf("insert session: %w", err)
}
return token, sess, nil
}
// Lookup returns the active session for a raw token. It rejects expired and
// revoked sessions and updates last_seen_at (best-effort, errors logged by caller).
func (s *SessionStore) Lookup(ctx context.Context, token string) (Session, error) {
if token == "" {
return Session{}, ErrSessionNotFound
}
const q = `
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1
AND revoked_at IS NULL
AND expires_at > now()
RETURNING id, user_id, expires_at, revoked_at`
var sess Session
err := s.pool.QueryRow(ctx, q, hashToken(token)).Scan(
&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrSessionNotFound
}
return Session{}, fmt.Errorf("lookup session: %w", err)
}
return sess, nil
}
// Revoke marks the session matching token as revoked. Missing tokens are a no-op
// so logout stays idempotent.
func (s *SessionStore) Revoke(ctx context.Context, token string) error {
if token == "" {
return nil
}
const q = `UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`
_, err := s.pool.Exec(ctx, q, hashToken(token))
if err != nil {
return fmt.Errorf("revoke session: %w", err)
}
return nil
}
// hashToken returns the lowercase hex SHA-256 digest of a raw token. The hash is
// what we store; the raw token never touches the database.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}