back to homepage

2FA With Google Authenticator & NestJS

How to use Google Authenticator with NestJS & passport

2024-08-30

At AirPay, we have been rewriting our api using NestJS & passport (sessions) for authentication. We needed our users to be able to login using 2FA via Google Authenticator. I thought I'd share how I got it working.

How it should work

First, let's understand our requirements and how the login flow should work:

Before we begin

In order to get this working, we need to first have authentication working with passport. I'm using sessions rather than JWT. I'm assuming you have already done this. If not, the NestJs passport docs are great and will help you get started. Once you understand the basics and have a working authentication system with the passport-local strategy, you can move on to the article.

Getting started

Let's start by adding 2 columns to our user. One to hold the 2FA secret and one to whether or not 2FA is enabled:

twoFactorEnabled (boolean) and twoFactorSecret (string).

After we've done that, we can create a couple new functions to handle the generation of the 2FA secret and the enabling of 2FA. In order to create the secret, we'll need to install the otplib package. We'll also want to install qrcode to, you guessed it, generate the QR code. I'm adding these to the auth.service.ts file. You'll want to put these in whatever service.ts file you're using to handle authenticating users.

auth.service.ts:

import { authenticator } from 'otplib';
import { toDataURL } from 'qrcode';

async generateTwoFactorSecret(userId: number) {
    const user = await this.prisma.user.findUnique({
        where: { id: userId },
    });
    if (!user) {
        throw new HttpException('Invalid user', HttpStatus.NOT_FOUND);
    }
    const secret = authenticator.generateSecret();
    const url = authenticator.keyuri(user.email, 'AirPay', secret);
    const qrCode = await toDataURL(url);

    return {
        secret,
        qrCode,
    };
}

async enableTwoFactor(secret: string, userId: number) {
    if (!secret) {
        throw new HttpException('Invalid secret', HttpStatus.BAD_REQUEST);
    }
    const user = await this.prisma.user.findUnique({
        where: { id: userId },
    });
    if (!user) {
        throw new HttpException('Invalid user', HttpStatus.NOT_FOUND);
    }
    await this.prisma.user.update({
        where: { userId: user.id },
        data: {
            twoFactorEnabled: true,
            twoFactorSecret: secret,
        },
    });

    return {
        success: true,
    };
}

You may have noticed that the twoFactorSecret is saved to the database in plaintext. Initially I thought that the secret was supposed to be encrpted, but that's not the case. You need the original secret to verify the code, so you can't just hash it. This is acceptable because Google Authenticator is only intended to be an extra layer of security in addition to the password. Just make sure your database is secure and that people can't access other users' secrets via a query.

We will also need to add endpoints to the auth.controller.ts file.

auth.controller.ts:

// we'll want these endpoints to be guarded by some kind of auth guard
// I'm assuming you have something like this set up already
@UseGuards(CookieAuthenticationGuard)
@Post('2fa/generate')
async generateTwoFactorSecret(@Req() req: Request) {
    const user = await this.authService.validateUser(req.user);
    const secret = await this.authService.generateTwoFactorSecret(user.id);
    return secret;
}

@UseGuards(CookieAuthenticationGuard)
@Post('2fa/enable')
async enableTwoFactor(@Req() req: Request, @Body() body: { secret: string }) {
    const user = await this.authService.validateUser(req.user);
    const { secret } = body;
    const result = await this.authService.enableTwoFactor(secret, user.id);
    return result;
}

Now that these are set up, let's build some basic UI. I won't focus too much on this, the goal will be to get the QR code to show up on the page. You can adapt these endpoints to work with your own front end. Here's some pseudo code:

const TurnOnTwoFactor = async () => {
	// get your qrCode
	const { secret, qrCode } = await api.post('/auth/2fa/generate');

	const turnOnTwoFactor = async () => {
		// enable 2FA
		const { success } = await api.post('/auth/2fa/enable', {
			secret,
		});
		if (success) {
			// redirect / refresh / show toast / whatever
		}
	};

	return (
		<div>
			<img src={qrCode} alt='qrCode' />
			<button onClick={turnOnTwoFactor}>Confirm 2FA Setup</button>
		</div>
	);
};

Now we can turn 2FA on, but of course it doesn't do anything yet. The next step is to prevent regular logins if 2FA is enabled. In your auth service, edit the getAuthenticatedUser function to throw an exception if the user has 2FA.

if (user.twoFactorEnabled) {
	throw new HttpException('User must log in with 2FA', HttpStatus.UNAUTHORIZED);
}

On the front end, we can consume this exception and conditionally show the OTP input field. For nice looking UI, you can use guilhermerodz's input-otp package, but you can also just use a regular input field.

import { OTPInput } from 'input-otp'

const Login = () => {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [otp, setOtp] = useState('');

    const [showOtp, setShowOtp] = useState(false);

    const handleLogin = async () => {
        try {
            const { success } = await api.post('/auth/login', {
                email,
                password,
            });
            if (success) {
                // redirect / refresh / show toast / whatever
            }
        } catch (e) {
            if (e.message === 'User must log in with 2FA') {
                setShowOtp(true);
            }
        }
    };

    const handleOTPLogin = async () => {
        // TODO
    };

    if (showOtp) {
        return (
            <div>
                <OTPInput maxLength={6} ... />
            </div>
        )
    }

    return (
        <div>
            <input
                type='email'
                value={email}
                onChange={(e) => setEmail(e.target.value)}
            />
            <input
                type='password'
                value={password}
                onChange={(e) => setPassword(e.target.value)}
            />
            <button onClick={handleLogin}>Login</button>
        </div>
    );
}

We want to be able to actually log in with this method, so let's add some code to the auth service. First let's add a function to validate the OTP.

auth.service.ts:

async validateOtp(userId: number, code: string) {
    const user = await this.prisma.user.findUnique({
        where: { id: userId },
    });
    if (!user) {
        throw new HttpException('Invalid user', HttpStatus.NOT_FOUND);
    }
    return authenticator.verify({
        token: code,
        secret: user.twoFactorSecret,
    });
}

We also need to add a new function that the strategy will use to get the user.

async getAuthenticatedUserWithTwoFactor(
    username: string,
    code: string,
) {
    const user = await this.prisma.user.findUnique({
        where: { email: username },
    });
    if (!user) {
        return null;
    }
    if (user.twoFactorEnabled && code) {
        const isVerified = await this.verifyTwoFactor(user.id, code);
        if (!isVerified) {
            throw new HttpException(
                'Invalid code',
                HttpStatus.UNAUTHORIZED,
            );
        }
        return user;
    }
    return null;
}

Create a new file called twoFactor.strategy.ts and add the following code:

twoFactor.strategy.ts:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class TwoFactorStrategy extends PassportStrategy(Strategy, '2fa') {
	constructor(private authService: AuthService) {
		super({ usernameField: 'email' });
	}

	async validate(username: string, code: string): Promise<UserWithRoles> {
		const user = await this.authService.getAuthenticatedUserWithTwoFactor(username, code);
		if (!user) {
			throw new UnauthorizedException();
		}
		return user;
	}
}

Then, create an auth guard. We'll call it logInWithTwoFactorGuard.ts:

logInWithTwoFactorGuard.ts:

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LoginWithTwoFactorGuard extends AuthGuard('2fa') {
	async canActivate(context: ExecutionContext): Promise<boolean> {
		// check the email and the password
		await super.canActivate(context);

		// initialize the session
		const request = context.switchToHttp().getRequest();
		await super.logIn(request);

		// if no exceptions were thrown, allow the access to the route
		return true;
	}
}

In the controller, we'll want to use the guard like so:

auth.controller.ts:

@UseGuards(LoginWithTwoFactorGuard)
@Post('2fa/login')
async login(@Req() req: Request) {
    // here, we can  use the same login function that we use in the regular login
    // the LoginWithTwoFactorGuard handles the authentication logic
    // my login function simply returns the user from the request
    const user = await this.authService.login(req);
}

Finally, let's add the strategy to the auth module. In the auth.module.ts file, import the TwoFactorStrategy and add it to the providers array.

auth.module.ts:

import { TwoFactorStrategy } from './twoFactor.strategy';

@Module({
	imports: [
        ...
	],
	providers: [AuthService, TwoFactorStrategy],
	exports: [AuthService],
})

With that, we're done. I hope this was helpful. If you notice any issues with this article, or if you have any questions about it, please don't hesitate to send me an email. Thanks for reading!

back to homepage

Nicholas Harrison

nicholas.robin.harrison[at]gmail.com© 2021-2024