feat(payments): trusted platform signal on the session
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s

Record the execution platform (kind vk|telegram|direct + device subtype
ios|android|web) on each session, captured at creation and carried
gateway->backend as a trusted X-Platform header, so the upcoming
store-compliance gate has an unforgeable execution context.

- backend.sessions gains nullable platform_kind/platform_subtype columns
  (migration 00011, CHECK-constrained, jet regenerated); session.Platform
  captures them at mint, resolve returns them, middleware exposes platform(c).
  kind is derived from the establish endpoint, never a client field; the
  account-merge session mint inherits the caller's platform.
- gateway derives the platform (VK subtype from the signed vk_platform via
  vkauth, Telegram/direct best-effort from the client) and injects X-Platform
  on every authenticated backend call through the request context.
- ui submits a best-effort device subtype on the telegram/guest/email login
  requests (new FBS subtype field); VK is server-derived from the signed params.
- an unattributed session is untrusted (view-only); VK/TG self-heal on the next
  cold-start re-mint, direct/email on re-login.

Signal plumbing only, no user-visible change; X-Platform is inert until the
gate consumes it.
This commit is contained in:
Ilia Denisov
2026-07-08 03:31:51 +02:00
parent 07815c5a30
commit 92633f935e
39 changed files with 970 additions and 221 deletions
@@ -0,0 +1,178 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/pressly/goose/v3"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/account"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/postgres/migrations"
"scrabble/backend/internal/session"
)
// TestSessionPlatformCaptureAndUntrusted proves a session round-trips its captured
// platform through create and a cold-cache (DB-backed) resolve for each kind, and
// that an unattributed session records no platform — the untrusted, view-only case.
func TestSessionPlatformCaptureAndUntrusted(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
for _, tc := range []struct {
name string
platform session.Platform
}{
{"vk-ios", session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-android", session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid}},
{"direct-web", session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"untrusted", session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
acc, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
token, sess, err := svc.Create(ctx, acc.ID, tc.platform)
if err != nil {
t.Fatalf("create: %v", err)
}
if sess.Platform != tc.platform {
t.Errorf("created platform = %+v, want %+v", sess.Platform, tc.platform)
}
// Resolve through a cold cache so the value comes back via the DB columns.
cold := session.NewService(store, session.NewCache())
if err := cold.Warm(ctx); err != nil {
t.Fatalf("warm: %v", err)
}
got, err := cold.Resolve(ctx, token)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got.Platform != tc.platform {
t.Errorf("resolved platform = %+v, want %+v", got.Platform, tc.platform)
}
if got.Platform.Trusted() != tc.platform.Trusted() {
t.Errorf("resolved Trusted() = %v, want %v", got.Platform.Trusted(), tc.platform.Trusted())
}
})
}
}
// TestSessionPlatformCheckConstraints proves the CHECK constraints reject an
// out-of-range kind or subtype, so a corrupt value cannot be persisted.
func TestSessionPlatformCheckConstraints(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
const pgCheckViolation = "23514"
for _, tc := range []struct {
name string
kind string
subtype string
}{
{"bad-kind", "facebook", "web"},
{"bad-subtype", "vk", "windows"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := testDB.ExecContext(ctx,
`INSERT INTO backend.sessions (session_id, account_id, token_hash, platform_kind, platform_subtype)
VALUES ($1, $2, $3, $4, $5)`,
uuid.New(), acc.ID, uuid.NewString(), tc.kind, tc.subtype)
if !isPgCode(err, pgCheckViolation) {
t.Errorf("insert %s: err = %v, want check_violation", tc.name, err)
}
})
}
}
// TestSessionPlatformMigrationReversible proves migration 00011 is expand-contract:
// it applies, rolls back (dropping the platform columns), and re-applies cleanly —
// so a backend image rollback stays DB-safe. It uses its own container so the Down
// does not disturb the shared suite database.
func TestSessionPlatformMigrationReversible(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
t.Fatalf("start container: %v", err)
}
defer func() { _ = container.Terminate(context.Background()) }()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("search path: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
t.Fatalf("open pool: %v", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after up")
}
goose.SetBaseFS(migrations.Migrations())
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
t.Fatalf("set dialect: %v", err)
}
// Down past 00011 (to 00010) and assert the columns are gone.
if err := goose.DownToContext(ctx, db, ".", 10); err != nil {
t.Fatalf("down to 10: %v", err)
}
if sessionsPlatformColumnsExist(ctx, t, db) {
t.Error("platform columns survived the down migration")
}
// Re-apply and assert they return.
if err := goose.UpContext(ctx, db, "."); err != nil {
t.Fatalf("re-apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after re-apply")
}
}
// sessionsPlatformColumnsExist reports whether both platform columns are present on
// backend.sessions.
func sessionsPlatformColumnsExist(ctx context.Context, t *testing.T, db *sql.DB) bool {
t.Helper()
var n int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'backend' AND table_name = 'sessions'
AND column_name IN ('platform_kind', 'platform_subtype')`).Scan(&n); err != nil {
t.Fatalf("count platform columns: %v", err)
}
return n == 2
}
+7 -3
View File
@@ -26,7 +26,8 @@ func TestSessionLifecycle(t *testing.T) {
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
token, sess, err := svc.Create(ctx, acc.ID)
platform := session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}
token, sess, err := svc.Create(ctx, acc.ID, platform)
if err != nil {
t.Fatalf("create session: %v", err)
}
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
if token == sess.TokenHash {
t.Error("plaintext token must not equal the stored hash")
}
if sess.Platform != platform {
t.Errorf("session platform = %+v, want %+v", sess.Platform, platform)
}
// Resolve via the warm write-through cache.
got, err := svc.Resolve(ctx, token)
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
if _, ok := cold.Get(session.HashToken(token)); !ok {
t.Error("Warm must load the active session into the cache")
}
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID {
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID)
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID || got2.Platform != platform {
t.Errorf("resolve after warm = (%s, %+v, %v), want %s / %+v", got2.ID, got2.Platform, err, sess.ID, platform)
}
// Revoke, then the token no longer resolves; revoke again is a no-op.
+5 -1
View File
@@ -200,7 +200,11 @@ func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (Merge
}
res := MergeResult{PrimaryID: primary}
if primary != callerID {
token, _, err := s.sessions.Create(ctx, primary)
// The switched-to session inherits the caller's current trusted platform
// (the context the merge was initiated from); absent when the caller's
// session itself is untrusted.
platform, _ := session.PlatformFromContext(ctx)
token, _, err := s.sessions.Create(ctx, primary, platform)
if err != nil {
return MergeResult{}, err
}
@@ -13,11 +13,13 @@ import (
)
type Sessions struct {
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
PlatformKind *string
PlatformSubtype *string
}
@@ -17,13 +17,15 @@ type sessionsTable struct {
postgres.Table
// Columns
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
PlatformKind postgres.ColumnString
PlatformSubtype postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
var (
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
PlatformKindColumn = postgres.StringColumn("platform_kind")
PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return sessionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
PlatformKind: PlatformKindColumn,
PlatformSubtype: PlatformSubtypeColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,23 @@
-- Record the trusted execution platform on a session: platform_kind (the wrapper the
-- session was established through — vk/telegram/direct) plus platform_subtype (the device
-- family — ios/android/web). Both are nullable: a session minted before this migration, or
-- one the gateway could not attribute, carries NULL and is treated as untrusted (view-only)
-- by the payments compliance gate. kind is derived server-side from the validated establish
-- path (never a client field); the subtype is trustworthy only for VK (vk_platform rides
-- inside the signed launch params) and best-effort for telegram/direct.
--
-- Expand-contract: the columns are additive and nullable, so a backend image rollback stays
-- DB-safe (older code neither writes nor reads them). The table shape changes, so the
-- generated go-jet model IS regenerated.
-- +goose Up
ALTER TABLE backend.sessions ADD COLUMN platform_kind text;
ALTER TABLE backend.sessions ADD COLUMN platform_subtype text;
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_kind_chk CHECK ((platform_kind IS NULL) OR (platform_kind = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])));
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_subtype_chk CHECK ((platform_subtype IS NULL) OR (platform_subtype = ANY (ARRAY['ios'::text, 'android'::text, 'web'::text])));
-- +goose Down
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_subtype_chk;
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_kind_chk;
ALTER TABLE backend.sessions DROP COLUMN platform_subtype;
ALTER TABLE backend.sessions DROP COLUMN platform_kind;
+7 -3
View File
@@ -28,10 +28,14 @@ type okResponse struct {
}
// resolveResponse maps a session token to its account. IsGuest lets the gateway
// gate guest-forbidden operations without an extra round-trip.
// gate guest-forbidden operations without an extra round-trip. PlatformKind and
// PlatformSubtype carry the session's trusted execution platform so the gateway can
// inject X-Platform; both are empty for an untrusted (pre-capture) session.
type resolveResponse struct {
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
PlatformKind string `json:"platform_kind"`
PlatformSubtype string `json:"platform_subtype"`
}
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
+32 -14
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/session"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -23,7 +24,9 @@ import (
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first
// contact only).
// contact only). Subtype is the client-reported device family (ios/android/web);
// Telegram's initData does not sign it, so it is recorded best-effort and the
// payments gate never relies on it.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
@@ -31,6 +34,7 @@ type telegramAuthRequest struct {
LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
Subtype string `json:"subtype"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -63,19 +67,22 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
}
}
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
// params. LanguageCode (vk_language) and DisplayName (read client-side via
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
// offset) seeds its time zone. All seeds apply on first contact only.
// offset) seeds its time zone. All seeds apply on first contact only. Subtype is the
// device family the gateway derived from the signed vk_platform param — trusted,
// since it rides inside the verified launch signature.
type vkAuthRequest struct {
ExternalID string `json:"external_id"`
LanguageCode string `json:"language_code"`
DisplayName string `json:"display_name"`
BrowserTZ string `json:"browser_tz"`
Subtype string `json:"subtype"`
}
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
@@ -94,7 +101,7 @@ func (s *Server) handleVKAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindVK, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
@@ -150,6 +157,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
// time zone, so robot timing is anchored to the player's zone from the first game.
type guestAuthRequest struct {
BrowserTZ string `json:"browser_tz"`
// Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
@@ -164,7 +174,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// emailRequest is an email-login code request. BrowserTZ (the client's detected
@@ -196,10 +206,12 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// emailLoginRequest verifies an email login code.
// emailLoginRequest verifies an email login code. Subtype is the client-reported
// device family (ios/android/web) of this direct session; recorded best-effort.
type emailLoginRequest struct {
Email string `json:"email"`
Code string `json:"code"`
Email string `json:"email"`
Code string `json:"code"`
Subtype string `json:"subtype"`
}
// handleEmailLogin verifies the code and mints a session for the owning account.
@@ -214,7 +226,7 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
@@ -248,7 +260,8 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) {
s.abortErr(c, err)
return
}
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
// A magic-link login always opens in a browser, so its session is direct/web.
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb})
if err != nil {
s.abortErr(c, err)
return
@@ -288,7 +301,11 @@ func (s *Server) handleResolveSession(c *gin.Context) {
// is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()}
resp := resolveResponse{
UserID: sess.AccountID.String(),
PlatformKind: sess.Platform.Kind,
PlatformSubtype: sess.Platform.Subtype,
}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest
}
@@ -309,9 +326,10 @@ func (s *Server) handleRevokeSession(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// mintSession creates a session for acc and writes the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
// mintSession creates a session for acc carrying the captured platform and writes
// the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account, platform session.Platform) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, platform)
if err != nil {
s.abortErr(c, err)
return
+43
View File
@@ -4,9 +4,12 @@ import (
"context"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/session"
)
// headerUserID is the identity header the gateway injects after resolving a
@@ -41,6 +44,46 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok
}
// headerPlatform is the trusted execution-platform header the gateway injects after
// resolving a session's platform. Its value is "<kind>/<subtype>" (e.g. "vk/ios");
// it is omitted for an untrusted session, in which case platform(c) reports the
// zero Platform. Like X-User-ID, the value is the gateway's — never a client body.
const headerPlatform = "X-Platform"
// platformContext returns middleware that parses the gateway-injected X-Platform
// header into the request context, so handlers (and the link/merge session mint)
// read the caller's trusted platform. An absent or malformed header leaves the
// context without a platform (untrusted) and never rejects the request — X-Platform
// is a capability signal, not an identity gate.
func platformContext() gin.HandlerFunc {
return func(c *gin.Context) {
if p, ok := parsePlatformHeader(c.GetHeader(headerPlatform)); ok {
c.Request = c.Request.WithContext(session.WithPlatform(c.Request.Context(), p))
}
c.Next()
}
}
// parsePlatformHeader splits a "<kind>/<subtype>" X-Platform value into a Platform.
// A blank value or blank kind yields no platform (an untrusted session).
func parsePlatformHeader(h string) (session.Platform, bool) {
if h == "" {
return session.Platform{}, false
}
kind, subtype, _ := strings.Cut(h, "/")
if kind == "" {
return session.Platform{}, false
}
return session.Platform{Kind: kind, Subtype: subtype}, true
}
// platform returns the caller's trusted execution platform, or the zero Platform
// and false when the session was not attributed to one — an untrusted, view-only
// context for the payments gate.
func platform(c *gin.Context) (session.Platform, bool) {
return session.PlatformFromContext(c.Request.Context())
}
// requireSameOrigin guards the admin console's state-changing requests: it rejects
// a non-safe request whose Origin (or, failing that, Referer) host does not match
// the request Host. The gateway authenticates the operator with Basic-Auth in front
@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/session"
)
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
@@ -58,3 +60,51 @@ func TestRequireUserID(t *testing.T) {
}
})
}
// TestPlatformContext checks that the middleware parses the gateway-injected
// X-Platform header into a trusted platform reachable via platform(c), treats an
// absent or blank-kind header as untrusted, and never rejects the request.
func TestPlatformContext(t *testing.T) {
gin.SetMode(gin.TestMode)
var seen session.Platform
var ok bool
r := gin.New()
r.Use(platformContext())
r.GET("/x", func(c *gin.Context) {
seen, ok = platform(c)
c.String(http.StatusOK, "ok")
})
for _, tc := range []struct {
name string
header string
setHeader bool
wantOK bool
want session.Platform
}{
{"vk-ios", "vk/ios", true, true, session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-no-subtype", "telegram", true, true, session.Platform{Kind: session.PlatformKindTelegram}},
{"direct-web", "direct/web", true, true, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"absent", "", false, false, session.Platform{}},
{"empty", "", true, false, session.Platform{}},
{"blank-kind", "/ios", true, false, session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
seen, ok = session.Platform{}, false
req := httptest.NewRequest(http.MethodGet, "/x", nil)
if tc.setHeader {
req.Header.Set("X-Platform", tc.header)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (X-Platform never rejects)", rec.Code)
}
if ok != tc.wantOK || seen != tc.want {
t.Fatalf("platform = %+v (ok=%v), want %+v (ok=%v)", seen, ok, tc.want, tc.wantOK)
}
})
}
}
+4
View File
@@ -238,6 +238,10 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.public = v1.Group("/public")
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
// Capture the gateway-injected trusted platform (X-Platform) into the request context,
// so the payments gate can read it via platform(c). Optional: an untrusted session simply
// carries no platform and is treated as view-only. Never rejects.
s.user.Use(platformContext())
// The suspension gate runs after identity is established: a blocked account is refused on
// every user route (except the block-status probe) so the UI can show the blocked screen.
s.user.Use(s.requireNotSuspended())
+65
View File
@@ -0,0 +1,65 @@
package session
import "context"
// Platform is the trusted execution context recorded on a session: the wrapper
// Kind the session was established through and the device Subtype. An empty Kind
// marks an untrusted session — one minted before platform capture, or one the
// gateway could not attribute — which the payments compliance gate treats as
// view-only.
//
// Kind is always trustworthy: it is derived server-side from the validated
// establish path, never from a client-supplied field. Subtype is trustworthy only
// for VK (vk_platform rides inside the signed launch params); for telegram and
// direct it is client-reported and best-effort, so the gate must never rely on it.
type Platform struct {
Kind string
Subtype string
}
// Platform kinds recorded in platform_kind.
const (
PlatformKindVK = "vk"
PlatformKindTelegram = "telegram"
PlatformKindDirect = "direct"
)
// Platform subtypes recorded in platform_subtype.
const (
SubtypeIOS = "ios"
SubtypeAndroid = "android"
SubtypeWeb = "web"
)
// Trusted reports whether the session was attributed to a platform (a non-empty
// Kind). The zero Platform is untrusted.
func (p Platform) Trusted() bool { return p.Kind != "" }
// NormalizeSubtype coerces subtype to one of the known device families, defaulting
// an empty or unrecognised value to web. A newly established session always carries
// at least a web subtype, so a trusted session is never left without one.
func NormalizeSubtype(subtype string) string {
switch subtype {
case SubtypeIOS, SubtypeAndroid, SubtypeWeb:
return subtype
default:
return SubtypeWeb
}
}
// platformCtxKey types the request-context slot the trusted platform rides in.
type platformCtxKey struct{}
// WithPlatform returns a copy of ctx carrying platform. The backend middleware
// calls it after parsing the gateway-injected X-Platform header, so downstream
// handlers and the link/merge session mint read the caller's platform from ctx.
func WithPlatform(ctx context.Context, platform Platform) context.Context {
return context.WithValue(ctx, platformCtxKey{}, platform)
}
// PlatformFromContext returns the platform stored by WithPlatform and whether one
// was present. A missing value yields the zero (untrusted) Platform.
func PlatformFromContext(ctx context.Context) (Platform, bool) {
p, ok := ctx.Value(platformCtxKey{}).(Platform)
return p, ok
}
+6 -4
View File
@@ -29,14 +29,16 @@ func (svc *Service) Ready() bool {
return svc.cache.Ready()
}
// Create mints a new active session for accountID and returns the plaintext
// token (shown to the caller once) together with the persisted session.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) {
// Create mints a new active session for accountID carrying the captured platform
// and returns the plaintext token (shown to the caller once) together with the
// persisted session. Pass the zero Platform for a session that could not be
// attributed to a trusted platform.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID, platform Platform) (string, Session, error) {
token, tokenHash, err := GenerateToken()
if err != nil {
return "", Session{}, err
}
sess, err := svc.store.Insert(ctx, accountID, tokenHash)
sess, err := svc.store.Insert(ctx, accountID, tokenHash, platform)
if err != nil {
return "", Session{}, err
}
+25 -5
View File
@@ -25,7 +25,8 @@ const (
var ErrNotFound = errors.New("session: not found")
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
// SHA-256 of the bearer token.
// SHA-256 of the bearer token. Platform is the trusted execution context captured
// at creation; its zero value marks an untrusted session (see Platform).
type Session struct {
ID uuid.UUID
AccountID uuid.UUID
@@ -34,6 +35,7 @@ type Session struct {
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
Platform Platform
}
// Store is the Postgres-backed query surface for backend.sessions.
@@ -46,9 +48,10 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Insert persists a new active session for accountID carrying tokenHash and
// returns the persisted row.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) {
// Insert persists a new active session for accountID carrying tokenHash and the
// captured platform, and returns the persisted row. An empty platform Kind/Subtype
// is written as SQL NULL, so an untrusted session records no platform.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string, platform Platform) (Session, error) {
id, err := uuid.NewV7()
if err != nil {
return Session{}, fmt.Errorf("session: new id: %w", err)
@@ -57,7 +60,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash strin
table.Sessions.SessionID,
table.Sessions.AccountID,
table.Sessions.TokenHash,
).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns)
table.Sessions.PlatformKind,
table.Sessions.PlatformSubtype,
).VALUES(id, accountID, tokenHash, stringOrNull(platform.Kind), stringOrNull(platform.Subtype)).RETURNING(table.Sessions.AllColumns)
var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
@@ -173,5 +178,20 @@ func modelToSession(row model.Sessions) Session {
t := *row.RevokedAt
s.RevokedAt = &t
}
if row.PlatformKind != nil {
s.Platform.Kind = *row.PlatformKind
}
if row.PlatformSubtype != nil {
s.Platform.Subtype = *row.PlatformSubtype
}
return s
}
// stringOrNull maps an empty string to a SQL NULL literal and any other value to a
// string literal, so an untrusted session's absent platform is stored as NULL.
func stringOrNull(s string) postgres.Expression {
if s == "" {
return postgres.NULL
}
return postgres.String(s)
}