fix: enforce player ownership authorization for multiplayer games
Add comprehensive authorization checks to prevent room members from moving opponents' players in multiplayer games like matching pairs. Server-side validation: - Add ValidationContext interface with userId and playerOwnership map - Update GameValidator interface to accept optional context parameter - Modify MatchingGameValidator.validateFlipCard to check player ownership - Update session-manager.applyGameMove to fetch player ownership from DB and pass it to validator - Reject moves with error "You can only move your own players" if user tries to move opponent's player Client-side authorization: - Update ArcadeMemoryPairsContext.canFlipCard to check if current player is local (owned by current user) - Prevent clicking/flipping cards when it's a network player's turn - Log helpful console messages when authorization fails UI improvements: - Update PlayerStatusBar to distinguish local vs network players - Show "Your turn" (red, glowing) when it's your player's turn - Show "Their turn" (blue, pulsing) when it's opponent's player's turn - Add isLocalPlayer property to player display data This fixes the security issue where any room member could move for any player, regardless of ownership. Now moves are properly authorized at both client and server levels, and the UI clearly indicates whose turn it is. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,13 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer)
|
||||
const isYourTurn = currentPlayer?.isLocalPlayer === true
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
@@ -250,14 +255,16 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
color: player.isLocalPlayer ? 'red.600' : 'blue.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
animation: player.isLocalPlayer
|
||||
? 'none'
|
||||
: 'gentle-pulse 2s ease-in-out infinite',
|
||||
textShadow: player.isLocalPlayer ? '0 0 15px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
{player.isLocalPlayer ? ' • Your turn' : ' • Their turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
|
||||
@@ -146,6 +146,8 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const { players } = useGameMode()
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
@@ -159,9 +161,27 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
// If current player is not local (isLocal === false), prevent flipping
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log('[Client] Cannot flip - not your turn (current player is remote)')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards]
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
|
||||
@@ -159,8 +159,28 @@ export async function applyGameMove(userId: string, move: GameMove): Promise<Ses
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
})
|
||||
|
||||
// Validate the move
|
||||
const validationResult = validator.validateMove(session.gameState, move)
|
||||
// Fetch player ownership for authorization checks (room-based games)
|
||||
let playerOwnership: Record<string, string> | undefined
|
||||
if (session.roomId) {
|
||||
try {
|
||||
const players = await db.query.players.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
})
|
||||
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]))
|
||||
console.log('[SessionManager] Player ownership map:', playerOwnership)
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Failed to fetch player ownership:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the move with authorization context
|
||||
const validationResult = validator.validateMove(session.gameState, move, {
|
||||
userId,
|
||||
playerOwnership,
|
||||
})
|
||||
|
||||
console.log('[SessionManager] Validation result:', {
|
||||
valid: validationResult.valid,
|
||||
|
||||
@@ -15,10 +15,14 @@ import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchVali
|
||||
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
|
||||
|
||||
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
|
||||
validateMove(state: MemoryPairsState, move: MatchingGameMove): ValidationResult {
|
||||
validateMove(
|
||||
state: MemoryPairsState,
|
||||
move: MatchingGameMove,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'FLIP_CARD':
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId)
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
|
||||
@@ -37,7 +41,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
private validateFlipCard(
|
||||
state: MemoryPairsState,
|
||||
cardId: string,
|
||||
playerId: string
|
||||
playerId: string,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
// Game must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
@@ -63,6 +68,22 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
// Check player ownership authorization (if context provided)
|
||||
if (context?.userId && context?.playerOwnership) {
|
||||
const playerOwner = context.playerOwnership[playerId]
|
||||
if (playerOwner && playerOwner !== context.userId) {
|
||||
console.log('[Validator] Player ownership check failed:', {
|
||||
playerId,
|
||||
playerOwner,
|
||||
requestingUserId: context.userId,
|
||||
})
|
||||
return {
|
||||
valid: false,
|
||||
error: 'You can only move your own players',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the card
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card) {
|
||||
|
||||
@@ -49,14 +49,25 @@ export type MatchingGameMove =
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
|
||||
/**
|
||||
* Validation context for authorization checks
|
||||
*/
|
||||
export interface ValidationContext {
|
||||
userId?: string
|
||||
playerOwnership?: Record<string, string> // playerId -> userId mapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Base validator interface that all games must implement
|
||||
*/
|
||||
export interface GameValidator<TState = unknown, TMove extends GameMove = GameMove> {
|
||||
/**
|
||||
* Validate a game move and return the new state if valid
|
||||
* @param state Current game state
|
||||
* @param move The move to validate
|
||||
* @param context Optional validation context for authorization checks
|
||||
*/
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
|
||||
/**
|
||||
* Check if the game is in a terminal state (completed)
|
||||
|
||||
Reference in New Issue
Block a user