feat(vk): embed the game as a VK Mini App
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/ entry, authenticates from VK's signed launch parameters, and provisions a 'vk' platform identity — the minimum to run the game in VK test mode. - Gateway verifies the launch signature in-process (internal/vkauth: HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET, base64url) — a pure offline check, no side-service. New auth.vk op (gated on the secret), backendclient.VKAuth, /vk/ SPA mount. - Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity kind widened to include 'vk' (migration 00005, expand-contract). - UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits the name from the signed params, so the client reads it via VKWebAppGetUserInfo as an unsigned display seed. - Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through compose + .env.example + CI (TEST_) + prod-deploy (PROD_). - Admin console: surface the VK user id (link to the VK profile) next to the Telegram id on the user card. - Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK integration reference under .claude/. Signature algorithm verified against dev.vk.com plus independent Node/Python references and a %2C edge-case vector.
This commit is contained in:
+11
-3
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
@@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
|
||||
```
|
||||
|
||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
|
||||
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
||||
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
||||
|
||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
||||
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
|
||||
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
|
||||
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
|
||||
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
|
||||
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||
(`auth.vk` is unregistered).
|
||||
|
||||
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||
live events
|
||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||
|
||||
@@ -190,7 +190,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||
}
|
||||
|
||||
registry := transcode.NewRegistry(backend, validator)
|
||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
|
||||
@@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
return out, err
|
||||
}
|
||||
|
||||
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
||||
// account's preferred language from languageCode (the vk_language hint), its display
|
||||
// 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) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||
map[string]string{
|
||||
"external_id": externalID,
|
||||
"language_code": languageCode,
|
||||
"display_name": displayName,
|
||||
"browser_tz": browserTz,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
||||
// external_id (empty when they have no Telegram identity), preferred language, and
|
||||
// whether they confined notifications to the in-app stream.
|
||||
|
||||
@@ -32,6 +32,10 @@ type Config struct {
|
||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||
// Login Widget data. Empty disables the telegram auth path.
|
||||
ValidatorAddr string
|
||||
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
|
||||
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||
VKAppSecret string
|
||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||
BotLink BotLinkConfig
|
||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
|
||||
@@ -183,14 +183,15 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
||||
// public landing lives in its own static container behind the contour caddy,
|
||||
// so the catch-all redirects a stray root hit to the app shell — which
|
||||
// keeps a local no-caddy run usable.
|
||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||
// router. The public landing lives in its own static container behind the contour
|
||||
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||
// local no-caddy run usable.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Message types in the vertical slice.
|
||||
const (
|
||||
MsgAuthTelegram = "auth.telegram"
|
||||
MsgAuthVK = "auth.vk"
|
||||
MsgAuthGuest = "auth.guest"
|
||||
MsgAuthEmailReq = "auth.email.request"
|
||||
MsgAuthEmailLogin = "auth.email.login"
|
||||
@@ -89,8 +91,9 @@ type TelegramValidator interface {
|
||||
|
||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||
// The Telegram auth op is registered only when a validator is supplied (the
|
||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
||||
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||
// (e.g. WithVKAuth) are applied last from opts.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
|
||||
r := &Registry{ops: make(map[string]Op)}
|
||||
if tg != nil {
|
||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||
@@ -126,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||
registerSocialOps(r, backend)
|
||||
registerLinkOps(r, backend, tg)
|
||||
for _, opt := range opts {
|
||||
opt(r, backend)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Option configures an optional registry operation at construction. It is kept out of
|
||||
// NewRegistry's positional signature so existing call sites stay unaffected.
|
||||
type Option func(r *Registry, backend *backendclient.Client)
|
||||
|
||||
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
|
||||
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
|
||||
// the op is simply unknown wherever VK is not configured.
|
||||
func WithVKAuth(secret string) Option {
|
||||
return func(r *Registry, backend *backendclient.Client) {
|
||||
if secret != "" {
|
||||
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||
return "invalid_init_data", true
|
||||
}
|
||||
if errors.Is(err, vkauth.ErrInvalid) {
|
||||
return "invalid_vk_params", true
|
||||
}
|
||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||
return "invalid_login_widget", true
|
||||
}
|
||||
@@ -175,6 +199,25 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
||||
}
|
||||
}
|
||||
|
||||
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
|
||||
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
|
||||
// VK omits the user's name from the signed params, so the client-supplied display_name
|
||||
// rides the wire as a cosmetic seed for a brand-new account.
|
||||
func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
|
||||
user, err := vkauth.Verify(string(in.Params()), secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
// The guest bootstrap historically carried no payload; the detected zone is
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/transcode"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
const (
|
||||
vkTestSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
|
||||
// vkTestSecret, computed independently (a Python reference) — so a green test
|
||||
// exercises VK's real algorithm end to end, not a self-consistent fake.
|
||||
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// vkParams is the canonical VK launch-parameter set (without the sign) that
|
||||
// vkGoldenSign covers.
|
||||
func vkParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func vkLoginPayload(params, browserTz, displayName string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
p := b.CreateString(params)
|
||||
tz := b.CreateString(browserTz)
|
||||
dn := b.CreateString(displayName)
|
||||
fb.VKLoginRequestStart(b)
|
||||
fb.VKLoginRequestAddParams(b, p)
|
||||
fb.VKLoginRequestAddBrowserTz(b, tz)
|
||||
fb.VKLoginRequestAddDisplayName(b, dn)
|
||||
b.Finish(fb.VKLoginRequestEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func TestVKAuthForwardsSeedFields(t *testing.T) {
|
||||
var gotBody map[string]string
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/internal/sessions/vk" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, ok := reg.Lookup(transcode.MsgAuthVK)
|
||||
if !ok {
|
||||
t.Fatal("auth.vk not registered")
|
||||
}
|
||||
|
||||
signed := vkParams()
|
||||
signed.Set("sign", vkGoldenSign)
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
sess := fb.GetRootAsSession(payload, 0)
|
||||
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
|
||||
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
|
||||
}
|
||||
// The verified vk_user_id and vk_language plus the client-supplied display name are
|
||||
// forwarded so the backend can seed a brand-new account.
|
||||
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
|
||||
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
|
||||
// and the backend is never called.
|
||||
func TestVKAuthInvalidSign(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("backend must not be called when the sign is invalid")
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, _ := reg.Lookup(transcode.MsgAuthVK)
|
||||
|
||||
tampered := vkParams()
|
||||
tampered.Set("sign", "deadbeef")
|
||||
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
|
||||
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
|
||||
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
|
||||
// unregistered.
|
||||
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||
defer cleanup()
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
|
||||
t.Error("auth.vk should be unregistered without a VK app secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
|
||||
// launch query string with the app's protected ("secure") key; the gateway holds that
|
||||
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
|
||||
// side-service, because the check is a pure offline HMAC with no VK API round-trip
|
||||
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
|
||||
// token). See docs/ARCHITECTURE.md §12.
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalid is returned when launch params fail signature verification, are
|
||||
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
|
||||
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.
|
||||
type Identity struct {
|
||||
ExternalID string
|
||||
Language string
|
||||
}
|
||||
|
||||
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||
// returns the launching user's identity. Per VK's documented algorithm the signature
|
||||
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
|
||||
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
|
||||
// the constrained launch-parameter charset) — under the app secret, then base64url
|
||||
// without padding; the comparison is constant-time.
|
||||
//
|
||||
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
|
||||
// is deliberately not enforced here: the gateway mints its own short-lived session, and
|
||||
// a replay only re-authenticates the same vk_user_id.
|
||||
func Verify(params, secret string) (Identity, error) {
|
||||
values, err := url.ParseQuery(params)
|
||||
if err != nil {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
sign := values.Get("sign")
|
||||
if sign == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
// Only vk_*-prefixed parameters are signed; any other query parameter the client
|
||||
// appended is outside the signature and must be excluded from the check.
|
||||
signed := url.Values{}
|
||||
for k, v := range values {
|
||||
if strings.HasPrefix(k, "vk_") {
|
||||
signed[k] = v
|
||||
}
|
||||
}
|
||||
if len(signed) == 0 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signed.Encode()))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
externalID := values.Get("vk_user_id")
|
||||
if externalID == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package vkauth_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
|
||||
// padding, computed independently from a Python reference over goldenParams — so a
|
||||
// green test proves Verify matches VK's documented algorithm, not merely itself.
|
||||
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// goldenParams is the canonical VK launch-parameter set the golden signature covers
|
||||
// (a representative real launch, including the empty vk_access_token_settings).
|
||||
func goldenParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func signedParams() url.Values {
|
||||
p := goldenParams()
|
||||
p.Set("sign", goldenSign)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestVerifyValid(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
|
||||
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
|
||||
// independently (Node crypto) over these params under testSecret — so a green test
|
||||
// proves Go's encoding matches VK's for that character.
|
||||
func TestVerifyCommaValue(t *testing.T) {
|
||||
p := url.Values{
|
||||
"vk_access_token_settings": {"email,phone"},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"mobile_web"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
|
||||
}
|
||||
id, err := vkauth.Verify(p.Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
|
||||
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
|
||||
func TestVerifyIgnoresForeignParams(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
secret string
|
||||
}{
|
||||
{name: "tampered param", secret: testSecret, raw: func() string {
|
||||
p := signedParams()
|
||||
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
|
||||
return p.Encode()
|
||||
}()},
|
||||
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
|
||||
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
|
||||
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
|
||||
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
|
||||
t.Fatalf("Verify error = %v, want ErrInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user