Migrate server setup functionality
Changes:
- Check if admin is set up
- Redirect to admin setup if not already done
- Create admin password
- Login as admin and get access token for future requests
- Add a router for setup
- Middleware to prevent any route from being accessible on the server till the admin setup is done
GitLab: #80
GitLab: #73
Change-Id: I8b7ecab68f6b4d5c6313ce2e72a4ae4fdef9eda0
diff --git a/server/.gitignore b/server/.gitignore
index efc99e0..12fee05 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -1 +1,2 @@
creds.json
+admin.json
\ No newline at end of file
diff --git a/server/src/admin-config.ts b/server/src/admin-config.ts
new file mode 100644
index 0000000..b52d608
--- /dev/null
+++ b/server/src/admin-config.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 { readFileSync } from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+
+import { Service } from 'typedi';
+
+@Service()
+export class AdminConfig {
+ private readonly file = 'admin.json';
+ private account: { admin: string };
+
+ constructor() {
+ let buffer: Buffer;
+
+ try {
+ buffer = readFileSync(this.file);
+ } catch (e) {
+ console.error(e);
+ buffer = Buffer.from('{}');
+ }
+
+ this.account = JSON.parse(buffer.toString());
+ }
+
+ get() {
+ return this.account.admin;
+ }
+
+ set(password: string) {
+ this.account.admin = password;
+ }
+
+ async save() {
+ await writeFile(this.file, JSON.stringify(this.account) + '/n');
+ }
+}
diff --git a/server/src/app.ts b/server/src/app.ts
index 4c1914b..f6086cd 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -23,6 +23,7 @@
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';
import { callRouter } from './routers/call-router.js';
@@ -30,6 +31,7 @@
import { conversationRouter } from './routers/conversation-router.js';
import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
import { nameserverRouter } from './routers/nameserver-router.js';
+import { setupRouter } from './routers/setup-router.js';
@Service()
export class App {
@@ -41,6 +43,10 @@
app.use(cors());
app.use(json());
+ // Enforce admin setup
+ app.use('/setup', setupRouter);
+ app.use(checkAdminSetup);
+
// Setup routing
app.use('/auth', authRouter);
app.use('/account', accountRouter);
diff --git a/server/src/middleware/setup.ts b/server/src/middleware/setup.ts
new file mode 100644
index 0000000..3575fd0
--- /dev/null
+++ b/server/src/middleware/setup.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 { NextFunction, Request, Response } from 'express';
+import { HttpStatusCode } from 'jami-web-common';
+import { Container } from 'typedi';
+
+import { AdminConfig } from '../admin-config.js';
+
+const adminConfig = Container.get(AdminConfig);
+
+export async function checkAdminSetup(_req: Request, res: Response, next: NextFunction) {
+ const isSetupComplete = adminConfig.get() !== undefined;
+
+ if (!isSetupComplete) {
+ res.sendStatus(HttpStatusCode.Forbidden);
+ return;
+ }
+ next();
+}
diff --git a/server/src/routers/setup-router.ts b/server/src/routers/setup-router.ts
new file mode 100644
index 0000000..d155ef5
--- /dev/null
+++ b/server/src/routers/setup-router.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 argon2 from 'argon2';
+import { Router } from 'express';
+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';
+
+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;
+ res.send({ isSetupComplete });
+});
+
+setupRouter.post(
+ '/admin/create',
+ asyncHandler(async (req: Request<ParamsDictionary, string, { password?: string }>, res, _next) => {
+ const isAdminCreated = adminConfig.get() !== undefined;
+ if (isAdminCreated) {
+ res.sendStatus(HttpStatusCode.BadRequest);
+ return;
+ }
+
+ const { password } = req.body;
+ if (!password) {
+ res.status(HttpStatusCode.BadRequest).send('Missing password');
+ return;
+ }
+
+ const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
+ adminConfig.set(hashedPassword);
+ await adminConfig.save();
+
+ res.sendStatus(HttpStatusCode.Created);
+ })
+);
+
+// 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.
+setupRouter.use(checkAdminSetup);
+
+setupRouter.post(
+ '/admin/login',
+ asyncHandler(
+ async (req: Request<ParamsDictionary, { accessToken: string } | string, { password: string }>, res, _next) => {
+ const { password } = req.body;
+ if (!password) {
+ res.status(HttpStatusCode.BadRequest).send('Missing password');
+ return;
+ }
+
+ const hashedPassword = adminConfig.get();
+ const isPasswordVerified = await argon2.verify(hashedPassword, password);
+ if (!isPasswordVerified) {
+ res.sendStatus(HttpStatusCode.Forbidden);
+ 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);
+ res.send({ accessToken: jwt });
+ }
+ )
+);