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() } }