In a lot of web applications, emails play a significant role. If we create an online ordering system, we need to be confident that our users get a confirmation email. When our services include a mailing list, we want to make sure that the provided email is valid. We also might want to implement the password resetting feature, for which the email address is essential. Requiring our users to confirm the email address might also serve as an additional layer of security against bots. Therefore, in this article, we look into confirming the email addresses.
Confirming the email address
First, we need a way to store the information about whether the email is confirmed. To do that, let’s expand on the entity of the user.
1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2
3@Entity()
4class User {
5 @PrimaryGeneratedColumn()
6 public id: number;
7
8 @Column({ unique: true })
9 public email: string;
10
11 @Column({ default: false })
12 public isEmailConfirmed: boolean;
13
14 // ...
15}
16
17export default User;To confirm the email address, we aim to send an email message with an URL containing the JWT. To do that, we need additional environment variables.
If you want to know more about JWT, check out API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies
1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from '@hapi/joi';
4
5@Module({
6 imports: [
7 ConfigModule.forRoot({
8 validationSchema: Joi.object({
9 JWT_VERIFICATION_TOKEN_SECRET: Joi.string().required(),
10 JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: Joi.string().required(),
11 EMAIL_CONFIRMATION_URL: Joi.string().required(),
12 // ...
13 })
14 }),
15 // ...
16 ],
17 controllers: [],
18})
19export class AppModule {}We’ve already used JWT in other parts of this series. To increase security, we want to use a different secret token to encode and decode JWT for email verification. We also want the token to expire after a few hours in case the email account of our user gets hijacked.
1JWT_VERIFICATION_TOKEN_SECRET=7AnEd5epXmdaJfUrokkQ
2JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=21600
3EMAIL_CONFIRMATION_URL=https://my-app.com/confirm-emailAbove, we define the expiration time in seconds.
Sending the verification link
To be able to send the verification link, we need to set up Nodemailer. We’ve already created the EmailService that does that in the 25th part of this series. Let’s reuse it in a new service that manages email confirmation.
1import { Injectable } from '@nestjs/common';
2import { JwtService } from '@nestjs/jwt';
3import { ConfigService } from '@nestjs/config';
4import VerificationTokenPayload from './verificationTokenPayload.interface';
5import EmailService from '../email/email.service';
6import { UsersService } from '../users/users.service';
7
8@Injectable()
9export class EmailConfirmationService {
10 constructor(
11 private readonly jwtService: JwtService,
12 private readonly configService: ConfigService,
13 private readonly emailService: EmailService,
14 ) {}
15
16 public sendVerificationLink(email: string) {
17 const payload: VerificationTokenPayload = { email };
18 const token = this.jwtService.sign(payload, {
19 secret: this.configService.get('JWT_VERIFICATION_TOKEN_SECRET'),
20 expiresIn: `${this.configService.get('JWT_VERIFICATION_TOKEN_EXPIRATION_TIME')}s`
21 });
22
23 const url = `${this.configService.get('EMAIL_CONFIRMATION_URL')}?token=${token}`;
24
25 const text = `Welcome to the application. To confirm the email address, click here: ${url}`;
26
27 return this.emailService.sendMail({
28 to: email,
29 subject: 'Email confirmation',
30 text,
31 })
32 }
33}1interface VerificationTokenPayload {
2 email: string;
3}
4
5export default VerificationTokenPayload;Let’s modify our AuthenticationController and use the above service.
1import {
2 Body,
3 Controller,
4 Post,
5 ClassSerializerInterceptor,
6 UseInterceptors,
7} from '@nestjs/common';
8import { AuthenticationService } from './authentication.service';
9import RegisterDto from './dto/register.dto';
10import { UsersService } from '../users/users.service';
11import { EmailConfirmationService } from '../emailConfirmation/emailConfirmation.service';
12
13@Controller('authentication')
14@UseInterceptors(ClassSerializerInterceptor)
15export class AuthenticationController {
16 constructor(
17 private readonly authenticationService: AuthenticationService,
18 private readonly usersService: UsersService,
19 private readonly emailConfirmationService: EmailConfirmationService
20 ) {}
21
22 @Post('register')
23 async register(@Body() registrationData: RegisterDto) {
24 const user = await this.authenticationService.register(registrationData);
25 await this.emailConfirmationService.sendVerificationLink(registrationData.email);
26 return user;
27 }
28
29 // ...
30}Now, as soon as users sign in, they receive a link through email.
Feel free to make the contents of the email more refined.
Confirming the email address
Once the user goes to the link above, our frontend application needs to get the token from the URL and send it to our API. To do support that, we need to create an endpoint for it.
1import {
2 Controller,
3 ClassSerializerInterceptor,
4 UseInterceptors,
5 Post,
6 Body,
7 UseGuards,
8 Req,
9} from '@nestjs/common';
10import ConfirmEmailDto from './confirmEmail.dto';
11import { EmailConfirmationService } from './emailConfirmation.service';
12import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
13import RequestWithUser from '../authentication/requestWithUser.interface';
14
15@Controller('email-confirmation')
16@UseInterceptors(ClassSerializerInterceptor)
17export class EmailConfirmationController {
18 constructor(
19 private readonly emailConfirmationService: EmailConfirmationService
20 ) {}
21
22 @Post('confirm')
23 async confirm(@Body() confirmationData: ConfirmEmailDto) {
24 const email = await this.emailConfirmationService.decodeConfirmationToken(confirmationData.token);
25 await this.emailConfirmationService.confirmEmail(email);
26 }
27}1import { IsString, IsNotEmpty } from 'class-validator';
2
3export class ConfirmEmailDto {
4 @IsString()
5 @IsNotEmpty()
6 token: string;
7}
8
9export default ConfirmEmailDto;Above, a few notable things are happening. We expect the frontend application to send the token from the URL in the request body back to the API. We then decode it and confirm the email.
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { JwtService } from '@nestjs/jwt';
3import { ConfigService } from '@nestjs/config';
4import EmailService from '../email/email.service';
5import { UsersService } from '../users/users.service';
6
7@Injectable()
8export class EmailConfirmationService {
9 constructor(
10 private readonly jwtService: JwtService,
11 private readonly configService: ConfigService,
12 private readonly emailService: EmailService,
13 private readonly usersService: UsersService,
14 ) {}
15
16 public async confirmEmail(email: string) {
17 const user = await this.usersService.getByEmail(email);
18 if (user.isEmailConfirmed) {
19 throw new BadRequestException('Email already confirmed');
20 }
21 await this.usersService.markEmailAsConfirmed(email);
22 }
23
24 public async decodeConfirmationToken(token: string) {
25 try {
26 const payload = await this.jwtService.verify(token, {
27 secret: this.configService.get('JWT_VERIFICATION_TOKEN_SECRET'),
28 });
29
30 if (typeof payload === 'object' && 'email' in payload) {
31 return payload.email;
32 }
33 throw new BadRequestException();
34 } catch (error) {
35 if (error?.name === 'TokenExpiredError') {
36 throw new BadRequestException('Email confirmation token expired');
37 }
38 throw new BadRequestException('Bad confirmation token');
39 }
40 }
41
42 // ...
43}Please notice, that we throw an error if the email is already confirmed. Therefore, our JWT can’t be used more than once.
If the confirmEmail method, we use the UsersService to mark the email as confirmed. We need to implement this functionality.
1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5
6@Injectable()
7export class UsersService {
8 constructor(
9 @InjectRepository(User)
10 private usersRepository: Repository<User>,
11 ) {}
12
13 async markEmailAsConfirmed(email: string) {
14 return this.usersRepository.update({ email }, {
15 isEmailConfirmed: true
16 });
17 }
18
19 // ...
20}Resending the confirmation link
Since we set an expiration time for our tokens, the user might not use the token on time. Therefore, we should implement a feature of resending the link.
1import {
2 Controller,
3 ClassSerializerInterceptor,
4 UseInterceptors,
5 Post,
6 UseGuards,
7 Req,
8} from '@nestjs/common';
9import { EmailConfirmationService } from './emailConfirmation.service';
10import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
11import RequestWithUser from '../authentication/requestWithUser.interface';
12
13@Controller('email-confirmation')
14@UseInterceptors(ClassSerializerInterceptor)
15export class EmailConfirmationController {
16 constructor(
17 private readonly emailConfirmationService: EmailConfirmationService
18 ) {}
19
20 @Post('resend-confirmation-link')
21 @UseGuards(JwtAuthenticationGuard)
22 async resendConfirmationLink(@Req() request: RequestWithUser) {
23 await this.emailConfirmationService.resendConfirmationLink(request.user.id);
24 }
25
26 // ...
27}A thing worth noting is that we require the user to authenticate before resending the confirmation link. Thanks to that, users can’t require email confirmation for other people.
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3
4@Injectable()
5export class EmailConfirmationService {
6 constructor(
7 private readonly usersService: UsersService,
8 ) {}
9
10 public async resendConfirmationLink(userId: number) {
11 const user = await this.usersService.getById(userId);
12 if (user.isEmailConfirmed) {
13 throw new BadRequestException('Email already confirmed');
14 }
15 await this.sendVerificationLink(user.email);
16 }
17
18 // ...
19}A crucial thing to notice security-wise is that sending a new confirmation link doesn’t invalidate the previous links. If we would like to achieve that, we could, for example, store the most recent confirmation token in the database and check it before confirming.
Requiring the email address to be confirmed
Depending on the use case, we might want to prevent the user from accessing certain endpoints if the user didn’t confirm the email. To do that, we can create an additional guard.
1import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
2import RequestWithUser from '../authentication/requestWithUser.interface';
3
4@Injectable()
5export class EmailConfirmationGuard implements CanActivate {
6 canActivate(
7 context: ExecutionContext,
8 ) {
9 const request: RequestWithUser = context.switchToHttp().getRequest();
10
11 if (!request.user?.isEmailConfirmed) {
12 throw new UnauthorizedException('Confirm your email first');
13 }
14
15 return true;
16 }
17}For our guard to work, we need to attach it to an endpoint.
1import { Controller, Req, UseGuards, Get } from '@nestjs/common';
2import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
3import RequestWithUser from '../authentication/requestWithUser.interface';
4import StripeService from '../stripe/stripe.service';
5import { EmailConfirmationGuard } from '../emailConfirmation/emailConfirmation.guard';
6
7@Controller('credit-cards')
8export default class CreditCardsController {
9 constructor(
10 private readonly stripeService: StripeService
11 ) {}
12
13 @Get()
14 @UseGuards(EmailConfirmationGuard)
15 @UseGuards(JwtAuthenticationGuard)
16 async getCreditCards(@Req() request: RequestWithUser) {
17 return this.stripeService.listCreditCards(request.user.stripeCustomerId);
18 }
19
20 // ...
21}In Typescript, decorators resolve from bottom to top. In our implementation, the EmailConfirmationGuard requires the request.user object to work properly. Because of that, the crucial thing is to first use the EmailConfirmationGuard, then apply the JwtAuthenticationGuard.
Summary
In this article, we’ve implemented the feature of confirming email addresses. To do that, we had to send emails containing JWT. We’ve also created a NestJS guard that we can use to block certain endpoints if the user didn’t confirm the email. With it, we’ve added a feature that might prove to be useful if we depend on email messaging a lot in our application.