2FA with NestJs & passeport using Google Authenticator
Matthew
Posted on July 7, 2022
I recently had to implement a two factor authentication on a project for my company and it was a whole new thing for me. Sure I had already used 2fa before but I had never implemented it.
What is 2fa ? Well we all know that passwords aren't really secure enough to avoid security breaches so...
2FA is an extra layer of security used to make sure that people trying to gain access to an online account are who they say they are. First, a user will enter their username and a password. Then, instead of immediately gaining access, they will be required to provide another piece of information.
In this case, I was asked to use the google authenticator app to generate a 2fa code that would be used to authenticate the user after the login step.
I'll be using nestJs with passportjs. Here's the GitHub link if you want to check it out: https://github.com/MatthieuHahn/2fa
Initializing the NestJS project with basic login password authentication
Let's create a new nestJs project.
nest new 2fa
Creating the authentication module
First let's install the nestJs passeport dependencies and types. We'll need them in the next step.
yarn add @nestjs/passport passport passport-local
yarn add -D @types/passport-local
Then, we'll generate the authentication module, controller and service.
nest generate resource authentication
It'll ask you a few questions: choose REST API and then answer No (it's an auth module, we don't need CRUD endpoints)
Then we'll create a user module, service and interface
nest generate module users
nest generate service users
export interface User {
userId: number;
username: string;
password: string;
}
The users service will contain fake data, but, of course, you should add a data layer to persist data 😅
import { Injectable } from '@nestjs/common';
import { User } from "./user.entity";
@Injectable()
export class UsersService {
private readonly users = [
{
userId: 1,
username: 'john',
password: 'changeme',
},
{
userId: 2,
username: 'maria',
password: 'guess',
},
];
async findOne(username: string): Promise<User | undefined> {
return this.users.find(user => user.username === username);
}
}
Let's add a validateUser method that will check if the user and password that we'll send are a match with our data.
import { Injectable } from '@nestjs/common';
import { UsersService } from "../users/users.service";
import { User } from "../users/user.entity";
@Injectable()
export class AuthenticationService {
constructor(private usersService: UsersService) {
}
async validateUser(email: string, pass: string): Promise<Partial<User>> {
const user = await this.usersService.findOne(email);
try {
// Of course, we should consider encrypting the password
const isMatch = pass === user.password;
if (user && isMatch) {
const { password: _, ...userWithoutPassword } = user;
return userWithoutPassword;
}
} catch (e) {
return null;
}
}
}
If you are not familiar with nestJs, this framework provides a UseGuard decorator that will act as an auth middleware which can rely on the passportjs library. So let's use this feature and the nestJs passport library to manage the user/password login.
First we define the local auth guard which extends the passportjs local strategy.
local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
And then we define the local authentication strategy. The constructor contains the auth fields that will be sent by the front-end via the login POST route (email, password).
The validate method will use the validateUser method we created earlier. If it returns no user, then it will throw a 401 Unauthorized error, else it will return the user without the password.
local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthenticationService } from '../authentication.service';
import { User } from '../../users/user.entity';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authenticationService: AuthenticationService) {
super({
usernameField: 'email',
passwordField: 'password',
});
}
async validate(email: string, password: string): Promise<Partial<User>> {
const userWithoutPsw = await this.authenticationService.validateUser(email, password);
if (!userWithoutPsw) {
throw new UnauthorizedException();
}
return userWithoutPsw;
}
}
We can now create the login method in the authentication controller and service. For now it'll only return the user's email. But later on, we'll add a Jwt access token
First we add the login method to the authentication service.
async login(userWithoutPsw: Partial<User>, isTwoFactorAuthenticated = false) {
const payload = {
email: userWithoutPsw.email,
isTwoFactorAuthenticationEnabled: !!userWithoutPsw.isTwoFactorAuthenticationEnabled,
isTwoFactorAuthenticated,
};
return {
email: payload.email,
};
}
Then we create the controller route with the LocalAuthGuard
@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(200)
async login(@Request() req) {
const userWithoutPsw: Partial<User> = req.user;
return this.authenticationService.login(userWithoutPsw);
}
Ok, so now, if we send a post request on the /authentication/login route with correct credentials, it will return the user's email. Let's add the Jwt management now.
First we add the nestJs Jwt package.
yarn add @nestjs/jwt
Then we have to register the JwtModule in the Authentication module in order to be able to use it. The secret should, of course, be secret and in an .env file.
JwtModule.register({
secret: 'secret',
signOptions: { expiresIn: '1d' },
})
Then we just have to add the JwtService to the AuthenticationService and use it to generate the access_token we'll return to the front-end.
Here's what the authentication service should look like now.
import { Injectable } from '@nestjs/common';
import { UsersService } from "../users/users.service";
import { User } from "../users/user.entity";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthenticationService {
constructor(private usersService: UsersService, private jwtService: JwtService) {
}
async validateUser(email: string, pass: string): Promise<Partial<User>> {
const user = await this.usersService.findOne(email);
try {
// Of course, we should consider encrypting the password
const isMatch = pass === user.password;
if (user && isMatch) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _, ...userWithoutPassword } = user;
return userWithoutPassword;
}
} catch (e) {
return null;
}
}
async login(userWithoutPsw: Partial<User>) {
const payload = {
email: userWithoutPsw.email,
};
return {
email: payload.email,
access_token: this.jwtService.sign(payload),
};
}
}
Now the frontend has a Jwt token it'll be able to use to authenticate requests to the backend. But how will the backend manage the Authorization token ?
Well, let's create a JwtAuthGuard using passportjs jwt package.
yarn add passport-jwt
We can now create the jwt strategy which is based on passportjs.
jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../../users/users.service';
import { TokenPayload } from '../token-payload.entity';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: 'secret',
});
}
async validate(payload: TokenPayload) {
const user = await this.userService.findOne(payload.email);
if (user) {
return user;
}
}
}
And create the JwtAuthGuard.
jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
So for now, we have a complete basic authentication using JWT to authenticate requests. But as we said before, it is based on a user/password login and therefore it is not very secure. So let's add the two factor authentication.
Two factor authentication
Here's the login flow for 2fa authentication:
- The user logs in with his email and password
- If the 2fa is not enabled, he can enable it using the turn-on route. This will generate a QrCode that the user will scan with the google authenticator app.
- The use then uses the random code the app has generated to authenticate
Creating the 2fa system
First we have to create a unique secret for every user that turns on 2fa, but we'll also need a special otp authentication url that we'll be using later to create a QrCode. The otplib package is a good match, so let's install it.
yarn add otplib
We should also update the user interface and add the twoFactorAuthenticationSecret property.
export interface User {
userId: number;
email: string;
username: string;
password: string;
twoFactorAuthenticationSecret: string;
}
Then we create a method to generate the secret and otpAuthUrl in the authentication service and return both of them. The AUTH_APP_NAME is the name that will appear in the google authenticator app.
async generateTwoFactorAuthenticationSecret(user: User) {
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(user.email, 'AUTH_APP_NAME', secret);
await this.usersService.setTwoFactorAuthenticationSecret(secret, user.userId);
return {
secret,
otpauthUrl
}
}
We have to update the user with the secret that has just been generated. Once again, this should all be in a database.
async setTwoFactorAuthenticationSecret(secret: string, userId: number) {
this.users.find(user => user.userId === userId).twoFactorAuthenticationSecret = secret;
}
Now, we can generate the QrCode that will be used to add our application to the google authenticator app.
yarn add qrcode
Let's add the generate method in the authentication service.
import { toDataURL } from 'qrcode';
async generateQrCodeDataURL(otpAuthUrl: string) {
return toDataURL(otpAuthUrl);
}
Now we need to offer the possiblity for the user to turn on the 2fa. So let's add another property to the user interface.
export interface User {
userId: number;
email: string;
username: string;
password: string;
twoFactorAuthenticationSecret: string;
isTwoFactorAuthenticationEnabled: boolean;
}
Add the turnOn method in the users service
async turnOnTwoFactorAuthentication(userId: number) {
this.users.find(user => user.userId === userId).isTwoFactorAuthenticationEnabled = true;
}
Add the method that will verify the authentication code with the user's secret
isTwoFactorAuthenticationCodeValid(twoFactorAuthenticationCode: string, user: User) {
return authenticator.verify({
token: twoFactorAuthenticationCode,
secret: user.twoFactorAuthenticationSecret,
});
}
Add the turn on route in the authentication controller
@Post('2fa/turn-on')
@UseGuards(JwtAuthGuard)
async turnOnTwoFactorAuthentication(@Req() request, @Body() body) {
const isCodeValid =
this.authenticationService.isTwoFactorAuthenticationCodeValid(
body.twoFactorAuthenticationCode,
request.user,
);
if (!isCodeValid) {
throw new UnauthorizedException('Wrong authentication code');
}
await this.usersService.turnOnTwoFactorAuthentication(request.user.id);
}
Logging in with 2fa
Let's add a login with 2fa method in the authentication service. The difference with the default login function is that we add the 2fa status in the payload.
async loginWith2fa(userWithoutPsw: Partial<User>) {
const payload = {
email: userWithoutPsw.email,
isTwoFactorAuthenticationEnabled: !!userWithoutPsw.isTwoFactorAuthenticationEnabled,
isTwoFactorAuthenticated: true,
};
return {
email: payload.email,
access_token: this.jwtService.sign(payload),
};
}
We can now create the 2fa authentication route in the controller. If the code sent in the post body is valid, then we try to login with 2fa else we throw an error.
@Post('2fa/authenticate')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
async authenticate(@Request() request, @Body() body) {
const isCodeValid = this.authenticationService.isTwoFactorAuthenticationCodeValid(
body.twoFactorAuthenticationCode,
request.user,
);
if (!isCodeValid) {
throw new UnauthorizedException('Wrong authentication code');
}
return this.authenticationService.loginWith2fa(request.user);
}
It is now possible to create an AuthGuard strategy based on Jwt and the 2fa status. If the 2fa is not turned on then we can rely on the jwt only, if the 2fa is enabled then we check if the user is 2fa authenticated.
jwt-2fa.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../../users/users.service';
@Injectable()
export class Jwt2faStrategy extends PassportStrategy(Strategy, 'jwt-2fa') {
constructor(private readonly userService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: 'secret',
});
}
async validate(payload: any) {
const user = await this.userService.findOne(payload.email);
if (!user.isTwoFactorAuthenticationEnabled) {
return user;
}
if (payload.isTwoFactorAuthenticated) {
return user;
}
}
}
jwt-2fa-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class Jwt2faAuthGuard extends AuthGuard('jwt-2fa') {}
Let's see how this goes right now.
Testing
So first we'll do a POST request to log in with the user and password:
This will return the following:
Then, we need to get the QrCode to add our app to the google authenticator app
This will return a base64 data url which in turn will happen to be a QrCode
If you scan this with the Google Authenticator App it should add your app:
And then you should be able to call the authenticate route with the current code from the google authenticator app
Posted on July 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.