server: add account creation and login
GitLab: #10
Change-Id: Iddd7ecee7210bc7b839cb2cec5d4291ac725d104
diff --git a/server/src/app.ts b/server/src/app.ts
index f126ed4..04cf04b 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -23,7 +23,7 @@
import { AuthRouter } from './routers/auth-router.js';
@Service()
-class App {
+export class App {
constructor(private authRouter: AuthRouter) {}
async build() {
@@ -47,5 +47,3 @@
return app;
}
}
-
-export { App };
diff --git a/server/src/constants.ts b/server/src/constants.ts
index 5a1666b..1e50c68 100644
--- a/server/src/constants.ts
+++ b/server/src/constants.ts
@@ -16,7 +16,7 @@
* <https://www.gnu.org/licenses/>.
*/
-enum StatusCode {
+export enum StatusCode {
OK = 200,
CREATED = 201,
ACCEPTED = 202,
@@ -33,5 +33,3 @@
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
}
-
-export { StatusCode };
diff --git a/server/src/creds.ts b/server/src/creds.ts
new file mode 100644
index 0000000..9c43dc5
--- /dev/null
+++ b/server/src/creds.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 661693f..3c40b28 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -23,12 +23,14 @@
import { Container } from 'typedi';
import { App } from './app.js';
+import { Creds } from './creds.js';
import { Ws } from './ws.js';
log.setLevel(process.env.NODE_ENV === 'production' ? 'error' : 'trace');
const port: string | number = 5000;
+await Container.get(Creds).build();
const app = await Container.get(App).build();
const wss = await Container.get(Ws).build();
diff --git a/server/src/jamid.ts b/server/src/jamid.ts
new file mode 100644
index 0000000..34531cd
--- /dev/null
+++ b/server/src/jamid.ts
@@ -0,0 +1,258 @@
+/*
+ * 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 log from 'loglevel';
+import { filter, firstValueFrom, Subject } from 'rxjs';
+import { Service } from 'typedi';
+
+import { itMap, itRange, itToArr, itToMap, require } from './utils.js';
+
+enum Bool {
+ False = 'false',
+ True = 'true',
+}
+
+interface SwigVec<T> {
+ size(): number;
+ get(i: number): T; // TODO: | undefined;
+}
+
+interface SwigMap<T, U> {
+ keys(): SwigVec<T>;
+ get(k: T): U; // TODO: | undefined;
+ set(k: T, v: U): void;
+}
+
+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)]);
+
+// type IntVect = SwigVec<number>;
+// type UintVect = SwigVec<number>;
+// type FloatVect = SwigVec<number>;
+type StringVect = SwigVec<string>;
+// type IntegerMap = SwigMap<string, number>;
+type StringMap = SwigMap<string, string>;
+// type VectMap = SwigVec<StringMap>;
+// type Blob = SwigVec<number>;
+
+const stringVectToArr = (sv: StringVect) => itToArr(swigVecToIt(sv));
+const stringMapToMap = (sm: StringMap) => itToMap(swigMapToIt(sm));
+// const vectMapToJs = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToMap));
+
+interface JamiSwig {
+ init(args: Record<string, unknown>): void;
+
+ // IntVect(): IntVect;
+ // UintVect(): UintVect;
+ // FloatVect(): FloatVect;
+ // StringVect(): StringVect;
+ // IntegerMap(): IntegerMap
+ // StringMap(): StringMap;
+ // VectMap(): VectMap;
+ // IntegerMap(): IntegerMap;
+
+ addAccount(details: StringMap): string;
+ removeAccount(id: string): void;
+
+ getAccountList(): StringVect;
+
+ registerName(id: string, password: string, username: string): boolean;
+ lookupName(id: string, nameserver: string, username: string): boolean;
+ lookupAddress(id: string, nameserver: string, address: string): boolean;
+
+ getAccountDetails(id: string): StringMap;
+ setAccountDetails(id: string, details: StringMap): void;
+ setAccountActive(id: string, active: Bool): void;
+}
+
+enum JamiSignal {
+ // using DRing::ConfigurationSignal;
+ AccountsChanged = 'AccountsChanged',
+ AccountDetailsChanged = 'AccountDetailsChanged',
+ RegistrationStateChanged = 'RegistrationStateChanged',
+ ContactAdded = 'ContactAdded',
+ ContactRemoved = 'ContactRemoved',
+ ExportOnRingEnded = 'ExportOnRingEnded',
+ NameRegistrationEnded = 'NameRegistrationEnded',
+ RegisteredNameFound = 'RegisteredNameFound',
+ VolatileDetailsChanged = 'VolatileDetailsChanged',
+ KnownDevicesChanged = 'KnownDevicesChanged',
+ IncomingAccountMessage = 'IncomingAccountMessage',
+ AccountMessageStatusChanged = 'AccountMessageStatusChanged',
+
+ // using DRing::CallSignal;
+ StateChange = 'StateChange',
+ IncomingMessage = 'IncomingMessage',
+ IncomingCall = 'IncomingCall',
+ IncomingCallWithMedia = 'IncomingCallWithMedia',
+ MediaChangeRequested = 'MediaChangeRequested',
+
+ // using DRing::ConversationSignal;
+ ConversationLoaded = 'ConversationLoaded',
+ MessagesFound = 'MessagesFound',
+ MessageReceived = 'MessageReceived',
+ ConversationProfileUpdated = 'ConversationProfileUpdated',
+ ConversationRequestReceived = 'ConversationRequestReceived',
+ ConversationRequestDeclined = 'ConversationRequestDeclined',
+ ConversationReady = 'ConversationReady',
+ ConversationRemoved = 'ConversationRemoved',
+ ConversationMemberEvent = 'ConversationMemberEvent',
+ OnConversationError = 'OnConversationError',
+ OnConferenceInfosUpdated = 'OnConferenceInfosUpdated',
+}
+
+interface VolatileDetailsChanged {
+ accountId: string;
+ details: Map<string, string>;
+}
+
+interface RegistrationStateChanged {
+ accountId: string;
+ state: string;
+ code: number;
+ details: string;
+}
+
+interface NameRegistrationEnded {
+ accountId: string;
+ state: number;
+ username: string;
+}
+
+interface RegisteredNameFound {
+ accountId: string;
+ state: number;
+ address: string;
+ username: string;
+}
+
+@Service()
+export class Jamid {
+ private readonly jamid: JamiSwig;
+ private readonly mapUsernameToAccountId: Map<string, string>;
+ private readonly events;
+
+ constructor() {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ this.jamid = require('../jamid.node') as JamiSwig;
+
+ const handlers: Record<string, unknown> = {};
+ const handler = (sig: string) => {
+ return (...args: unknown[]) => log.warn('Unhandled', sig, args);
+ };
+ Object.keys(JamiSignal).forEach((sig) => (handlers[sig] = handler(sig)));
+
+ const onVolatileDetailsChanged = new Subject<VolatileDetailsChanged>();
+ handlers.VolatileDetailsChanged = (accountId: string, details: Record<string, string>) =>
+ onVolatileDetailsChanged.next({ accountId, details: new Map(Object.entries(details)) });
+
+ const onRegistrationStateChanged = new Subject<RegistrationStateChanged>();
+ handlers.RegistrationStateChanged = (accountId: string, state: string, code: number, details: string) =>
+ onRegistrationStateChanged.next({ accountId, state, code, details });
+
+ const onNameRegistrationEnded = new Subject<NameRegistrationEnded>();
+ handlers.NameRegistrationEnded = (accountId: string, state: number, username: string) =>
+ onNameRegistrationEnded.next({ accountId, state, username });
+
+ const onRegisteredNameFound = new Subject<RegisteredNameFound>();
+ handlers.RegisteredNameFound = (accountId: string, state: number, address: string, username: string) =>
+ onRegisteredNameFound.next({ accountId, state, address, username });
+
+ this.events = {
+ onVolatileDetailsChanged: onVolatileDetailsChanged.asObservable(),
+ onRegistrationStateChanged: onRegistrationStateChanged.asObservable(),
+ onNameRegistrationEnded: onNameRegistrationEnded.asObservable(),
+ onRegisteredNameFound: onRegisteredNameFound.asObservable(),
+ };
+
+ this.events.onVolatileDetailsChanged.subscribe(({ accountId, details }) => {
+ log.debug('[1] Received onVolatileDetailsChanged with', { accountId, details });
+ // Keep map of usernames to account IDs as Jamid cannot do this by itself (AFAIK)
+ const username = details.get('Account.registeredName');
+ if (username) {
+ this.mapUsernameToAccountId.set(username, accountId);
+ }
+ });
+ this.events.onRegistrationStateChanged.subscribe((ctx) =>
+ log.debug('[1] Received onRegistrationStateChanged with', ctx)
+ );
+ this.events.onNameRegistrationEnded.subscribe((ctx) => log.debug('[1] Received onNameRegistrationEnded with', ctx));
+ this.events.onRegisteredNameFound.subscribe((ctx) => log.debug('[1] Received onRegisteredNameFound with', ctx));
+
+ this.mapUsernameToAccountId = new Map<string, string>();
+
+ // 1. You cannot change event handlers after init
+ // 2. You cannot specify multiple handlers for the same event
+ // 3. You cannot specify a default handler
+ // So we rely on the Subject() instead of Observable()
+ // Also, handlers receive multiple argument instead of tuple or object!
+ this.jamid.init(handlers);
+ }
+
+ getAccountList() {
+ return stringVectToArr(this.jamid.getAccountList());
+ }
+
+ async createAccount(details: Map<string, string | number | boolean>) {
+ // TODO: add proper typing directly into JamiSwig
+ const stringMapDetails: StringMap = new (this.jamid as any).StringMap();
+
+ stringMapDetails.set('Account.type', 'RING');
+ itMap(details.entries(), ([k, v]) => stringMapDetails.set('Account.' + k, v.toString()));
+
+ const id = this.jamid.addAccount(stringMapDetails);
+ return firstValueFrom(
+ this.events.onRegistrationStateChanged.pipe(
+ filter(({ accountId }) => accountId === id),
+ // TODO: is it the only state?
+ filter(({ state }) => state === 'REGISTERED')
+ )
+ );
+ }
+
+ destroyAccount(id: string) {
+ this.jamid.removeAccount(id);
+ }
+
+ async registerUsername(id: string, username: string, password: string) {
+ const hasRingNs = this.jamid.registerName(id, password, username);
+ if (!hasRingNs) {
+ log.error('Jami does not have NS');
+ throw new Error('Jami does not have NS');
+ }
+ return firstValueFrom(this.events.onNameRegistrationEnded.pipe(filter(({ accountId }) => accountId === id)));
+ }
+
+ // TODO: Ideally, we would fetch the username directly from Jami instead of
+ // keeping an internal map.
+ usernameToAccountId(username: string) {
+ return this.mapUsernameToAccountId.get(username);
+ }
+
+ async lookupUsername(username: string) {
+ const hasRingNs = this.jamid.lookupName('', '', username);
+ if (!hasRingNs) {
+ log.error('Jami does not have NS');
+ throw new Error('Jami does not have NS');
+ }
+ return firstValueFrom(this.events.onRegisteredNameFound.pipe(filter((r) => r.username === username)));
+ }
+
+ getAccountDetails(id: string) {
+ return stringMapToMap(this.jamid.getAccountDetails(id));
+ }
+}
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index dec19b3..b5afd36 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -15,35 +15,119 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
+import argon2 from 'argon2';
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
+import { ParamsDictionary, Request } from 'express-serve-static-core';
+import { SignJWT } from 'jose';
+import log from 'loglevel';
import { Service } from 'typedi';
import { StatusCode } from '../constants.js';
+import { Creds } from '../creds.js';
+import { Jamid } from '../jamid.js';
+import { Vault } from '../vault.js';
+
+interface Credentials {
+ username?: string;
+ password?: string;
+}
@Service()
-class AuthRouter {
+export class AuthRouter {
+ constructor(private readonly jamid: Jamid, private readonly creds: Creds, private readonly vault: Vault) {}
+
async build() {
const router = Router();
+ const privKey = await this.vault.privKey();
+
router.post(
'/new-account',
- asyncHandler(async (_req, res, _next) => {
- await Promise.resolve(42);
- res.sendStatus(StatusCode.NOT_IMPLEMENTED);
+ asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+ const { username, password } = req.body;
+ if (!username || !password) {
+ res.status(StatusCode.BAD_REQUEST).send('Missing username or password');
+ return;
+ }
+
+ const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
+
+ // 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 this.jamid.createAccount(new Map());
+
+ // TODO: understand why the password arg in this call must be empty
+ const { state } = await this.jamid.registerUsername(accountId, username, '');
+ if (state !== 0) {
+ this.jamid.destroyAccount(accountId);
+ if (state === 2) {
+ res.status(StatusCode.BAD_REQUEST).send('Invalid username or password');
+ } else if (state === 3) {
+ res.status(StatusCode.CONFLICT).send('Username already exists');
+ } else {
+ log.error(`POST - Unhandled state ${state}`);
+ res.sendStatus(StatusCode.INTERNAL_SERVER_ERROR);
+ }
+ return;
+ }
+
+ this.creds.set(username, hashedPassword);
+ await this.creds.save();
+
+ res.sendStatus(StatusCode.CREATED);
})
);
router.post(
'/login',
- asyncHandler(async (_req, res, _next) => {
- await Promise.resolve(42);
- res.sendStatus(StatusCode.NOT_IMPLEMENTED);
+ asyncHandler(async (req: Request<ParamsDictionary, any, Credentials>, res, _next) => {
+ const { username, password } = req.body;
+ if (!username || !password) {
+ res.status(StatusCode.BAD_REQUEST).send('Missing username or password');
+ return;
+ }
+
+ // The account may either be:
+ // 1. not be found
+ // 2. found but not on this instance (but I'm not sure about this)
+ const accountId = this.jamid.usernameToAccountId(username);
+ if (accountId === undefined) {
+ res.status(StatusCode.NOT_FOUND).send('Username not found');
+ return;
+ }
+
+ // TODO: load the password from Jami
+ const hashedPassword = this.creds.get(username);
+ if (!hashedPassword) {
+ res.status(StatusCode.NOT_FOUND).send('Password not found');
+ return;
+ }
+
+ log.debug(this.jamid.getAccountDetails(accountId));
+
+ const isPasswordVerified = await argon2.verify(hashedPassword, password);
+ if (!isPasswordVerified) {
+ res.sendStatus(StatusCode.UNAUTHORIZED);
+ return;
+ }
+
+ const jwt = await new SignJWT({ id: accountId })
+ .setProtectedHeader({ alg: 'EdDSA' })
+ .setIssuedAt()
+ // TODO: use valid issuer and andiance
+ .setIssuer('urn:example:issuer')
+ .setAudience('urn:example:audience')
+ .setExpirationTime('2h')
+ .sign(privKey);
+ res.json({ accessToken: jwt });
})
);
return router;
}
}
-
-export { AuthRouter };
diff --git a/server/src/utils.ts b/server/src/utils.ts
new file mode 100644
index 0000000..4f0b027
--- /dev/null
+++ b/server/src/utils.ts
@@ -0,0 +1,52 @@
+/*
+ * 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';
+
+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 itToMap = <T, U>(it: Iterable<[T, U]>) => {
+ const m = new Map<T, U>();
+ for (const [k, v] of it) {
+ m.set(k, v);
+ }
+ return m;
+};
+
+export const require = createRequire(import.meta.url);
diff --git a/server/src/vault.ts b/server/src/vault.ts
new file mode 100644
index 0000000..a0ae67c
--- /dev/null
+++ b/server/src/vault.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 } from 'node:fs/promises';
+
+import { importPKCS8, importSPKI } from 'jose';
+import { Service } from 'typedi';
+
+@Service()
+export class Vault {
+ async privKey() {
+ const privKeyBuf = await readFile('privkey.pem');
+ return importPKCS8(privKeyBuf.toString(), 'EdDSA');
+ }
+
+ async pubKey() {
+ const pubKeyBuf = await readFile('pubkey.pem');
+ return importSPKI(pubKeyBuf.toString(), 'EdDSA');
+ }
+}
diff --git a/server/src/ws.ts b/server/src/ws.ts
index f75260a..7670695 100644
--- a/server/src/ws.ts
+++ b/server/src/ws.ts
@@ -18,32 +18,54 @@
import { IncomingMessage } from 'node:http';
import { Duplex } from 'node:stream';
+import { jwtVerify } from 'jose';
+import log from 'loglevel';
import { Service } from 'typedi';
+import { URL } from 'whatwg-url';
import { WebSocket, WebSocketServer } from 'ws';
-@Service()
-class Ws {
- async build() {
- await Promise.resolve(42);
+import { Vault } from './vault.js';
- const wss = new WebSocketServer({
- noServer: true,
- });
- wss.on('connection', (ws: WebSocket, _req: IncomingMessage, id: string) => {
- ws.on('message', (data) => {
- ws.send(
- JSON.stringify({
- id,
- data,
- })
- );
+@Service()
+export class Ws {
+ constructor(private readonly vault: Vault) {}
+
+ async build() {
+ const wss = new WebSocketServer({ noServer: true });
+ wss.on('connection', (ws: WebSocket, _req: IncomingMessage, accountId: string) => {
+ log.info('New connection', accountId);
+
+ ws.on('message', (_data) => {
+ ws.send(JSON.stringify({ accountId }));
});
});
+ const pubKey = await this.vault.pubKey();
+
return (request: IncomingMessage, socket: Duplex, head: Buffer) => {
- wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request, '42'));
+ // 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 400 Bad Request\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ jwtVerify(accessToken, pubKey, {
+ 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();
+ });
};
}
}
-
-export { Ws };