Files
2026-08-24 13:06:29 +05:30

205 lines
7.1 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Querier is satisfied by *pgxpool.Pool and by pgx.Tx, so every method below
// works inside or outside a transaction.
//
// Declared here rather than imported from internal/repo: that package's
// Querier is identical, but this one keeps the authentication foundation
// independent of the resource/descriptor layer, which it otherwise shares
// nothing with. Go interfaces are structural, so both are satisfied by the
// same values.
type Querier interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
// PGStore is the sessions table.
//
// Every statement here is a constant string with bind parameters. Nothing —
// not an id, not a token hash, not a timestamp — is ever formatted into SQL.
// There is no identifier taken from a caller, so there is nothing to quote and
// nothing to escape.
type PGStore struct {
db Querier
}
// NewPGStore builds the store over a pool or a transaction.
func NewPGStore(db Querier) *PGStore { return &PGStore{db: db} }
// Compile-time check that the persistence layer satisfies the lifecycle's
// expectations. If Store gains a method, this line is where it is noticed.
var _ Store = (*PGStore)(nil)
// sessionColumns is the projection every read below shares, in the order the
// scan expects.
const sessionColumns = `id::text, user_id::text, token_hash,
expires_at, absolute_expires_at, created_date, last_seen_at`
// Create inserts a session.
//
// The id is left to the database's gen_random_uuid() default when the caller
// did not choose one, and returned so the caller's Session is complete.
// created_date and last_seen_at come from the caller rather than now(), so the
// row agrees with the deadlines the Manager computed from the same instant.
func (s *PGStore) Create(ctx context.Context, sess *Session) error {
if sess.UserID == "" {
return errors.New("auth: session user id is required")
}
// The last line of defence against writing a raw token to disk. The
// database CHECK enforces the same shape; this turns it into a Go error
// naming the actual mistake instead of a constraint violation.
if !IsTokenHash(sess.TokenHash) {
return errors.New("auth: session token_hash is not a SHA-256 hex digest")
}
const q = `INSERT INTO sessions
(id, user_id, token_hash, expires_at, absolute_expires_at, created_date, last_seen_at)
VALUES
(COALESCE($1::uuid, gen_random_uuid()), $2::uuid, $3::text,
$4::timestamptz, $5::timestamptz, $6::timestamptz, $7::timestamptz)
RETURNING id::text`
var id *string
if sess.ID != "" {
id = &sess.ID
}
if err := s.db.QueryRow(ctx, q, id, sess.UserID, sess.TokenHash,
sess.ExpiresAt, sess.AbsoluteExpiresAt, sess.CreatedDate, sess.LastSeenAt,
).Scan(&sess.ID); err != nil {
return fmt.Errorf("auth: create session: %w", err)
}
return nil
}
// FindByTokenHash reads one session by the hash of its token.
//
// The parameter is a hash, never a token: the Manager hashes before it calls
// here, so a raw secret never reaches the query layer at all. Returns
// ErrSessionNotFound when no row matches, which callers must not distinguish
// from an expired session when answering a client.
//
// Expiry is deliberately not filtered in SQL. The caller decides what an
// expired row means — Authenticate deletes it, a diagnostic might report it —
// and a WHERE clause here would collapse "revoked" and "timed out" into one
// indistinguishable answer at the wrong layer.
func (s *PGStore) FindByTokenHash(ctx context.Context, tokenHash string) (Session, error) {
if tokenHash == "" {
return Session{}, ErrEmptyToken
}
if !IsTokenHash(tokenHash) {
// A value of the wrong shape cannot match any row, and querying with
// it would be an unnecessary round trip on every malformed cookie.
return Session{}, ErrSessionNotFound
}
const q = `SELECT ` + sessionColumns + ` FROM sessions WHERE token_hash = $1::text`
var out Session
err := s.db.QueryRow(ctx, q, tokenHash).Scan(
&out.ID, &out.UserID, &out.TokenHash,
&out.ExpiresAt, &out.AbsoluteExpiresAt, &out.CreatedDate, &out.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrSessionNotFound
}
if err != nil {
return Session{}, fmt.Errorf("auth: find session: %w", err)
}
return out, nil
}
// Touch moves the sliding deadline and records the activity.
//
// The UPDATE is guarded by `expires_at <= absolute_expires_at` in the database
// CHECK; the Manager clamps before calling, so a violation here would mean a
// bug rather than a race.
func (s *PGStore) Touch(ctx context.Context, id string, expiresAt, lastSeenAt time.Time) error {
if id == "" {
return errors.New("auth: session id is required")
}
const q = `UPDATE sessions
SET expires_at = $2::timestamptz, last_seen_at = $3::timestamptz
WHERE id = $1::uuid`
tag, err := s.db.Exec(ctx, q, id, expiresAt, lastSeenAt)
if err != nil {
return fmt.Errorf("auth: touch session: %w", err)
}
if tag.RowsAffected() == 0 {
// The session was revoked between the read and this write. Reporting
// it as absent is honest; the caller treats it as a failed lookup.
return ErrSessionNotFound
}
return nil
}
// Delete removes one session by id. Returns ErrSessionNotFound if there was
// nothing to remove; Manager.RevokeID absorbs that, because logging out of a
// session that is already gone is a success.
func (s *PGStore) Delete(ctx context.Context, id string) error {
if id == "" {
return errors.New("auth: session id is required")
}
const q = `DELETE FROM sessions WHERE id = $1::uuid`
tag, err := s.db.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrSessionNotFound
}
return nil
}
// DeleteByTokenHash removes one session by the hash of its token. This is the
// logout path: the cookie is all the client has.
func (s *PGStore) DeleteByTokenHash(ctx context.Context, tokenHash string) error {
if tokenHash == "" {
return ErrEmptyToken
}
if !IsTokenHash(tokenHash) {
return ErrSessionNotFound
}
const q = `DELETE FROM sessions WHERE token_hash = $1::text`
tag, err := s.db.Exec(ctx, q, tokenHash)
if err != nil {
return fmt.Errorf("auth: delete session by token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrSessionNotFound
}
return nil
}
// DeleteExpired removes every session past either deadline and reports the
// count.
//
// Both deadlines are tested. Filtering on expires_at alone would leave behind
// a session whose sliding window is still open but whose absolute ceiling has
// passed — precisely the row the absolute bound exists to kill.
func (s *PGStore) DeleteExpired(ctx context.Context, now time.Time) (int64, error) {
const q = `DELETE FROM sessions
WHERE expires_at <= $1::timestamptz OR absolute_expires_at <= $1::timestamptz`
tag, err := s.db.Exec(ctx, q, now)
if err != nil {
return 0, fmt.Errorf("auth: delete expired sessions: %w", err)
}
return tag.RowsAffected(), nil
}