docs: reorder & testing
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user