93 lines
3.5 KiB
Go
93 lines
3.5 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/auth"
|
|
)
|
|
|
|
// Lookup resolves a presented token. Expiry and revocation are clauses of THIS query, not checks a
|
|
// caller could forget: a row that comes back is live by construction.
|
|
func (s *Store) Lookup(ctx context.Context, digest []byte, now time.Time) (auth.Session, error) {
|
|
const q = `
|
|
select user_id, idle_expires_at, absolute_expires_at
|
|
from sessions
|
|
where token_sha256 = $1
|
|
and revoked_at is null
|
|
and idle_expires_at > $2
|
|
and absolute_expires_at > $2`
|
|
var out auth.Session
|
|
err := s.pool.QueryRow(ctx, q, digest, now).
|
|
Scan(&out.UserID, &out.IdleExpiresAt, &out.AbsoluteExpiresAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return auth.Session{}, auth.ErrNoSession
|
|
}
|
|
if err != nil {
|
|
return auth.Session{}, fmt.Errorf("pgstore: lookup session: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Touch slides the idle window. It never moves the absolute expiry — that is the point of having
|
|
// two clocks — and it is called only in the window's second half, so reads stay reads.
|
|
//
|
|
// Its WHERE matches Lookup's, idle clause included (PD-4): reachable only after a successful
|
|
// Lookup today, but a query that can resurrect an idle-expired session is not one to leave lying
|
|
// around for the next caller.
|
|
func (s *Store) Touch(ctx context.Context, digest []byte, now time.Time, idleTTL time.Duration) error {
|
|
// Deadlines are computed in Go and travel as timestamps: one clock, one place, and no interval
|
|
// encoding to reason about.
|
|
const q = `
|
|
update sessions
|
|
set last_used_at = $2,
|
|
idle_expires_at = least($3::timestamptz, absolute_expires_at)
|
|
where token_sha256 = $1
|
|
and revoked_at is null
|
|
and idle_expires_at > $2
|
|
and absolute_expires_at > $2`
|
|
if _, err := s.pool.Exec(ctx, q, digest, now, now.Add(idleTTL)); err != nil {
|
|
return fmt.Errorf("pgstore: touch session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateSession stores the digest; the plaintext never reaches this package.
|
|
func (s *Store) CreateSession(ctx context.Context, digest []byte, userID string, now time.Time, idleTTL, maxAge time.Duration) error {
|
|
const q = `
|
|
insert into sessions (token_sha256, user_id, created_at, last_used_at, idle_expires_at, absolute_expires_at)
|
|
values ($1, $2, $3, $3, $4, $5)`
|
|
if _, err := s.pool.Exec(ctx, q, digest, userID, now, now.Add(idleTTL), now.Add(maxAge)); err != nil {
|
|
return fmt.Errorf("pgstore: create session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeSession ends one session immediately.
|
|
func (s *Store) RevokeSession(ctx context.Context, digest []byte, now time.Time) error {
|
|
const q = `update sessions set revoked_at = $2 where token_sha256 = $1 and revoked_at is null`
|
|
if _, err := s.pool.Exec(ctx, q, digest, now); err != nil {
|
|
return fmt.Errorf("pgstore: revoke session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SweepSessions deletes rows nothing can authenticate with again: past either expiry, or revoked.
|
|
// A revoked row is the one a compromised account most wants gone, and it used to sit until its
|
|
// absolute expiry ninety days later. The audit lives in the login journal, not here.
|
|
func (s *Store) SweepSessions(ctx context.Context, now time.Time) (int64, error) {
|
|
const q = `
|
|
delete from sessions
|
|
where absolute_expires_at <= $1
|
|
or idle_expires_at <= $1
|
|
or revoked_at is not null`
|
|
tag, err := s.pool.Exec(ctx, q, now)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: sweep sessions: %w", err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|