feat: Security
This commit is contained in:
@@ -1,13 +1,53 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { registerRoutes } from './routes';
|
||||
import { getCorsAllowlist } from './config/variables';
|
||||
|
||||
const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many authentication attempts', errorCode: 'RATE_LIMITED' }
|
||||
});
|
||||
const joinRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many join attempts', errorCode: 'RATE_LIMITED' }
|
||||
});
|
||||
|
||||
function buildCorsOptions() {
|
||||
const allowlist = getCorsAllowlist();
|
||||
|
||||
if (allowlist.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
origin(origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) {
|
||||
if (!origin || allowlist.includes(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error('CORS origin not allowed'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', true);
|
||||
app.use(cors());
|
||||
// Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY.
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.use(cors(buildCorsOptions()));
|
||||
app.use(express.json());
|
||||
app.use('/api/users/login', authRateLimiter);
|
||||
app.use('/api/users/register', authRateLimiter);
|
||||
app.use('/api/servers/:id/join', joinRateLimiter);
|
||||
|
||||
registerRoutes(app);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ServerVariablesConfig {
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
serverTag: string;
|
||||
corsAllowlist: string[];
|
||||
linkPreview: LinkPreviewConfig;
|
||||
openApiDocs: OpenApiDocsConfig;
|
||||
}
|
||||
@@ -113,6 +114,17 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||
}
|
||||
|
||||
function normalizeCorsAllowlist(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as Record<string, unknown>
|
||||
@@ -169,6 +181,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
serverTag: normalizeServerTag(remainingParsed.serverTag),
|
||||
corsAllowlist: normalizeCorsAllowlist(remainingParsed.corsAllowlist),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||
};
|
||||
@@ -186,11 +199,23 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost,
|
||||
serverTag: normalized.serverTag,
|
||||
corsAllowlist: normalized.corsAllowlist,
|
||||
linkPreview: normalized.linkPreview,
|
||||
openApiDocs: normalized.openApiDocs
|
||||
};
|
||||
}
|
||||
|
||||
export function getCorsAllowlist(): string[] {
|
||||
if (hasEnvironmentOverride(process.env.CORS_ALLOWLIST)) {
|
||||
return (process.env.CORS_ALLOWLIST ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
return getVariablesConfig().corsAllowlist;
|
||||
}
|
||||
|
||||
export function getVariablesConfig(): ServerVariablesConfig {
|
||||
return ensureVariablesConfig();
|
||||
}
|
||||
|
||||
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserEntity } from '../../../entities';
|
||||
|
||||
export async function handleUpdateUserPasswordHash(
|
||||
dataSource: DataSource,
|
||||
userId: string,
|
||||
passwordHash: string
|
||||
): Promise<void> {
|
||||
const repo = dataSource.getRepository(AuthUserEntity);
|
||||
|
||||
await repo.update({ id: userId }, { passwordHash });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserEntity } from '../../../entities';
|
||||
|
||||
export async function handleUpdateUserSigningPublicKey(
|
||||
dataSource: DataSource,
|
||||
userId: string,
|
||||
signingPublicKey: string
|
||||
): Promise<void> {
|
||||
const repo = dataSource.getRepository(AuthUserEntity);
|
||||
|
||||
await repo.update({ id: userId }, { signingPublicKey });
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { handleGetTrendingServers } from './queries/handlers/getTrendingServers'
|
||||
import { handleGetServerById } from './queries/handlers/getServerById';
|
||||
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
||||
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
||||
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
|
||||
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
|
||||
|
||||
export const registerUser = (user: AuthUserPayload) =>
|
||||
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
||||
@@ -62,3 +64,9 @@ export const getJoinRequestById = (requestId: string) =>
|
||||
|
||||
export const getPendingRequestsForServer = (serverId: string) =>
|
||||
handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource());
|
||||
|
||||
export const updateUserPasswordHash = (userId: string, passwordHash: string) =>
|
||||
handleUpdateUserPasswordHash(getDataSource(), userId, passwordHash);
|
||||
|
||||
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
|
||||
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
|
||||
|
||||
@@ -14,7 +14,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
username: row.username,
|
||||
passwordHash: row.passwordHash,
|
||||
displayName: row.displayName,
|
||||
createdAt: row.createdAt
|
||||
createdAt: row.createdAt,
|
||||
signingPublicKey: row.signingPublicKey ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AuthUserPayload {
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
signingPublicKey?: string | null;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity,
|
||||
DeviceTokenEntity
|
||||
DeviceTokenEntity,
|
||||
SessionTokenEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import {
|
||||
@@ -272,7 +273,8 @@ export async function initDatabase(): Promise<void> {
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity,
|
||||
DeviceTokenEntity
|
||||
DeviceTokenEntity,
|
||||
SessionTokenEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
@@ -20,4 +20,7 @@ export class AuthUserEntity {
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
signingPublicKey!: string | null;
|
||||
}
|
||||
|
||||
22
server/src/entities/SessionTokenEntity.ts
Normal file
22
server/src/entities/SessionTokenEntity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('session_tokens')
|
||||
export class SessionTokenEntity {
|
||||
@PrimaryColumn('text')
|
||||
token!: string;
|
||||
|
||||
@Index('idx_session_tokens_user_id')
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('integer')
|
||||
issuedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
expiresAt!: number;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export { PluginDataEntity } from './PluginDataEntity';
|
||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||
export { DeviceTokenEntity } from './DeviceTokenEntity';
|
||||
export { SessionTokenEntity } from './SessionTokenEntity';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ServerHttpProtocol
|
||||
} from './config/variables';
|
||||
import { setupWebSocket } from './websocket';
|
||||
import { pruneExpiredSessionTokens } from './services/session-auth.service';
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
@@ -61,6 +62,7 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
||||
|
||||
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let sessionTokenPruneInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
@@ -99,6 +101,11 @@ async function bootstrap(): Promise<void> {
|
||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
|
||||
sessionTokenPruneInterval = setInterval(() => {
|
||||
pruneExpiredSessionTokens()
|
||||
.catch(err => console.error('Failed to prune expired session tokens:', err));
|
||||
}, 60 * 1000);
|
||||
|
||||
const onListening = () => {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||
@@ -137,6 +144,11 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
||||
staleJoinRequestInterval = null;
|
||||
}
|
||||
|
||||
if (sessionTokenPruneInterval) {
|
||||
clearInterval(sessionTokenPruneInterval);
|
||||
sessionTokenPruneInterval = null;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
||||
|
||||
if (listeningServer?.listening) {
|
||||
|
||||
72
server/src/middleware/require-auth.ts
Normal file
72
server/src/middleware/require-auth.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import '../types/express-augmentation';
|
||||
import {
|
||||
NextFunction,
|
||||
Request,
|
||||
Response
|
||||
} from 'express';
|
||||
import { consumeSessionToken } from '../services/session-auth.service';
|
||||
|
||||
function readBearerToken(req: Request): string | null {
|
||||
const header = req.header('authorization');
|
||||
|
||||
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = header.slice(7).trim();
|
||||
|
||||
return token || null;
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = readBearerToken(req);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await consumeSessionToken(token);
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.authToken = session.token;
|
||||
req.authUserId = session.user.id;
|
||||
req.authUser = session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function getAuthenticatedUserId(req: Request): string {
|
||||
const userId = req.authUserId;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Authenticated user id missing after requireAuth');
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function rejectSpoofedUserId(
|
||||
req: Request,
|
||||
res: Response,
|
||||
bodyUserId: unknown,
|
||||
fieldName: string
|
||||
): bodyUserId is string {
|
||||
if (typeof bodyUserId !== 'string' || !bodyUserId.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bodyUserId !== req.authUserId) {
|
||||
res.status(400).json({
|
||||
error: `${fieldName} must match the authenticated user`,
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SessionTokens1000000000011 implements MigrationInterface {
|
||||
name = 'SessionTokens1000000000011';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "session_tokens" (
|
||||
"token" TEXT PRIMARY KEY NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"issuedAt" INTEGER NOT NULL,
|
||||
"expiresAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_session_tokens_user_id" ON "session_tokens" ("userId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "session_tokens"`);
|
||||
}
|
||||
}
|
||||
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SigningPublicKey1000000000012 implements MigrationInterface {
|
||||
name = 'SigningPublicKey1000000000012';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "signingPublicKey" text');
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "signingPublicKey"');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
||||
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
||||
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
|
||||
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -21,5 +23,7 @@ export const serverMigrations = [
|
||||
PluginSupport1000000000007,
|
||||
ServerPluginInstallMetadata1000000000008,
|
||||
ServerIcons1000000000009,
|
||||
DeviceTokens1000000000010
|
||||
DeviceTokens1000000000010,
|
||||
SessionTokens1000000000011,
|
||||
SigningPublicKey1000000000012
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
server/src/services/password-auth.service.spec.ts
Normal file
45
server/src/services/password-auth.service.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
hashPasswordForStorage,
|
||||
isLegacySha256Hash,
|
||||
verifyPassword
|
||||
} from './password-auth.service';
|
||||
|
||||
describe('password-auth.service', () => {
|
||||
it('stores bcrypt hashes for new passwords', async () => {
|
||||
const hash = await hashPasswordForStorage('secret-pass');
|
||||
|
||||
expect(hash.startsWith('$2')).toBe(true);
|
||||
expect(isLegacySha256Hash(hash)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies bcrypt passwords', async () => {
|
||||
const hash = await hashPasswordForStorage('secret-pass');
|
||||
|
||||
await expect(verifyPassword('secret-pass', hash)).resolves.toBe(true);
|
||||
await expect(verifyPassword('wrong-pass', hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('verifies legacy sha256 passwords', async () => {
|
||||
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
|
||||
.digest('hex');
|
||||
|
||||
expect(isLegacySha256Hash(legacyHash)).toBe(true);
|
||||
await expect(verifyPassword('legacy-pass', legacyHash)).resolves.toBe(true);
|
||||
await expect(verifyPassword('wrong-pass', legacyHash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rehashes legacy passwords to bcrypt after successful verification', async () => {
|
||||
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
|
||||
.digest('hex');
|
||||
const upgraded = await hashPasswordForStorage('legacy-pass', legacyHash);
|
||||
|
||||
expect(upgraded.startsWith('$2')).toBe(true);
|
||||
await expect(verifyPassword('legacy-pass', upgraded)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
33
server/src/services/password-auth.service.ts
Normal file
33
server/src/services/password-auth.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
export function isLegacySha256Hash(passwordHash: string): boolean {
|
||||
return /^[a-f0-9]{64}$/i.test(passwordHash);
|
||||
}
|
||||
|
||||
export async function hashPasswordForStorage(
|
||||
password: string,
|
||||
existingHash?: string
|
||||
): Promise<string> {
|
||||
if (existingHash && isLegacySha256Hash(existingHash)) {
|
||||
const legacyMatches = crypto.createHash('sha256').update(password)
|
||||
.digest('hex') === existingHash;
|
||||
|
||||
if (legacyMatches) {
|
||||
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
}
|
||||
}
|
||||
|
||||
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
if (isLegacySha256Hash(passwordHash)) {
|
||||
return crypto.createHash('sha256').update(password)
|
||||
.digest('hex') === passwordHash;
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
@@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise<number>
|
||||
return await getMembershipRepository().count({ where: { serverId } });
|
||||
}
|
||||
|
||||
export async function usersShareServerMembership(userA: string, userB: string): Promise<boolean> {
|
||||
if (userA === userB) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const repo = getMembershipRepository();
|
||||
const membershipsForA = await repo.find({
|
||||
where: { userId: userA },
|
||||
select: ['serverId']
|
||||
});
|
||||
|
||||
if (membershipsForA.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const membership of membershipsForA) {
|
||||
const shared = await repo.findOne({
|
||||
where: { serverId: membership.serverId, userId: userB }
|
||||
});
|
||||
|
||||
if (shared) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
|
||||
110
server/src/services/session-auth.service.ts
Normal file
110
server/src/services/session-auth.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getDataSource } from '../db/database';
|
||||
import { SessionTokenEntity } from '../entities/SessionTokenEntity';
|
||||
import { getUserById } from '../cqrs';
|
||||
import type { AuthUserPayload } from '../cqrs/types';
|
||||
|
||||
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface IssuedSessionToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AuthenticatedSession {
|
||||
token: string;
|
||||
user: AuthUserPayload;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function getTokenRepository() {
|
||||
return getDataSource().getRepository(SessionTokenEntity);
|
||||
}
|
||||
|
||||
export function getSessionTokenTtlMs(): number {
|
||||
const configured = Number(process.env.SESSION_TOKEN_TTL_MS);
|
||||
|
||||
return Number.isFinite(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function issueSessionToken(userId: string): Promise<IssuedSessionToken> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const issuedAt = Date.now();
|
||||
const expiresAt = issuedAt + getSessionTokenTtlMs();
|
||||
const repo = getTokenRepository();
|
||||
|
||||
await repo.save(repo.create({
|
||||
token,
|
||||
userId,
|
||||
issuedAt,
|
||||
expiresAt
|
||||
}));
|
||||
|
||||
return { token, userId, issuedAt, expiresAt };
|
||||
}
|
||||
|
||||
export async function consumeSessionToken(token: string): Promise<AuthenticatedSession | null> {
|
||||
const normalized = token.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repo = getTokenRepository();
|
||||
const record = await repo.findOne({ where: { token: normalized } });
|
||||
|
||||
if (!record || record.expiresAt < Date.now()) {
|
||||
if (record) {
|
||||
await repo.delete({ token: normalized });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await getUserById(record.userId);
|
||||
|
||||
if (!user) {
|
||||
await repo.delete({ token: normalized });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: record.token,
|
||||
user,
|
||||
issuedAt: record.issuedAt,
|
||||
expiresAt: record.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeSessionToken(token: string): Promise<void> {
|
||||
const normalized = token.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getTokenRepository().delete({ token: normalized });
|
||||
}
|
||||
|
||||
export async function revokeAllSessionTokensForUser(userId: string): Promise<void> {
|
||||
await getTokenRepository().delete({ userId });
|
||||
}
|
||||
|
||||
export async function pruneExpiredSessionTokens(): Promise<void> {
|
||||
const repo = getTokenRepository();
|
||||
const now = Date.now();
|
||||
const expired = await repo.createQueryBuilder('token')
|
||||
.where('token.expiresAt < :now', { now })
|
||||
.getMany();
|
||||
|
||||
if (expired.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.remove(expired);
|
||||
}
|
||||
11
server/src/types/express-augmentation.ts
Normal file
11
server/src/types/express-augmentation.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { AuthUserPayload } from '../cqrs/types';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
authUserId?: string;
|
||||
authUser?: AuthUserPayload;
|
||||
authToken?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
120
server/src/websocket/handler-auth.spec.ts
Normal file
120
server/src/websocket/handler-auth.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||
usersShareServerMembership: vi.fn(async () => false)
|
||||
}));
|
||||
|
||||
vi.mock('../services/session-auth.service', () => ({
|
||||
consumeSessionToken: vi.fn(async (token: string) => {
|
||||
if (token !== 'valid-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: Date.now()
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(connectionId: string): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now()
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('server websocket handler - authentication', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects non-identify messages until the connection is authenticated', async () => {
|
||||
createConnectedUser('conn-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'typing', serverId: 'server-1' });
|
||||
|
||||
const user = connectedUsers.get('conn-1');
|
||||
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
const response = JSON.parse(sentMessages[0]) as { type: string };
|
||||
|
||||
expect(response.type).toBe('auth_required');
|
||||
expect(user?.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects identify without a session token', async () => {
|
||||
createConnectedUser('conn-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice'
|
||||
});
|
||||
|
||||
const user = connectedUsers.get('conn-1');
|
||||
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
const response = JSON.parse(sentMessages[0]) as { type: string; code: string };
|
||||
|
||||
expect(response.type).toBe('auth_error');
|
||||
expect(response.code).toBe('MISSING_TOKEN');
|
||||
expect(user?.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('binds identify to the authenticated user id from the token', async () => {
|
||||
createConnectedUser('conn-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
token: 'valid-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice'
|
||||
});
|
||||
|
||||
const user = connectedUsers.get('conn-1');
|
||||
|
||||
expect(user?.authenticated).toBe(true);
|
||||
expect(user?.oderId).toBe('user-1');
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ function createConnectedUser(
|
||||
displayName: `User ${oderId}`,
|
||||
lastPong: Date.now(),
|
||||
oderId,
|
||||
authenticated: true,
|
||||
serverIds: new Set(),
|
||||
ws: createMockWs(),
|
||||
...overrides
|
||||
|
||||
@@ -14,6 +14,41 @@ vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||
}));
|
||||
|
||||
let authenticatedUserId = 'user-1';
|
||||
|
||||
vi.mock('../services/session-auth.service', () => ({
|
||||
consumeSessionToken: vi.fn(async (token: string) => {
|
||||
if (token !== 'test-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: authenticatedUserId,
|
||||
username: 'test-user',
|
||||
displayName: 'Test User',
|
||||
passwordHash: 'hash',
|
||||
createdAt: Date.now()
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||
requirements: [],
|
||||
eventDefinitions: []
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal mock WebSocket that records sent messages.
|
||||
*/
|
||||
@@ -38,6 +73,7 @@ function createConnectedUser(
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws,
|
||||
authenticated: true,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
@@ -168,7 +204,8 @@ describe('server websocket handler - status_update', () => {
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
|
||||
// Identify first (required for handler)
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
authenticatedUserId = 'user-1';
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
// user-2 joins server -> should receive server_users with user-1's status
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
@@ -201,7 +238,8 @@ describe('server websocket handler - user_joined includes status', () => {
|
||||
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||
|
||||
// Identify user-1
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
authenticatedUserId = 'user-1';
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
|
||||
@@ -237,8 +275,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
||||
bob.serverIds.add('server-1');
|
||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||
|
||||
authenticatedUserId = 'user-1';
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice Updated',
|
||||
description: 'Updated bio',
|
||||
@@ -261,8 +301,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
authenticatedUserId = 'user-1';
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
description: 'Alice bio',
|
||||
@@ -291,8 +333,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
authenticatedUserId = 'user-1';
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
homeSignalServerUrl: 'http://signal.example.com:3001/'
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
import {
|
||||
authorizeWebSocketJoin,
|
||||
findServerMembership,
|
||||
usersShareServerMembership
|
||||
} from '../services/server-access.service';
|
||||
import { consumeSessionToken } from '../services/session-auth.service';
|
||||
import {
|
||||
getPluginRequirementsSnapshot,
|
||||
PluginSupportError,
|
||||
@@ -131,8 +136,67 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
const DIRECT_SIGNALING_TYPES = new Set([
|
||||
'direct-message',
|
||||
'direct-message-status',
|
||||
'direct-message-mutation',
|
||||
'direct-message-typing',
|
||||
'direct-message-sync-request',
|
||||
'direct-message-sync',
|
||||
'direct-call'
|
||||
]);
|
||||
const SERVER_SCOPED_SIGNALING_TYPES = new Set([
|
||||
'server_icon_peer_request',
|
||||
'server_icon_peer_data',
|
||||
'server_icon_available',
|
||||
'server_icon_sync_request'
|
||||
]);
|
||||
|
||||
function sendAuthRequired(user: ConnectedUser): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'auth_required',
|
||||
message: 'identify with a valid session token before sending messages'
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const token = typeof message['token'] === 'string' ? message['token'].trim() : '';
|
||||
|
||||
if (!token) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'auth_error',
|
||||
code: 'MISSING_TOKEN',
|
||||
message: 'identify requires a session token'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await consumeSessionToken(token);
|
||||
|
||||
if (!session) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'auth_error',
|
||||
code: 'INVALID_TOKEN',
|
||||
message: 'invalid or expired session token'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const claimedOderId = readMessageId(message['oderId']);
|
||||
|
||||
if (claimedOderId && claimedOderId !== session.user.id) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'auth_error',
|
||||
code: 'USER_ID_MISMATCH',
|
||||
message: 'oderId must match the authenticated user'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newOderId = session.user.id;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
@@ -140,6 +204,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.authenticated = true;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||
@@ -277,11 +342,45 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
);
|
||||
}
|
||||
|
||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
async function canForwardRtcMessage(user: ConnectedUser, message: WsMessage, targetUserId: string): Promise<boolean> {
|
||||
if (!targetUserId || targetUserId === user.oderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DIRECT_SIGNALING_TYPES.has(message.type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SERVER_SCOPED_SIGNALING_TYPES.has(message.type)) {
|
||||
const serverId = readMessageId(message['serverId']);
|
||||
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const senderMembership = await findServerMembership(serverId, user.oderId);
|
||||
const targetMembership = await findServerMembership(serverId, targetUserId);
|
||||
|
||||
return !!senderMembership && !!targetMembership;
|
||||
}
|
||||
|
||||
if (message.type === 'offer' || message.type === 'answer' || message.type === 'ice_candidate') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return usersShareServerMembership(user.oderId, targetUserId);
|
||||
}
|
||||
|
||||
async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||
|
||||
if (!(await canForwardRtcMessage(user, message, targetUserId))) {
|
||||
console.log(`Blocked ${message.type} relay from ${user.oderId} to ${targetUserId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = findUserByOderId(targetUserId);
|
||||
|
||||
if (targetUser) {
|
||||
@@ -482,13 +581,18 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
user.lastPong = Date.now();
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
if (!user.authenticated && message.type !== 'identify' && message.type !== 'keepalive') {
|
||||
sendAuthRequired(user);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'keepalive':
|
||||
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
|
||||
break;
|
||||
|
||||
case 'identify':
|
||||
handleIdentify(user, message, connectionId);
|
||||
await handleIdentify(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'join_server':
|
||||
@@ -515,7 +619,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
case 'direct-call':
|
||||
case 'server_icon_peer_request':
|
||||
case 'server_icon_peer_data':
|
||||
forwardRtcMessage(user, message);
|
||||
await forwardRtcMessage(user, message);
|
||||
break;
|
||||
|
||||
case 'chat_message':
|
||||
|
||||
@@ -80,7 +80,13 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
||||
const connectionId = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||
connectedUsers.set(connectionId, {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
lastPong: now
|
||||
});
|
||||
|
||||
ws.on('pong', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws';
|
||||
export interface ConnectedUser {
|
||||
oderId: string;
|
||||
ws: WebSocket;
|
||||
authenticated: boolean;
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
|
||||
Reference in New Issue
Block a user