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.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -11,6 +12,18 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"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.
|
// retainedRow is one row of the retention journal, read directly for assertions.
|
||||||
type retainedRow struct {
|
type retainedRow struct {
|
||||||
kind, externalID, reason string
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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))
|
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user