205 lines
7.1 KiB
Go
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
|
|
}
|