textmachine/platform/internal/pgstore/identity.go

276 lines
9.4 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 {
err := s.q.PutLoginState(ctx, PutLoginStateParams{
StateSha256: st.Hash,
Provider: st.Provider,
Issuer: st.Issuer,
Nonce: st.Nonce,
CodeVerifier: st.Verifier,
ReturnTo: st.ReturnTo,
StartID: st.StartID,
CreatedAt: st.CreatedAt,
ExpiresAt: 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) {
row, err := s.q.TakeLoginState(ctx, TakeLoginStateParams{StateSha256: hash, Now: now})
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)
}
// Six of these eight are strings, and they used to be filled by position. PD-62 is what happens
// when that mapping slips.
return login.State{
Hash: hash,
Provider: row.Provider,
Issuer: row.Issuer,
Nonce: row.Nonce,
Verifier: row.CodeVerifier,
ReturnTo: row.ReturnTo,
StartID: row.StartID,
CreatedAt: row.CreatedAt,
ExpiresAt: row.ExpiresAt,
}, nil
}
// DeleteExpiredLoginStates is the sweep for abandoned logins.
func (s *Store) DeleteExpiredLoginStates(ctx context.Context, now time.Time) (int64, error) {
n, err := s.q.DeleteExpiredLoginStates(ctx, now)
if err != nil {
return 0, fmt.Errorf("pgstore: sweep login states: %w", err)
}
return n, 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) {
n, err := s.q.DeleteOldLoginEvents(ctx, before)
if err != nil {
return 0, fmt.Errorf("pgstore: sweep login journal: %w", err)
}
return n, 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) {
userID, err := s.q.UserByIdentity(ctx, UserByIdentityParams{Provider: provider, Subject: subject})
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 {
// Bound to the CALLER'S transaction: the lock taken below and every write that follows it belong
// to one transaction or the identity can be created twice.
q := New(tx)
userID, err := q.LockIdentity(ctx, LockIdentityParams{Provider: in.Provider, Subject: in.Subject})
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 := q.RefreshIdentity(ctx, RefreshIdentityParams{
Provider: in.Provider,
Subject: in.Subject,
Email: nullableText(in.Email),
EmailVerified: in.EmailVerified,
Now: now,
}); err != nil {
return fmt.Errorf("pgstore: refresh identity: %w", err)
}
if in.EmailVerified && in.Email != "" {
if err := q.RefreshAccountEmail(ctx, RefreshAccountEmailParams{ID: userID, Email: in.Email}); err != nil {
return fmt.Errorf("pgstore: refresh account email: %w", err)
}
}
case errors.Is(err, pgx.ErrNoRows):
userID = newID("u")
// The account's address is set only from a VERIFIED one; an unverified address stays on the
// identity and is never promoted here.
var email *string
if in.EmailVerified {
email = nullableText(in.Email)
}
if err := q.CreateUser(ctx, CreateUserParams{ID: userID, Email: email, CreatedAt: now}); err != nil {
return fmt.Errorf("pgstore: create user: %w", err)
}
created, err := q.CreateIdentity(ctx, CreateIdentityParams{
Provider: in.Provider,
Subject: in.Subject,
UserID: userID,
Email: nullableText(in.Email),
EmailVerified: in.EmailVerified,
Now: now,
})
if err != nil {
return fmt.Errorf("pgstore: create identity: %w", err)
}
if created == 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) {
n, err := s.q.RevokeUserSessions(ctx, RevokeUserSessionsParams{UserID: userID, Now: now})
if err != nil {
return 0, fmt.Errorf("pgstore: revoke user sessions: %w", err)
}
return n, nil
}
// RecordLogin appends to the login journal.
func (s *Store) RecordLogin(ctx context.Context, ev login.LoginEvent) error {
err := s.q.RecordLogin(ctx, RecordLoginParams{
UserID: nullableText(ev.UserID),
Provider: ev.Provider,
Outcome: ev.Outcome,
Reason: ev.Reason,
IpPrefix: ev.IPPrefix,
Client: ev.Client,
At: 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) {
rows, err := s.q.RecentLogins(ctx, RecentLoginsParams{UserID: userID, Lim: int64(limit)})
if err != nil {
return nil, fmt.Errorf("pgstore: recent logins: %w", err)
}
out := make([]LoginEntry, 0, len(rows))
for _, r := range rows {
// Five of these six are strings. By name, so that a transposition is a compile error rather
// than an operator reading an IP prefix under PROVIDER.
out = append(out, LoginEntry{
Provider: r.Provider,
Outcome: r.Outcome,
Reason: r.Reason,
IPPrefix: r.IpPrefix,
Client: r.Client,
At: r.At,
})
}
return out, nil
}
// 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
}
// nullableText is `nullable` for the generated layer, which is typed: same rule, a *string instead
// of an `any`. Kept separate rather than changing `nullable`, whose other callers are in the
// hand-written read model.
func nullableText(s string) *string {
if s == "" {
return nil
}
return &s
}