Reorganize server files and address TODO comments
Changes:
- Remove unneeded dependencies from package.json
- Remove unneeded async build() methods from services
- Use constructor as often as possible
- Rename and move storage services for clarity
- creds.ts -> accounts.ts, and creds.json -> accounts.json
- admin-config.ts -> admin-account.ts
- vault.ts -> signing-keys.ts
- Rename ws.ts to websocket-server.ts for clarity and consistency
- Make WebSocketServer initialize using constructor and bind server upgrade to WebSocketServer.upgrade
- Remove unused send-account-message endpoint from account-router.ts
- Set issuer and audience claims for JWT
- Create new utils/jwt.ts file to remove code duplication for JWT signing and verifying
- Delete utils.ts and merge it with jami-swig.ts
- Handle potentially undefined types in jami-swig.ts
- Replace hard to read one-liners with functions in jami-swig.ts
- Rename types in jami-swig.ts for consistency with daemon
- Remove handled/answered TODO comments
- Remove TODO comment about using .env for jamid.node as it does not work for require()
GitLab: #87
Change-Id: I1e5216ffa79ea34dd7e9b61540fb7e37d1f66c9f
diff --git a/server/src/app.ts b/server/src/app.ts
index f6086cd..27e6e99 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -22,7 +22,6 @@
import log from 'loglevel';
import { Service } from 'typedi';
-import { bindWebRTCCallbacks } from './handlers/webrtc-handler.js';
import { checkAdminSetup } from './middleware/setup.js';
import { accountRouter } from './routers/account-router.js';
import { authRouter } from './routers/auth-router.js';
@@ -32,44 +31,43 @@
import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
import { nameserverRouter } from './routers/nameserver-router.js';
import { setupRouter } from './routers/setup-router.js';
+import { bindWebRTCCallbacks } from './websocket/webrtc-handler.js';
@Service()
export class App {
- async build() {
- const app = express();
+ app = express();
+ constructor() {
// Setup middleware
- app.use(helmet());
- app.use(cors());
- app.use(json());
+ this.app.use(helmet());
+ this.app.use(cors());
+ this.app.use(json());
// Enforce admin setup
- app.use('/setup', setupRouter);
- app.use(checkAdminSetup);
+ this.app.use('/setup', setupRouter);
+ this.app.use(checkAdminSetup);
// Setup routing
- app.use('/auth', authRouter);
- app.use('/account', accountRouter);
- app.use('/contacts', contactsRouter);
- app.use('/default-moderators', defaultModeratorsRouter);
- app.use('/conversations', conversationRouter);
- app.use('/calls', callRouter);
- app.use('/ns', nameserverRouter);
+ this.app.use('/auth', authRouter);
+ this.app.use('/account', accountRouter);
+ this.app.use('/contacts', contactsRouter);
+ this.app.use('/default-moderators', defaultModeratorsRouter);
+ this.app.use('/conversations', conversationRouter);
+ this.app.use('/calls', callRouter);
+ this.app.use('/ns', nameserverRouter);
// Setup WebSocket callbacks
bindWebRTCCallbacks();
// Setup 404 error handling
- app.use((_req, res) => {
+ this.app.use((_req, res) => {
res.sendStatus(HttpStatusCode.NotFound);
});
// Setup internal error handling
- app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
+ this.app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
log.error(err);
res.status(HttpStatusCode.InternalServerError).send(err.message);
});
-
- return app;
}
}
diff --git a/server/src/creds.ts b/server/src/creds.ts
deleted file mode 100644
index 9c43dc5..0000000
--- a/server/src/creds.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program. If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { readFile, writeFile } from 'node:fs/promises';
-
-import { Service } from 'typedi';
-
-@Service()
-export class Creds {
- readonly file = 'creds.json';
- db: Record<string, string>;
-
- constructor() {
- this.db = {};
- }
-
- async build() {
- const buffer = await readFile(this.file).catch(() => Buffer.from('{}'));
- this.db = JSON.parse(buffer.toString());
- return this;
- }
-
- get(username: string) {
- return this.db[username];
- }
-
- set(username: string, password: string) {
- this.db[username] = password;
- }
-
- async save() {
- await writeFile(this.file, JSON.stringify(this.db) + '\n');
- }
-}
diff --git a/server/src/index.ts b/server/src/index.ts
index d00f78b..43c3e45 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -26,25 +26,23 @@
import { Container } from 'typedi';
import { App } from './app.js';
-import { Creds } from './creds.js';
import { Jamid } from './jamid/jamid.js';
-import { Vault } from './vault.js';
-import { Ws } from './ws.js';
+import { SigningKeys } from './storage/signing-keys.js';
+import { WebSocketServer } from './websocket/websocket-server.js';
log.setLevel(process.env.NODE_ENV === 'production' ? 'error' : 'trace');
const port: string | number = 5000;
-await Container.get(Creds).build();
-await Container.get(Vault).build();
+await Container.get(SigningKeys).build();
const jamid = Container.get(Jamid);
-const app = await Container.get(App).build();
-const wss = await Container.get(Ws).build();
+const app = Container.get(App);
+const webSocketServer = Container.get(WebSocketServer);
const server = createServer();
-server.on('request', app);
-server.on('upgrade', wss);
+server.on('request', app.app);
+server.on('upgrade', webSocketServer.upgrade.bind(webSocketServer));
server.listen(port);
server.on('error', onError);
diff --git a/server/src/interfaces.ts b/server/src/interfaces/constructable.ts
similarity index 100%
rename from server/src/interfaces.ts
rename to server/src/interfaces/constructable.ts
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 7182270..3b4ddd8 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -15,36 +15,66 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Constructable } from '../interfaces.js';
-import { itMap, itRange, itToArr, itToRecord } from './utils.js';
+import { Constructable } from '../interfaces/constructable.js';
-interface SwigVec<T> {
+interface SwigVect<T> {
size(): number;
- get(i: number): T; // TODO: | undefined;
+ get(index: number): T | undefined;
}
interface SwigMap<T, U> {
- keys(): SwigVec<T>;
- get(k: T): U; // TODO: | undefined;
- set(k: T, v: U): void;
+ keys(): SwigVect<T>;
+ get(key: T): U | undefined;
+ set(key: T, value: U): void;
}
-// TODO: Review these conversion functions
-const swigVecToIt = <T>(v: SwigVec<T>) => itMap(itRange(0, v.size()), (i) => v.get(i));
-const swigMapToIt = <T, U>(m: SwigMap<T, U>) => itMap(swigVecToIt(m.keys()), (k): [T, U] => [k, m.get(k)]);
-
// export type IntVect = SwigVec<number>;
// export type UintVect = SwigVec<number>;
// export type FloatVect = SwigVec<number>;
-export type StringVect = SwigVec<string>;
+export type StringVect = SwigVect<string>;
// export type IntegerMap = SwigMap<string, number>;
export type StringMap = SwigMap<string, string>;
-export type VectMap = SwigVec<StringMap>;
+export type VectMap = SwigVect<StringMap>;
// export type Blob = SwigVec<number>;
-export const stringVectToArray = (sv: StringVect) => itToArr(swigVecToIt(sv));
-export const stringMapToRecord = (sm: StringMap) => itToRecord(swigMapToIt(sm));
-export const vectMapToRecordArray = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToRecord));
+function* swigVectToIt<T>(swigVect: SwigVect<T>) {
+ const size = swigVect.size();
+ for (let i = 0; i < size; i++) {
+ yield swigVect.get(i)!;
+ }
+}
+
+function* swigMapToIt<T, U>(swigMap: SwigMap<T, U>) {
+ const keys = swigVectToIt(swigMap.keys());
+ for (const key of keys) {
+ const value = swigMap.get(key)!;
+ yield [key, value];
+ }
+}
+
+export function stringVectToArray(stringVect: StringVect): string[] {
+ const elements = swigVectToIt(stringVect);
+ return Array.from(elements);
+}
+
+export function stringMapToRecord(stringMap: StringMap): Record<string, string> {
+ const keyValuePairs = swigMapToIt(stringMap);
+ const record: Record<string, string> = {};
+ for (const [key, value] of keyValuePairs) {
+ record[key] = value;
+ }
+ return record;
+}
+
+export function vectMapToRecordArray(vectMap: VectMap): Record<string, string>[] {
+ const stringMaps = swigVectToIt(vectMap);
+ const records = [];
+ for (const stringMap of stringMaps) {
+ const record = stringMapToRecord(stringMap);
+ records.push(record);
+ }
+ return records;
+}
/**
* Non-exhaustive list of properties for JamiSwig.
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index 0c54adf..773b298 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -15,6 +15,8 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
+import { createRequire } from 'node:module';
+
import {
AccountDetails,
AccountTextMessage,
@@ -28,7 +30,7 @@
import { filter, firstValueFrom, map, Subject } from 'rxjs';
import { Service } from 'typedi';
-import { Ws } from '../ws.js';
+import { WebSocketServer } from '../websocket/websocket-server.js';
import { JamiSignal } from './jami-signal.js';
import {
AccountDetailsChanged,
@@ -47,22 +49,19 @@
VolatileDetailsChanged,
} from './jami-signal-interfaces.js';
import { JamiSwig, StringMap, stringMapToRecord, stringVectToArray, vectMapToRecordArray } from './jami-swig.js';
-import { require } from './utils.js';
-// TODO: Mechanism to map account IDs to a list of WebSockets
+const require = createRequire(import.meta.url);
+
// TODO: Convert Records to interfaces and replace them in common/ (e.g. Contact)
@Service()
export class Jamid {
- private readonly jamiSwig: JamiSwig;
- private readonly usernamesToAccountIds: Map<string, string>;
+ private jamiSwig: JamiSwig;
+ private usernamesToAccountIds = new Map<string, string>();
private readonly events;
- constructor(private ws: Ws) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- this.jamiSwig = require('../../jamid.node') as JamiSwig; // TODO: we should put the path in the .env
-
- this.usernamesToAccountIds = new Map<string, string>();
+ constructor(private webSocketServer: WebSocketServer) {
+ this.jamiSwig = require('../../jamid.node') as JamiSwig;
// Setup signal handlers
const handlers: Record<string, unknown> = {};
@@ -380,12 +379,17 @@
const message: WebSocketMessage<any> = JSON.parse(signal.payload['application/json']);
if (message === undefined) {
- log.debug('Undefined account message');
+ log.warn('Undefined account message');
+ return;
+ }
+
+ if (message.type === undefined || message.data === undefined) {
+ log.warn('Account message is not a valid WebSocketMessage (missing type or data fields)');
return;
}
if (!Object.values(WebSocketMessageType).includes(message.type)) {
- log.warn(`Unhandled account message type: ${message.type}`);
+ log.warn(`Invalid WebSocket message type: ${message.type}`);
return;
}
@@ -394,10 +398,8 @@
to: signal.accountId,
message: message.data.message,
};
- message.data = data;
- log.info(`Sending ${JSON.stringify(message)} to ${signal.accountId}`);
- this.ws.send(signal.accountId, message.type, data);
+ this.webSocketServer.send(signal.accountId, message.type, data);
});
this.events.onAccountMessageStatusChanged.subscribe((signal) => {
@@ -433,7 +435,7 @@
conversationId: signal.conversationId,
message: signal.message,
};
- this.ws.send(signal.accountId, WebSocketMessageType.ConversationMessage, data);
+ this.webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationMessage, data);
});
}
}
diff --git a/server/src/jamid/utils.ts b/server/src/jamid/utils.ts
deleted file mode 100644
index f692578..0000000
--- a/server/src/jamid/utils.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program. If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { createRequire } from 'node:module';
-
-// TODO: Move these functions to jami-swig.ts
-
-export function* itRange(lo: number, hi: number) {
- for (let i = lo; i < hi; ++i) {
- yield i;
- }
-}
-
-export function* itMap<T, U>(it: Iterable<T>, cb: (value: T, index: number) => U) {
- let i = 0;
- for (const item of it) {
- yield cb(item, i++);
- }
-}
-
-export function* itFilter<T>(it: Iterable<T>, cb: (value: T, index: number) => boolean) {
- let i = 0;
- for (const item of it) {
- if (cb(item, i++)) {
- yield item;
- }
- }
-}
-
-export const itToArr = <T>(it: Iterable<T>) => Array.from(it);
-
-export const itToRecord = <T>(it: Iterable<[string, T]>) => {
- const r: Record<string, T> = {};
- for (const [k, v] of it) {
- r[k] = v;
- }
- return r;
-};
-
-export const require = createRequire(import.meta.url);
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index 4f06992..7bbd20c 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -17,15 +17,11 @@
*/
import { NextFunction, Request, Response } from 'express';
import { HttpStatusCode } from 'jami-web-common';
-import { jwtVerify } from 'jose';
-import { Container } from 'typedi';
-import { Vault } from '../vault.js';
+import { verifyJwt } from '../utils/jwt.js';
function createAuthenticationMiddleware(isAuthenticationRequired: boolean) {
return async (req: Request, res: Response, next: NextFunction) => {
- const publicKey = Container.get(Vault).publicKey;
-
const authorizationHeader = req.headers.authorization;
if (!authorizationHeader) {
if (isAuthenticationRequired) {
@@ -45,11 +41,8 @@
}
try {
- const { payload } = await jwtVerify(token, publicKey, {
- issuer: 'urn:example:issuer',
- audience: 'urn:example:audience',
- });
- res.locals.accountId = payload.id;
+ const { payload } = await verifyJwt(token);
+ res.locals.accountId = payload.accountId;
next();
} catch (e) {
res.status(HttpStatusCode.Unauthorized).send('Invalid access token');
diff --git a/server/src/middleware/setup.ts b/server/src/middleware/setup.ts
index a697b5a..d9fc3e7 100644
--- a/server/src/middleware/setup.ts
+++ b/server/src/middleware/setup.ts
@@ -19,12 +19,12 @@
import { HttpStatusCode } from 'jami-web-common';
import { Container } from 'typedi';
-import { AdminConfig } from '../admin-config.js';
+import { AdminAccount } from '../storage/admin-account.js';
-const adminConfig = Container.get(AdminConfig);
+const adminAccount = Container.get(AdminAccount);
export async function checkAdminSetup(_req: Request, res: Response, next: NextFunction) {
- const isSetupComplete = adminConfig.get() !== undefined;
+ const isSetupComplete = adminAccount.get() !== undefined;
if (!isSetupComplete) {
res.status(HttpStatusCode.Forbidden).send('Setup not complete');
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
index 9b72667..505d1d9 100644
--- a/server/src/routers/account-router.ts
+++ b/server/src/routers/account-router.ts
@@ -15,10 +15,9 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Request, Router } from 'express';
+import { Router } from 'express';
import asyncHandler from 'express-async-handler';
-import { ParamsDictionary } from 'express-serve-static-core';
-import { AccountDetails, AccountTextMessage, HttpStatusCode } from 'jami-web-common';
+import { AccountDetails, HttpStatusCode } from 'jami-web-common';
import { Container } from 'typedi';
import { Jamid } from '../jamid/jamid.js';
@@ -67,15 +66,3 @@
res.sendStatus(HttpStatusCode.NoContent);
});
-
-// TODO: Should this endpoint be removed?
-accountRouter.post('/send-account-message', (req: Request<ParamsDictionary, any, AccountTextMessage<unknown>>, res) => {
- const { from, to, message } = req.body;
- if (from === undefined || to === undefined || message === undefined) {
- res.status(HttpStatusCode.BadRequest).send('Missing from, to, or message in body');
- return;
- }
-
- jamid.sendAccountTextMessage(from, to, JSON.stringify(message));
- res.sendStatus(HttpStatusCode.NoContent);
-});
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index f04529e..4255655 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -20,27 +20,25 @@
import asyncHandler from 'express-async-handler';
import { ParamsDictionary, Request } from 'express-serve-static-core';
import { HttpStatusCode } from 'jami-web-common';
-import { SignJWT } from 'jose';
import { Container } from 'typedi';
-import { Creds } from '../creds.js';
import { Jamid } from '../jamid/jamid.js';
-import { Vault } from '../vault.js';
+import { Accounts } from '../storage/accounts.js';
+import { signJwt } from '../utils/jwt.js';
interface Credentials {
- username?: string;
- password?: string;
+ username: string;
+ password: string;
}
const jamid = Container.get(Jamid);
-const creds = Container.get(Creds);
-const vault = Container.get(Vault);
+const accounts = Container.get(Accounts);
export const authRouter = Router();
authRouter.post(
'/new-account',
- asyncHandler(async (req: Request<ParamsDictionary, string, Credentials>, res, _next) => {
+ asyncHandler(async (req: Request<ParamsDictionary, string, Partial<Credentials>>, res, _next) => {
const { username, password } = req.body;
if (username === undefined || password === undefined) {
res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
@@ -57,12 +55,8 @@
// TODO: add JAMS support
// managerUri: 'https://jams.savoirfairelinux.com',
// managerUsername: data.username,
- // TODO: find a way to store the password directly in Jami
- // Maybe by using the "password" field? But as I tested, it's not
- // returned when getting user infos.
const accountId = await jamid.addAccount(new Map());
- // TODO: understand why the password arg in this call must be empty
const state = await jamid.registerUsername(accountId, username, '');
if (state !== 0) {
jamid.removeAccount(accountId);
@@ -76,8 +70,8 @@
return;
}
- creds.set(username, hashedPassword);
- await creds.save();
+ accounts.set(username, hashedPassword);
+ await accounts.save();
res.sendStatus(HttpStatusCode.Created);
})
@@ -85,45 +79,37 @@
authRouter.post(
'/login',
- asyncHandler(async (req: Request<ParamsDictionary, { accessToken: string } | string, Credentials>, res, _next) => {
- const { username, password } = req.body;
- if (username === undefined || password === undefined) {
- res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
- return;
- }
+ asyncHandler(
+ async (req: Request<ParamsDictionary, { accessToken: string } | string, Partial<Credentials>>, res, _next) => {
+ const { username, password } = req.body;
+ if (username === undefined || password === undefined) {
+ res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
+ return;
+ }
- // The account may either be:
- // 1. not found
- // 2. found but not on this instance (but I'm not sure about this)
- const accountId = jamid.getAccountIdFromUsername(username);
- if (accountId === undefined) {
- res.status(HttpStatusCode.NotFound).send('Username not found');
- return;
- }
+ // Check if the account is stored stored on this daemon instance
+ const accountId = jamid.getAccountIdFromUsername(username);
+ if (accountId === undefined) {
+ res.status(HttpStatusCode.NotFound).send('Username not found');
+ return;
+ }
- // TODO: load the password from Jami
- const hashedPassword = creds.get(username);
- if (!hashedPassword) {
- res
- .status(HttpStatusCode.NotFound)
- .send('Password not found (the account does not have a password set on the server)');
- return;
- }
+ const hashedPassword = accounts.get(username);
+ if (hashedPassword === undefined) {
+ res
+ .status(HttpStatusCode.NotFound)
+ .send('Password not found (the account does not have a password set on the server)');
+ return;
+ }
- const isPasswordVerified = await argon2.verify(hashedPassword, password);
- if (!isPasswordVerified) {
- res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
- return;
- }
+ const isPasswordVerified = await argon2.verify(hashedPassword, password);
+ if (!isPasswordVerified) {
+ res.status(HttpStatusCode.Unauthorized).send('Incorrect password');
+ return;
+ }
- const jwt = await new SignJWT({ id: accountId })
- .setProtectedHeader({ alg: 'EdDSA' })
- .setIssuedAt()
- // TODO: use valid issuer and audience
- .setIssuer('urn:example:issuer')
- .setAudience('urn:example:audience')
- .setExpirationTime('2h')
- .sign(vault.privateKey);
- res.send({ accessToken: jwt });
- })
+ const jwt = await signJwt(accountId);
+ res.send({ accessToken: jwt });
+ }
+ )
);
diff --git a/server/src/routers/setup-router.ts b/server/src/routers/setup-router.ts
index 7dfa24f..3bc3088 100644
--- a/server/src/routers/setup-router.ts
+++ b/server/src/routers/setup-router.ts
@@ -20,20 +20,18 @@
import asyncHandler from 'express-async-handler';
import { ParamsDictionary, Request } from 'express-serve-static-core';
import { HttpStatusCode } from 'jami-web-common';
-import { SignJWT } from 'jose';
import { Container } from 'typedi';
-import { AdminConfig } from '../admin-config.js';
import { checkAdminSetup } from '../middleware/setup.js';
-import { Vault } from '../vault.js';
+import { AdminAccount } from '../storage/admin-account.js';
+import { signJwt } from '../utils/jwt.js';
+
+const adminAccount = Container.get(AdminAccount);
export const setupRouter = Router();
-const vault = Container.get(Vault);
-const adminConfig = Container.get(AdminConfig);
-
setupRouter.get('/check', (_req, res, _next) => {
- const isSetupComplete = adminConfig.get() !== undefined;
+ const isSetupComplete = adminAccount.get() !== undefined;
res.send({ isSetupComplete });
});
@@ -51,7 +49,7 @@
return;
}
- const isAdminCreated = adminConfig.get() !== undefined;
+ const isAdminCreated = adminAccount.get() !== undefined;
if (isAdminCreated) {
res.status(HttpStatusCode.Conflict).send('Admin already exists');
return;
@@ -59,8 +57,8 @@
const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
- adminConfig.set(hashedPassword);
- await adminConfig.save();
+ adminAccount.set(hashedPassword);
+ await adminAccount.save();
res.sendStatus(HttpStatusCode.Created);
})
@@ -68,7 +66,7 @@
// Every request handler after this line will be submitted to this middleware
// in order to ensure that the admin account is set up before proceeding with
-// setup related requests
+// setup-related requests
setupRouter.use(checkAdminSetup);
setupRouter.post(
@@ -81,7 +79,11 @@
return;
}
- const hashedPassword = adminConfig.get();
+ const hashedPassword = adminAccount.get();
+ if (hashedPassword === undefined) {
+ res.status(HttpStatusCode.InternalServerError).send('Admin password not found');
+ return;
+ }
const isPasswordVerified = await argon2.verify(hashedPassword, password);
if (!isPasswordVerified) {
@@ -89,14 +91,7 @@
return;
}
- const jwt = await new SignJWT({ id: 'admin' })
- .setProtectedHeader({ alg: 'EdDSA' })
- .setIssuedAt()
- // TODO: use valid issuer and audience
- .setIssuer('urn:example:issuer')
- .setAudience('urn:example:audience')
- .setExpirationTime('2h')
- .sign(vault.privateKey);
+ const jwt = await signJwt('admin');
res.send({ accessToken: jwt });
}
)
diff --git a/server/src/admin-config.ts b/server/src/storage/accounts.ts
similarity index 66%
copy from server/src/admin-config.ts
copy to server/src/storage/accounts.ts
index 0f745b9..77a8d9d 100644
--- a/server/src/admin-config.ts
+++ b/server/src/storage/accounts.ts
@@ -21,32 +21,31 @@
import { Service } from 'typedi';
@Service()
-export class AdminConfig {
- private readonly file = 'admin.json';
- private account: { admin: string };
+export class Accounts {
+ private readonly filename = 'accounts.json';
+ private accounts: Record<string, string>;
constructor() {
let buffer: Buffer;
try {
- buffer = readFileSync(this.file);
+ buffer = readFileSync(this.filename);
} catch (e) {
- console.error(e);
buffer = Buffer.from('{}');
}
- this.account = JSON.parse(buffer.toString());
+ this.accounts = JSON.parse(buffer.toString());
}
- get() {
- return this.account.admin;
+ get(username: string): string | undefined {
+ return this.accounts[username];
}
- set(password: string) {
- this.account.admin = password;
+ set(username: string, password: string): void {
+ this.accounts[username] = password;
}
- async save() {
- await writeFile(this.file, JSON.stringify(this.account) + '\n');
+ async save(): Promise<void> {
+ await writeFile(this.filename, JSON.stringify(this.accounts, null, 2) + '\n');
}
}
diff --git a/server/src/admin-config.ts b/server/src/storage/admin-account.ts
similarity index 79%
rename from server/src/admin-config.ts
rename to server/src/storage/admin-account.ts
index 0f745b9..b207cfd 100644
--- a/server/src/admin-config.ts
+++ b/server/src/storage/admin-account.ts
@@ -21,32 +21,31 @@
import { Service } from 'typedi';
@Service()
-export class AdminConfig {
- private readonly file = 'admin.json';
+export class AdminAccount {
+ private readonly filename = 'admin.json';
private account: { admin: string };
constructor() {
let buffer: Buffer;
try {
- buffer = readFileSync(this.file);
+ buffer = readFileSync(this.filename);
} catch (e) {
- console.error(e);
buffer = Buffer.from('{}');
}
this.account = JSON.parse(buffer.toString());
}
- get() {
+ get(): string | undefined {
return this.account.admin;
}
- set(password: string) {
+ set(password: string): void {
this.account.admin = password;
}
- async save() {
- await writeFile(this.file, JSON.stringify(this.account) + '\n');
+ async save(): Promise<void> {
+ await writeFile(this.filename, JSON.stringify(this.account, null, 2) + '\n');
}
}
diff --git a/server/src/vault.ts b/server/src/storage/signing-keys.ts
similarity index 92%
rename from server/src/vault.ts
rename to server/src/storage/signing-keys.ts
index 07e2914..8e48c94 100644
--- a/server/src/vault.ts
+++ b/server/src/storage/signing-keys.ts
@@ -19,7 +19,7 @@
import { Service } from 'typedi';
@Service()
-export class Vault {
+export class SigningKeys {
privateKey!: KeyLike;
publicKey!: KeyLike;
@@ -27,7 +27,7 @@
const privatekey = process.env.PRIVATE_KEY;
const publicKey = process.env.PUBLIC_KEY;
- if (!privatekey || !publicKey) {
+ if (privatekey === undefined || publicKey === undefined) {
throw new Error('Missing private or public key environment variables. Try running "npm run genkeys"');
}
diff --git a/server/src/utils/jwt.ts b/server/src/utils/jwt.ts
new file mode 100644
index 0000000..672c5c3
--- /dev/null
+++ b/server/src/utils/jwt.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { jwtVerify, JWTVerifyResult, SignJWT } from 'jose';
+import { Container } from 'typedi';
+
+import { SigningKeys } from '../storage/signing-keys.js';
+
+const jwtIssuer = 'https://jami.net/';
+const jwtAudience = 'https://jami.net/';
+
+const signingKeys = Container.get(SigningKeys);
+
+export async function signJwt(accountId: string): Promise<string> {
+ return new SignJWT({ accountId })
+ .setProtectedHeader({ alg: 'EdDSA' })
+ .setIssuedAt()
+ .setIssuer(jwtIssuer)
+ .setAudience(jwtAudience)
+ .setExpirationTime('2h')
+ .sign(signingKeys.privateKey);
+}
+
+export async function verifyJwt(token: string): Promise<JWTVerifyResult> {
+ return jwtVerify(token, signingKeys.publicKey, {
+ issuer: jwtIssuer,
+ audience: jwtAudience,
+ });
+}
diff --git a/server/src/handlers/webrtc-handler.ts b/server/src/websocket/webrtc-handler.ts
similarity index 68%
rename from server/src/handlers/webrtc-handler.ts
rename to server/src/websocket/webrtc-handler.ts
index 409a030..e6ebcbc 100644
--- a/server/src/handlers/webrtc-handler.ts
+++ b/server/src/websocket/webrtc-handler.ts
@@ -20,21 +20,22 @@
import { Container } from 'typedi';
import { Jamid } from '../jamid/jamid.js';
-import { Ws } from '../ws.js';
+import { WebSocketServer } from './websocket-server.js';
const jamid = Container.get(Jamid);
-const ws = Container.get(Ws);
+const webSocketServer = Container.get(WebSocketServer);
function sendWebRTCData<T>(data: Partial<AccountTextMessage<T>>) {
- if (!data.from || !data.to || !data.message) {
- log.warn('Incorrect format for AccountTextMessage (require from, to and message):', data);
+ if (data.from === undefined || data.to === undefined || data.message === undefined) {
+ log.warn('Message is not a valid AccountTextMessage (missing from, to, or message fields)');
return;
}
+
jamid.sendAccountTextMessage(data.from, data.to, JSON.stringify(data.message));
}
export function bindWebRTCCallbacks() {
- ws.bind(WebSocketMessageType.WebRTCOffer, sendWebRTCData);
- ws.bind(WebSocketMessageType.WebRTCAnswer, sendWebRTCData);
- ws.bind(WebSocketMessageType.IceCandidate, sendWebRTCData);
+ webSocketServer.bind(WebSocketMessageType.WebRTCOffer, sendWebRTCData);
+ webSocketServer.bind(WebSocketMessageType.WebRTCAnswer, sendWebRTCData);
+ webSocketServer.bind(WebSocketMessageType.IceCandidate, sendWebRTCData);
}
diff --git a/server/src/websocket/websocket-server.ts b/server/src/websocket/websocket-server.ts
new file mode 100644
index 0000000..6750129
--- /dev/null
+++ b/server/src/websocket/websocket-server.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { IncomingMessage } from 'node:http';
+import { Duplex } from 'node:stream';
+
+import { WebSocketCallbacks, WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
+import log from 'loglevel';
+import { Service } from 'typedi';
+import { URL } from 'whatwg-url';
+import * as WebSocket from 'ws';
+
+import { verifyJwt } from '../utils/jwt.js';
+
+@Service()
+export class WebSocketServer {
+ private wss = new WebSocket.WebSocketServer({ noServer: true });
+ private sockets = new Map<string, WebSocket.WebSocket[]>();
+ private callbacks: WebSocketCallbacks = {
+ [WebSocketMessageType.ConversationMessage]: [],
+ [WebSocketMessageType.ConversationView]: [],
+ [WebSocketMessageType.WebRTCOffer]: [],
+ [WebSocketMessageType.WebRTCAnswer]: [],
+ [WebSocketMessageType.IceCandidate]: [],
+ };
+
+ constructor() {
+ this.wss.on('connection', (ws: WebSocket.WebSocket, _request: IncomingMessage, accountId: string) => {
+ log.info('New connection for account', accountId);
+ const accountSockets = this.sockets.get(accountId);
+ if (accountSockets) {
+ accountSockets.push(ws);
+ } else {
+ this.sockets.set(accountId, [ws]);
+ }
+
+ ws.on('message', <T extends WebSocketMessageType>(messageString: string) => {
+ const message: WebSocketMessage<T> = JSON.parse(messageString);
+ if (message.type === undefined || message.data === undefined) {
+ log.warn('WebSocket message is not a valid WebSocketMessage (missing type or data fields)');
+ return;
+ }
+
+ if (!Object.values(WebSocketMessageType).includes(message.type)) {
+ log.warn(`Invalid WebSocket message type: ${message.type}`);
+ return;
+ }
+
+ const callbacks = this.callbacks[message.type];
+ for (const callback of callbacks) {
+ callback(message.data);
+ }
+ });
+
+ ws.on('close', () => {
+ log.info('Closing connection for account', accountId);
+ const accountSockets = this.sockets.get(accountId);
+ if (accountSockets === undefined) {
+ return;
+ }
+
+ const index = accountSockets.indexOf(ws);
+ if (index !== -1) {
+ accountSockets.splice(index, 1);
+ if (accountSockets.length === 0) {
+ this.sockets.delete(accountId);
+ }
+ }
+ });
+ });
+ }
+
+ async upgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
+ // Do not use parseURL because it returns a URLRecord and not a URL
+ const url = new URL(request.url ?? '/', 'http://localhost/');
+ const token = url.searchParams.get('accessToken') ?? undefined;
+ if (token === undefined) {
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ try {
+ const { payload } = await verifyJwt(token);
+ const accountId = payload.accountId as string;
+ log.info('Authentication successful for account', accountId);
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
+ this.wss.emit('connection', ws, request, accountId);
+ });
+ } catch (e) {
+ log.debug('Authentication failed:', e);
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ }
+ }
+
+ bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void): void {
+ this.callbacks[type].push(callback);
+ }
+
+ send<T extends WebSocketMessageType>(accountId: string, type: T, data: WebSocketMessageTable[T]): boolean {
+ const accountSockets = this.sockets.get(accountId);
+ if (accountSockets === undefined) {
+ return false;
+ }
+
+ const webSocketMessageString = JSON.stringify({ type, data });
+ for (const accountSocket of accountSockets) {
+ accountSocket.send(webSocketMessageString);
+ }
+ return true;
+ }
+}
diff --git a/server/src/ws.ts b/server/src/ws.ts
deleted file mode 100644
index d33dd76..0000000
--- a/server/src/ws.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program. If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { IncomingMessage } from 'node:http';
-import { Duplex } from 'node:stream';
-
-import { WebSocketCallbacks, WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
-import { jwtVerify } from 'jose';
-import log from 'loglevel';
-import { Service } from 'typedi';
-import { URL } from 'whatwg-url';
-import { WebSocket, WebSocketServer } from 'ws';
-
-import { Vault } from './vault.js';
-
-@Service()
-export class Ws {
- private sockets: Map<string, WebSocket[]>;
- private callbacks: WebSocketCallbacks;
-
- constructor(private readonly vault: Vault) {
- this.sockets = new Map();
- this.callbacks = {
- [WebSocketMessageType.ConversationMessage]: [],
- [WebSocketMessageType.ConversationView]: [],
- [WebSocketMessageType.WebRTCOffer]: [],
- [WebSocketMessageType.WebRTCAnswer]: [],
- [WebSocketMessageType.IceCandidate]: [],
- };
- }
-
- async build() {
- const wss = new WebSocketServer({ noServer: true });
-
- wss.on('connection', (ws: WebSocket, _req: IncomingMessage, accountId: string) => {
- log.info('New connection', accountId);
- const accountSockets = this.sockets.get(accountId);
- if (accountSockets) {
- accountSockets.push(ws);
- } else {
- this.sockets.set(accountId, [ws]);
- }
-
- ws.on('message', <T extends WebSocketMessageType>(messageString: string) => {
- const message: WebSocketMessage<T> = JSON.parse(messageString);
- if (!message.type || !message.data) {
- ws.send('Incorrect format (require type and data)');
- return;
- }
- if (!Object.values(WebSocketMessageType).includes(message.type)) {
- log.warn(`Unhandled account message type: ${message.type}`);
- return;
- }
- const callbacks = this.callbacks[message.type];
- for (const callback of callbacks) {
- callback(message.data);
- }
- });
-
- ws.on('close', () => {
- log.info('Connection close', accountId);
- const accountSockets = this.sockets.get(accountId);
- const index = accountSockets?.indexOf(ws);
- if (index !== undefined) {
- accountSockets?.splice(index, 1);
- if (accountSockets?.length === 0) {
- this.sockets.delete(accountId);
- }
- }
- });
- });
-
- return (request: IncomingMessage, socket: Duplex, head: Buffer) => {
- // Do not use parseURL because it returns a URLRecord and not a URL.
- const url = new URL(request.url ?? '/', 'http://localhost/');
- const accessToken = url.searchParams.get('accessToken');
- if (!accessToken) {
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
- socket.destroy();
- return;
- }
-
- jwtVerify(accessToken, this.vault.publicKey, {
- issuer: 'urn:example:issuer',
- audience: 'urn:example:audience',
- })
- .then(({ payload }) => {
- const id = payload.id as string;
- log.info('Authentication successful', id);
- wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, id));
- })
- .catch((reason) => {
- log.debug('Authentication failed', reason);
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
- socket.destroy();
- });
- };
- }
-
- bind<T extends WebSocketMessageType>(type: T, callback: (data: WebSocketMessageTable[T]) => void): void {
- this.callbacks[type].push(callback);
- }
-
- send<T extends WebSocketMessageType>(accountId: string, type: T, data: WebSocketMessageTable[T]): boolean {
- const accountSockets = this.sockets.get(accountId);
- if (!accountSockets) {
- return false;
- }
- for (const accountSocket of accountSockets) {
- accountSocket.send(JSON.stringify({ type, data }));
- }
- return true;
- }
-}