first commit
This commit is contained in:
204
go-api/internal/auth/store.go
Normal file
204
go-api/internal/auth/store.go
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user