245 lines
9.1 KiB
Go
245 lines
9.1 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"textmachine/platform/internal/login"
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
// newID mints an opaque identifier. Opaque on purpose: an id that encodes a row number tells a
|
|
// caller how many accounts exist and lets them guess a neighbour's.
|
|
func newID(prefix string) string {
|
|
var b [10]byte
|
|
rand.Read(b[:])
|
|
return prefix + "_" + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
|
|
}
|
|
|
|
// PutLoginState stores one in-flight authorization request.
|
|
func (s *Store) PutLoginState(ctx context.Context, st login.State) error {
|
|
const q = `
|
|
insert into auth_states (state_sha256, provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
|
|
_, err := s.pool.Exec(ctx, q, st.Hash, st.Provider, st.Issuer, st.Nonce, st.Verifier, st.ReturnTo,
|
|
st.StartID, st.CreatedAt, st.ExpiresAt)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: put login state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TakeLoginState consumes the state. Deleting and returning in ONE statement is what makes it
|
|
// single-use under concurrency: a second callback with the same state deletes nothing and gets
|
|
// nothing, with no window between the check and the removal.
|
|
func (s *Store) TakeLoginState(ctx context.Context, hash []byte, now time.Time) (login.State, error) {
|
|
const q = `
|
|
delete from auth_states
|
|
where state_sha256 = $1 and expires_at > $2
|
|
returning provider, issuer, nonce, code_verifier, return_to, start_id, created_at, expires_at`
|
|
var st login.State
|
|
st.Hash = hash
|
|
err := s.pool.QueryRow(ctx, q, hash, now).
|
|
Scan(&st.Provider, &st.Issuer, &st.Nonce, &st.Verifier, &st.ReturnTo, &st.StartID,
|
|
&st.CreatedAt, &st.ExpiresAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return login.State{}, login.ErrNoState
|
|
}
|
|
if err != nil {
|
|
return login.State{}, fmt.Errorf("pgstore: take login state: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// DeleteExpiredLoginStates is the sweep for abandoned logins.
|
|
func (s *Store) DeleteExpiredLoginStates(ctx context.Context, now time.Time) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `delete from auth_states where expires_at <= $1`, now)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: sweep login states: %w", err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// DeleteOldLoginEvents applies the journal's retention. /auth/callback writes a row on every
|
|
// refusal and needs no credential to do it, so a journal that only grows is a liability rather
|
|
// than an audit.
|
|
func (s *Store) DeleteOldLoginEvents(ctx context.Context, before time.Time) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `delete from login_events where at < $1`, before)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: sweep login journal: %w", err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// UpsertIdentity resolves (provider, subject) to an account.
|
|
//
|
|
// The pair is the ONLY key: an unknown pair always creates a new user, whatever address it arrives
|
|
// with. Attaching a second provider to an existing account is an authenticated action elsewhere,
|
|
// never a side effect of signing in. Why, in full: 00005_identity_oauth.sql.
|
|
//
|
|
// The signup grant is written in the SAME transaction as the account. A user that exists without
|
|
// their free tier — or a grant against a user that failed to commit — is not a state worth having.
|
|
func (s *Store) UpsertIdentity(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) {
|
|
// One retry: two first logins of the same brand-new identity can race, and the loser sees the
|
|
// row the winner inserted.
|
|
for attempt := range 2 {
|
|
userID, err := s.upsertIdentityOnce(ctx, in, now, signupGrant)
|
|
if err == nil {
|
|
return userID, nil
|
|
}
|
|
if !errors.Is(err, errIdentityRace) || attempt == 1 {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", errIdentityRace
|
|
}
|
|
|
|
var errIdentityRace = errors.New("pgstore: identity created concurrently")
|
|
|
|
func (s *Store) upsertIdentityOnce(ctx context.Context, in login.Identity, now time.Time, signupGrant int64) (string, error) {
|
|
var userID string
|
|
err := s.inTx(ctx, func(tx pgx.Tx) error {
|
|
return upsertIdentityTx(ctx, tx, in, now, signupGrant, &userID)
|
|
})
|
|
return userID, err
|
|
}
|
|
|
|
// UserByIdentity resolves a provider's subject to the account it belongs to, without creating one.
|
|
//
|
|
// It exists for the seeding command, which signs in over HTTP — through the same door a user walks —
|
|
// and then needs the account id to credit it from the admin side. Answering ErrNoAccount rather than
|
|
// minting anything keeps the CREATION of an account on the one path that also writes its signup
|
|
// grant.
|
|
func (s *Store) UserByIdentity(ctx context.Context, provider, subject string) (string, error) {
|
|
var userID string
|
|
err := s.pool.QueryRow(ctx,
|
|
`select user_id from identities where provider = $1 and subject = $2`, provider, subject).Scan(&userID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrNoAccount
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("pgstore: read identity: %w", err)
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
func upsertIdentityTx(ctx context.Context, tx pgx.Tx, in login.Identity, now time.Time, signupGrant int64, out *string) error {
|
|
var userID string
|
|
err := tx.QueryRow(ctx, `select user_id from identities where provider = $1 and subject = $2 for update`,
|
|
in.Provider, in.Subject).Scan(&userID)
|
|
switch {
|
|
case err == nil:
|
|
// Known identity. The address is refreshed only when the provider says it is verified —
|
|
// an unverified one is kept on the identity and never promoted to the account.
|
|
if _, err := tx.Exec(ctx, `
|
|
update identities set email = $3, email_verified = $4, last_login_at = $5
|
|
where provider = $1 and subject = $2`,
|
|
in.Provider, in.Subject, nullable(in.Email), in.EmailVerified, now); err != nil {
|
|
return fmt.Errorf("pgstore: refresh identity: %w", err)
|
|
}
|
|
if in.EmailVerified && in.Email != "" {
|
|
if _, err := tx.Exec(ctx, `update users set email = $2 where id = $1`, userID, in.Email); err != nil {
|
|
return fmt.Errorf("pgstore: refresh account email: %w", err)
|
|
}
|
|
}
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
userID = newID("u")
|
|
var email any
|
|
if in.EmailVerified {
|
|
email = nullable(in.Email)
|
|
}
|
|
if _, err := tx.Exec(ctx, `insert into users (id, email, created_at) values ($1, $2, $3)`,
|
|
userID, email, now); err != nil {
|
|
return fmt.Errorf("pgstore: create user: %w", err)
|
|
}
|
|
tag, err := tx.Exec(ctx, `
|
|
insert into identities (provider, subject, user_id, email, email_verified, created_at, last_login_at)
|
|
values ($1, $2, $3, $4, $5, $6, $6)
|
|
on conflict (provider, subject) do nothing`,
|
|
in.Provider, in.Subject, userID, nullable(in.Email), in.EmailVerified, now)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: create identity: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return errIdentityRace
|
|
}
|
|
if signupGrant > 0 {
|
|
// A brand-new account cannot have spent this key, so "already applied" is not a case.
|
|
if _, err := appendLedger(ctx, tx, userID, "grant", money.MicroUSD(signupGrant), "signup", userID, "free tier", now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("pgstore: find identity: %w", err)
|
|
}
|
|
*out = userID
|
|
return nil
|
|
}
|
|
|
|
// RevokeUserSessions ends every session of a user at once.
|
|
func (s *Store) RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx,
|
|
`update sessions set revoked_at = $2 where user_id = $1 and revoked_at is null`, userID, now)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: revoke user sessions: %w", err)
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// RecordLogin appends to the login journal.
|
|
func (s *Store) RecordLogin(ctx context.Context, ev login.LoginEvent) error {
|
|
const q = `
|
|
insert into login_events (user_id, provider, outcome, reason, ip_prefix, client, at)
|
|
values ($1, $2, $3, $4, $5, $6, $7)`
|
|
_, err := s.pool.Exec(ctx, q, nullable(ev.UserID), ev.Provider, ev.Outcome, ev.Reason, ev.IPPrefix, ev.Client, ev.At)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: record login: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoginEntry is one journal line as an operator reads it.
|
|
type LoginEntry struct {
|
|
Provider string
|
|
Outcome string
|
|
Reason string
|
|
IPPrefix string
|
|
Client string
|
|
At time.Time
|
|
}
|
|
|
|
// RecentLogins backs "where have I signed in from" and the admin CLI.
|
|
func (s *Store) RecentLogins(ctx context.Context, userID string, limit int) ([]LoginEntry, error) {
|
|
const q = `
|
|
select provider, outcome, reason, ip_prefix, client, at
|
|
from login_events where user_id = $1 order by at desc limit $2`
|
|
rows, err := s.pool.Query(ctx, q, userID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgstore: recent logins: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []LoginEntry
|
|
for rows.Next() {
|
|
var e LoginEntry
|
|
if err := rows.Scan(&e.Provider, &e.Outcome, &e.Reason, &e.IPPrefix, &e.Client, &e.At); err != nil {
|
|
return nil, fmt.Errorf("pgstore: scan login: %w", err)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// nullable turns "" into SQL NULL: an empty string and "no address" are different facts, and a
|
|
// unique index would treat them differently too.
|
|
func nullable(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|