92633f935e
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.
160 lines
5.7 KiB
Go
160 lines
5.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/session"
|
|
)
|
|
|
|
// headerUserID is the identity header the gateway injects after resolving a
|
|
// session to an internal account.
|
|
const headerUserID = "X-User-ID"
|
|
|
|
// contextKey is an unexported type for request-context keys set by this package.
|
|
type contextKey string
|
|
|
|
const userIDContextKey contextKey = "scrabble.user_id"
|
|
|
|
// RequireUserID returns middleware that requires a valid X-User-ID header and
|
|
// stores the parsed account id in the request context. Requests without a
|
|
// parseable UUID are rejected with 401. The backend treats X-User-ID as the
|
|
// sole identity input and never derives identity from the request body.
|
|
func RequireUserID() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
id, err := uuid.Parse(c.GetHeader(headerUserID))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid X-User-ID"})
|
|
return
|
|
}
|
|
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userIDContextKey, id))
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// UserIDFromContext returns the authenticated account id stored by
|
|
// RequireUserID, and whether it was present.
|
|
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
|
return id, ok
|
|
}
|
|
|
|
// headerPlatform is the trusted execution-platform header the gateway injects after
|
|
// resolving a session's platform. Its value is "<kind>/<subtype>" (e.g. "vk/ios");
|
|
// it is omitted for an untrusted session, in which case platform(c) reports the
|
|
// zero Platform. Like X-User-ID, the value is the gateway's — never a client body.
|
|
const headerPlatform = "X-Platform"
|
|
|
|
// platformContext returns middleware that parses the gateway-injected X-Platform
|
|
// header into the request context, so handlers (and the link/merge session mint)
|
|
// read the caller's trusted platform. An absent or malformed header leaves the
|
|
// context without a platform (untrusted) and never rejects the request — X-Platform
|
|
// is a capability signal, not an identity gate.
|
|
func platformContext() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if p, ok := parsePlatformHeader(c.GetHeader(headerPlatform)); ok {
|
|
c.Request = c.Request.WithContext(session.WithPlatform(c.Request.Context(), p))
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// parsePlatformHeader splits a "<kind>/<subtype>" X-Platform value into a Platform.
|
|
// A blank value or blank kind yields no platform (an untrusted session).
|
|
func parsePlatformHeader(h string) (session.Platform, bool) {
|
|
if h == "" {
|
|
return session.Platform{}, false
|
|
}
|
|
kind, subtype, _ := strings.Cut(h, "/")
|
|
if kind == "" {
|
|
return session.Platform{}, false
|
|
}
|
|
return session.Platform{Kind: kind, Subtype: subtype}, true
|
|
}
|
|
|
|
// platform returns the caller's trusted execution platform, or the zero Platform
|
|
// and false when the session was not attributed to one — an untrusted, view-only
|
|
// context for the payments gate.
|
|
func platform(c *gin.Context) (session.Platform, bool) {
|
|
return session.PlatformFromContext(c.Request.Context())
|
|
}
|
|
|
|
// requireSameOrigin guards the admin console's state-changing requests: it rejects
|
|
// a non-safe request whose Origin (or, failing that, Referer) host does not match
|
|
// the request Host. The gateway authenticates the operator with Basic-Auth in front
|
|
// of /_gm; this same-origin check is the console's CSRF defence, stopping a
|
|
// cross-site form POST from riding the cached credential. Safe methods pass through.
|
|
func requireSameOrigin() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
switch c.Request.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
c.Next()
|
|
return
|
|
}
|
|
if !sameOrigin(c.Request) {
|
|
c.AbortWithStatus(http.StatusForbidden)
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// sameOrigin reports whether the request's Origin (or, failing that, Referer) host
|
|
// matches the request Host. A state-changing request carrying neither header is
|
|
// rejected.
|
|
func sameOrigin(r *http.Request) bool {
|
|
for _, h := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
|
|
if h == "" {
|
|
continue
|
|
}
|
|
u, err := url.Parse(h)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Host == r.Host
|
|
}
|
|
return false
|
|
}
|
|
|
|
// codeAccountBlocked is the stable error code the suspension gate returns for a blocked account.
|
|
// It threads through the gateway unchanged as the Execute result_code, so the UI can detect the
|
|
// block from any call and switch to the terminal blocked screen.
|
|
const codeAccountBlocked = "account_blocked"
|
|
|
|
// blockStatusPath is the one /api/v1/user route exempt from the suspension gate: a blocked client
|
|
// must still reach it to fetch the block's expiry and reason for the blocked screen.
|
|
const blockStatusPath = "/api/v1/user/block-status"
|
|
|
|
// requireNotSuspended returns middleware that rejects a blocked account's requests with 403 and
|
|
// code "account_blocked", so the UI can detect an active block from any call. The block-status
|
|
// probe is exempt. It is a no-op when the account store is not wired. It runs after RequireUserID
|
|
// (which has already placed the account id in the context).
|
|
func (s *Server) requireNotSuspended() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if s.accounts == nil || c.FullPath() == blockStatusPath {
|
|
c.Next()
|
|
return
|
|
}
|
|
id, ok := userID(c)
|
|
if !ok {
|
|
c.Next() // RequireUserID runs first and has already rejected a missing id
|
|
return
|
|
}
|
|
_, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), id)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if blocked {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: codeAccountBlocked, Message: "account is blocked"}})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|