From 12af378b184b764c378b1e9a90537c9d5cc39a23 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 11:54:37 +0200 Subject: [PATCH] feat(account): stamp last-login time + IP on cold app-load The profile GET (fetched once per cold app-load by the SPA) stamps accounts .last_login_at/.last_login_ip, throttled to at most once per hour per account (best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For. Feeds the account-deletion dossier. Integration test covers the throttle. --- backend/internal/account/retention.go | 22 +++++++++++++ backend/internal/inttest/retention_test.go | 36 ++++++++++++++++++++++ backend/internal/server/handlers_user.go | 4 +++ 3 files changed, 62 insertions(+) diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go index 8e60945..6014718 100644 --- a/backend/internal/account/retention.go +++ b/backend/internal/account/retention.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/backend/table" @@ -41,3 +42,24 @@ func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind } return nil } + +// StampLastLogin records the account's last cold-load time and client IP, but only when +// the stored value is missing or older than an hour — so it costs at most one write per +// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a +// best-effort audit signal that feeds the account-deletion dossier. +func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error { + now := time.Now().UTC() + upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP). + SET(postgres.TimestampzT(now), postgres.String(ip)). + WHERE( + table.Accounts.AccountID.EQ(postgres.UUID(accountID)). + AND( + table.Accounts.LastLoginAt.IS_NULL(). + OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))), + ), + ) + if _, err := upd.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: stamp last login %s: %w", accountID, err) + } + return nil +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go index 95fbba6..9b5e440 100644 --- a/backend/internal/inttest/retention_test.go +++ b/backend/internal/inttest/retention_test.go @@ -4,6 +4,7 @@ package inttest import ( "context" + "database/sql" "testing" "github.com/google/uuid" @@ -11,6 +12,18 @@ import ( "scrabble/backend/internal/account" ) +// lastLoginIP reads an account's stamped last-login IP ("" when unset). +func lastLoginIP(t *testing.T, accountID uuid.UUID) string { + t.Helper() + var ip sql.NullString + err := testDB.QueryRowContext(context.Background(), + "SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip) + if err != nil { + t.Fatalf("read last_login_ip %s: %v", accountID, err) + } + return ip.String +} + // retainedRow is one row of the retention journal, read directly for assertions. type retainedRow struct { kind, externalID, reason string @@ -90,3 +103,26 @@ func TestChangeEmailJournalsOldAddress(t *testing.T) { t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr) } } + +// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within +// the hour is a no-op (throttled), so it costs at most one write per account per hour. +func TestStampLastLoginThrottles(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision: %v", err) + } + if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil { + t.Fatalf("first stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("first stamp ip = %q, want 1.2.3.4", got) + } + if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil { + t.Fatalf("second stamp: %v", err) + } + if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { + t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) + } +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 9faf71e..f5bfaca 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } + // The SPA fetches the profile once per cold app-load, so stamp the account's last + // login time and client IP here (throttled to at most once an hour). Best-effort: it + // feeds the deletion dossier, never blocks the profile read. + _ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c)) c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) }