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
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:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user