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:
Thomas Hallock
2025-10-09 08:08:02 -05:00
parent 0543377bda
commit 71b0aac13c
5 changed files with 90 additions and 11 deletions

View File

@@ -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 && (

View File

@@ -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(

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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)