Multiplayer UI - Two factor authentication login (Part 2)
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:
- Executing
npm run start:server:2fa
to enable login authentication with 2FA. - Adding
TWO_FACTOR_AUTHENTICATION_ENABLED="true"
to the.env
file and then starting the server locally with thenpm 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:
1npm 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:
1const mongoose = require('mongoose');2
3const 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
14module.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.
1const { authenticator } = require('otplib');2const qrcode = require('qrcode');3
4class 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.
1const server = createServer(app);2require('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)
.
- 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.
1return res.status(403).json({ error: 'totp_verification_required', id: user._id.toString() });
- 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.
1if (!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:
1router.post('/bind-totp', UserController.bindTotp);2router.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.
1class 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.
1class 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.
1import { useEffect, useRef, useState } from "react";2import { useAuth } from "./useAuth";3
4const 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
28export 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.
1const [userName, setUserName] = useState('');2const [showWelcomeMessage, setShowWelcomeMessage] = useState(false);3const [zoomOut, setZoomOut] = useState(false);4const { login } = useAuth();5const { zoomOut, showWelcomeMessage, userName, loginUser } = useLogin();6
7const 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}
Navigate to the 2FA page
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.
1const 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
16const 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.
1import 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.
1import React, { useRef, useState } from 'react';2import './Totp.scss';3import useFetch from '../../hooks/useFetch';4import { Link, Navigate, useLocation } from 'react-router-dom';5import { useLocalStorage } from '../../hooks/useLocalStorage';6import useEnter from '../../hooks/useEnter';7import LoginWrapper from '../../components/LoginWrapper';8import useLogin from '../../hooks/useLogin';9
10const 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
82export 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.
1const { state } = useLocation();2const { 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.
1if (user) {2 return <Navigate to='/' />;3}4
5if (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.
1const 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.
1const { 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.
1const 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.