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:
@@ -27,7 +27,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
|
|||||||
| Stage | Title | Release | Status |
|
| Stage | Title | Release | Status |
|
||||||
|-------|-------|---------|--------|
|
|-------|-------|---------|--------|
|
||||||
| E0 | Payments data foundation | 1 | DONE |
|
| E0 | Payments data foundation | 1 | DONE |
|
||||||
| E1 | Trusted platform signal | 1 | TODO |
|
| E1 | Trusted platform signal | 1 | DONE |
|
||||||
| E2 | Currency + benefit core | 1 | TODO |
|
| E2 | Currency + benefit core | 1 | TODO |
|
||||||
| E3 | Wallet UI | 1 | TODO |
|
| E3 | Wallet UI | 1 | TODO |
|
||||||
| E4 | Durability (PITR) | 2 | TODO |
|
| E4 | Durability (PITR) | 2 | TODO |
|
||||||
@@ -210,56 +210,74 @@ role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
|||||||
|
|
||||||
## E1 — Trusted platform signal
|
## E1 — Trusted platform signal
|
||||||
|
|
||||||
**Status:** TODO · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
||||||
|
|
||||||
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
|
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
|
||||||
carried on the session and re-confirmed each cold start. This is the foundation the gate
|
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
|
||||||
(E2) stands on; without it the gate is meaningless.
|
meaningless. Signal plumbing only — no user-visible change.
|
||||||
|
|
||||||
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a
|
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
|
||||||
property of the **session**. On cold start the client submits the fresh wrapper signature
|
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
|
||||||
(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve)
|
the validated establish path (VK launch / TG initData / a web-native session), never a client
|
||||||
validates it and records/refreshes the session's platform. `direct` needs no signature — it
|
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
|
||||||
is implied by a web/native session's creation path.
|
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
|
||||||
|
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
|
||||||
|
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
|
||||||
|
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
|
||||||
|
(`direct` needs no signature).
|
||||||
|
|
||||||
|
**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
|
||||||
|
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
|
||||||
|
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
|
||||||
|
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform` →
|
||||||
|
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
|
||||||
|
platform to the backend at establish; the backend persists it and returns it on resolve.
|
||||||
|
|
||||||
**Backend.**
|
**Backend.**
|
||||||
|
|
||||||
- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session
|
- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
|
||||||
row + `Session` struct; set it at creation and refresh it on a validated cold-start call.
|
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
|
||||||
- Session resolve (`handlers_auth.go handleResolveSession` → `resolveResponse` DTO
|
captures it; `Session` carries it through the store and the warm cache.
|
||||||
`dto.go`): return `platform` so the gateway can carry it.
|
- `handleResolveSession` → `resolveResponse` (`dto.go`) returns the platform; the establish
|
||||||
- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`)
|
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
|
||||||
— reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app
|
`subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
|
||||||
secret) — new small verifier, unit-tested against known VK vectors.
|
best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
|
||||||
- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror
|
caller's platform from the request context.
|
||||||
`X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from
|
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
|
||||||
the client request body.
|
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
|
||||||
- Backend middleware: read `X-Platform` into context alongside `X-User-ID`
|
|
||||||
(`middleware.go`); expose a typed accessor `platform(c)`.
|
|
||||||
- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments
|
|
||||||
interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing
|
|
||||||
lands here).
|
|
||||||
|
|
||||||
**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/
|
**Gateway.**
|
||||||
refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts`
|
|
||||||
(`initData`). `direct` submits nothing (web/native). Compose the platform from
|
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
|
||||||
`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single
|
every authenticated backend call; `connectsrv.Execute` enriches the request context from the
|
||||||
discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there).
|
resolved session's platform (carried through the gateway session cache + `ResolveSession`).
|
||||||
|
Never from a client request body.
|
||||||
|
|
||||||
|
**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
|
||||||
|
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
|
||||||
|
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
|
||||||
|
nothing (the gateway derives the trusted subtype from the signed launch params).
|
||||||
|
|
||||||
**Tests.**
|
**Tests.**
|
||||||
|
|
||||||
- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path
|
- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
|
||||||
(existing) covered; platform kind+subtype derivation.
|
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
|
||||||
- integration: session carries platform; resolve returns it; stale/absent → untrusted.
|
client injects `X-Platform` from the context and omits it when untrusted.
|
||||||
- UI: cold start submits the right signature per wrapper (mock).
|
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
|
||||||
|
kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
|
||||||
|
applies forward **and backward** on a throwaway PG.
|
||||||
|
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).
|
||||||
|
|
||||||
**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body
|
**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
|
||||||
cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and
|
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
|
||||||
observable. No user-visible change.
|
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.
|
||||||
|
|
||||||
**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3).
|
**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
|
||||||
Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable;
|
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
|
||||||
re-cold-start fixes it). Keep the VK app secret in config/secret store, not code.
|
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
|
||||||
|
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
|
||||||
|
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
|
||||||
|
+ the trusted VK subtype only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
store := session.NewStore(testDB)
|
||||||
svc := session.NewService(store, session.NewCache())
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("create session: %v", err)
|
t.Fatalf("create session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
|
|||||||
if token == sess.TokenHash {
|
if token == sess.TokenHash {
|
||||||
t.Error("plaintext token must not equal the stored hash")
|
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.
|
// Resolve via the warm write-through cache.
|
||||||
got, err := svc.Resolve(ctx, token)
|
got, err := svc.Resolve(ctx, token)
|
||||||
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
|
|||||||
if _, ok := cold.Get(session.HashToken(token)); !ok {
|
if _, ok := cold.Get(session.HashToken(token)); !ok {
|
||||||
t.Error("Warm must load the active session into the cache")
|
t.Error("Warm must load the active session into the cache")
|
||||||
}
|
}
|
||||||
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != 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), want %s", got2.ID, err, sess.ID)
|
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.
|
// 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}
|
res := MergeResult{PrimaryID: primary}
|
||||||
if primary != callerID {
|
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 {
|
if err != nil {
|
||||||
return MergeResult{}, err
|
return MergeResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Sessions struct {
|
type Sessions struct {
|
||||||
SessionID uuid.UUID `sql:"primary_key"`
|
SessionID uuid.UUID `sql:"primary_key"`
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
TokenHash string
|
TokenHash string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
RevokedAt *time.Time
|
RevokedAt *time.Time
|
||||||
|
PlatformKind *string
|
||||||
|
PlatformSubtype *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ type sessionsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
SessionID postgres.ColumnString
|
SessionID postgres.ColumnString
|
||||||
AccountID postgres.ColumnString
|
AccountID postgres.ColumnString
|
||||||
TokenHash postgres.ColumnString
|
TokenHash postgres.ColumnString
|
||||||
Status postgres.ColumnString
|
Status postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
LastSeenAt postgres.ColumnTimestampz
|
LastSeenAt postgres.ColumnTimestampz
|
||||||
RevokedAt postgres.ColumnTimestampz
|
RevokedAt postgres.ColumnTimestampz
|
||||||
|
PlatformKind postgres.ColumnString
|
||||||
|
PlatformSubtype postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
|
|||||||
|
|
||||||
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
|
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
|
||||||
var (
|
var (
|
||||||
SessionIDColumn = postgres.StringColumn("session_id")
|
SessionIDColumn = postgres.StringColumn("session_id")
|
||||||
AccountIDColumn = postgres.StringColumn("account_id")
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
TokenHashColumn = postgres.StringColumn("token_hash")
|
TokenHashColumn = postgres.StringColumn("token_hash")
|
||||||
StatusColumn = postgres.StringColumn("status")
|
StatusColumn = postgres.StringColumn("status")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
|
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
|
||||||
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
|
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
|
||||||
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
|
PlatformKindColumn = postgres.StringColumn("platform_kind")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
|
PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
|
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{
|
return sessionsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
SessionID: SessionIDColumn,
|
SessionID: SessionIDColumn,
|
||||||
AccountID: AccountIDColumn,
|
AccountID: AccountIDColumn,
|
||||||
TokenHash: TokenHashColumn,
|
TokenHash: TokenHashColumn,
|
||||||
Status: StatusColumn,
|
Status: StatusColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
LastSeenAt: LastSeenAtColumn,
|
LastSeenAt: LastSeenAtColumn,
|
||||||
RevokedAt: RevokedAtColumn,
|
RevokedAt: RevokedAtColumn,
|
||||||
|
PlatformKind: PlatformKindColumn,
|
||||||
|
PlatformSubtype: PlatformSubtypeColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
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
|
// 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 {
|
type resolveResponse struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
IsGuest bool `json:"is_guest"`
|
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
|
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
// 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
|
// 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
|
// "±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
|
// 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 {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -31,6 +34,7 @@ type telegramAuthRequest struct {
|
|||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
StartParam string `json:"start_param"`
|
StartParam string `json:"start_param"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
// 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
|
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||||
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||||
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
// 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
|
// 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 {
|
type vkAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
// 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)
|
s.abortErr(c, err)
|
||||||
return
|
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.
|
// 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.
|
// time zone, so robot timing is anchored to the player's zone from the first game.
|
||||||
type guestAuthRequest struct {
|
type guestAuthRequest struct {
|
||||||
BrowserTZ string `json:"browser_tz"`
|
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,
|
// 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)
|
s.abortErr(c, err)
|
||||||
return
|
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
|
// 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})
|
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 {
|
type emailLoginRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEmailLogin verifies the code and mints a session for the owning account.
|
// 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)
|
s.abortErr(c, err)
|
||||||
return
|
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,
|
// 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)
|
s.abortErr(c, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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
|
// 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
|
// valid resolve (the auth hot path), so a read error falls back to false; the
|
||||||
// per-operation backend gate remains the authoritative guest check.
|
// 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 {
|
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
|
||||||
resp.IsGuest = acc.IsGuest
|
resp.IsGuest = acc.IsGuest
|
||||||
}
|
}
|
||||||
@@ -309,9 +326,10 @@ func (s *Server) handleRevokeSession(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mintSession creates a session for acc and writes the credential response.
|
// mintSession creates a session for acc carrying the captured platform and writes
|
||||||
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
|
// the credential response.
|
||||||
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// headerUserID is the identity header the gateway injects after resolving a
|
// 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
|
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
|
// 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
|
// 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
|
// 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/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
|
// 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.public = v1.Group("/public")
|
||||||
s.user = v1.Group("/user")
|
s.user = v1.Group("/user")
|
||||||
s.user.Use(RequireUserID())
|
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
|
// 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.
|
// every user route (except the block-status probe) so the UI can show the blocked screen.
|
||||||
s.user.Use(s.requireNotSuspended())
|
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()
|
return svc.cache.Ready()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mints a new active session for accountID and returns the plaintext
|
// Create mints a new active session for accountID carrying the captured platform
|
||||||
// token (shown to the caller once) together with the persisted session.
|
// and returns the plaintext token (shown to the caller once) together with the
|
||||||
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) {
|
// 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()
|
token, tokenHash, err := GenerateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Session{}, err
|
return "", Session{}, err
|
||||||
}
|
}
|
||||||
sess, err := svc.store.Insert(ctx, accountID, tokenHash)
|
sess, err := svc.store.Insert(ctx, accountID, tokenHash, platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Session{}, err
|
return "", Session{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const (
|
|||||||
var ErrNotFound = errors.New("session: not found")
|
var ErrNotFound = errors.New("session: not found")
|
||||||
|
|
||||||
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
|
// 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 {
|
type Session struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
@@ -34,6 +35,7 @@ type Session struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
RevokedAt *time.Time
|
RevokedAt *time.Time
|
||||||
|
Platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store is the Postgres-backed query surface for backend.sessions.
|
// Store is the Postgres-backed query surface for backend.sessions.
|
||||||
@@ -46,9 +48,10 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
return &Store{db: db}
|
return &Store{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert persists a new active session for accountID carrying tokenHash and
|
// Insert persists a new active session for accountID carrying tokenHash and the
|
||||||
// returns the persisted row.
|
// captured platform, and returns the persisted row. An empty platform Kind/Subtype
|
||||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) {
|
// 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()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("session: new id: %w", err)
|
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.SessionID,
|
||||||
table.Sessions.AccountID,
|
table.Sessions.AccountID,
|
||||||
table.Sessions.TokenHash,
|
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
|
var row model.Sessions
|
||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
@@ -173,5 +178,20 @@ func modelToSession(row model.Sessions) Session {
|
|||||||
t := *row.RevokedAt
|
t := *row.RevokedAt
|
||||||
s.RevokedAt = &t
|
s.RevokedAt = &t
|
||||||
}
|
}
|
||||||
|
if row.PlatformKind != nil {
|
||||||
|
s.Platform.Kind = *row.PlatformKind
|
||||||
|
}
|
||||||
|
if row.PlatformSubtype != nil {
|
||||||
|
s.Platform.Subtype = *row.PlatformSubtype
|
||||||
|
}
|
||||||
return s
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+18
-3
@@ -130,8 +130,8 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
solver version, so it cannot drift from the running backend. The **move journal, history
|
solver version, so it cannot drift from the running backend. The **move journal, history
|
||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
from the body. Because every sync call targets the one backend host, the
|
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -199,6 +199,21 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
|
until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
|
||||||
on an account merge (§4), **every** session of the retired account
|
on an account merge (§4), **every** session of the retired account
|
||||||
(`RevokeAllForAccount`, which also evicts them from the warm cache).
|
(`RevokeAllForAccount`, which also evicts them from the warm cache).
|
||||||
|
- **Trusted execution platform.** Each session also records a `platform` — a `kind`
|
||||||
|
(`vk`/`telegram`/`direct`) plus a device `subtype` (`ios`/`android`/`web`) — captured
|
||||||
|
at creation, so a store-compliance gate has an unforgeable execution context
|
||||||
|
(`docs/PAYMENTS.md` §8). `kind` is derived server-side from the validated establish
|
||||||
|
path (the VK launch, the Telegram initData, or a web-native session), **never a client
|
||||||
|
field**; the `subtype` is trusted only for VK (it rides inside the signed `vk_platform`
|
||||||
|
parameter) and is client-reported best-effort for telegram/direct. VK and Telegram
|
||||||
|
re-mint (and so re-validate and re-capture) on every cold start; a direct session
|
||||||
|
captures it once. The gateway injects the resolved platform as **`X-Platform`**
|
||||||
|
(`<kind>/<subtype>`) alongside `X-User-ID`, sourced from the session and never the
|
||||||
|
request body; an absent or unrecorded platform — a session predating the feature, or
|
||||||
|
one the gateway could not attribute — is **untrusted**, treated as view-only (no
|
||||||
|
spends). Consistent with the revoke-only model, platform freshness carries no separate
|
||||||
|
TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a
|
||||||
|
reused direct/email session) on re-login.
|
||||||
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
|
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
|
||||||
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
|
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
|
||||||
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
||||||
@@ -1150,7 +1165,7 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` + trusted platform resolution, `X-User-ID` / `X-Platform` injection | gateway (platform sourced from the session, never the request body — §3) |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
||||||
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
|
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
|
||||||
|
|||||||
+16
-13
@@ -180,20 +180,23 @@ an archive, so history/receipts/tax are independent of later catalog edits.
|
|||||||
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
||||||
never the source of truth.
|
never the source of truth.
|
||||||
|
|
||||||
- The platform is a **property of the session**, re-confirmed by a fresh signature on **every
|
- The platform is a **property of the session**, captured when the session is created. VK and
|
||||||
cold start** — VK launch-params `sign` / TG `initData` (validator already exists at
|
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params `sign`
|
||||||
`platform/telegram/internal/initdata`). VK/TG wrappers resend the signature on every open,
|
/ Telegram `initData`) on **every cold start**, so their platform is re-confirmed each launch;
|
||||||
so this is not a one-time login.
|
a `direct` session captures it once, established by the fact of a web/native session's creation
|
||||||
- `direct` is established by the fact of a web/native session's creation (no external
|
(no external signer, and none needed — reaching vk/tg segments in a direct context still
|
||||||
signer, and none needed — reaching vk/tg segments in a direct context still requires a
|
requires a real attachment, §6).
|
||||||
real attachment, §6).
|
|
||||||
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
|
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
|
||||||
(`ios`/`android`/`web`) — the subtype is mandatory (VK iOS is frozen).
|
(`ios`/`android`/`web`). `kind` is always trusted — the server derives it from the validated
|
||||||
- The gateway resolves the session and passes `platform` to the backend (alongside the
|
launch, never a client field. The **subtype is trusted only for VK**: it rides inside the
|
||||||
existing `X-User-ID`).
|
signed launch parameters, which is what makes the **VK iOS freeze** enforceable; for Telegram
|
||||||
- **Fail-closed:** an untrusted/unconfirmed platform (a VK/TG session without a valid
|
and direct the subtype is client-reported and best-effort, so the gate never keys off it.
|
||||||
cold-start signature; an old session with no recorded platform) denies spends/purchases
|
- The gateway resolves the session and passes `platform` to the backend (alongside the existing
|
||||||
and applying any foreign origin — view only.
|
`X-User-ID`), sourced from the session — never the client request body.
|
||||||
|
- **Fail-closed:** an untrusted platform — a session with no recorded platform, one predating
|
||||||
|
this feature or one the gateway could not attribute — denies spends/purchases and applying any
|
||||||
|
foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused
|
||||||
|
direct/email session on re-login.
|
||||||
|
|
||||||
## 9. Payment intake
|
## 9. Payment intake
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -180,20 +180,23 @@ durable).
|
|||||||
Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
|
Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
|
||||||
никогда не источник правды.
|
никогда не источник правды.
|
||||||
|
|
||||||
- Платформа — **свойство сессии**, переподтверждается свежей подписью на **каждом холодном
|
- Платформа — **свойство сессии**, фиксируется при создании сессии. Обёртки VK и Telegram
|
||||||
старте** — VK launch-params `sign` / TG `initData` (валидатор уже есть в
|
пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params `sign` /
|
||||||
`platform/telegram/internal/initdata`). Обёртки VK/TG шлют подпись при каждом открытии,
|
Telegram `initData`) на **каждом холодном старте**, поэтому их платформа переподтверждается
|
||||||
так что это не одноразовый вход.
|
при каждом запуске; `direct`-сессия фиксирует платформу один раз, самим фактом создания
|
||||||
- `direct` устанавливается самим фактом создания веб/native-сессии (внешней подписи нет и
|
веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте
|
||||||
не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной
|
всё равно требует реальной привязки, §6).
|
||||||
привязки, §6).
|
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип** (`ios`/`android`/`web`).
|
||||||
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип**
|
`kind` доверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля.
|
||||||
(`ios`/`android`/`web`) — подтип обязателен (VK iOS заморожен).
|
**Подтип доверенный только у VK**: он лежит внутри подписанных параметров запуска, что и делает
|
||||||
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим
|
**заморозку VK iOS** выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и
|
||||||
`X-User-ID`).
|
гейт на него не опирается.
|
||||||
- **Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной
|
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим `X-User-ID`),
|
||||||
подписи на холодном старте; старая сессия без записанной платформы) запрещает
|
беря его из сессии — не из тела клиентского запроса.
|
||||||
траты/покупки и применение любого чужого origin — только просмотр.
|
- **Fail-closed:** недоверенная платформа — сессия без записанной платформы, созданная до этой
|
||||||
|
функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение
|
||||||
|
любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном
|
||||||
|
старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.
|
||||||
|
|
||||||
## 9. Приём платежей
|
## 9. Приём платежей
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ type ChatResp struct {
|
|||||||
// brand-new account's display name and language from the validated launch fields, its
|
// brand-new account's display name and language from the validated launch fields, its
|
||||||
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||||
// validated launch deep-link startParam, its variant preferences (first contact only).
|
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -221,6 +221,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
"first_name": firstName,
|
"first_name": firstName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
"start_param": startParam,
|
"start_param": startParam,
|
||||||
|
"subtype": subtype,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -230,7 +231,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||||
// name from the signed launch params) and its time zone from browserTz (the client's
|
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||||
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -238,6 +239,7 @@ func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayNa
|
|||||||
"language_code": languageCode,
|
"language_code": languageCode,
|
||||||
"display_name": displayName,
|
"display_name": displayName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
|
"subtype": subtype,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -290,10 +292,10 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
|
|||||||
|
|
||||||
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
||||||
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
||||||
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
|
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
||||||
map[string]string{"browser_tz": browserTz}, &out)
|
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +309,10 @@ func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EmailLogin verifies a login code and mints a session.
|
// EmailLogin verifies a login code and mints a session.
|
||||||
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
|
func (c *Client) EmailLogin(ctx context.Context, email, code, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
||||||
map[string]string{"email": email, "code": code}, &out)
|
map[string]string{"email": email, "code": code, "subtype": subtype}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,16 +333,24 @@ func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLin
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
// ResolveSession maps a token to its account id, guest flag, and trusted platform
|
||||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
|
||||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
// ops; the platform ("<kind>/<subtype>", empty for an untrusted session) is injected
|
||||||
|
// as X-Platform on the caller's backend requests.
|
||||||
|
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, string, error) {
|
||||||
var out struct {
|
var out struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
IsGuest bool `json:"is_guest"`
|
IsGuest bool `json:"is_guest"`
|
||||||
|
PlatformKind string `json:"platform_kind"`
|
||||||
|
PlatformSubtype string `json:"platform_subtype"`
|
||||||
}
|
}
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||||
map[string]string{"token": token}, &out)
|
map[string]string{"token": token}, &out)
|
||||||
return out.UserID, out.IsGuest, err
|
platform := ""
|
||||||
|
if out.PlatformKind != "" {
|
||||||
|
platform = out.PlatformKind + "/" + out.PlatformSubtype
|
||||||
|
}
|
||||||
|
return out.UserID, out.IsGuest, platform, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile returns the authenticated account's profile.
|
// Profile returns the authenticated account's profile.
|
||||||
|
|||||||
@@ -118,8 +118,30 @@ func (e *APIError) Error() string {
|
|||||||
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// platformCtxKey types the request-context slot the trusted X-Platform value rides in.
|
||||||
|
type platformCtxKey struct{}
|
||||||
|
|
||||||
|
// WithPlatform returns a copy of ctx carrying the trusted platform header value
|
||||||
|
// ("<kind>/<subtype>", e.g. "vk/ios") that do and getRaw inject as X-Platform on the
|
||||||
|
// outbound backend request. An empty platform (an untrusted session) leaves ctx
|
||||||
|
// unchanged, so no header is sent and the backend treats the request as untrusted.
|
||||||
|
func WithPlatform(ctx context.Context, platform string) context.Context {
|
||||||
|
if platform == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, platformCtxKey{}, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformFromContext returns the platform header value stored by WithPlatform, or
|
||||||
|
// an empty string when none is present.
|
||||||
|
func platformFromContext(ctx context.Context) string {
|
||||||
|
p, _ := ctx.Value(platformCtxKey{}).(string)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
|
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||||
|
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||||
// response is returned as an *APIError carrying the backend error code.
|
// response is returned as an *APIError carrying the backend error code.
|
||||||
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
@@ -141,6 +163,9 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
if clientIP != "" {
|
if clientIP != "" {
|
||||||
req.Header.Set("X-Forwarded-For", clientIP)
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
}
|
}
|
||||||
|
if p := platformFromContext(ctx); p != "" {
|
||||||
|
req.Header.Set("X-Platform", p)
|
||||||
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
||||||
@@ -174,6 +199,9 @@ func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", userID)
|
||||||
}
|
}
|
||||||
|
if p := platformFromContext(ctx); p != "" {
|
||||||
|
req.Header.Set("X-Platform", p)
|
||||||
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
||||||
|
|||||||
@@ -82,3 +82,43 @@ func TestSyncBans(t *testing.T) {
|
|||||||
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXPlatformInjection verifies the trusted platform carried on the context
|
||||||
|
// (WithPlatform) rides an authenticated backend request as X-Platform, and that an
|
||||||
|
// untrusted context (no platform) sends no header at all — the fail-closed default.
|
||||||
|
func TestXPlatformInjection(t *testing.T) {
|
||||||
|
var gotPlatform string
|
||||||
|
var hadHeader bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPlatform = r.Header.Get("X-Platform")
|
||||||
|
_, hadHeader = r.Header["X-Platform"]
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backendclient: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Close() }()
|
||||||
|
|
||||||
|
t.Run("trusted forwards header", func(t *testing.T) {
|
||||||
|
ctx := backendclient.WithPlatform(context.Background(), "vk/ios")
|
||||||
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if gotPlatform != "vk/ios" {
|
||||||
|
t.Fatalf("X-Platform = %q, want vk/ios", gotPlatform)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrusted omits header", func(t *testing.T) {
|
||||||
|
hadHeader = true // ensure the handler actually clears it
|
||||||
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if hadHeader {
|
||||||
|
t.Fatal("X-Platform must be absent for an untrusted context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
|
|
||||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
|
uid, isGuest, platform, err := s.resolve(ctx, req.Header(), clientIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = "unauthenticated"
|
result = "unauthenticated"
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -297,6 +297,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
|
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
|
||||||
}
|
}
|
||||||
tr.UserID = uid
|
tr.UserID = uid
|
||||||
|
// Carry the resolved trusted platform on the context so the backend client injects
|
||||||
|
// X-Platform on every downstream REST call for this request. Empty for an untrusted
|
||||||
|
// session ⇒ no header ⇒ the backend treats the request as untrusted (view-only).
|
||||||
|
ctx = backendclient.WithPlatform(ctx, platform)
|
||||||
} else {
|
} else {
|
||||||
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
||||||
result = "rate_limited"
|
result = "rate_limited"
|
||||||
@@ -331,7 +335,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||||
uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -494,7 +498,7 @@ func (s *Server) dictBytesHandler() http.Handler {
|
|||||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, _, err := s.resolve(r.Context(), r.Header, ip)
|
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -552,7 +556,7 @@ func (s *Server) localEvalMetricsHandler() http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := peerIP(r.RemoteAddr, r.Header)
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
|
if _, _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -659,10 +663,10 @@ func truncate(s string, n int) string {
|
|||||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// or unknown.
|
||||||
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) {
|
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, string, error) {
|
||||||
token := bearerToken(h.Get("Authorization"))
|
token := bearerToken(h.Get("Authorization"))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
||||||
}
|
}
|
||||||
// The honeytoken is a planted value no real client holds: presenting it is a
|
// The honeytoken is a planted value no real client holds: presenting it is a
|
||||||
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
|
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
|
||||||
@@ -672,9 +676,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
|
|||||||
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
|
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
|
||||||
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
|
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
|
||||||
}
|
}
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||||
}
|
}
|
||||||
uid, isGuest, err := s.sessions.Resolve(ctx, token)
|
uid, isGuest, platform, err := s.sessions.Resolve(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||||
// stays silent; anything else — a resolve timeout, a refused connection, a
|
// stays silent; anything else — a resolve timeout, a refused connection, a
|
||||||
@@ -685,9 +689,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
|
|||||||
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
||||||
s.log.Warn("session resolve failed", zap.Error(err))
|
s.log.Warn("session resolve failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||||
}
|
}
|
||||||
return uid, isGuest, nil
|
return uid, isGuest, platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolver resolves a token to an account id and its guest flag at the backend
|
// Resolver resolves a token to an account id, its guest flag and its trusted
|
||||||
// (the cache miss path). backendclient.Client satisfies it.
|
// platform ("<kind>/<subtype>", empty when untrusted) at the backend (the cache
|
||||||
|
// miss path). backendclient.Client satisfies it.
|
||||||
type Resolver interface {
|
type Resolver interface {
|
||||||
ResolveSession(ctx context.Context, token string) (string, bool, error)
|
ResolveSession(ctx context.Context, token string) (string, bool, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache resolves session tokens to account ids, caching hits for ttl.
|
// Cache resolves session tokens to account ids, caching hits for ttl.
|
||||||
@@ -29,9 +30,10 @@ type Cache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
userID string
|
userID string
|
||||||
isGuest bool
|
isGuest bool
|
||||||
expires time.Time
|
platform string
|
||||||
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache constructs a Cache over backend with the given TTL and maximum size.
|
// NewCache constructs a Cache over backend with the given TTL and maximum size.
|
||||||
@@ -48,19 +50,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns the account id and guest flag for token, consulting the cache
|
// Resolve returns the account id, guest flag and trusted platform for token,
|
||||||
// first and the backend on a miss (caching the result). An empty token is rejected
|
// consulting the cache first and the backend on a miss (caching the result). An
|
||||||
// by the backend like any unknown token.
|
// empty token is rejected by the backend like any unknown token.
|
||||||
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
|
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, string, error) {
|
||||||
if uid, guest, ok := c.lookup(token); ok {
|
if uid, guest, platform, ok := c.lookup(token); ok {
|
||||||
return uid, guest, nil
|
return uid, guest, platform, nil
|
||||||
}
|
}
|
||||||
uid, guest, err := c.backend.ResolveSession(ctx, token)
|
uid, guest, platform, err := c.backend.ResolveSession(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, "", err
|
||||||
}
|
}
|
||||||
c.store(token, uid, guest)
|
c.store(token, uid, guest, platform)
|
||||||
return uid, guest, nil
|
return uid, guest, platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate drops a token from the cache (e.g. after a revoke).
|
// Invalidate drops a token from the cache (e.g. after a revoke).
|
||||||
@@ -70,26 +72,26 @@ func (c *Cache) Invalidate(token string) {
|
|||||||
delete(c.entries, token)
|
delete(c.entries, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup returns a live cached account id and guest flag for token.
|
// lookup returns a live cached account id, guest flag and platform for token.
|
||||||
func (c *Cache) lookup(token string) (string, bool, bool) {
|
func (c *Cache) lookup(token string) (string, bool, string, bool) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
e, ok := c.entries[token]
|
e, ok := c.entries[token]
|
||||||
if !ok || !c.now().Before(e.expires) {
|
if !ok || !c.now().Before(e.expires) {
|
||||||
return "", false, false
|
return "", false, "", false
|
||||||
}
|
}
|
||||||
return e.userID, e.isGuest, true
|
return e.userID, e.isGuest, e.platform, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
|
// store caches token -> (userID, isGuest, platform), sweeping expired entries and
|
||||||
// the size.
|
// bounding the size.
|
||||||
func (c *Cache) store(token, userID string, isGuest bool) {
|
func (c *Cache) store(token, userID string, isGuest bool, platform string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if len(c.entries) >= c.max {
|
if len(c.entries) >= c.max {
|
||||||
c.evictLocked()
|
c.evictLocked()
|
||||||
}
|
}
|
||||||
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
|
c.entries[token] = entry{userID: userID, isGuest: isGuest, platform: platform, expires: c.now().Add(c.ttl)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
|
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeResolver struct {
|
type fakeResolver struct {
|
||||||
uid string
|
uid string
|
||||||
guest bool
|
guest bool
|
||||||
err error
|
platform string
|
||||||
calls int
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
|
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, string, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return "", false, f.err
|
return "", false, "", f.err
|
||||||
}
|
}
|
||||||
return f.uid, f.guest, nil
|
return f.uid, f.guest, f.platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCachesBackendHit(t *testing.T) {
|
func TestResolveCachesBackendHit(t *testing.T) {
|
||||||
@@ -27,7 +28,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
|||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
uid, _, err := c.Resolve(context.Background(), "tok")
|
uid, _, _, err := c.Resolve(context.Background(), "tok")
|
||||||
if err != nil || uid != "user-1" {
|
if err != nil || uid != "user-1" {
|
||||||
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
||||||
}
|
}
|
||||||
@@ -37,24 +38,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
|
func TestResolveCarriesAndCachesGuestFlagAndPlatform(t *testing.T) {
|
||||||
r := &fakeResolver{uid: "guest-1", guest: true}
|
r := &fakeResolver{uid: "guest-1", guest: true, platform: "vk/ios"}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
uid, guest, err := c.Resolve(context.Background(), "tok")
|
uid, guest, platform, err := c.Resolve(context.Background(), "tok")
|
||||||
if err != nil || uid != "guest-1" || !guest {
|
if err != nil || uid != "guest-1" || !guest || platform != "vk/ios" {
|
||||||
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
|
t.Fatalf("resolve #%d = (%q, guest=%v, platform=%q, %v)", i, uid, guest, platform, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.calls != 1 {
|
if r.calls != 1 {
|
||||||
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
|
t.Fatalf("backend calls = %d, want 1 (guest flag + platform cached)", r.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolvePropagatesBackendError(t *testing.T) {
|
func TestResolvePropagatesBackendError(t *testing.T) {
|
||||||
r := &fakeResolver{err: errors.New("nope")}
|
r := &fakeResolver{err: errors.New("nope")}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
|
||||||
t.Fatal("expected backend error to propagate")
|
t.Fatal("expected backend error to propagate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,11 +66,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
|||||||
base := time.Now()
|
base := time.Now()
|
||||||
c.now = func() time.Time { return base }
|
c.now = func() time.Time { return base }
|
||||||
|
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
|
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if r.calls != 2 {
|
if r.calls != 2 {
|
||||||
@@ -80,9 +81,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
|||||||
func TestInvalidateForcesReResolve(t *testing.T) {
|
func TestInvalidateForcesReResolve(t *testing.T) {
|
||||||
r := &fakeResolver{uid: "user-1"}
|
r := &fakeResolver{uid: "user-1"}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||||
c.Invalidate("tok")
|
c.Invalidate("tok")
|
||||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||||
if r.calls != 2 {
|
if r.calls != 2 {
|
||||||
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
|
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
|||||||
if q, perr := url.ParseQuery(initData); perr == nil {
|
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||||
startParam = q.Get("start_param")
|
startParam = q.Get("start_param")
|
||||||
}
|
}
|
||||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam, string(in.Subtype()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()), user.Subtype())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -238,11 +238,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
|||||||
// The guest bootstrap historically carried no payload; the detected zone is
|
// The guest bootstrap historically carried no payload; the detected zone is
|
||||||
// optional, so an absent or empty one simply yields no time-zone seed (rather
|
// optional, so an absent or empty one simply yields no time-zone seed (rather
|
||||||
// than panicking in GetRootAs* on a zero-length buffer).
|
// than panicking in GetRootAs* on a zero-length buffer).
|
||||||
var browserTz string
|
var browserTz, subtype string
|
||||||
if len(req.Payload) > 0 {
|
if len(req.Payload) > 0 {
|
||||||
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
|
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
|
||||||
|
browserTz = string(g.BrowserTz())
|
||||||
|
subtype = string(g.Subtype())
|
||||||
}
|
}
|
||||||
sess, err := backend.GuestAuth(ctx, browserTz)
|
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -263,7 +265,7 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
|||||||
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
|
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
|
||||||
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
|
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()), string(in.Subtype()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,28 @@ var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
|||||||
|
|
||||||
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||||
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||||
// seeds a brand-new account's preferred language.
|
// seeds a brand-new account's preferred language; Platform is the raw signed
|
||||||
|
// vk_platform value (e.g. "mobile_iphone", "desktop_web"), from which Subtype derives
|
||||||
|
// the trusted device family.
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Language string
|
Language string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtype maps the signed vk_platform to the trusted device family recorded on the
|
||||||
|
// session — ios (iPhone/iPad, the store-frozen case), android, or web (desktop/mobile
|
||||||
|
// web and anything unrecognised). Because vk_platform rides inside the verified
|
||||||
|
// signature, this subtype is trustworthy, unlike a client-reported one.
|
||||||
|
func (i Identity) Subtype() string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(i.Platform, "android"):
|
||||||
|
return "android"
|
||||||
|
case strings.Contains(i.Platform, "iphone"), strings.Contains(i.Platform, "ipad"):
|
||||||
|
return "ios"
|
||||||
|
default:
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||||
@@ -68,5 +86,5 @@ func Verify(params, secret string) (Identity, error) {
|
|||||||
if externalID == "" {
|
if externalID == "" {
|
||||||
return Identity{}, ErrInvalid
|
return Identity{}, ErrInvalid
|
||||||
}
|
}
|
||||||
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
return Identity{ExternalID: externalID, Language: values.Get("vk_language"), Platform: values.Get("vk_platform")}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ func TestVerifyValid(t *testing.T) {
|
|||||||
if id.ExternalID != "494075" || id.Language != "ru" {
|
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||||
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||||
}
|
}
|
||||||
|
if id.Platform != "android" || id.Subtype() != "android" {
|
||||||
|
t.Fatalf("platform = %q subtype = %q, want android/android", id.Platform, id.Subtype())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubtype locks the vk_platform -> device-family mapping (the trusted subtype),
|
||||||
|
// notably that iPhone and iPad both surface as the store-frozen "ios" and that any
|
||||||
|
// unrecognised or empty value falls back to web.
|
||||||
|
func TestSubtype(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
platform string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"mobile_iphone", "ios"},
|
||||||
|
{"mobile_ipad", "ios"},
|
||||||
|
{"mobile_iphone_messenger", "ios"},
|
||||||
|
{"mobile_android", "android"},
|
||||||
|
{"android", "android"},
|
||||||
|
{"desktop_web", "web"},
|
||||||
|
{"mobile_web", "web"},
|
||||||
|
{"", "web"},
|
||||||
|
{"something_new", "web"},
|
||||||
|
} {
|
||||||
|
if got := (vkauth.Identity{Platform: tc.platform}).Subtype(); got != tc.want {
|
||||||
|
t.Errorf("Subtype(%q) = %q, want %q", tc.platform, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ table MoveRecord {
|
|||||||
table TelegramLoginRequest {
|
table TelegramLoginRequest {
|
||||||
init_data:string;
|
init_data:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
|
// Client-reported device family (ios/android/web). Telegram's initData does not sign it, so
|
||||||
|
// the server records it best-effort and the payments gate never relies on it.
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||||
@@ -128,6 +131,9 @@ table VKLoginRequest {
|
|||||||
table GuestLoginRequest {
|
table GuestLoginRequest {
|
||||||
locale:string;
|
locale:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
|
// Client-reported device family (ios/android/web) of this direct session; best-effort
|
||||||
|
// (a direct session has no external signer).
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
||||||
@@ -150,6 +156,8 @@ table EmailRequestRequest {
|
|||||||
table EmailLoginRequest {
|
table EmailLoginRequest {
|
||||||
email:string;
|
email:string;
|
||||||
code:string;
|
code:string;
|
||||||
|
// Client-reported device family (ios/android/web) of this direct session; best-effort.
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
|
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *EmailLoginRequest) Code() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *EmailLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func EmailLoginRequestStart(builder *flatbuffers.Builder) {
|
func EmailLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
||||||
@@ -66,6 +74,9 @@ func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.U
|
|||||||
func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
|
||||||
}
|
}
|
||||||
|
func EmailLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *GuestLoginRequest) BrowserTz() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GuestLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
|
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
|
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
|
||||||
@@ -66,6 +74,9 @@ func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers
|
|||||||
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
}
|
}
|
||||||
|
func GuestLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *TelegramLoginRequest) BrowserTz() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *TelegramLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
|
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
|
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
|
||||||
@@ -66,6 +74,9 @@ func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flat
|
|||||||
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
}
|
}
|
||||||
|
func TelegramLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ code(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startEmailLoginRequest(builder:flatbuffers.Builder) {
|
static startEmailLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
|||||||
builder.addFieldOffset(1, codeOffset, 0);
|
builder.addFieldOffset(1, codeOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
EmailLoginRequest.startEmailLoginRequest(builder);
|
EmailLoginRequest.startEmailLoginRequest(builder);
|
||||||
EmailLoginRequest.addEmail(builder, emailOffset);
|
EmailLoginRequest.addEmail(builder, emailOffset);
|
||||||
EmailLoginRequest.addCode(builder, codeOffset);
|
EmailLoginRequest.addCode(builder, codeOffset);
|
||||||
|
EmailLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return EmailLoginRequest.endEmailLoginRequest(builder);
|
return EmailLoginRequest.endEmailLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startGuestLoginRequest(builder:flatbuffers.Builder) {
|
static startGuestLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
|
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
|
|||||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
GuestLoginRequest.startGuestLoginRequest(builder);
|
GuestLoginRequest.startGuestLoginRequest(builder);
|
||||||
GuestLoginRequest.addLocale(builder, localeOffset);
|
GuestLoginRequest.addLocale(builder, localeOffset);
|
||||||
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
|
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
GuestLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return GuestLoginRequest.endGuestLoginRequest(builder);
|
return GuestLoginRequest.endGuestLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
|
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
|
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
|
|||||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
TelegramLoginRequest.startTelegramLoginRequest(builder);
|
TelegramLoginRequest.startTelegramLoginRequest(builder);
|
||||||
TelegramLoginRequest.addInitData(builder, initDataOffset);
|
TelegramLoginRequest.addInitData(builder, initDataOffset);
|
||||||
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
|
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
TelegramLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return TelegramLoginRequest.endTelegramLoginRequest(builder);
|
return TelegramLoginRequest.endTelegramLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,16 +110,18 @@ describe('codec', () => {
|
|||||||
|
|
||||||
it('carries the detected browser zone on every account-creating auth request', () => {
|
it('carries the detected browser zone on every account-creating auth request', () => {
|
||||||
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
|
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
|
||||||
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
|
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00', 'ios')),
|
||||||
);
|
);
|
||||||
expect(tg.initData()).toBe('init-data-blob');
|
expect(tg.initData()).toBe('init-data-blob');
|
||||||
expect(tg.browserTz()).toBe('+03:00');
|
expect(tg.browserTz()).toBe('+03:00');
|
||||||
|
expect(tg.subtype()).toBe('ios');
|
||||||
|
|
||||||
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
|
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
|
||||||
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
|
new ByteBuffer(encodeGuestLogin('ru', '-05:30', 'android')),
|
||||||
);
|
);
|
||||||
expect(guest.locale()).toBe('ru');
|
expect(guest.locale()).toBe('ru');
|
||||||
expect(guest.browserTz()).toBe('-05:30');
|
expect(guest.browserTz()).toBe('-05:30');
|
||||||
|
expect(guest.subtype()).toBe('android');
|
||||||
|
|
||||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
||||||
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
|
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
|
||||||
|
|||||||
+17
-3
@@ -182,13 +182,19 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
|
|||||||
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
|
export function encodeTelegramLogin(
|
||||||
|
initData: string,
|
||||||
|
browserTz: string,
|
||||||
|
subtype: string,
|
||||||
|
): Uint8Array {
|
||||||
const b = new Builder(512);
|
const b = new Builder(512);
|
||||||
const d = b.createString(initData);
|
const d = b.createString(initData);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
||||||
fb.TelegramLoginRequest.addInitData(b, d);
|
fb.TelegramLoginRequest.addInitData(b, d);
|
||||||
fb.TelegramLoginRequest.addBrowserTz(b, tz);
|
fb.TelegramLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.TelegramLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,13 +210,19 @@ export function encodeVKLogin(params: string, browserTz: string, displayName: st
|
|||||||
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
export function encodeGuestLogin(
|
||||||
|
locale: string,
|
||||||
|
browserTz: string,
|
||||||
|
subtype: string,
|
||||||
|
): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const l = b.createString(locale);
|
const l = b.createString(locale);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.GuestLoginRequest.startGuestLoginRequest(b);
|
fb.GuestLoginRequest.startGuestLoginRequest(b);
|
||||||
fb.GuestLoginRequest.addLocale(b, l);
|
fb.GuestLoginRequest.addLocale(b, l);
|
||||||
fb.GuestLoginRequest.addBrowserTz(b, tz);
|
fb.GuestLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.GuestLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +244,15 @@ export function encodeEmailRequest(
|
|||||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeEmailLogin(email: string, code: string): Uint8Array {
|
export function encodeEmailLogin(email: string, code: string, subtype: string): Uint8Array {
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const e = b.createString(email);
|
const e = b.createString(email);
|
||||||
const c = b.createString(code);
|
const c = b.createString(code);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.EmailLoginRequest.startEmailLoginRequest(b);
|
fb.EmailLoginRequest.startEmailLoginRequest(b);
|
||||||
fb.EmailLoginRequest.addEmail(b, e);
|
fb.EmailLoginRequest.addEmail(b, e);
|
||||||
fb.EmailLoginRequest.addCode(b, c);
|
fb.EmailLoginRequest.addCode(b, c);
|
||||||
|
fb.EmailLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeSubtype } from './platform';
|
||||||
|
|
||||||
|
describe('normalizeSubtype', () => {
|
||||||
|
it('maps iPhone and iPad variants to the store-frozen ios', () => {
|
||||||
|
expect(normalizeSubtype('ios')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('iphone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('mobile_iphone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('mobile_ipad')).toBe('ios');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps android variants to android', () => {
|
||||||
|
expect(normalizeSubtype('android')).toBe('android');
|
||||||
|
expect(normalizeSubtype('mobile_android')).toBe('android');
|
||||||
|
expect(normalizeSubtype('android_x')).toBe('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(normalizeSubtype('IPhone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('Android')).toBe('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults desktop, tdesktop, web, empty and unknown values to web', () => {
|
||||||
|
expect(normalizeSubtype('web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('mobile_web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('desktop_web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('tdesktop')).toBe('web');
|
||||||
|
expect(normalizeSubtype('')).toBe('web');
|
||||||
|
expect(normalizeSubtype('something_new')).toBe('web');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Platform subtype (device family) derivation for the trusted platform signal. The
|
||||||
|
// server records the wrapper kind (vk / telegram / direct) itself, from the validated
|
||||||
|
// establish path; the client supplies only this device subtype. For VK it is ignored
|
||||||
|
// server-side (the gateway derives a trusted subtype from the signed vk_platform); for
|
||||||
|
// Telegram and a direct (web/native) session it is client-reported and best-effort.
|
||||||
|
|
||||||
|
import { insideTelegram, telegramPlatform } from './telegram';
|
||||||
|
import { clientChannel } from './channel';
|
||||||
|
|
||||||
|
export type Subtype = 'ios' | 'android' | 'web';
|
||||||
|
|
||||||
|
// normalizeSubtype coerces a raw device string (Telegram's WebApp.platform, a
|
||||||
|
// Capacitor platform, VK's vk_platform, …) to the ios/android/web wire subtype,
|
||||||
|
// mapping any iPhone/iPad variant to ios and defaulting anything unrecognised —
|
||||||
|
// desktop, tdesktop, an empty value — to web.
|
||||||
|
export function normalizeSubtype(raw: string): Subtype {
|
||||||
|
const s = raw.toLowerCase();
|
||||||
|
if (s.includes('android')) return 'android';
|
||||||
|
if (s.includes('iphone') || s.includes('ipad') || s === 'ios') return 'ios';
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformSubtype reports this client's best-effort device family for the current
|
||||||
|
// launch: Telegram's reported platform inside a Mini App, otherwise the Capacitor /
|
||||||
|
// web channel for a direct session. VK does not use it (server-derived from the signed
|
||||||
|
// launch params).
|
||||||
|
export function platformSubtype(): Subtype {
|
||||||
|
if (insideTelegram()) return normalizeSubtype(telegramPlatform());
|
||||||
|
return normalizeSubtype(clientChannel());
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Gateway } from '../gen/edge/v1/edge_pb';
|
|||||||
import { GatewayError, type GatewayClient } from './client';
|
import { GatewayError, type GatewayClient } from './client';
|
||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
|
import { platformSubtype } from './platform';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { offlineMode } from './offline.svelte';
|
import { offlineMode } from './offline.svelte';
|
||||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
@@ -124,13 +125,13 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async authTelegram(initData) {
|
async authTelegram(initData) {
|
||||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset(), platformSubtype())));
|
||||||
},
|
},
|
||||||
async authVK(params, displayName) {
|
async authVK(params, displayName) {
|
||||||
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
||||||
},
|
},
|
||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype())));
|
||||||
},
|
},
|
||||||
async authEmailRequest(email, language, pwa) {
|
async authEmailRequest(email, language, pwa) {
|
||||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
|
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
|
||||||
@@ -139,7 +140,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
||||||
},
|
},
|
||||||
async authEmailLogin(email, code) {
|
async authEmailLogin(email, code) {
|
||||||
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code, platformSubtype())));
|
||||||
},
|
},
|
||||||
|
|
||||||
async profileGet() {
|
async profileGet() {
|
||||||
|
|||||||
Reference in New Issue
Block a user