Files
scrabble-game/backend/internal/server/middleware.go
T
Ilia Denisov d1ba666495
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
feat(admin): manual account blocking (suspensions)
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
2026-06-14 21:55:59 +02:00

117 lines
3.9 KiB
Go

package server
import (
"context"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// 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
}
// 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()
}
}