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:
@@ -211,7 +211,7 @@ type ChatResp struct {
|
||||
// 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
|
||||
// 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
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{
|
||||
@@ -221,6 +221,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
"first_name": firstName,
|
||||
"browser_tz": browserTz,
|
||||
"start_param": startParam,
|
||||
"subtype": subtype,
|
||||
}, &out)
|
||||
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 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.
|
||||
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
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||
map[string]string{
|
||||
@@ -238,6 +239,7 @@ func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayNa
|
||||
"language_code": languageCode,
|
||||
"display_name": displayName,
|
||||
"browser_tz": browserTz,
|
||||
"subtype": subtype,
|
||||
}, &out)
|
||||
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
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -307,10 +309,10 @@ func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language st
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -331,16 +333,24 @@ func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLin
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
||||
// ResolveSession maps a token to its account id, guest flag, and trusted platform
|
||||
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
|
||||
// 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 {
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
PlatformKind string `json:"platform_kind"`
|
||||
PlatformSubtype string `json:"platform_subtype"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||
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.
|
||||
|
||||
@@ -118,8 +118,30 @@ func (e *APIError) Error() string {
|
||||
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;
|
||||
// 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.
|
||||
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
||||
var reader io.Reader
|
||||
@@ -141,6 +163,9 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if 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)
|
||||
if err != nil {
|
||||
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 != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
if p := platformFromContext(ctx); p != "" {
|
||||
req.Header.Set("X-Platform", p)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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}
|
||||
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 {
|
||||
result = "unauthenticated"
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
||||
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
|
||||
// heartbeat until the client disconnects.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -494,7 +498,7 @@ func (s *Server) dictBytesHandler() http.Handler {
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
uid, _, err := s.resolve(r.Context(), r.Header, ip)
|
||||
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -552,7 +556,7 @@ func (s *Server) localEvalMetricsHandler() http.Handler {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -659,10 +663,10 @@ func truncate(s string, n int) string {
|
||||
// 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
|
||||
// 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"))
|
||||
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
|
||||
// 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) {
|
||||
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 {
|
||||
// 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
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Resolver resolves a token to an account id and its guest flag at the backend
|
||||
// (the cache miss path). backendclient.Client satisfies it.
|
||||
// Resolver resolves a token to an account id, its guest flag and its trusted
|
||||
// platform ("<kind>/<subtype>", empty when untrusted) at the backend (the cache
|
||||
// miss path). backendclient.Client satisfies it.
|
||||
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.
|
||||
@@ -29,9 +30,10 @@ type Cache struct {
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
userID string
|
||||
isGuest bool
|
||||
expires time.Time
|
||||
userID string
|
||||
isGuest bool
|
||||
platform string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// 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
|
||||
// first and the backend on a miss (caching the result). An empty token is rejected
|
||||
// by the backend like any unknown token.
|
||||
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
|
||||
if uid, guest, ok := c.lookup(token); ok {
|
||||
return uid, guest, nil
|
||||
// Resolve returns the account id, guest flag and trusted platform for token,
|
||||
// consulting the cache first and the backend on a miss (caching the result). An
|
||||
// empty token is rejected by the backend like any unknown token.
|
||||
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, string, error) {
|
||||
if uid, guest, platform, ok := c.lookup(token); ok {
|
||||
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 {
|
||||
return "", false, err
|
||||
return "", false, "", err
|
||||
}
|
||||
c.store(token, uid, guest)
|
||||
return uid, guest, nil
|
||||
c.store(token, uid, guest, platform)
|
||||
return uid, guest, platform, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// lookup returns a live cached account id and guest flag for token.
|
||||
func (c *Cache) lookup(token string) (string, bool, bool) {
|
||||
// lookup returns a live cached account id, guest flag and platform for token.
|
||||
func (c *Cache) lookup(token string) (string, bool, string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[token]
|
||||
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
|
||||
// the size.
|
||||
func (c *Cache) store(token, userID string, isGuest bool) {
|
||||
// store caches token -> (userID, isGuest, platform), sweeping expired entries and
|
||||
// bounding the size.
|
||||
func (c *Cache) store(token, userID string, isGuest bool, platform string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.entries) >= c.max {
|
||||
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
|
||||
|
||||
@@ -8,18 +8,19 @@ import (
|
||||
)
|
||||
|
||||
type fakeResolver struct {
|
||||
uid string
|
||||
guest bool
|
||||
err error
|
||||
calls int
|
||||
uid string
|
||||
guest bool
|
||||
platform string
|
||||
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++
|
||||
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) {
|
||||
@@ -27,7 +28,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
|
||||
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" {
|
||||
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
||||
}
|
||||
@@ -37,24 +38,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
|
||||
r := &fakeResolver{uid: "guest-1", guest: true}
|
||||
func TestResolveCarriesAndCachesGuestFlagAndPlatform(t *testing.T) {
|
||||
r := &fakeResolver{uid: "guest-1", guest: true, platform: "vk/ios"}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
for i := 0; i < 2; i++ {
|
||||
uid, guest, err := c.Resolve(context.Background(), "tok")
|
||||
if err != nil || uid != "guest-1" || !guest {
|
||||
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
|
||||
uid, guest, platform, err := c.Resolve(context.Background(), "tok")
|
||||
if err != nil || uid != "guest-1" || !guest || platform != "vk/ios" {
|
||||
t.Fatalf("resolve #%d = (%q, guest=%v, platform=%q, %v)", i, uid, guest, platform, err)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
r := &fakeResolver{err: errors.New("nope")}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -65,11 +66,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
||||
base := time.Now()
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if r.calls != 2 {
|
||||
@@ -80,9 +81,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
||||
func TestInvalidateForcesReResolve(t *testing.T) {
|
||||
r := &fakeResolver{uid: "user-1"}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
||||
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||
c.Invalidate("tok")
|
||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
||||
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||
if r.calls != 2 {
|
||||
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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -238,11 +238,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
// 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
|
||||
// than panicking in GetRootAs* on a zero-length buffer).
|
||||
var browserTz string
|
||||
var browserTz, subtype string
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -263,7 +265,7 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
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 {
|
||||
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
|
||||
// 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 {
|
||||
ExternalID 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
|
||||
@@ -68,5 +86,5 @@ func Verify(params, secret string) (Identity, error) {
|
||||
if externalID == "" {
|
||||
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" {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user