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": {
|
||||
"@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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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, {
|
||||
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<import("mysql2").ResultSetHeader>(
|
||||
`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<string, unknown>;
|
||||
const [savedRows] = await db.execute<RowDataPacket[]>(
|
||||
"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<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;
|
||||
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue