feat: add JWT cookie auth to Socket.io connections and enforce DM-only atmosphere
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fae1e75f6f
commit
a37600fdfa
3 changed files with 78 additions and 24 deletions
8
server/package-lock.json
generated
8
server/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|
@ -504,6 +505,13 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cookie-parser": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
|
import { parse as parseCookie } from "cookie";
|
||||||
|
import type { RowDataPacket } from "mysql2";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { rollDice } from "./dice.js";
|
import { rollDice } from "./dice.js";
|
||||||
|
import { verifyToken } from "./auth/jwt.js";
|
||||||
|
|
||||||
interface EffectState {
|
interface EffectState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
@ -16,6 +19,19 @@ interface AtmosphereUpdateData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupSocket(io: Server) {
|
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) => {
|
io.on("connection", (socket) => {
|
||||||
socket.on("join-campaign", (campaignId: string) => {
|
socket.on("join-campaign", (campaignId: string) => {
|
||||||
socket.join(`campaign:${campaignId}`);
|
socket.join(`campaign:${campaignId}`);
|
||||||
|
|
@ -27,7 +43,7 @@ export function setupSocket(io: Server) {
|
||||||
|
|
||||||
socket.on(
|
socket.on(
|
||||||
"roll:request",
|
"roll:request",
|
||||||
(data: {
|
async (data: {
|
||||||
campaignId: number;
|
campaignId: number;
|
||||||
characterId?: number;
|
characterId?: number;
|
||||||
characterName?: string;
|
characterName?: string;
|
||||||
|
|
@ -39,6 +55,30 @@ export function setupSocket(io: Server) {
|
||||||
advantage?: boolean;
|
advantage?: boolean;
|
||||||
disadvantage?: boolean;
|
disadvantage?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
// Verify user is a member of this campaign
|
||||||
|
const userId = socket.data.user?.userId;
|
||||||
|
const [memberRows] = await db.execute<RowDataPacket[]>(
|
||||||
|
"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<RowDataPacket[]>(
|
||||||
|
"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, {
|
const result = rollDice(data.dice, {
|
||||||
advantage: data.advantage,
|
advantage: data.advantage,
|
||||||
disadvantage: data.disadvantage,
|
disadvantage: data.disadvantage,
|
||||||
|
|
@ -49,34 +89,29 @@ export function setupSocket(io: Server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect nat 20
|
|
||||||
const isD20Roll = data.dice.match(/d20/i);
|
const isD20Roll = data.dice.match(/d20/i);
|
||||||
let nat20 = false;
|
let nat20 = false;
|
||||||
if (isD20Roll && result.rolls.length > 0) {
|
if (isD20Roll && result.rolls.length > 0) {
|
||||||
if (data.advantage) {
|
if (data.advantage) {
|
||||||
// With advantage, nat20 if the chosen (higher) die is 20
|
|
||||||
nat20 = Math.max(...result.rolls) === 20;
|
nat20 = Math.max(...result.rolls) === 20;
|
||||||
} else if (data.disadvantage) {
|
} else if (data.disadvantage) {
|
||||||
// With disadvantage, nat20 if the chosen (lower) die is 20
|
|
||||||
nat20 = Math.min(...result.rolls) === 20;
|
nat20 = Math.min(...result.rolls) === 20;
|
||||||
} else {
|
} else {
|
||||||
nat20 = result.rolls[0] === 20;
|
nat20 = result.rolls[0] === 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = db
|
const [insertResult] = await db.execute<import("mysql2").ResultSetHeader>(
|
||||||
.prepare(
|
`INSERT INTO roll_log
|
||||||
`
|
(campaign_id, character_id, character_name, character_color, type, label,
|
||||||
INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
|
dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
`,
|
[
|
||||||
)
|
|
||||||
.run(
|
|
||||||
data.campaignId,
|
data.campaignId,
|
||||||
data.characterId ?? null,
|
data.characterId ?? null,
|
||||||
data.characterName || "Roll",
|
data.characterName ?? "Roll",
|
||||||
data.characterColor || "",
|
data.characterColor ?? "",
|
||||||
data.type || "custom",
|
data.type ?? "custom",
|
||||||
data.label,
|
data.label,
|
||||||
data.dice,
|
data.dice,
|
||||||
JSON.stringify(result.rolls),
|
JSON.stringify(result.rolls),
|
||||||
|
|
@ -85,25 +120,35 @@ export function setupSocket(io: Server) {
|
||||||
data.advantage ? 1 : 0,
|
data.advantage ? 1 : 0,
|
||||||
data.disadvantage ? 1 : 0,
|
data.disadvantage ? 1 : 0,
|
||||||
nat20 ? 1 : 0,
|
nat20 ? 1 : 0,
|
||||||
);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const saved = db
|
const [savedRows] = await db.execute<RowDataPacket[]>(
|
||||||
.prepare("SELECT * FROM roll_log WHERE id = ?")
|
"SELECT * FROM roll_log WHERE id = ?",
|
||||||
.get(row.lastInsertRowid) as Record<string, unknown>;
|
[insertResult.insertId]
|
||||||
|
);
|
||||||
|
|
||||||
const broadcast = {
|
const broadcast = {
|
||||||
...saved,
|
...savedRows[0],
|
||||||
rolls: result.rolls,
|
rolls: result.rolls,
|
||||||
advantage: data.advantage || false,
|
advantage: data.advantage ?? false,
|
||||||
disadvantage: data.disadvantage || false,
|
disadvantage: data.disadvantage ?? false,
|
||||||
nat20,
|
nat20,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast);
|
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<RowDataPacket[]>(
|
||||||
|
"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;
|
const { campaignId, ...atmosphere } = data;
|
||||||
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
|
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue