From a37600fdfa6d054ef8ba6f39c82ef4054ec36f52 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:33:35 -0400 Subject: [PATCH] feat: add JWT cookie auth to Socket.io connections and enforce DM-only atmosphere Co-Authored-By: Claude Sonnet 4.6 --- server/package-lock.json | 8 ++++ server/package.json | 1 + server/src/socket.ts | 93 +++++++++++++++++++++++++++++----------- 3 files changed, 78 insertions(+), 24 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index e9e5d63..1fc7662 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", + "@types/cookie": "^0.6.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", @@ -504,6 +505,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie-parser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", diff --git a/server/package.json b/server/package.json index 63eeab8..6b99b4d 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", + "@types/cookie": "^0.6.0", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", diff --git a/server/src/socket.ts b/server/src/socket.ts index 6236fa5..11c0681 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -1,6 +1,9 @@ import { Server } from "socket.io"; +import { parse as parseCookie } from "cookie"; +import type { RowDataPacket } from "mysql2"; import db from "./db.js"; import { rollDice } from "./dice.js"; +import { verifyToken } from "./auth/jwt.js"; interface EffectState { active: boolean; @@ -16,6 +19,19 @@ interface AtmosphereUpdateData { } export function setupSocket(io: Server) { + // Verify JWT from cookie on every connection + io.use((socket, next) => { + try { + const cookies = parseCookie(socket.handshake.headers.cookie ?? ""); + const token = cookies["darkwatch_token"]; + if (!token) throw new Error("No token"); + socket.data.user = verifyToken(token); + next(); + } catch { + next(new Error("Unauthorized")); + } + }); + io.on("connection", (socket) => { socket.on("join-campaign", (campaignId: string) => { socket.join(`campaign:${campaignId}`); @@ -27,7 +43,7 @@ export function setupSocket(io: Server) { socket.on( "roll:request", - (data: { + async (data: { campaignId: number; characterId?: number; characterName?: string; @@ -39,6 +55,30 @@ export function setupSocket(io: Server) { advantage?: boolean; disadvantage?: boolean; }) => { + // Verify user is a member of this campaign + const userId = socket.data.user?.userId; + const [memberRows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [data.campaignId, userId] + ); + if (memberRows.length === 0) { + socket.emit("roll:error", { error: "Not a campaign member" }); + return; + } + const isDM = memberRows[0].role === "dm"; + + // If rolling for a specific character, verify ownership (DMs can roll any) + if (data.characterId && !isDM) { + const [charRows] = await db.execute( + "SELECT user_id FROM characters WHERE id = ?", + [data.characterId] + ); + if (charRows.length === 0 || charRows[0].user_id !== userId) { + socket.emit("roll:error", { error: "Cannot roll for another player's character" }); + return; + } + } + const result = rollDice(data.dice, { advantage: data.advantage, disadvantage: data.disadvantage, @@ -49,34 +89,29 @@ export function setupSocket(io: Server) { return; } - // Detect nat 20 const isD20Roll = data.dice.match(/d20/i); let nat20 = false; if (isD20Roll && result.rolls.length > 0) { if (data.advantage) { - // With advantage, nat20 if the chosen (higher) die is 20 nat20 = Math.max(...result.rolls) === 20; } else if (data.disadvantage) { - // With disadvantage, nat20 if the chosen (lower) die is 20 nat20 = Math.min(...result.rolls) === 20; } else { nat20 = result.rolls[0] === 20; } } - const row = db - .prepare( - ` - INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( + const [insertResult] = await db.execute( + `INSERT INTO roll_log + (campaign_id, character_id, character_name, character_color, type, label, + dice_expression, rolls, modifier, total, advantage, disadvantage, nat20) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ data.campaignId, data.characterId ?? null, - data.characterName || "Roll", - data.characterColor || "", - data.type || "custom", + data.characterName ?? "Roll", + data.characterColor ?? "", + data.type ?? "custom", data.label, data.dice, JSON.stringify(result.rolls), @@ -85,25 +120,35 @@ export function setupSocket(io: Server) { data.advantage ? 1 : 0, data.disadvantage ? 1 : 0, nat20 ? 1 : 0, - ); + ] + ); - const saved = db - .prepare("SELECT * FROM roll_log WHERE id = ?") - .get(row.lastInsertRowid) as Record; + const [savedRows] = await db.execute( + "SELECT * FROM roll_log WHERE id = ?", + [insertResult.insertId] + ); const broadcast = { - ...saved, + ...savedRows[0], rolls: result.rolls, - advantage: data.advantage || false, - disadvantage: data.disadvantage || false, + advantage: data.advantage ?? false, + disadvantage: data.disadvantage ?? false, nat20, }; io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); - }, + } ); - socket.on("atmosphere:update", (data: AtmosphereUpdateData) => { + socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => { + // Only DMs can broadcast atmosphere changes + const userId = socket.data.user?.userId; + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [data.campaignId, userId] + ); + if (rows.length === 0 || rows[0].role !== "dm") return; + const { campaignId, ...atmosphere } = data; io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere); });