feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -4,6 +4,7 @@ import {
listDeviceTokensForUser,
upsertDeviceToken
} from '../services/push-dispatch.service';
import { requireAuth } from '../middleware/require-auth';
export interface DeviceTokenRecord {
userId: string;
@@ -14,6 +15,8 @@ export interface DeviceTokenRecord {
const router = Router();
router.use(requireAuth);
router.post('/', async (req, res) => {
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
@@ -21,12 +24,20 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
}
if (userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
await upsertDeviceToken({ userId, platform, token });
res.status(201).json({ ok: true });
});
router.get('/:userId', async (req, res) => {
if (req.params.userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
const records = await listDeviceTokensForUser(req.params.userId);
res.json({
@@ -40,6 +51,10 @@ router.get('/:userId', async (req, res) => {
});
router.post('/:userId/dispatch', async (req, res) => {
if (req.params.userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
const { title, body, data } = req.body as {
title?: string;
body?: string;

View File

@@ -7,20 +7,29 @@ import {
} from '../cqrs';
import { notifyUser } from '../websocket/broadcast';
import { resolveServerPermission } from '../services/server-permissions.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
router.put('/:id', async (req, res) => {
router.put('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const request = await getJoinRequestById(id);
if (!request)
return res.status(404).json({ error: 'Request not found' });
if (ownerId && ownerId !== authenticatedUserId) {
return res.status(400).json({
error: 'ownerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
const server = await getServerById(request.serverId);
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
if (!server || !resolveServerPermission(server, authenticatedUserId, 'manageServer'))
return res.status(403).json({ error: 'Not authorized' });
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);

View File

@@ -8,6 +8,7 @@ import {
upsertPluginRequirement
} from '../services/plugin-support.service';
import { broadcastToServer } from '../websocket/broadcast';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
@@ -21,8 +22,28 @@ function sendPluginSupportError(error: unknown, res: Response): void {
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
}
function readActorUserId(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
type AuthenticatedRequest = Parameters<typeof getAuthenticatedUserId>[0] & { body: { actorUserId?: unknown } };
function readOptionalActorUserId(req: AuthenticatedRequest, res: Response): string | null {
let authenticatedUserId: string;
try {
authenticatedUserId = getAuthenticatedUserId(req);
} catch {
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
return null;
}
if (typeof req.body.actorUserId === 'string' && req.body.actorUserId.trim() && req.body.actorUserId !== authenticatedUserId) {
res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
return null;
}
return authenticatedUserId;
}
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
@@ -43,12 +64,17 @@ router.get('/:serverId/plugins', async (req, res) => {
}
});
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
router.put('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
const { serverId, pluginId } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
const requirement = await upsertPluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
installUrl: req.body.installUrl,
manifest: req.body.manifest,
pluginId,
@@ -66,12 +92,17 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
}
});
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
router.delete('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
const { serverId, pluginId } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
await deletePluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
pluginId,
serverId
});
@@ -83,12 +114,17 @@ router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
}
});
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
router.put('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
const eventDefinition = await upsertPluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
direction: req.body.direction,
eventName,
maxPayloadBytes: req.body.maxPayloadBytes,
@@ -106,12 +142,17 @@ router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) =>
}
});
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
router.delete('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
await deletePluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
eventName,
pluginId,
serverId

View File

@@ -35,6 +35,11 @@ import {
canModerateServerMember,
resolveServerPermission
} from '../services/server-permissions.service';
import {
getAuthenticatedUserId,
requireAuth,
rejectSpoofedUserId
} from '../middleware/require-auth';
const router = Router();
@@ -185,7 +190,7 @@ router.get('/trending', async (req, res) => {
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
});
router.post('/', async (req, res) => {
router.post('/', requireAuth, async (req, res) => {
const {
id: clientId,
name,
@@ -204,12 +209,17 @@ router.post('/', async (req, res) => {
if (!name || !ownerId || !ownerPublicKey)
return res.status(400).json({ error: 'Missing required fields' });
if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) {
return;
}
const authenticatedOwnerId = getAuthenticatedUserId(req);
const passwordHash = passwordHashForInput(password);
const server: ServerPayload = {
id: clientId || uuidv4(),
name,
description,
ownerId,
ownerId: authenticatedOwnerId,
ownerPublicKey,
hasPassword: !!passwordHash,
passwordHash,
@@ -225,12 +235,12 @@ router.post('/', async (req, res) => {
};
await upsertServer(server);
await ensureServerMembership(server.id, ownerId);
await ensureServerMembership(server.id, authenticatedOwnerId);
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
});
router.put('/:id', async (req, res) => {
router.put('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const {
currentOwnerId,
@@ -242,13 +252,16 @@ router.put('/:id', async (req, res) => {
...updates
} = req.body;
const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const authenticatedOwnerId = getAuthenticatedUserId(req);
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (!authenticatedOwnerId) {
return res.status(400).json({ error: 'Missing currentOwnerId' });
if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) {
return res.status(400).json({
error: 'currentOwnerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
@@ -276,18 +289,22 @@ router.put('/:id', async (req, res) => {
res.json(await enrichServer(server, getRequestOrigin(req)));
});
router.post('/:id/join', async (req, res) => {
router.post('/:id/join', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { userId, password, inviteId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
if (userId && userId !== authenticatedUserId) {
return res.status(400).json({
error: 'userId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
try {
const result = await joinServerWithAccess({
serverId,
userId: String(userId),
userId: authenticatedUserId,
password: typeof password === 'string' ? password : undefined,
inviteId: typeof inviteId === 'string' ? inviteId : undefined
});
@@ -305,12 +322,16 @@ router.post('/:id/join', async (req, res) => {
}
});
router.post('/:id/invites', async (req, res) => {
router.post('/:id/invites', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { requesterUserId, requesterDisplayName } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
if (!requesterUserId) {
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
if (requesterUserId && requesterUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'requesterUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
const server = await getServerById(serverId);
@@ -322,7 +343,7 @@ router.post('/:id/invites', async (req, res) => {
try {
const invite = await createServerInvite(
serverId,
String(requesterUserId),
authenticatedUserId,
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
);
@@ -332,9 +353,10 @@ router.post('/:id/invites', async (req, res) => {
}
});
router.post('/:id/moderation/kick', async (req, res) => {
router.post('/:id/moderation/kick', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, targetUserId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
@@ -345,7 +367,14 @@ router.post('/:id/moderation/kick', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'kickMembers')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -354,9 +383,10 @@ router.post('/:id/moderation/kick', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/moderation/ban', async (req, res) => {
router.post('/:id/moderation/ban', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
@@ -367,7 +397,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'banMembers')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -375,7 +412,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
serverId,
userId: String(targetUserId),
banId: typeof banId === 'string' ? banId : undefined,
bannedBy: String(actorUserId || ''),
bannedBy: authenticatedUserId,
displayName: typeof displayName === 'string' ? displayName : undefined,
reason: typeof reason === 'string' ? reason : undefined,
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
@@ -384,16 +421,24 @@ router.post('/:id/moderation/ban', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/moderation/unban', async (req, res) => {
router.post('/:id/moderation/unban', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, banId, targetUserId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!resolveServerPermission(server, authenticatedUserId, 'manageBans')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -406,25 +451,29 @@ router.post('/:id/moderation/unban', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/leave', async (req, res) => {
router.post('/:id/leave', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { userId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
if (userId && userId !== authenticatedUserId) {
return res.status(400).json({
error: 'userId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
await leaveServerUser(serverId, String(userId));
await leaveServerUser(serverId, authenticatedUserId);
res.json({ ok: true });
});
router.post('/:id/heartbeat', async (req, res) => {
router.post('/:id/heartbeat', requireAuth, async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const existing = await getServerById(id);
@@ -442,30 +491,38 @@ router.post('/:id/heartbeat', async (req, res) => {
res.json({ ok: true });
});
router.delete('/:id', async (req, res) => {
router.delete('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
const authenticatedOwnerId = getAuthenticatedUserId(req);
const existing = await getServerById(id);
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (existing.ownerId !== ownerId)
if (ownerId && ownerId !== authenticatedOwnerId) {
return res.status(400).json({
error: 'ownerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (existing.ownerId !== authenticatedOwnerId)
return res.status(403).json({ error: 'Not authorized' });
await deleteServer(id);
res.json({ ok: true });
});
router.get('/:id/requests', async (req, res) => {
router.get('/:id/requests', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server)
return res.status(404).json({ error: 'Server not found' });
if (server.ownerId !== ownerId)
if (!resolveServerPermission(server, authenticatedUserId, 'manageServer'))
return res.status(403).json({ error: 'Not authorized' });
const requests = await getPendingRequestsForServer(serverId);

View File

@@ -1,13 +1,30 @@
import crypto from 'crypto';
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getUserByUsername, registerUser } from '../cqrs';
import {
getUserById,
getUserByUsername,
registerUser,
updateUserPasswordHash,
updateUserSigningPublicKey
} from '../cqrs';
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
function hashPassword(pw: string): string {
return crypto.createHash('sha256').update(pw)
.digest('hex');
function buildAuthResponse(user: {
id: string;
username: string;
displayName: string;
}, token: string, expiresAt: number) {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
token,
expiresAt
};
}
router.post('/register', async (req, res) => {
@@ -24,23 +41,64 @@ router.post('/register', async (req, res) => {
const user = {
id: uuidv4(),
username,
passwordHash: hashPassword(password),
passwordHash: await hashPasswordForStorage(password),
displayName: displayName || username,
createdAt: Date.now()
};
await registerUser(user);
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
const session = await issueSessionToken(user.id);
res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt));
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await getUserByUsername(username);
if (!user || user.passwordHash !== hashPassword(password))
if (!user || !(await verifyPassword(password, user.passwordHash)))
return res.status(401).json({ error: 'Invalid credentials' });
res.json({ id: user.id, username: user.username, displayName: user.displayName });
const upgradedHash = await hashPasswordForStorage(password, user.passwordHash);
if (upgradedHash !== user.passwordHash) {
await updateUserPasswordHash(user.id, upgradedHash);
}
const session = await issueSessionToken(user.id);
res.json(buildAuthResponse(user, session.token, session.expiresAt));
});
router.put('/me/signing-key', requireAuth, async (req, res) => {
const { publicKeyJwk } = req.body;
const userId = getAuthenticatedUserId(req);
if (!publicKeyJwk || typeof publicKeyJwk !== 'object') {
return res.status(400).json({ error: 'Missing publicKeyJwk', errorCode: 'INVALID_SIGNING_KEY' });
}
await updateUserSigningPublicKey(userId, JSON.stringify(publicKeyJwk));
res.json({ ok: true });
});
router.get('/:id/signing-public-key', async (req, res) => {
const user = await getUserById(req.params.id);
if (!user?.signingPublicKey) {
return res.status(404).json({ error: 'Signing public key not found', errorCode: 'SIGNING_KEY_NOT_FOUND' });
}
res.json({ publicKeyJwk: JSON.parse(user.signingPublicKey) });
});
router.post('/logout', requireAuth, async (req, res) => {
if (req.authToken) {
await revokeSessionToken(req.authToken);
}
res.json({ ok: true });
});
export default router;