Two-factor authentication
While developing our application, security should be one of our main concerns. One of the ways we can improve it is by implementing a two-factor authentication…
March 8, 2021
While developing our application, security should be one of our main concerns. One of the ways we can improve it is by implementing a two-factor authentication mechanism. This article goes through its principles and puts them into practice with NestJS and Google Authenticator.
Adding two-factor authentication#
The core idea behind two-factor authentication is to confirm the user’s identity in two ways. There is an important distinction between two-step authentication and two-factor authentication. A common example is with the ATM. To use it, we need both a credit card and a PIN code. We call it a two-factor authentication because it requires both something we have and something we know. For example, requiring a password and a PIN code could be called a two-step flow instead.
The very first thing is to create a secret key unique for every user. The speakeasy library used to be a popular choice, but it is no longer maintained. Therefore, in this article, we use the otplib package for this purpose.
npm install otplibAlong with the above secret, we also generate a URL with the otpauth:// protocol. It is used by applications such as Google Authenticator. We need to provide a name for our application to display it on our users’ devices. To do that, let’s add an environment variable called TWO_FACTOR_AUTHENTICATION_APP_NAME.
twoFactorAuthentication.service.ts#
import { Injectable } from "@nestjs/common"
import { authenticator } from "otplib"
import User from "../../users/user.entity"
import { UsersService } from "../../users/users.service"
@Injectable()
export class TwoFactorAuthenticationService {
constructor(
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
public async generateTwoFactorAuthenticationSecret(user: User) {
const secret = authenticator.generateSecret()
const otpauthUrl = authenticator.keyuri(
user.email,
this.configService.get("TWO_FACTOR_AUTHENTICATION_APP_NAME"),
secret,
)
await this.usersService.setTwoFactorAuthenticationSecret(secret, user.id)
return {
secret,
otpauthUrl,
}
}
}An essential thing above is that we save the generated secret in the database. We will need it later.
user.entity.ts#
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
class User {
@PrimaryGeneratedColumn()
public id: number
@Column({ nullable: true })
public twoFactorAuthenticationSecret?: string // ...
}
export default Useruser.service.ts#
import { Injectable } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import User from "./user.entity"
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async setTwoFactorAuthenticationSecret(secret: string, userId: number) {
return this.usersRepository.update(userId, {
twoFactorAuthenticationSecret: secret,
})
} // ...
}We also need to serve the otpauth URL to the user in a QR code. To do that, we can use the qrcode library.
twoFactorAuthentication.service.ts#
import { Injectable } from "@nestjs/common"
import { toFileStream } from "qrcode"
import { Response } from "express"
@Injectable()
export class TwoFactorAuthenticationService {
// ...
public async pipeQrCodeStream(stream: Response, otpauthUrl: string) {
return toFileStream(stream, otpauthUrl)
}
}Once we have all of the above, we can create a controller that uses this logic.
twoFactorAuthentication.controller.ts#
import {
ClassSerializerInterceptor,
Controller,
Header,
Post,
UseInterceptors,
Res,
UseGuards,
Req,
} from "@nestjs/common"
import { TwoFactorAuthenticationService } from "./twoFactorAuthentication.service"
import { Response } from "express"
import JwtAuthenticationGuard from "../jwt-authentication.guard"
import RequestWithUser from "../requestWithUser.interface"
@Controller("2fa")
@UseInterceptors(ClassSerializerInterceptor)
export class TwoFactorAuthenticationController {
constructor(private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService) {}
@Post("generate")
@UseGuards(JwtAuthenticationGuard)
async register(@Res() response: Response, @Req() request: RequestWithUser) {
const { otpauthUrl } =
await this.twoFactorAuthenticationService.generateTwoFactorAuthenticationSecret(request.user)
return this.twoFactorAuthenticationService.pipeQrCodeStream(response, otpauthUrl)
}
}Calling the above endpoint results in the API returning a QR code. Our users can now scan it with the Google Authenticator application.
Turning on the two-factor authentication#
So far, our users can generate a QR code and scan it with the Google Authenticator application. Now we need to implement the logic of turning on the two-factor authentication. It requires the user to provide the code from the Authenticator application. We then need to validate it against the secret string we’ve saved in the database while generating a QR code.
We need to save the information about the two-factor authentication being turned on in the database. To do that, let’s expand the entity of the user.
user.entity.ts#
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
class User {
@PrimaryGeneratedColumn()
public id: number
@Column({ default: false })
public isTwoFactorAuthenticationEnabled: boolean // ...
}
export default UserWe also need to create a method in the service to set this flag to true.
users.service.ts#
import { Injectable } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import User from "./user.entity"
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async turnOnTwoFactorAuthentication(userId: number) {
return this.usersRepository.update(userId, {
isTwoFactorAuthenticationEnabled: true,
})
} // ...
}The most crucial part here is verifying the user’s code against the secret saved in the database. Let’s do that in the TwoFactorAuthenticationService:
twoFactorAuthentication.service.ts#
import { Injectable } from "@nestjs/common"
import { authenticator } from "otplib"
import User from "../../users/user.entity"
@Injectable()
export class TwoFactorAuthenticationService {
public isTwoFactorAuthenticationCodeValid(twoFactorAuthenticationCode: string, user: User) {
return authenticator.verify({
token: twoFactorAuthenticationCode,
secret: user.twoFactorAuthenticationSecret,
})
} // ...
}Once we’ve got all of the above ready to go, we can use this logic in our controller:
twoFactorAuthentication.controller.ts#
import {
ClassSerializerInterceptor,
Controller,
Post,
UseInterceptors,
UseGuards,
Req,
Body,
UnauthorizedException,
HttpCode,
} from "@nestjs/common"
import { TwoFactorAuthenticationService } from "./twoFactorAuthentication.service"
import JwtAuthenticationGuard from "../jwt-authentication.guard"
import RequestWithUser from "../requestWithUser.interface"
import { TurnOnTwoFactorAuthenticationDto } from "./dto/turnOnTwoFactorAuthentication.dto"
import { UsersService } from "../../users/users.service"
@Controller("2fa")
@UseInterceptors(ClassSerializerInterceptor)
export class TwoFactorAuthenticationController {
constructor(
private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
private readonly usersService: UsersService,
) {}
@Post("turn-on")
@HttpCode(200)
@UseGuards(JwtAuthenticationGuard)
async turnOnTwoFactorAuthentication(
@Req() request: RequestWithUser,
@Body() { twoFactorAuthenticationCode }: TwoFactorAuthenticationCodeDto,
) {
const isCodeValid = this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
twoFactorAuthenticationCode,
request.user,
)
if (!isCodeValid) {
throw new UnauthorizedException("Wrong authentication code")
}
await this.usersService.turnOnTwoFactorAuthentication(request.user.id)
} // ...
}Now, the user can generate a QR code, save it in the Google Authenticator application, and send a valid code to the /2fa/turn-on endpoint. If that’s the case, we acknowledge that the two-factor authentication has been saved.
Logging in with two-factor authentication#
The next step in our two-factor authentication flow is allowing the user to log in. In this article, we implement the following approach:
- the user logs in using the email and the password, and we respond with a JWT token,
- if the 2FA is turned off, we give full access to the user,
- if the 2FA is turned on, we provide the access just to the
/2fa/authenticateendpoint, - the user looks up the Authenticator application code and sends it to the
/2fa/authenticateendpoint; we respond with a new JWT token with full access.
The first missing part of the above flow is the route that allows the user to send the two-factor authentication code.
twoFactorAuthentication.controller.ts#
import {
ClassSerializerInterceptor,
Controller,
Post,
UseInterceptors,
UseGuards,
Req,
Body,
UnauthorizedException,
HttpCode,
} from "@nestjs/common"
import { TwoFactorAuthenticationService } from "./twoFactorAuthentication.service"
import JwtAuthenticationGuard from "../jwt-authentication.guard"
import RequestWithUser from "../requestWithUser.interface"
import { UsersService } from "../../users/users.service"
import { TwoFactorAuthenticationCodeDto } from "./dto/twoFactorAuthenticationCode.dto"
import { AuthenticationService } from "../authentication.service"
@Controller("2fa")
@UseInterceptors(ClassSerializerInterceptor)
export class TwoFactorAuthenticationController {
constructor(
private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
private readonly usersService: UsersService,
private readonly authenticationService: AuthenticationService,
) {}
@Post("authenticate")
@HttpCode(200)
@UseGuards(JwtAuthenticationGuard)
async authenticate(
@Req() request: RequestWithUser,
@Body() { twoFactorAuthenticationCode }: TwoFactorAuthenticationCodeDto,
) {
const isCodeValid = this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
twoFactorAuthenticationCode,
request.user,
)
if (!isCodeValid) {
throw new UnauthorizedException("Wrong authentication code")
}
const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(
request.user.id,
true,
)
request.res.setHeader("Set-Cookie", [accessTokenCookie])
return request.user
} // ...
}A crucial thing to notice above is that we’ve added an argument to the getCookieWithJwtAccessToken method.
authentication.service.ts#
import { Injectable } from "@nestjs/common"
import { UsersService } from "../users/users.service"
import { JwtService } from "@nestjs/jwt"
import { ConfigService } from "@nestjs/config"
import TokenPayload from "./tokenPayload.interface"
@Injectable()
export class AuthenticationService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
public getCookieWithJwtAccessToken(userId: number, isSecondFactorAuthenticated = false) {
const payload: TokenPayload = { userId, isSecondFactorAuthenticated }
const token = this.jwtService.sign(payload, {
secret: this.configService.get("JWT_ACCESS_TOKEN_SECRET"),
expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`,
})
return `Authentication=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}`
} // ...
}Thanks to setting the isSecondFactorAuthenticated property, we can now distinguish between tokens created with and without two-factor authentication.
Checking if the user is authenticated with the second factor#
Since we can authenticate users using the second factor, we now should check it before we grant them access to various resources. Previously, we’ve created a Passport strategy that parses the cookie and the JWT token. Let’s expand on this idea and create a strategy and a guard that check if the two-factor authentication was successful.
authentication.service.ts#
import { ExtractJwt, Strategy } from "passport-jwt"
import { PassportStrategy } from "@nestjs/passport"
import { Injectable } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"
import { Request } from "express"
import { UsersService } from "../users/users.service"
import TokenPayload from "./tokenPayload.interface"
@Injectable()
export class JwtTwoFactorStrategy extends PassportStrategy(Strategy, "jwt-two-factor") {
constructor(
private readonly configService: ConfigService,
private readonly userService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
return request?.cookies?.Authentication
},
]),
secretOrKey: configService.get("JWT_ACCESS_TOKEN_SECRET"),
})
}
async validate(payload: TokenPayload) {
const user = await this.userService.getById(payload.userId)
if (!user.isTwoFactorAuthenticationEnabled) {
return user
}
if (payload.isSecondFactorAuthenticated) {
return user
}
}
}Above, the crucial logic happens in the validate method. If the two-factor authentication is not enabled for the current user, we don’t check if the token contains the isSecondFactorAuthenticated flag.
To use the above strategy, we need to create a guard:
jwt-two-factor.guard.ts#
import { Injectable } from "@nestjs/common"
import { AuthGuard } from "@nestjs/passport"
@Injectable()
export default class JwtTwoFactorGuard extends AuthGuard("jwt-two-factor") {}We can now use it on endpoints that we want to protect with two-factor authentication.
posts.controller.ts#
import {
Body,
Controller,
Post,
UseGuards,
Req,
UseInterceptors,
ClassSerializerInterceptor,
} from "@nestjs/common"
import PostsService from "./posts.service"
import CreatePostDto from "./dto/createPost.dto"
import RequestWithUser from "../authentication/requestWithUser.interface"
import JwtTwoFactorGuard from "../authentication/jwt-two-factor.guard"
@Controller("posts")
@UseInterceptors(ClassSerializerInterceptor)
export default class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
@UseGuards(JwtTwoFactorGuard)
async createPost(@Body() post: CreatePostDto, @Req() req: RequestWithUser) {
return this.postsService.createPost(post, req.user)
} // ...
}It is crucial not to use the
JwtTwoFactorGuardon the/2fa/authenticateendpoint, because we need users to access it before authenticating with the second factor.
Modifying the basic logging-in logic#
The last step is modifying the regular /authentication/log-in endpoint. It always responds with the user’s data, even if we didn’t perform two-factor authentication yet. Let’s change it.
authentication.controller.ts#
import {
Req,
Controller,
HttpCode,
Post,
UseGuards,
ClassSerializerInterceptor,
UseInterceptors,
} from "@nestjs/common"
import { AuthenticationService } from "./authentication.service"
import RequestWithUser from "./requestWithUser.interface"
import { LocalAuthenticationGuard } from "./localAuthentication.guard"
import { UsersService } from "../users/users.service"
@Controller("authentication")
@UseInterceptors(ClassSerializerInterceptor)
export class AuthenticationController {
constructor(
private readonly authenticationService: AuthenticationService,
private readonly usersService: UsersService,
) {}
@HttpCode(200)
@UseGuards(LocalAuthenticationGuard)
@Post("log-in")
async logIn(@Req() request: RequestWithUser) {
const { user } = request
const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(user.id)
const { cookie: refreshTokenCookie, token: refreshToken } =
this.authenticationService.getCookieWithJwtRefreshToken(user.id)
await this.usersService.setCurrentRefreshToken(refreshToken, user.id)
request.res.setHeader("Set-Cookie", [accessTokenCookie, refreshTokenCookie])
if (user.isTwoFactorAuthenticationEnabled) {
return
}
return user
} // ...
}Summary#
In this article, we’ve implemented a fully working two-factor authentication flow. Our users can now generate a unique, secret key, and we present them with a QR image. After turning on the two-factor authentication, we validate upcoming requests.
The above approach might benefit from additional features. An example would be support for backup codes that the user could use in case of losing the phone. I encourage you to improve the flow presented in this article.