Frontend UX improvements: - Single-click Discord OAuth from home page (no intermediate /auth page) - Auto-redirect authenticated users from home to /games - Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout - Games page now has proper card container with shadow/border styling - Layout header includes working logout with API cookie clearing Games list enhancements: - Display team names (lname) instead of just team IDs - Show current score for each team - Show inning indicator (Top/Bot X) for active games - Responsive header with wrapped buttons on mobile Backend improvements: - Added team caching to SbaApiClient (1-hour TTL) - Enhanced GameListItem with team names, scores, inning data - Games endpoint now enriches response with SBA API team data Docker optimizations: - Optimized Dockerfile using --chown flag on COPY (faster than chown -R) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
6.9 KiB
Markdown
247 lines
6.9 KiB
Markdown
# Plan 001: WebSocket Authorization
|
|
|
|
**Priority**: CRITICAL
|
|
**Effort**: 4-6 hours
|
|
**Status**: NOT STARTED
|
|
**Risk Level**: HIGH - Security vulnerability
|
|
|
|
---
|
|
|
|
## Problem Statement
|
|
|
|
WebSocket handlers in `backend/app/websocket/handlers.py` have **11 TODO comments** indicating missing access control. Any connected user can:
|
|
- Join any game
|
|
- Submit decisions for teams they don't own
|
|
- View game states they shouldn't access
|
|
- Manipulate games in progress
|
|
|
|
## Impact
|
|
|
|
- **Security**: Unauthorized game access
|
|
- **Data Integrity**: Users can cheat by controlling opponent actions
|
|
- **Trust**: Players can't trust game outcomes
|
|
|
|
## Files to Modify
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `backend/app/websocket/handlers.py` | Add authorization checks to all handlers |
|
|
| `backend/app/websocket/connection_manager.py` | Track user-game associations |
|
|
| `backend/app/models/db_models.py` | May need game participant query |
|
|
| `backend/app/database/operations.py` | Add participant validation queries |
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1: Create Authorization Utility (30 min)
|
|
|
|
Create `backend/app/websocket/auth.py`:
|
|
|
|
```python
|
|
from uuid import UUID
|
|
from app.database.operations import db_ops
|
|
|
|
async def get_user_role_in_game(user_id: int, game_id: UUID) -> str | None:
|
|
"""
|
|
Returns user's role in game: 'home', 'away', 'spectator', or None if not authorized.
|
|
"""
|
|
game = await db_ops.get_game(game_id)
|
|
if not game:
|
|
return None
|
|
|
|
if game.home_user_id == user_id:
|
|
return "home"
|
|
elif game.away_user_id == user_id:
|
|
return "away"
|
|
elif game.allow_spectators:
|
|
return "spectator"
|
|
return None
|
|
|
|
async def require_game_participant(sid: str, game_id: UUID, required_role: str | None = None) -> bool:
|
|
"""
|
|
Validate user can access game. Emits error and returns False if unauthorized.
|
|
"""
|
|
user_id = await manager.get_user_id(sid)
|
|
role = await get_user_role_in_game(user_id, game_id)
|
|
|
|
if role is None:
|
|
await sio.emit("error", {"message": "Not authorized for this game"}, to=sid)
|
|
return False
|
|
|
|
if required_role and role != required_role:
|
|
await sio.emit("error", {"message": f"Requires {required_role} role"}, to=sid)
|
|
return False
|
|
|
|
return True
|
|
|
|
async def require_team_control(sid: str, game_id: UUID, team_id: int) -> bool:
|
|
"""
|
|
Validate user controls specified team.
|
|
"""
|
|
user_id = await manager.get_user_id(sid)
|
|
game = await db_ops.get_game(game_id)
|
|
|
|
if team_id == game.home_team_id and game.home_user_id == user_id:
|
|
return True
|
|
elif team_id == game.away_team_id and game.away_user_id == user_id:
|
|
return True
|
|
|
|
await sio.emit("error", {"message": "Not authorized for this team"}, to=sid)
|
|
return False
|
|
```
|
|
|
|
### Step 2: Add User Tracking to ConnectionManager (30 min)
|
|
|
|
Update `backend/app/websocket/connection_manager.py`:
|
|
|
|
```python
|
|
class ConnectionManager:
|
|
def __init__(self):
|
|
self.active_connections: dict[str, int] = {} # sid -> user_id
|
|
self.user_games: dict[int, set[UUID]] = {} # user_id -> game_ids
|
|
self.game_rooms: dict[UUID, set[str]] = {} # game_id -> sids
|
|
|
|
async def get_user_id(self, sid: str) -> int | None:
|
|
return self.active_connections.get(sid)
|
|
|
|
async def get_user_games(self, user_id: int) -> set[UUID]:
|
|
return self.user_games.get(user_id, set())
|
|
```
|
|
|
|
### Step 3: Update join_game Handler (30 min)
|
|
|
|
```python
|
|
@sio.event
|
|
async def join_game(sid, data):
|
|
game_id = UUID(data.get("game_id"))
|
|
|
|
# Authorization check
|
|
if not await require_game_participant(sid, game_id):
|
|
return # Error already emitted
|
|
|
|
user_id = await manager.get_user_id(sid)
|
|
role = await get_user_role_in_game(user_id, game_id)
|
|
|
|
await manager.join_game(sid, game_id, role)
|
|
# ... rest of handler
|
|
```
|
|
|
|
### Step 4: Update Decision Handlers (1-2 hours)
|
|
|
|
Each decision handler needs team ownership validation:
|
|
|
|
```python
|
|
@sio.event
|
|
async def submit_defensive_decision(sid, data):
|
|
game_id = UUID(data.get("game_id"))
|
|
team_id = data.get("team_id")
|
|
|
|
# Authorization: must control this team
|
|
if not await require_team_control(sid, game_id, team_id):
|
|
return
|
|
|
|
# ... rest of handler
|
|
```
|
|
|
|
Apply to:
|
|
- [ ] `submit_defensive_decision`
|
|
- [ ] `submit_offensive_decision`
|
|
- [ ] `request_pinch_hitter`
|
|
- [ ] `request_defensive_replacement`
|
|
- [ ] `request_pitching_change`
|
|
- [ ] `roll_dice`
|
|
- [ ] `submit_manual_outcome`
|
|
|
|
### Step 5: Update Spectator-Only Handlers (30 min)
|
|
|
|
```python
|
|
@sio.event
|
|
async def get_lineup(sid, data):
|
|
game_id = UUID(data.get("game_id"))
|
|
|
|
# Authorization: any participant (including spectators)
|
|
if not await require_game_participant(sid, game_id):
|
|
return
|
|
|
|
# ... rest of handler
|
|
```
|
|
|
|
Apply to:
|
|
- [ ] `get_lineup`
|
|
- [ ] `get_box_score`
|
|
|
|
### Step 6: Add Database Queries (30 min)
|
|
|
|
Add to `backend/app/database/operations.py`:
|
|
|
|
```python
|
|
async def get_game_participants(self, game_id: UUID) -> dict:
|
|
"""Get home_user_id, away_user_id, allow_spectators for game."""
|
|
async with AsyncSessionLocal() as session:
|
|
result = await session.execute(
|
|
select(Game.home_user_id, Game.away_user_id, Game.allow_spectators)
|
|
.where(Game.id == game_id)
|
|
)
|
|
row = result.first()
|
|
if row:
|
|
return {
|
|
"home_user_id": row.home_user_id,
|
|
"away_user_id": row.away_user_id,
|
|
"allow_spectators": row.allow_spectators
|
|
}
|
|
return None
|
|
```
|
|
|
|
### Step 7: Write Tests (1 hour)
|
|
|
|
Create `backend/tests/unit/websocket/test_authorization.py`:
|
|
|
|
```python
|
|
import pytest
|
|
from app.websocket.auth import get_user_role_in_game, require_game_participant
|
|
|
|
class TestWebSocketAuthorization:
|
|
"""Tests for WebSocket authorization utilities."""
|
|
|
|
async def test_home_user_gets_home_role(self):
|
|
"""Home team owner gets 'home' role."""
|
|
|
|
async def test_away_user_gets_away_role(self):
|
|
"""Away team owner gets 'away' role."""
|
|
|
|
async def test_spectator_allowed_when_enabled(self):
|
|
"""Non-participant gets 'spectator' when allowed."""
|
|
|
|
async def test_unauthorized_user_rejected(self):
|
|
"""Non-participant rejected when spectators disabled."""
|
|
|
|
async def test_require_team_control_validates_ownership(self):
|
|
"""User can only control their own team."""
|
|
```
|
|
|
|
## Verification Checklist
|
|
|
|
- [ ] All 11 TODO comments addressed
|
|
- [ ] Home user can only control home team
|
|
- [ ] Away user can only control away team
|
|
- [ ] Spectators can view but not act
|
|
- [ ] Unauthorized users rejected with clear error
|
|
- [ ] Unit tests pass
|
|
- [ ] Manual test: try joining game as wrong user
|
|
|
|
## Rollback Plan
|
|
|
|
If issues arise:
|
|
1. Revert `handlers.py` changes
|
|
2. Keep authorization utility for future use
|
|
3. Add rate limiting as temporary mitigation
|
|
|
|
## Dependencies
|
|
|
|
- None (can be implemented independently)
|
|
|
|
## Notes
|
|
|
|
- Consider caching user-game associations to reduce DB queries
|
|
- May want to add audit logging for authorization failures
|
|
- Future: Add game invite system for private games
|