docs: reorder & testing

This commit is contained in:
Ilia Denisov
2026-05-07 00:58:53 +03:00
committed by GitHub
parent f446c6a2ac
commit 604fe40bcf
148 changed files with 9150 additions and 2757 deletions
+61 -9
View File
@@ -72,7 +72,7 @@ func startPostgres(t *testing.T) *sql.DB {
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = pgOpTO
db, err := pgshared.OpenPrimary(ctx, cfg)
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
if err != nil {
t.Fatalf("open primary: %v", err)
}
@@ -155,8 +155,7 @@ func (p *recordingPush) snapshot() []recordedPush {
}
// stubGeo implements auth.GeoService with no real lookups. The country
// it returns is configurable per call via CountryForIP; LanguageForIP
// returns "" so the auth flow exercises the "en" fallback path.
// it returns is configurable per call via countryByIP.
type stubGeo struct {
countryByIP map[string]string
}
@@ -169,8 +168,6 @@ func (g *stubGeo) LookupCountry(sourceIP string) string {
return g.countryByIP[sourceIP]
}
func (g *stubGeo) LanguageForIP(_ string) string { return "" }
func (g *stubGeo) SetDeclaredCountryAtRegistration(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
@@ -279,7 +276,10 @@ func TestAuthEndToEnd(t *testing.T) {
t.Fatalf("GetSession user_id = %s, want %s", got.UserID, session.UserID)
}
revoked, err := svc.RevokeSession(ctx, session.DeviceSessionID)
revoked, err := svc.RevokeSession(ctx, session.DeviceSessionID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: session.UserID.String(),
})
if err != nil {
t.Fatalf("RevokeSession: %v", err)
}
@@ -294,7 +294,10 @@ func TestAuthEndToEnd(t *testing.T) {
t.Fatalf("GetSession after revoke = %v, want ErrSessionNotFound", err)
}
again, err := svc.RevokeSession(ctx, session.DeviceSessionID)
again, err := svc.RevokeSession(ctx, session.DeviceSessionID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: session.UserID.String(),
})
if err != nil {
t.Fatalf("idempotent RevokeSession: %v", err)
}
@@ -330,6 +333,49 @@ func TestSendEmailCodePermanentlyBlocked(t *testing.T) {
}
}
// TestConfirmEmailCodePermanentlyBlockedAfterSend covers the case where
// an admin applies permanent_block in the window between send and
// confirm. The send-time guard let the challenge through because the
// account was unblocked at that moment; the confirm-time guard must
// catch the late block and reject the registration.
func TestConfirmEmailCodePermanentlyBlockedAfterSend(t *testing.T) {
db := startPostgres(t)
svc, mailer, _, _ := buildService(t, db)
ctx := context.Background()
const email = "blockedlater@example.test"
if _, err := db.Exec(`
INSERT INTO backend.accounts (
user_id, email, user_name, preferred_language, time_zone
) VALUES ($1, $2, $3, $4, $5)
`, uuid.New(), email, "Player-XXBLATER", "en", "UTC"); err != nil {
t.Fatalf("seed account: %v", err)
}
id, err := svc.SendEmailCode(ctx, email, "en", "", "")
if err != nil {
t.Fatalf("SendEmailCode: %v", err)
}
_, code, _ := mailer.snapshot()
if _, err := db.Exec(`
UPDATE backend.accounts SET permanent_block = true WHERE email = $1
`, email); err != nil {
t.Fatalf("apply permanent_block: %v", err)
}
_, err = svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: id,
Code: code,
ClientPublicKey: randomKey(t),
TimeZone: "UTC",
})
if !errors.Is(err, auth.ErrEmailPermanentlyBlocked) {
t.Fatalf("ConfirmEmailCode after block = %v, want ErrEmailPermanentlyBlocked", err)
}
}
func TestSendEmailCodeThrottleReusesChallenge(t *testing.T) {
db := startPostgres(t)
svc, mailer, _, _ := buildService(t, db)
@@ -468,7 +514,10 @@ func TestRevokeAllForUser(t *testing.T) {
deviceSessionIDs = append(deviceSessionIDs, sess.DeviceSessionID)
}
revoked, err := svc.RevokeAllForUser(ctx, userID)
revoked, err := svc.RevokeAllForUser(ctx, userID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: userID.String(),
})
if err != nil {
t.Fatalf("RevokeAllForUser: %v", err)
}
@@ -485,7 +534,10 @@ func TestRevokeAllForUser(t *testing.T) {
}
// Idempotent: revoking again returns an empty slice.
again, err := svc.RevokeAllForUser(ctx, userID)
again, err := svc.RevokeAllForUser(ctx, userID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: userID.String(),
})
if err != nil {
t.Fatalf("idempotent RevokeAllForUser: %v", err)
}
+23
View File
@@ -136,6 +136,29 @@ func (c *Cache) Remove(deviceSessionID uuid.UUID) {
}
}
// ListByUser returns a freshly-allocated snapshot of every cached
// session belonging to userID. The user-surface "list my sessions"
// handler consumes this. An empty slice is returned for an unknown
// userID.
func (c *Cache) ListByUser(userID uuid.UUID) []Session {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
set, ok := c.byUser[userID]
if !ok {
return nil
}
out := make([]Session, 0, len(set))
for id := range set {
if sess, ok := c.byID[id]; ok {
out = append(out, sess)
}
}
return out
}
// RemoveByUser evicts every cached entry belonging to userID and returns
// the device_session_ids it removed. The returned slice is safe for the
// caller to hold past the call — it is freshly allocated.
+23 -7
View File
@@ -28,10 +28,11 @@ import (
//
// locale (request body, BCP 47) takes precedence over acceptLanguage
// (the standard HTTP header forwarded by gateway) when both are
// supplied. The captured value is persisted on the challenge row as
// `preferred_language`, replayed at confirm-email-code, and used only
// for newly-registered accounts; existing accounts keep their stored
// language.
// supplied. When neither is supplied SendEmailCode falls back to the
// platform default ("en"). The resolved value is persisted on the
// challenge row as `preferred_language` and used by confirm-email-code
// only for newly-registered accounts; existing accounts keep their
// stored language.
func (s *Service) SendEmailCode(
ctx context.Context,
email, locale, acceptLanguage, sourceIP string,
@@ -50,6 +51,9 @@ func (s *Service) SendEmailCode(
}
captured := pickCapturedLocale(locale, acceptLanguage)
if captured == "" {
captured = defaultLanguage
}
now := s.deps.Now()
windowStart := now.Add(-s.deps.Config.ChallengeThrottle.Window)
@@ -178,11 +182,23 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
return Session{}, err
}
// Re-check permanent_block after verifying the code. SendEmailCode
// guards against fresh challenges for already-blocked addresses;
// this guard catches the case where an admin applied
// permanent_block in the window between send and confirm.
permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, loaded.Email)
if err != nil {
return Session{}, fmt.Errorf("auth: check permanent block at confirm: %w", err)
}
if permanent {
return Session{}, ErrEmailPermanentlyBlocked
}
preferredLang := loaded.PreferredLanguage
if preferredLang == "" {
preferredLang = s.deps.Geo.LanguageForIP(in.SourceIP)
}
if preferredLang == "" {
// Defensive fallback: SendEmailCode now always persists a
// non-empty preferred_language, but a row written by an older
// build could still be empty.
preferredLang = defaultLanguage
}
+4 -4
View File
@@ -33,12 +33,12 @@ type UserEnsurer interface {
}
// GeoService provides the geo helpers auth needs at confirm-email-code:
// a country lookup for the `preferred_language` fallback and a
// post-commit write of `accounts.declared_country`. Both methods are
// best-effort — auth never blocks the registration flow on geo failures.
// a country lookup that backfills `accounts.declared_country` for newly
// registered accounts and a post-commit write of the same column. Both
// methods are best-effort — auth never blocks the registration flow on
// geo failures.
type GeoService interface {
LookupCountry(sourceIP string) string
LanguageForIP(sourceIP string) string
SetDeclaredCountryAtRegistration(ctx context.Context, userID uuid.UUID, sourceIP string) error
}
+103 -22
View File
@@ -8,12 +8,48 @@ import (
"go.uber.org/zap"
)
// ActorKind enumerates the principals that can drive a session revoke.
// The values are persisted into `session_revocations.actor_kind` and
// must stay aligned with `user.SessionRevokeActor*` constants and any
// admin/operator tooling that joins on the audit table.
type ActorKind string
const (
// ActorKindUserSelf indicates the session's owner initiated the
// revoke (logout self / logout-all-self through the user surface).
ActorKindUserSelf ActorKind = "user_self"
// ActorKindAdminSanction indicates an admin-applied sanction (most
// notably permanent_block) caused the revoke.
ActorKindAdminSanction ActorKind = "admin_sanction"
// ActorKindSoftDeleteUser indicates the session's owner triggered
// account soft-delete on themselves.
ActorKindSoftDeleteUser ActorKind = "soft_delete_user"
// ActorKindSoftDeleteAdmin indicates an admin soft-deleted the
// account and the cascade revoked the sessions.
ActorKindSoftDeleteAdmin ActorKind = "soft_delete_admin"
)
// RevokeContext records the audit metadata persisted alongside every
// session revoke. ActorID is the stable identifier of the principal (a
// user UUID for self-driven flows, an admin username for admin-driven
// flows). Reason is a free-form note kept verbatim.
type RevokeContext struct {
ActorKind ActorKind
ActorID string
Reason string
}
// GetSession returns the active session keyed by deviceSessionID. The
// lookup is cache-only: the cache is the write-through projection of
// `device_sessions WHERE status='active'`, so a miss means the session
// is either revoked or absent. Either way the gateway sees
// ErrSessionNotFound and treats the calling client as unauthenticated.
func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Session, error) {
// lookup hits the cache; on a miss the session is either revoked or
// absent. After a hit the call refreshes `last_seen_at` against
// Postgres so admin observers see when each cached session was last
// resolved by gateway. The refresh runs after the cache read and
// updates the cached row in-place; failures are logged but never block
// the lookup.
func (s *Service) GetSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
if deviceSessionID == uuid.Nil {
return Session{}, ErrSessionNotFound
}
@@ -21,31 +57,73 @@ func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Sess
if !ok {
return Session{}, ErrSessionNotFound
}
return sess, nil
now := s.deps.Now()
if updated, err := s.deps.Store.TouchSessionLastSeen(ctx, deviceSessionID, now); err == nil {
s.deps.Cache.Add(updated)
return updated, nil
} else if errors.Is(err, ErrSessionNotFound) {
// The row vanished between Cache.Get and the touch — treat as
// revoked from the caller's perspective.
s.deps.Cache.Remove(deviceSessionID)
return Session{}, ErrSessionNotFound
} else {
s.deps.Logger.Warn("auth: touch last_seen_at failed",
zap.String("device_session_id", deviceSessionID.String()),
zap.Error(err),
)
return sess, nil
}
}
// RevokeSession marks deviceSessionID revoked, evicts it from the cache,
// and emits a session_invalidation push event. The call is idempotent:
// a second revoke on an already-revoked session returns the existing
// row with status='revoked' (HTTP 200), not ErrSessionNotFound. An
// ListActiveByUser returns the cached active sessions for userID. The
// user-surface "list my sessions" handler consumes this. The slice is
// safe for the caller to retain — it is freshly allocated.
func (s *Service) ListActiveByUser(_ context.Context, userID uuid.UUID) []Session {
if userID == uuid.Nil {
return nil
}
return s.deps.Cache.ListByUser(userID)
}
// LookupSessionInCache returns the cached session for deviceSessionID
// without touching last_seen_at. The user-surface revoke handler
// consumes this to verify ownership before issuing a revoke. A miss
// means the session is either revoked or absent — handlers must treat
// the two cases identically so a caller cannot probe whether a foreign
// device_session_id exists.
func (s *Service) LookupSessionInCache(deviceSessionID uuid.UUID) (Session, bool) {
if deviceSessionID == uuid.Nil {
return Session{}, false
}
return s.deps.Cache.Get(deviceSessionID)
}
// RevokeSession marks deviceSessionID revoked atomically with an
// audit row in `session_revocations`, evicts it from the cache, and
// emits a session_invalidation push event. The call is idempotent: a
// second revoke on an already-revoked session returns the existing
// row with status='revoked' (HTTP 200) and writes no audit row. An
// unknown device_session_id yields ErrSessionNotFound.
//
// Cache eviction and the push emission run after the database UPDATE
// commits so a failed UPDATE leaves both cache and gateway view intact.
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
// commits so a failed UPDATE leaves both cache and gateway view
// intact.
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext) (Session, error) {
if deviceSessionID == uuid.Nil {
return Session{}, ErrSessionNotFound
}
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID)
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID, rc, s.deps.Now())
if err != nil {
return Session{}, err
}
if ok {
s.deps.Cache.Remove(deviceSessionID)
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, "auth.revoke_session")
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, string(rc.ActorKind))
s.deps.Logger.Info("auth session revoked",
zap.String("device_session_id", deviceSessionID.String()),
zap.String("user_id", revoked.UserID.String()),
zap.String("actor_kind", string(rc.ActorKind)),
zap.String("actor_id", rc.ActorID),
)
return revoked, nil
}
@@ -63,27 +141,30 @@ func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID)
return existing, nil
}
// RevokeAllForUser marks every active session for userID revoked,
// evicts each from the cache, and emits one session_invalidation push
// event per revoked row. Returns the list of revoked sessions in the
// order Postgres returned them. An empty result is a successful
// idempotent call (handler reports revoked_count=0).
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) {
// RevokeAllForUser marks every active session for userID revoked
// atomically with one audit row per revoked session, evicts each from
// the cache, and emits one session_invalidation push event per
// revoked row. Returns the list of revoked sessions in the order
// Postgres returned them. An empty result is a successful idempotent
// call (handler reports revoked_count=0).
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext) ([]Session, error) {
if userID == uuid.Nil {
return nil, nil
}
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID)
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID, rc, s.deps.Now())
if err != nil {
return nil, err
}
for _, sess := range revoked {
s.deps.Cache.Remove(sess.DeviceSessionID)
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, "auth.revoke_all_for_user")
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, string(rc.ActorKind))
}
if len(revoked) > 0 {
s.deps.Logger.Info("auth sessions revoked (bulk)",
zap.String("user_id", userID.String()),
zap.Int("count", len(revoked)),
zap.String("actor_kind", string(rc.ActorKind)),
zap.String("actor_id", rc.ActorID),
)
}
return revoked, nil
+128 -29
View File
@@ -332,15 +332,14 @@ func (s *Store) LoadSession(ctx context.Context, deviceSessionID uuid.UUID) (Ses
return modelToSession(row), nil
}
// RevokeSession transitions an active row to status='revoked' and
// returns the row as it stands after the update. The boolean reports
// whether the UPDATE actually changed a row — false means the row was
// already revoked or did not exist; the auth Service then falls back to
// LoadSession for idempotent-revoke responses.
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, bool, error) {
// TouchSessionLastSeen sets `last_seen_at` to at on the row keyed by
// deviceSessionID. The UPDATE is gated by `status='active'` so a
// revoked or absent row reports ErrSessionNotFound. Returns the post-
// update row so the cache can be refreshed without a second read.
func (s *Store) TouchSessionLastSeen(ctx context.Context, deviceSessionID uuid.UUID, at time.Time) (Session, error) {
stmt := table.DeviceSessions.
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
UPDATE(table.DeviceSessions.LastSeenAt).
SET(postgres.TimestampzT(at)).
WHERE(
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
@@ -350,39 +349,139 @@ func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (S
var row model.DeviceSessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Session{}, false, nil
return Session{}, ErrSessionNotFound
}
return Session{}, fmt.Errorf("auth store: touch last_seen %s: %w", deviceSessionID, err)
}
return modelToSession(row), nil
}
// RevokeSession transitions an active row to status='revoked' and
// inserts the matching audit row into session_revocations atomically
// inside one transaction. The boolean reports whether the UPDATE
// actually changed a row — false means the row was already revoked or
// did not exist, in which case no audit row is written and the auth
// Service falls back to LoadSession for the idempotent-revoke
// response.
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext, at time.Time) (Session, bool, error) {
var (
revoked Session
ok bool
)
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
updateStmt := table.DeviceSessions.
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
WHERE(
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
).
RETURNING(sessionColumns())
var row model.DeviceSessions
if err := updateStmt.QueryContext(ctx, tx, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil
}
return err
}
revoked = modelToSession(row)
ok = true
return insertRevocationTx(ctx, tx, deviceSessionID, revoked.UserID, rc, at)
})
if err != nil {
return Session{}, false, fmt.Errorf("auth store: revoke session %s: %w", deviceSessionID, err)
}
return modelToSession(row), true, nil
return revoked, ok, nil
}
// RevokeAllForUser transitions every active row for userID to
// status='revoked' and returns the rows as they stand after the update.
// An empty slice with a nil error is returned when the user owned no
// active sessions; the caller must treat that as a successful idempotent
// revoke (the API surface returns revoked_count=0 in that case).
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) {
stmt := table.DeviceSessions.
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
WHERE(
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
).
RETURNING(sessionColumns())
// status='revoked', writes one session_revocations row per revoked
// session, and returns the rows as they stand after the update. The
// UPDATE and the audit inserts run inside one transaction. An empty
// slice with a nil error is returned when the user owned no active
// sessions; the caller treats that as a successful idempotent revoke
// (the API surface returns revoked_count=0).
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext, at time.Time) ([]Session, error) {
var out []Session
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
updateStmt := table.DeviceSessions.
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
WHERE(
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
).
RETURNING(sessionColumns())
var rows []model.DeviceSessions
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
var rows []model.DeviceSessions
if err := updateStmt.QueryContext(ctx, tx, &rows); err != nil {
return err
}
out = make([]Session, 0, len(rows))
for _, row := range rows {
sess := modelToSession(row)
out = append(out, sess)
if err := insertRevocationTx(ctx, tx, sess.DeviceSessionID, sess.UserID, rc, at); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("auth store: revoke all for user %s: %w", userID, err)
}
out := make([]Session, 0, len(rows))
for _, row := range rows {
out = append(out, modelToSession(row))
}
return out, nil
}
// insertRevocationTx writes a single audit row inside an existing
// transaction. Callers are expected to mint a fresh revocation_id per
// row; collisions are not retried because revocation_id is a uuid.New
// in the only call sites.
func insertRevocationTx(ctx context.Context, tx *sql.Tx, deviceSessionID, userID uuid.UUID, rc RevokeContext, at time.Time) error {
actorUserID, actorUsername, err := revokeContextToColumns(rc)
if err != nil {
return err
}
stmt := table.SessionRevocations.INSERT(
table.SessionRevocations.RevocationID,
table.SessionRevocations.DeviceSessionID,
table.SessionRevocations.UserID,
table.SessionRevocations.ActorKind,
table.SessionRevocations.ActorUserID,
table.SessionRevocations.ActorUsername,
table.SessionRevocations.Reason,
table.SessionRevocations.RevokedAt,
).VALUES(uuid.New(), deviceSessionID, userID, string(rc.ActorKind), actorUserID, actorUsername, rc.Reason, at)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert session_revocations: %w", err)
}
return nil
}
// revokeContextToColumns splits RevokeContext.ActorID into the
// (actor_user_id, actor_username) pair persisted by session_revocations.
// User-driven kinds parse ActorID as a UUID; admin-driven kinds keep it
// as the operator username. Empty ActorID lands as NULL/NULL.
func revokeContextToColumns(rc RevokeContext) (any, any, error) {
if rc.ActorID == "" {
return nil, nil, nil
}
switch rc.ActorKind {
case ActorKindUserSelf, ActorKindSoftDeleteUser:
uid, err := uuid.Parse(rc.ActorID)
if err != nil {
return nil, nil, fmt.Errorf("auth store: actor_id %q is not a uuid: %w", rc.ActorID, err)
}
return uid, nil, nil
case ActorKindAdminSanction, ActorKindSoftDeleteAdmin:
return nil, rc.ActorID, nil
default:
return nil, nil, nil
}
}
// modelToChallenge projects a generated model row into the public
// Challenge struct. Pointer fields are copied so callers cannot mutate
// the underlying scan buffer.