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/client/src/App.tsx b/client/src/App.tsx
index 3f95af2..c202c37 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -16,9 +16,27 @@
* <https://www.gnu.org/licenses/>.
*/
import { useState } from 'react';
-import { Outlet } from 'react-router-dom';
+import { json, LoaderFunctionArgs, Outlet, redirect } from 'react-router-dom';
import WelcomeAnimation from './components/welcome';
+import { apiUrl } from './utils/constants';
+
+export async function checkSetupStatus(): Promise<boolean> {
+ const url = new URL('/setup/check', apiUrl);
+ const response = await fetch(url);
+ const { isSetupComplete } = await response.json();
+ return isSetupComplete;
+}
+
+export async function appLoader({ request }: LoaderFunctionArgs) {
+ const initialUrl = new URL(request.url);
+ const isSetupComplete = await checkSetupStatus();
+
+ if (!isSetupComplete && initialUrl.pathname !== '/setup/login') {
+ return redirect('/setup/login');
+ }
+ return json({ isSetupComplete }, { status: 200 });
+}
const App = () => {
const [displayWelcome, setDisplayWelcome] = useState<boolean>(true);
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 69e8e0e..3185015 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -70,6 +70,14 @@
"registration_form_submit_button": "REGISTER",
"registration_form_to_login_text": "Already have an account?",
"registration_form_to_login_link": "LOG IN",
+ "logout": "LOGOUT",
+ "setup_login_title": "Jami web node setup",
+ "setup_login_welcome": "Welcome to the Jami web node setup.",
+ "setup_login_admin_creation": "Let's start by creating a new administrator account to control access to the server configuration.",
+ "password_placeholder": "Password",
+ "setup_login_password_placeholder_creation": "New password",
+ "setup_login_password_placeholder_repeat": "Repeat password",
+ "admin_creation_submit_button": "CREATE ADMIN ACCOUNT",
"severity_error": "Error",
"severity_success": "Success"
}
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index 61c3a73..b3098f4 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -70,6 +70,14 @@
"registration_form_submit_button": "S'INSCRIRE",
"registration_form_to_login_text": "Déjà inscrit?",
"registration_form_to_login_link": "SE CONNECTER",
+ "logout": "SE DÉCONNECTER",
+ "setup_login_title": "Configuration du noeud web Jami",
+ "setup_login_welcome": "Bienvenue à la configuration du noeud web Jami.",
+ "setup_login_admin_creation": "Commençons par créer un nouveau compte administrateur pour contrôler l'accès à la configuration du serveur.",
+ "password_placeholder": "Mot de passe",
+ "setup_login_password_placeholder_creation": "Nouveau mot de passe",
+ "setup_login_password_placeholder_repeat": "Répéter le mot de passe",
+ "admin_creation_submit_button": "CRÉER UN COMPTE ADMIN",
"severity_error": "Erreur",
"severity_success": "Succès"
}
diff --git a/client/src/pages/ServerSetup.tsx b/client/src/pages/ServerSetup.tsx
deleted file mode 100644
index e6ff0aa..0000000
--- a/client/src/pages/ServerSetup.tsx
+++ /dev/null
@@ -1,88 +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 GroupAddRounded from '@mui/icons-material/GroupAddRounded';
-import { Box, Card, CardContent, Container, Fab, Input, Typography } from '@mui/material';
-import { FormEvent, useState } from 'react';
-
-export default function ServerSetup() {
- const [password, setPassword] = useState('');
- const [passwordRepeat, setPasswordRepeat] = useState('');
- const [loading, setLoading] = useState(false);
-
- const isValid = () => password && password === passwordRepeat;
-
- const handleSubmit = (e: FormEvent) => {
- e.preventDefault();
- setLoading(true);
- if (!isValid()) return;
- // TODO: Migrate to new server
- // authManager.setup(password);
- };
-
- return (
- <Container className="message-list">
- <Card>
- <CardContent component="form" onSubmit={handleSubmit}>
- <Typography gutterBottom variant="h5" component="h2">
- Jami Web Node setup
- </Typography>
- <Typography variant="body2" color="textSecondary" component="p">
- Welcome to the Jami web node setup.
- <br />
- Let's start by creating a new administrator account to control access to the server configuration.
- </Typography>
-
- <Box style={{ textAlign: 'center', marginTop: 8, marginBottom: 16 }}>
- <div>
- <Input value="admin" name="username" autoComplete="username" disabled />
- </div>
- <div>
- <Input
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- name="password"
- type="password"
- placeholder="New password"
- autoComplete="new-password"
- disabled={loading}
- />
- </div>
- <div>
- <Input
- value={passwordRepeat}
- onChange={(e) => setPasswordRepeat(e.target.value)}
- name="password"
- error={!!passwordRepeat && !isValid()}
- type="password"
- placeholder="Repeat password"
- autoComplete="new-password"
- disabled={loading}
- />
- </div>
- </Box>
- <Box style={{ textAlign: 'center', marginTop: 24 }}>
- <Fab variant="extended" color="primary" type="submit" disabled={!isValid() || loading}>
- <GroupAddRounded />
- Create admin account
- </Fab>
- </Box>
- </CardContent>
- </Card>
- </Container>
- );
-}
diff --git a/client/src/pages/Setup.tsx b/client/src/pages/Setup.tsx
new file mode 100644
index 0000000..28aee3b
--- /dev/null
+++ b/client/src/pages/Setup.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { Button } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import { Navigate, useNavigate } from 'react-router-dom';
+
+import LoadingPage from '../components/Loading';
+
+export default function Setup() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const accessToken = localStorage.getItem('adminAccessToken');
+
+ const adminLogout = () => {
+ localStorage.removeItem('adminAccessToken');
+ navigate('/login');
+ };
+
+ if (!accessToken) {
+ return <Navigate to="/login" replace />;
+ }
+ return (
+ <>
+ <Button variant="contained" type="submit" onClick={adminLogout}>
+ {t('logout')}
+ </Button>
+ <LoadingPage />
+ </>
+ );
+}
diff --git a/client/src/pages/SetupLogin.tsx b/client/src/pages/SetupLogin.tsx
new file mode 100644
index 0000000..79079be
--- /dev/null
+++ b/client/src/pages/SetupLogin.tsx
@@ -0,0 +1,164 @@
+/*
+ * 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 GroupAddRounded from '@mui/icons-material/GroupAddRounded';
+import { Box, Card, CardContent, Container, Fab, Input, Typography } from '@mui/material';
+import { HttpStatusCode } from 'jami-web-common';
+import { FormEvent, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import { checkSetupStatus } from '../App';
+import { apiUrl } from '../utils/constants';
+
+export default function SetupLogin() {
+ const [isSetupComplete, setIsSetupComplete] = useState(false);
+ const [password, setPassword] = useState('');
+ const [passwordRepeat, setPasswordRepeat] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ checkSetupStatus().then(setIsSetupComplete);
+ }, []);
+
+ const adminCreation = async (password: string) => {
+ const url = new URL('/setup/admin/create', apiUrl);
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ password }),
+ });
+ } catch (e) {
+ throw new Error(`Admin creation failed`);
+ }
+
+ if (response.status !== HttpStatusCode.Created) {
+ throw new Error('Admin creation failed');
+ }
+ };
+
+ const adminLogin = async (password: string) => {
+ const url = new URL('/setup/admin/login', apiUrl);
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ password }),
+ });
+ } catch (err) {
+ throw new Error(`Admin login failed`);
+ }
+
+ if (response.status === HttpStatusCode.Forbidden) {
+ throw new Error('Invalid password');
+ }
+
+ if (response.status !== HttpStatusCode.Ok) {
+ throw new Error('Admin login failed');
+ }
+
+ const data: { accessToken: string } = await response.json();
+ localStorage.setItem('adminAccessToken', data.accessToken);
+ };
+
+ const isValid = isSetupComplete || (password && password === passwordRepeat);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ if (!isValid) return;
+
+ try {
+ if (!isSetupComplete) {
+ await adminCreation(password);
+ }
+ await adminLogin(password);
+ } catch (e) {
+ console.error(e);
+ navigate('/login');
+ return;
+ }
+ navigate('/setup');
+ };
+
+ return (
+ <Container className="message-list">
+ <Card>
+ <CardContent component="form" onSubmit={handleSubmit}>
+ <Typography gutterBottom variant="h5" component="h2">
+ {t('setup_login_title')}
+ </Typography>
+ <Typography variant="body2" color="textSecondary" component="p">
+ {t('setup_login_welcome')}
+ <br />
+ {isSetupComplete ? '' : t('setup_login_admin_creation')}
+ </Typography>
+
+ <Box style={{ textAlign: 'center', marginTop: 8, marginBottom: 16 }}>
+ <div>
+ <Input value="admin" name="username" autoComplete="username" disabled />
+ </div>
+ <div>
+ <Input
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ name="password"
+ type="password"
+ placeholder={
+ isSetupComplete ? t('password_placeholder') : t('setup_login_password_placeholder_creation')
+ }
+ autoComplete="new-password"
+ disabled={loading}
+ />
+ </div>
+ {!isSetupComplete && (
+ <div>
+ <Input
+ value={passwordRepeat}
+ onChange={(e) => setPasswordRepeat(e.target.value)}
+ name="password"
+ error={!!passwordRepeat && !isValid}
+ type="password"
+ placeholder={t('setup_login_password_placeholder_repeat')}
+ autoComplete="new-password"
+ disabled={loading}
+ />
+ </div>
+ )}
+ </Box>
+ <Box style={{ textAlign: 'center', marginTop: 24 }}>
+ <Fab variant="extended" color="primary" type="submit" disabled={!isValid || loading}>
+ <GroupAddRounded />
+ {isSetupComplete ? t('login_form_submit_button') : t('admin_creation_submit_button')}
+ </Fab>
+ </Box>
+ </CardContent>
+ </Card>
+ </Container>
+ );
+}
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 442473f..f378a37 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -17,14 +17,15 @@
*/
import { createBrowserRouter, createRoutesFromElements, Outlet, Route } from 'react-router-dom';
-import App from './App';
+import App, { appLoader } from './App';
import ContactList from './components/ContactList';
import AuthProvider from './contexts/AuthProvider';
import WebSocketProvider from './contexts/WebSocketProvider';
import AccountSettings from './pages/AccountSettings';
import CallInterface from './pages/CallInterface';
import Messenger from './pages/Messenger';
-import ServerSetup from './pages/ServerSetup';
+import Setup from './pages/Setup';
+import SetupLogin from './pages/SetupLogin';
import Welcome from './pages/Welcome';
import { ThemeDemonstrator } from './themes/ThemeDemonstrator';
import { RouteParams } from './utils/hooks';
@@ -34,8 +35,10 @@
export const router = createBrowserRouter(
createRoutesFromElements(
- <Route path="/" element={<App />}>
+ <Route path="/" element={<App />} loader={appLoader}>
<Route path="login" element={<Welcome />} />
+ <Route path="setup/login" element={<SetupLogin />} />
+ <Route path="setup" element={<Setup />} />
<Route path="theme" element={<ThemeDemonstrator />} />
<Route
element={
@@ -53,7 +56,6 @@
<Route path="contacts" element={<ContactList />} />
<Route index element={<Messenger />} />
</Route>
- <Route path="setup" element={<ServerSetup />} />
</Route>
)
);
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 });
+ }
+ )
+);