Multiplayer UI - Two factor authentication login (Part 2)

ui tutorials

8/6/2024

Kaloyan Geshev

Enabling two-factor authentication adds an extra layer of security, ensuring that only the rightful account owner can access it. This is typically achieved by generating a QR code that links the user’s account with an authenticator app like Google Authenticator. The app then generates a code required for logging into the account. The server processes this code to verify the login attempt.

You can find the rest Multiplayer UI series here.

Prerequisites

In this tutorial, we will use otplib implementing two-factor authentication and qrcode to generate the QR code needed for linking the user’s account with the Google Authenticator app.

Showcase Overview

We will extend the express server created for the Multiplayer UI in this post by adding two-factor authentication during user login. Additionally, we will include a new UI page for handling this authentication and displaying the QR code for the user to scan with the authenticator app.

Source location

You can find the complete sample source within the ${Gameface package}/Samples/uiresources/UITutorials/MultiplayerUI directory.

  • src folder contains the UI source code.
  • api folder contains the Express server source code.

Ensure to run npm i before testing the sample.

By default, two-factor authentication is disabled. To test it, run the express server locally by either:

  1. Executing npm run start:server:2fa to enable login authentication with 2FA.
  2. Adding TWO_FACTOR_AUTHENTICATION_ENABLED="true" to the .env file and then starting the server locally with the npm run server:start command.

Refer to the README.md file in this directory for information on running the sample locally or previewing it without building or starting any processes.

Getting started - Backend

We will begin by modifying the backend to enable 2FA in the login method and adding additional APIs for handling code verification and linking to Google Authenticator.

First, install the required modules:

Terminal window
1
npm i otplib qrcode dotenv

Add additional fields to the user

To store information about two-factor authentication in the user account, we need to extend the schema of the user in our database. We will add two new fields:

/api/db/user.js
1
const mongoose = require('mongoose');
2
3
const schema = new mongoose.Schema({
4
firstName: String,
5
lastName: String,
6
email: String,
7
password: String,
8
status: Boolean,
9
totpSecret: String,
10
twoFactorEnabled: Boolean,
11
friends: [{ type: String }]
12
});
13
14
module.exports = mongoose.model('user', schema);

The totpSecret field stores the generated secret needed for user authentication via the Authenticator app, while twoFactorEnabled indicates if the user’s account is linked with the app.

Enable 2FA check in the login method

To enable 2FA during user login, we need to update our login method.

/api/controllers/userController.js
1
const { authenticator } = require('otplib');
2
const qrcode = require('qrcode');
3
4
class UserController {
5
async login(req, res) {
6
const sessionId = req.headers['session-id'];
7
if (sessionId && await sessions.findOne({ _id: sessionId })) return res.send(sessionId);
8
9
const user = await User.findOne({ email: req.body.email, password: req.body.password });
10
if (!user) return res.status(404).send('Wrong email or password!');
11
12
if (!process.env.TWO_FACTOR_AUTHENTICATION_ENABLED) {
13
user.status = true;
14
await user.save();
15
emit('user-online', user.id);
16
res.json({ id: user._id.toString(), sessionId: req.sessionID, firstName: user.firstName, lastName: user.lastName });
17
return res.json({ id: user._id.toString(), sessionId: req.sessionID, firstName: user.firstName, lastName: user.lastName });
18
}
19
20
if (!user.totpSecret) {
21
const secret = authenticator.generateSecret();
22
const keyUri = authenticator.keyuri(user.email, 'CoherentSample', secret);
23
const secretQrCode = await qrcode.toDataURL(keyUri);
24
return res.status(403).json({ error: 'missing_totp', secretQrCode, secret, id: user._id.toString() });
25
}
26
27
return res.status(403).json({ error: 'totp_verification_required', id: user._id.toString() });
28
}
29
}

By default, 2FA is disabled during user login. This behavior is controlled via the TWO_FACTOR_AUTHENTICATION_ENABLED environment variable. To enable environment variables on the server, we previously installed the dotenv module and need to import it in the index.js file.

/api/index.js
1
const server = createServer(app);
2
require('dotenv').config();

Without the TWO_FACTOR_AUTHENTICATION_ENABLED variable, 2FA will remain disabled.

When 2FA is enabled, we need to handle user login properly by checking if the user has already linked the Authenticator app. We determine this by checking if the user has a totpSecret in the database - if (!user.totpSecret).

  1. If the user has a secret stored in the database, we return an error indicating that TOTP verification is required. The user should enter the generated code from the Authenticator app into the UI.
/api/controllers/userController.js
1
return res.status(403).json({ error: 'totp_verification_required', id: user._id.toString() });
  1. If the user does not have a secret stored in the database, the account should be linked with the Authenticator app. We generate a new secret and a QR code for adding the secret to the app by scanning it. The generated data is then sent to the UI.
/api/controllers/userController.js
1
if (!user.totpSecret) {
2
const secret = authenticator.generateSecret();
3
const keyUri = authenticator.keyuri(user.email, 'CoherentSample', secret);
4
const secretQrCode = await qrcode.toDataURL(keyUri);
5
return res.status(403).json({ error: 'missing_totp', secretQrCode, secret, id: user._id.toString() });
6
}

Entries for linking the app or verifying the code.

When the user needs to authenticate using the Authenticator app, there are two scenarios: the user has either already linked the app to their account or not. To handle these scenarios, we will create two new routes:

For that reason we will create two new entry points to the user routes:

/api/config/routes/user.js
1
router.post('/bind-totp', UserController.bindTotp);
2
router.post('/verify-totp', UserController.totpVerify);

bindTotp method

To link the Authenticator app with the user account, we need to validate the generated code from the app and then store the secret in the user’s account if the code is correct. After this, the user can be logged in.

/api/controllers/userController.js
1
class UserController {
2
async bindTotp(req, res) {
3
const { totpCode, userId, pendingTotpSecret } = req.body;
4
if (!userId) return res.status(404).send('Unauthorized');
5
6
const user = await User.findById(userId);
7
if (!user) return res.status(404).send('Unauthorized');
8
9
const totpVerified = authenticator.check(totpCode, pendingTotpSecret);
10
if (!totpVerified) return res.status(400).json({ error: 'Invalid TOTP code' });
11
12
user.twoFactorEnabled = true;
13
user.totpSecret = pendingTotpSecret;
14
user.status = true;
15
await user.save();
16
emit('user-online', user.id);
17
18
return res.json({ id: user._id.toString(), sessionId: req.sessionID, firstName: user.firstName, lastName: user.lastName });
19
}
20
}

totpVerify method

If the user has already linked the app to their account, we only need to check the generated code from the Authenticator app. If the code is correct, the user can successfully log in.

/api/controllers/userController.js
1
class UserController {
2
async totpVerify(req, res) {
3
const { totpCode, userId } = req.body;
4
if (!userId) return res.status(404).send('Unauthorized');
5
6
const user = await User.findById(userId);
7
if (!user) return res.status(404).send('Unauthorized');
8
9
const { totpSecret } = user;
10
const verified = authenticator.check(totpCode, totpSecret);
11
if (!verified) return res.status(400).json({ error: 'Invalid TOTP code' });
12
13
user.status = true;
14
await user.save();
15
emit('user-online', user.id);
16
return res.json({ id: user._id.toString(), sessionId: req.sessionID, firstName: user.firstName, lastName: user.lastName });
17
}
18
}

Getting started - Frontend

To implement two-factor authentication (2FA) in the frontend, we’ll create a dedicated page where users can either scan a QR code to link their account with an authenticator app or input the code generated by the app.

Refactoring the login page

First, we need to refactor the login page to reuse some of its logic on the 2FA page. We’ll create a custom hook for login operations that returns the states for the welcome message, user name, and a function to handle post-login operations.

/src/hooks/userLogin.jsx
1
import { useEffect, useRef, useState } from "react";
2
import { useAuth } from "./useAuth";
3
4
const useLogin = () => {
5
const { login } = useAuth();
6
const [userName, setUserName] = useState('');
7
const [showWelcomeMessage, setShowWelcomeMessage] = useState(false);
8
const [zoomOut, setZoomOut] = useState(false);
9
const timeoutRef = useRef(null);
10
11
useEffect(() => {
12
return (() => {
13
if (timeoutRef.value) clearTimeout(timeoutRef.value);
14
})
15
}, []);
16
17
const loginUser = (xhr) => {
18
login(xhr.responseText);
19
const userData = JSON.parse(xhr.responseText)
20
setUserName(userData.firstName + ' ' + userData.lastName);
21
setZoomOut(true);
22
timeoutRef.value = setTimeout(() => setShowWelcomeMessage(true), 2000);
23
}
24
25
return { loginUser, userName, showWelcomeMessage, zoomOut };
26
}
27
28
export default useLogin;

This hook manages the state for userName, the zoom-out animation, and the visibility of the welcome message. It also clears the timeout if the component is unmounted by using the useEffect hook.

Next, integrate this hook into the login page.

/src/pages/Login.jsx
1
const [userName, setUserName] = useState('');
2
const [showWelcomeMessage, setShowWelcomeMessage] = useState(false);
3
const [zoomOut, setZoomOut] = useState(false);
4
const { login } = useAuth();
5
const { zoomOut, showWelcomeMessage, userName, loginUser } = useLogin();
6
7
const onSubmit = async () => {
8
...
9
const [xhr, reqError] = await fetch('POST', `${process.env.SERVER_URL}/api/login`, { email, password });
10
if (reqError) {
11
setError(reqError);
12
return console.error(reqError);
13
}
14
15
loginUser(xhr);
16
login(xhr.responseText);
17
const userData = JSON.parse(xhr.responseText)
18
setUserName(userData.firstName + ' ' + userData.lastName);
19
setZoomOut(true);
20
setTimeout(() => {
21
setShowWelcomeMessage(true);
22
setTimeout(() => {
23
navigate("/");
24
}, 5000);
25
}, 2000);
26
}

When the user tries to log in and the server requires two-factor authentication, we need to navigate to the 2FA page. We’ll create a handler to check if 2FA is needed and navigate accordingly.

/src/pages/login
1
const should2faAuth = (xhr) => {
2
try {
3
const responseObj = JSON.parse(xhr.responseText);
4
if (responseObj.error && ['missing_totp', 'totp_verification_required'].includes(responseObj.error)) {
5
return navigate('/totp', { state: { ...responseObj } });
6
}
7
8
return false;
9
} catch (error) {
10
setError('Internal error. Please try again!');
11
console.error(error);
12
return false;
13
}
14
}
15
16
const onSubmit = async () => {
17
setError('');
18
const email = emailRef.current.value;
19
const password = passowrdRef.current.value;
20
const [xhr, reqError] = await fetch('POST', `${process.env.SERVER_URL}/api/login`, { email, password });
21
if (should2faAuth(xhr)) return;
22
if (reqError) {
23
setError(reqError);
24
return console.error(reqError);
25
}

2FA page

We will create a new page to enable users to perform two-factor authentication (2FA).

First, we need to add this page to the router as a public path.

/src/App.jsx
1
import Totp from './pages/TOTP/Totp';
2
...
3
<Routes>
4
<Route path='/' element={<ProtectedRoute><Home /></ProtectedRoute>} >
5
<Route index element={<Friends />} />
6
<Route path="/add-friends" element={<AddFriends />} />
7
</Route>
8
<Route path='/register' element={<Register />} />
9
<Route path='/login' element={<Login />} />
10
<Route path='/totp' element={<Totp />} />
11
</Routes>

Next, we will create a new page that reuses the login logic but ensures the user completes 2FA using their phone app.

/src/pages/Totp.jsx
1
import React, { useRef, useState } from 'react';
2
import './Totp.scss';
3
import useFetch from '../../hooks/useFetch';
4
import { Link, Navigate, useLocation } from 'react-router-dom';
5
import { useLocalStorage } from '../../hooks/useLocalStorage';
6
import useEnter from '../../hooks/useEnter';
7
import LoginWrapper from '../../components/LoginWrapper';
8
import useLogin from '../../hooks/useLogin';
9
10
const Totp = () => {
11
const { state } = useLocation();
12
const { error, secretQrCode, secret, id } = state;
13
const [user] = useLocalStorage('user');
14
const [errorMessage, setError] = useState('');
15
const { zoomOut, showWelcomeMessage, userName, loginUser } = useLogin();
16
17
if (user) {
18
return <Navigate to='/' />;
19
}
20
21
if (error !== 'missing_totp' && error !== 'totp_verification_required') {
22
return <Navigate to='/login' />;
23
}
24
25
const verificationCodeRef = useRef(null);
26
const fetch = useFetch();
27
28
const verifyIdendityRequest = async (userId, totpCode, secret, bindTotp = false) => {
29
const requestUrl = `${process.env.SERVER_URL}/api/${bindTotp ? 'bind-totp' : 'verify-totp'}`;
30
const reqBody = { userId, totpCode };
31
if (bindTotp) reqBody.pendingTotpSecret = secret;
32
33
return await fetch('POST', requestUrl, reqBody);
34
}
35
36
const hasUserVerified = (xhr) => {
37
try {
38
const responseObj = JSON.parse(xhr.responseText);
39
if (responseObj && !responseObj.error) return true;
40
setError(responseObj.error);
41
console.error(responseObj.error);
42
return false;
43
} catch (error) {
44
setError('Internal error. Please try again!');
45
console.error(error);
46
return false;
47
}
48
}
49
50
const onSubmit = async () => {
51
setError('');
52
const totpCode = verificationCodeRef.current.value;
53
54
const [xhr, reqError] = await verifyIdendityRequest(id, totpCode, secret, error === 'missing_totp');
55
if (!hasUserVerified(xhr)) return;
56
if (reqError) {
57
setError(reqError);
58
return console.error(reqError);
59
}
60
61
loginUser(xhr);
62
}
63
64
useEnter(onSubmit);
65
66
return (
67
<LoginWrapper zoomOut={zoomOut} showWelcomeMessage={showWelcomeMessage} message={`Wellcome, ${userName}`}>
68
{secretQrCode && <img className='totp-qr-code' src={secretQrCode} />}
69
<div className='form-item totp-code'>
70
<span className='label'>Verifiation code:</span>
71
<input ref={verificationCodeRef} tabIndex={1} type="text" />
72
</div>
73
{errorMessage && <span className='error-message'>{errorMessage}</span>}
74
<button className="totp-submit" onClick={onSubmit}>Verify</button>
75
<Link to='/login'>
76
<button className="totp-login-back" tabIndex={1}>Back to login</button>
77
</Link>
78
</LoginWrapper>
79
)
80
}
81
82
export default Totp;

Retreiving navigation state

This page receives state from the navigation API when the login page redirects here. We use this state to determine whether to show the QR code for linking the app or just the verification code input.

1
const { state } = useLocation();
2
const { error, secretQrCode, secret, id } = state;

If the server generates a secret when the user tries to log in, we display it as a QR code.

1
{secretQrCode && <img className='totp-qr-code' src={secretQrCode} />}

Otherwise, only the verification code input is displayed.

Handle bad navigation to the page

We handle scenarios where the user is already logged in or the error state from navigation is undefined or unrelated to 2FA.

1
if (user) {
2
return <Navigate to='/' />;
3
}
4
5
if (error !== 'missing_totp' && error !== 'totp_verification_required') {
6
return <Navigate to='/login' />;
7
}

Performing 2FA

To perform 2FA, we submit the verification code in the onSubmit method.

1
const onSubmit = async () => {
2
setError('');
3
const totpCode = verificationCodeRef.current.value;
4
5
const [xhr, reqError] = await verifyIdendityRequest(id, totpCode, secret, error === 'missing_totp');
6
if (!hasUserVerified(xhr)) return;
7
if (reqError) {
8
setError(reqError);
9
return console.error(reqError);
10
}
11
12
loginUser(xhr);
13
}

We also reuse the login logic wrapped in the useLogin hook.

1
const { zoomOut, showWelcomeMessage, userName, loginUser } = useLogin();

We send a request to the server via verifyIdentityRequest and check if the verification succeeded with hasUserVerified. If the user is verified, we call loginUser.

Verifying identity

There are two types of requests to the server from verifyIdentityRequest. One for linking the app if it hasn’t been done yet, and another for verifying the generated code if the app is already linked.

For linking the app, we send a POST request to /api/bind-totp with the generated code and the secret.

For verification, we send a POST request to /api/verify-totp with the code.

1
const verifyIdendityRequest = async (userId, totpCode, secret, bindTotp = false) => {
2
const requestUrl = `${process.env.SERVER_URL}/api/${bindTotp ? 'bind-totp' : 'verify-totp'}`;
3
const reqBody = { userId, totpCode };
4
if (bindTotp) reqBody.pendingTotpSecret = secret;
5
6
return await fetch('POST', requestUrl, reqBody);
7
}

By refactoring the login logic and handling 2FA requirements, we ensure a smooth user experience when integrating two-factor authentication into our application.

On this page