| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- // Package authd — store.go: Postgres-backed data access. The
- // schema is in migrations/009_auth.up.sql. This file is the only
- // place in the package that touches pgx directly; the rest of the
- // package uses the higher-level methods on *Authd.
- //
- // All methods take a context and respect cancellation.
- package authd
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "time"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
- )
- // Store wraps a pgx pool with auth-specific queries. Construct via
- // NewStore; do not instantiate directly.
- type Store struct {
- pool *postgres.Pool
- }
- // NewStore constructs a Store.
- func NewStore(pool *postgres.Pool) *Store {
- return &Store{pool: pool}
- }
- // ---------------------------------------------------------------------------
- // User types
- // ---------------------------------------------------------------------------
- // User is the row from auth.users, plus a couple of join fields
- // flattened for convenience.
- type User struct {
- ID string
- GlobalID string
- TenantID string
- Email string
- Role string
- Status string
- DisplayName string
- PasswordHash string
- LastLoginAt *time.Time
- CreatedAt time.Time
- UpdatedAt time.Time
- }
- // ---------------------------------------------------------------------------
- // User CRUD
- // ---------------------------------------------------------------------------
- // TenantIDBySlug resolves a tenant slug to its id. Returns
- // ErrUserNotFound if the slug doesn't exist (the error is
- // misnamed; v1.1 should split it).
- func (s *Store) TenantIDBySlug(ctx context.Context, slug string) (string, error) {
- if s.pool == nil {
- return "", errors.New("authd: no DB pool (test mode)")
- }
- const q = `SELECT id::text FROM auth.tenants WHERE slug = $1`
- var id string
- err := s.pool.QueryRow(ctx, q, slug).Scan(&id)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return "", ErrUserNotFound
- }
- return "", fmt.Errorf("tenant id by slug: %w", err)
- }
- return id, nil
- }
- // GetUserByEmail looks up a user by email. Email is unique per
- // tenant (or globally for super_admins), so we use the most recent
- // matching row if there are multiple.
- func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
- const q = `
- SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
- email, role, status, COALESCE(display_name, ''),
- COALESCE(password_hash, ''), last_login_at, created_at, updated_at
- FROM auth.users
- WHERE email = $1
- ORDER BY (tenant_id IS NULL) DESC, created_at DESC
- LIMIT 1
- `
- u := &User{}
- err := s.pool.QueryRow(ctx, q, email).Scan(
- &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
- &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
- )
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, ErrUserNotFound
- }
- return nil, fmt.Errorf("get user by email: %w", err)
- }
- return u, nil
- }
- // GetUserByID looks up a user by primary key.
- func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
- const q = `
- SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
- email, role, status, COALESCE(display_name, ''),
- COALESCE(password_hash, ''), last_login_at, created_at, updated_at
- FROM auth.users
- WHERE id = $1
- `
- u := &User{}
- err := s.pool.QueryRow(ctx, q, id).Scan(
- &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
- &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
- )
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, ErrUserNotFound
- }
- return nil, fmt.Errorf("get user by id: %w", err)
- }
- return u, nil
- }
- // CreateUser inserts a new pending user. Returns the new id.
- // Fails with a wrapped pg error if the email/tenant pair is not
- // unique.
- func (s *Store) CreateUser(ctx context.Context, tenantID, email, role string) (string, error) {
- const q = `
- INSERT INTO auth.users (tenant_id, email, role, status)
- VALUES (
- CASE WHEN $1 = '' THEN NULL ELSE $1::uuid END,
- $2, $3, 'pending'
- )
- RETURNING id::text
- `
- var id string
- err := s.pool.QueryRow(ctx, q, tenantID, email, role).Scan(&id)
- if err != nil {
- var pgErr *pgconn.PgError
- if errors.As(err, &pgErr) && pgErr.Code == "23505" {
- return "", fmt.Errorf("user %q already exists: %w", email, err)
- }
- return "", fmt.Errorf("create user: %w", err)
- }
- return id, nil
- }
- // SetUserPassword updates password_hash and status. Used by both
- // magic-link-first-login and password-reset paths.
- func (s *Store) SetUserPassword(ctx context.Context, userID, hash, newStatus string) error {
- const q = `UPDATE auth.users SET password_hash = $2, status = $3 WHERE id = $1`
- tag, err := s.pool.Exec(ctx, q, userID, hash, newStatus)
- if err != nil {
- return fmt.Errorf("set password: %w", err)
- }
- if tag.RowsAffected() == 0 {
- return ErrUserNotFound
- }
- return nil
- }
- // TouchUserLogin updates last_login_at. Fire-and-forget from caller.
- func (s *Store) TouchUserLogin(ctx context.Context, userID string) error {
- const q = `UPDATE auth.users SET last_login_at = NOW() WHERE id = $1`
- _, err := s.pool.Exec(ctx, q, userID)
- return err
- }
- // ---------------------------------------------------------------------------
- // Magic links
- // ---------------------------------------------------------------------------
- // InsertMagicLink stores the hash of a magic link token. The
- // plaintext is never persisted.
- func (s *Store) InsertMagicLink(ctx context.Context, userID string, hash []byte, purpose string, expiresAt time.Time) error {
- if s.pool == nil {
- return errors.New("authd: no DB pool (test mode)")
- }
- const q = `
- INSERT INTO auth.magic_links (token_hash, user_id, purpose, expires_at)
- VALUES ($1, $2::uuid, $3, $4)
- `
- _, err := s.pool.Exec(ctx, q, hash, userID, purpose, expiresAt)
- if err != nil {
- return fmt.Errorf("insert magic link: %w", err)
- }
- return nil
- }
- // ConsumeMagicLink atomically marks a link consumed and returns the
- // user_id. Returns ErrMagicLinkInvalid for unknown, expired, or
- // already-consumed links.
- func (s *Store) ConsumeMagicLink(ctx context.Context, hash []byte, ip, ua string) (string, error) {
- const q = `
- UPDATE auth.magic_links
- SET consumed_at = NOW(),
- consumed_ip = $2::inet,
- consumed_ua = $3
- WHERE token_hash = $1
- AND consumed_at IS NULL
- AND expires_at > NOW()
- RETURNING user_id::text
- `
- var userID string
- err := s.pool.QueryRow(ctx, q, hash, ip, ua).Scan(&userID)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return "", ErrMagicLinkInvalid
- }
- return "", fmt.Errorf("consume magic link: %w", err)
- }
- return userID, nil
- }
- // ---------------------------------------------------------------------------
- // Refresh tokens (delegate to SQL functions)
- // ---------------------------------------------------------------------------
- // IssueRefreshToken asks Postgres for a new token row. Returns the
- // plaintext token (the caller returns it to the client once and
- // discards it).
- func (s *Store) IssueRefreshToken(ctx context.Context, userID, accessJTI string, ttlSeconds int, ip, ua string) (string, error) {
- const q = `SELECT token FROM auth.issue_refresh_token($1::uuid, $2, $3, $4::inet, $5)`
- var token string
- err := s.pool.QueryRow(ctx, q, userID, accessJTI, ttlSeconds, ip, ua).Scan(&token)
- if err != nil {
- return "", fmt.Errorf("issue refresh token: %w", err)
- }
- return token, nil
- }
- // RotateRefreshToken swaps a refresh token for a new one. On
- // re-use, returns ErrTokenReuse and the whole family is killed in
- // the SQL function. Returns the user, the new plaintext token, and
- // the JTI to use for the new access JWT.
- func (s *Store) RotateRefreshToken(ctx context.Context, presentedToken string, ttlSeconds int, ip, ua string) (*User, string, string, error) {
- // We need the JTI for the new access token. The SQL function
- // returns (id, token, family_id, expires_at, killed_family) but
- // NOT a new JTI. We generate the JTI here and pass it in.
- // Patch: the SQL function signature is fixed (p_new_access_jti
- // is the last text arg), so we generate it client-side and pass
- // it in, then read it back from the returned columns.
- newJTI := newJTI()
- const q = `
- SELECT id::text, token, family_id, expires_at, killed_family
- FROM auth.rotate_refresh_token($1, $2, $3, $4::inet, $5)
- `
- var (
- id string
- token string
- familyID string
- exp time.Time
- killed bool
- )
- err := s.pool.QueryRow(ctx, q, presentedToken, newJTI, ttlSeconds, ip, ua).Scan(&id, &token, &familyID, &exp, &killed)
- if err != nil {
- // Distinguish re-use (22023 with refresh_token_reuse) from
- // other failures. The SQL raises different messages; we
- // match on the message.
- msg := err.Error()
- if contains(msg, "refresh_token_reuse") {
- return nil, "", "", ErrTokenReuse
- }
- if contains(msg, "expired_refresh_token") {
- return nil, "", "", errors.New("authd: refresh token expired")
- }
- if contains(msg, "unknown_refresh_token") {
- return nil, "", "", errors.New("authd: refresh token unknown")
- }
- return nil, "", "", fmt.Errorf("rotate refresh token: %w", err)
- }
- _ = killed // logged separately
- _ = familyID // future: pass back for client-side display
- // Fetch the user (we need tenant + role for the new access JWT)
- user, err := s.getUserByRefreshID(ctx, id)
- if err != nil {
- return nil, "", "", err
- }
- return user, token, newJTI, nil
- }
- // RevokeRefreshToken revokes a single token by plaintext. Returns
- // true if a row was actually revoked.
- func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error {
- const q = `SELECT auth.revoke_refresh_token($1)`
- var revoked bool
- err := s.pool.QueryRow(ctx, q, token).Scan(&revoked)
- if err != nil {
- return fmt.Errorf("revoke refresh token: %w", err)
- }
- _ = revoked
- return nil
- }
- // getUserByRefreshID is a join used by RotateRefreshToken.
- func (s *Store) getUserByRefreshID(ctx context.Context, refreshID string) (*User, error) {
- const q = `
- SELECT u.id::text, u.global_id::text, COALESCE(u.tenant_id::text, ''),
- u.email, u.role, u.status, COALESCE(u.display_name, ''),
- COALESCE(u.password_hash, ''), u.last_login_at, u.created_at, u.updated_at
- FROM auth.refresh_tokens rt
- JOIN auth.users u ON u.id = rt.user_id
- WHERE rt.id = $1::uuid
- `
- u := &User{}
- err := s.pool.QueryRow(ctx, q, refreshID).Scan(
- &u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
- &u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
- )
- if err != nil {
- return nil, fmt.Errorf("get user by refresh id: %w", err)
- }
- return u, nil
- }
- // ---------------------------------------------------------------------------
- // Audit log
- // ---------------------------------------------------------------------------
- // WriteAudit records an event. targetID and tenantID are optional
- // (empty string → NULL in the column). payload may be nil.
- func (s *Store) WriteAudit(
- ctx context.Context,
- action string,
- actorUserID string,
- actorIP string,
- actorUA string,
- targetID string,
- tenantID string,
- payload map[string]any,
- ) error {
- var payloadJSON []byte
- if payload != nil {
- var err error
- payloadJSON, err = json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("marshal audit payload: %w", err)
- }
- } else {
- payloadJSON = []byte("{}")
- }
- // Empty string → NULL for actor_user_id
- var actorArg any
- if actorUserID == "" {
- actorArg = nil
- } else {
- actorArg = actorUserID
- }
- const q = `
- INSERT INTO auth.audit_log
- (action, actor_user_id, actor_ip, actor_ua, target_id, tenant_id, payload)
- VALUES
- ($1, $2::uuid, NULLIF($3, '')::inet, NULLIF($4, ''), NULLIF($5, '')::uuid, NULLIF($6, '')::uuid, $7::jsonb)
- `
- _, err := s.pool.Exec(ctx, q, action, actorArg, actorIP, actorUA, targetID, tenantID, payloadJSON)
- if err != nil {
- return fmt.Errorf("write audit: %w", err)
- }
- return nil
- }
- // ---------------------------------------------------------------------------
- // helpers
- // ---------------------------------------------------------------------------
- func contains(s, substr string) bool {
- for i := 0; i+len(substr) <= len(s); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
- }
|