276 lines
9.4 KiB
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
|
|
}
|