textmachine/platform/internal/pgstore/identity.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
}