NestJS Authentication with OAuth2.0: Configuration and Operations
Afonso Barracha
Posted on January 14, 2023
Series Intro
This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:
- Express REST API;
- Fastify REST API;
- Apollo GraphQL API.
And it is divided in 5 parts:
- Configuration and operations;
- Express Local OAuth REST API;
- Fastify Local OAuth REST API;
- Apollo Local OAuth GraphQL API;
- Adding External OAuth Providers to our API;
Lets start the first part of this series.
Tutorial Intro
In this tutorial I will cover all the common operations necessary for implementing any type of OAuth system:
- User CRUD;
- User versioning for single user token revocation;
- JWT token generation;
- Auth module with token blacklisting.
TLDR: if you do not have 45 minutes to read the article, the code can be found on this repo
Overview
Local OAuth system, is an authentication system comprised on authentication through JSON Web Tokens (JWTs), where we use an access and refresh token pair:
- Access Token: the token we use to authenticate the current user by sending it on the Authorization header as a Bearer token. It has a small lifespan of 5 to 15 minutes;
- Refresh Token: this token is normally sent on a signed HTTP only cookie and is used to refresh the access tokens, this is achieved since the refresh token has a higher lifespan from 20 minutes to 7 days.
Set up
Start by creating a new NestJS app and open it on VSCode:
$ npm i -g @nestjs/cli
$ nest new nest-local-oauth -s
$ code nest-local-oauth
Create a new yarn config file (.yarnrc.yml
):
nodeLinker: node-modules
Install the latest version of yarn:
$ yarn set version stable
$ yarn plugin import interactive-tools
Before installing the packages add yarn cache to .gitignore
:
### Yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
On the tsconfig.json
add "esModuleInterop"
:
{
"compilerOptions": {
"...": "...",
"esModuleInterop": true
}
}
Finally install the packages and upgrade to the latest version:
$ yarn install
$ yarn upgrade-interactive
Technologies
For all adapters we will use the same tech-stack:
- MikroORM: to interact with our database;
- Bcrypt: for hashing passwords, note that for new projects you should use argon2 as it is more secure, however since bcrypt is still the norm I will explain how to build with it;
- JSON Web Tokens: the core of this authentication system;
- UUID: we will need unique identifiers to be able to blacklist our tokens;
- DayJS: for date manipulations.
So start by installing all packages:
$ yarn add @mikro-orm/core @mikro-orm/postgresql @mikro-orm/nestjs bcrypt jsonwebtoken uuid dayjs
$ yarn add -D @mikro-orm/cli @types/bcrypt @types/jsonwebtoken @types/nodemailer @types/uuid
Configuration
Before we start we need several things:
- Secrets and lifetimes of the tokens;
- Name and secret of cookie;
- Email configuration;
- Database url.
Types of token
For a complete authentication system we need 3 types of tokens:
- Access: the access token for authorization;
- Refresh: the refresh token for refreshing the access token;
- Reset: used to reset an user password given an email;
- Confirmation: use to confirm the user.
Therefore the access token will be something as follows:
# JWT tokens
JWT_ACCESS_TIME=600
JWT_CONFIRMATION_SECRET='random_string'
JWT_CONFIRMATION_TIME=3600
JWT_RESET_PASSWORD_SECRET='random_string'
JWT_RESET_PASSWORD_TIME=1800
JWT_REFRESH_SECRET='random_string'
JWT_REFRESH_TIME=604800
Since access tokens need to be decoded by the Gateway (or other services if you do not have a Gateway), it needs to use a public and private key pair. You can generate a 2048 bits RSA key here, and add them to a keys
directory on the root of your project.
Cookie config
Our refresh token will be sent on a http only signed cookie so we need a variable for the refresh cookie name and secret.
# Refresh token
REFRESH_COOKIE='cookie_name'
COOKIE_SECRET='random_string'
Email config
To send emails we will use Nodemailer se we just need to add typical email configuration parameters:
# Email config
EMAIL_HOST='smtp.gmail.com'
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER='johndoe@gmail.com'
EMAIL_PASSWORD='your_email_password'
Database config
For the database we just need the PostgreSQL URL:
# Database config
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/auth'
General config
Other move geneal variables:
- Node environment, change to production ;
- APP ID, an UUID for the api;
- PORT, the API port on the server (normally 5000);
- Front-end Domain.
APP_ID='00000-00000-00000-00000-00000'
NODE_ENV='development'
PORT=4000
DOMAIN='localhost:3000'
Config Module
For configuration we can use the nestjs ConfigModule
, so start by creating a config
folder on the src
folder. Then inside the config
folder start adding the interfaces folder with the config interfaces:
JWT interface:
// jwt.interface.ts
export interface ISingleJwt {
secret: string;
time: number;
}
export interface IAccessJwt {
publicKey: string;
privateKey: string;
time: number;
}
export interface IJwt {
access: IAccessJwt;
confirmation: ISingleJwt;
resetPassword: ISingleJwt;
refresh: ISingleJwt;
}
Email config interface:
// email-config.interface.ts
interface IEmailAuth {
user: string;
pass: string;
}
export interface IEmailConfig {
host: string;
port: number;
secure: boolean;
auth: IEmailAuth;
}
Config interface:
// config.interface.ts
import { MikroOrmModuleOptions } from '@mikro-orm/nestjs';
import { IEmailConfig } from './email-config.interface';
import { IJwt } from './jwt.interface';
export interface IConfig {
id: string;
port: number;
domain: string;
db: MikroOrmModuleOptions;
jwt: IJwt;
emailService: IEmailConfig;
}
Create the config function:
// index.ts
import { LoadStrategy } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';
import { readFileSync } from 'fs';
import { join } from 'path';
import { IConfig } from './interfaces/config.interface';
export function config(): IConfig {
const publicKey = readFileSync(
join(__dirname, '..', '..', 'keys/public.key'),
'utf-8',
);
const privateKey = readFileSync(
join(__dirname, '..', '..', 'keys/private.key'),
'utf-8',
);
return {
id: process.env.APP_ID,
port: parseInt(process.env.PORT, 10),
domain: process.env.DOMAIN,
jwt: {
access: {
privateKey,
publicKey,
time: parseInt(process.env.JWT_ACCESS_TIME, 10),
},
confirmation: {
secret: process.env.JWT_CONFIRMATION_SECRET,
time: parseInt(process.env.JWT_CONFIRMATION_TIME, 10),
},
resetPassword: {
secret: process.env.JWT_RESET_PASSWORD_SECRET,
time: parseInt(process.env.JWT_RESET_PASSWORD_TIME, 10),
},
refresh: {
secret: process.env.JWT_REFRESH_SECRET,
time: parseInt(process.env.JWT_REFRESH_TIME, 10),
},
},
emailService: {
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT, 10),
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
},
},
db: defineConfig({
clientUrl: process.env.DATABASE_URL,
entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
loadStrategy: LoadStrategy.JOINED,
allowGlobalContext: true,
}),
};
}
Install the config module:
$ yarn add @nestjs/config joi
Create a validation schema:
// config.schema.ts
import Joi from 'joi';
export const validationSchema = Joi.object({
APP_ID: Joi.string().uuid({ version: 'uuidv4' }).required(),
NODE_ENV: Joi.string().required(),
PORT: Joi.number().required(),
URL: Joi.string().required(),
DATABASE_URL: Joi.string().required(),
JWT_ACCESS_TIME: Joi.number().required(),
JWT_CONFIRMATION_SECRET: Joi.string().required(),
JWT_CONFIRMATION_TIME: Joi.number().required(),
JWT_RESET_PASSWORD_SECRET: Joi.string().required(),
JWT_RESET_PASSWORD_TIME: Joi.number().required(),
JWT_REFRESH_SECRET: Joi.string().required(),
JWT_REFRESH_TIME: Joi.number().required(),
REFRESH_COOKIE: Joi.string().required(),
COOKIE_SECRET: Joi.string().required(),
EMAIL_HOST: Joi.string().required(),
EMAIL_PORT: Joi.number().required(),
EMAIL_SECURE: Joi.bool().required(),
EMAIL_USER: Joi.string().email().required(),
EMAIL_PASSWORD: Joi.string().required(),
});
And finally import it to the app.module.ts
:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { config } from './config';
import { validationSchema } from './config/config.schema';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema,
load: [config],
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Mikro-ORM Config
Mikro-ORM needs a config file added to our package.json
so on the src
folder create the file:
// mikro-orm.config.ts
import { LoadStrategy, Options } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';
const config: Options = defineConfig({
clientUrl: process.env.DATABASE_URL,
entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
loadStrategy: LoadStrategy.JOINED,
allowGlobalContext: true,
});
export default config;
And on our package.json
file add the config file:
{
"...": "...",
"mikro-orm": {
"useTsNode": true,
"configPaths": [
"./src/mikro-orm.config.ts",
"./dist/mikro-orm.config.js"
]
}
}
To use it on our config
folder we need to add a class for the MikroOrmModule
:
// mikro-orm.config.ts
import {
MikroOrmModuleOptions,
MikroOrmOptionsFactory,
} from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MikroOrmConfig implements MikroOrmOptionsFactory {
constructor(private readonly configService: ConfigService) {}
public createMikroOrmOptions(): MikroOrmModuleOptions {
return this.configService.get<MikroOrmModuleOptions>('db');
}
}
And asynchronous register the module on our app.module.ts
file:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// ...
import { MikroOrmConfig } from './config/mikroorm.config';
@Module({
imports: [
// ...
MikroOrmModule.forRootAsync({
imports: [ConfigModule],
useClass: MikroOrmConfig,
}),
],
// ...
})
export class AppModule {}
Common Module
I like to have a common global module for entity validation, error handling and string manipulation.
For entity and input validation we will use class validator, as explained in the docs:
$ yarn add class-transformer class-validator
And add it to the main file:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
Create the module and service:
$ nest g mo common
$ nest g s common
On the common
folder add the Global
decorator to the module and export the service:
import { Global, Module } from '@nestjs/common';
import { CommonService } from './common.service';
@Global()
@Module({
providers: [CommonService],
exports: [CommonService],
})
export class CommonModule {}
Personally I like to have all my Regular Expressions inside common, so create a consts
folder with a regex.const.ts
file:
// checks if a password has at least one uppercase letter and a number or special character
export const PASSWORD_REGEX =
/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/;
// checks if a string has only letters, numbers, spaces, apostrophes, dots and dashes
export const NAME_REGEX = /(^[\p{L}\d'\.\s\-]*$)/u;
// checks if a string is a valid slug, useful for usernames
export const SLUG_REGEX = /^[a-z\d]+(?:(\.|-|_)[a-z\d]+)*$/;
// validates if passwords are valid bcrypt hashes
export const BCRYPT_HASH = /\$2[abxy]?\$\d{1,2}\$[A-Za-z\d\./]{53}/;
Another thing is utility functions for common checks, create a utils
function with a validation file:
// validation.util.ts
export const isUndefined = (value: unknown): value is undefined =>
typeof value === 'undefined';
export const isNull = (value: unknown): value is null => value === null;
Common Service
Start by adding a LoggerService
:
import { Dictionary, EntityRepository } from '@mikro-orm/core';
import { Injectable, Logger, LoggerService } from '@nestjs/common';
@Injectable()
export class CommonService {
private readonly loggerService: LoggerService;
constructor() {
this.loggerService = new Logger(CommonService.name);
}
}
We need the following methods:
Validator for entities
import { Dictionary } from '@mikro-orm/core';
import {
BadRequestException,
NotFoundException,
// ...
} from '@nestjs/common';
import { validate } from 'class-validator';
@Injectable()
export class CommonService {
// ...
/**
* Validate Entity
*
* Validates an entities with the class-validator library
*/
public async validateEntity(entity: Dictionary): Promise<void> {
const errors = await validate(entity);
const messages: string[] = [];
for (const error of errors) {
messages.push(...Object.values(error.constraints));
}
if (errors.length > 0) {
throw new BadRequestException(messages.join(',\n'));
}
}
}
Promise error wrappers
import { Dictionary, EntityRepository } from '@mikro-orm/core';
import {
BadRequestException,
ConflictException,
InternalServerErrorException,
// ...
} from '@nestjs/common';
// ...
@Injectable()
export class CommonService {
// ...
/**
* Throw Duplicate Error
*
* Checks is an error is of the code 23505, PostgreSQL's duplicate value error,
* and throws a conflict exception
*/
public async throwDuplicateError<T>(promise: Promise<T>, message?: string) {
try {
return await promise;
} catch (error) {
this.loggerService.error(error);
if (error.code === '23505') {
throw new ConflictException(message ?? 'Duplicated value in database');
}
throw new BadRequestException(error.message);
}
}
/**
* Throw Internal Error
*
* Function to abstract throwing internal server exception
*/
public async throwInternalError<T>(promise: Promise<T>): Promise<T> {
try {
return await promise;
} catch (error) {
this.loggerService.error(error);
throw new InternalServerErrorException(error);
}
}
}
Entity Actions
import { Dictionary, EntityRepository } from '@mikro-orm/core';
// ...
@Injectable()
export class CommonService {
// ...
/**
* Check Entity Existence
*
* Checks if a findOne query didn't return null or undefined
*/
public checkEntityExistence<T extends Dictionary>(
entity: T | null | undefined,
name: string,
): void {
if (isNull(entity) || isUndefined(entity)) {
throw new NotFoundException(`${name} not found`);
}
}
/**
* Save Entity
*
* Validates, saves and flushes entities into the DB
*/
public async saveEntity<T extends Dictionary>(
repo: EntityRepository<T>,
entity: T,
isNew = false,
): Promise<void> {
await this.validateEntity(entity);
if (isNew) {
repo.persist(entity);
}
await this.throwDuplicateError(repo.flush());
}
/**
* Remove Entity
*
* Removes an entities from the DB.
*/
public async removeEntity<T extends Dictionary>(
repo: EntityRepository<T>,
entity: T,
): Promise<void> {
await this.throwInternalError(repo.removeAndFlush(entity));
}
}
String manipulation
Start by installing slugify
:
$ yarn add slugify
Now add the methods:
// ...
import slugify from 'slugify';
// ...
@Injectable()
export class CommonService {
// ...
/**
* Format Name
*
* Takes a string trims it and capitalizes every word
*/
public formatName(title: string): string {
return title
.trim()
.replace(/\n/g, ' ')
.replace(/\s\s+/g, ' ')
.replace(/\w\S*/g, (w) => w.replace(/^\w/, (l) => l.toUpperCase()));
}
/**
* Generate Point Slug
*
* Takes a string and generates a slug with dtos as word separators
*/
public generatePointSlug(str: string): string {
return slugify(str, { lower: true, replacement: '.', remove: /['_\.\-]/g });
}
}
Message Generation
There are endpoints that have to return a single message string, for those type of endpoints I like to make a message interface with an id, so it is easier to filter on the front-end, create an interfaces
folder and add the following file:
// message.interface.ts
export interface IMessage {
id: string;
message: string;
}
And create a method for it:
// ...
import { v4 } from 'uuid';
// ...
@Injectable()
export class CommonService {
// ...
public generateMessage(message: string): IMessage {
return { id: v4(), message };
}
}
Users Module
Before creating the auth module we need a way to do CRUD operations on our users, so create a new users module and service:
$ nest g mo users
$ nest g s users
User Entity
Before creating the entity we need an interface with what we want in our user, create an interfaces
folder and the user.interface.ts
file:
export interface IUser {
id: number;
name: string;
username: string;
email: string;
password: string;
confirmed: boolean;
createdAt: Date;
updatedAt: Date;
}
Now implement that on an entity, start by creating an entities
folder:
// user.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { IsBoolean, IsEmail, IsString, Length, Matches } from 'class-validator';
import {
BCRYPT_HASH,
NAME_REGEX,
SLUG_REGEX,
} from '../../common/consts/regex.const';
import { IUser } from '../interfaces/users.interface';
@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
@PrimaryKey()
public id: number;
@Property({ columnType: 'varchar', length: 100 })
@IsString()
@Length(3, 100)
@Matches(NAME_REGEX, {
message: 'Name must not have special characters',
})
public name: string;
@Property({ columnType: 'varchar', length: 106 })
@IsString()
@Length(3, 106)
@Matches(SLUG_REGEX, {
message: 'Username must be a valid slugs',
})
public username: string;
@Property({ columnType: 'varchar', length: 255 })
@IsString()
@IsEmail()
@Length(5, 255)
public email: string;
@Property({ columnType: 'boolean', default: false })
@IsBoolean()
public confirmed: true | false = false; // since it is saved on the db as binary
@Property({ columnType: 'varchar', length: 60 })
@IsString()
@Length(59, 60)
@Matches(BCRYPT_HASH)
public password: string;
@Property({ onCreate: () => new Date() })
public createdAt: Date = new Date();
@Property({ onUpdate: () => new Date() })
public updatedAt: Date = new Date();
}
Add the entity to users.module.ts
and export the UserService
:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { UserEntity } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
imports: [MikroOrmModule.forFeature([UserEntity])],
providers: [UsersService],
exports: [UserService]
})
export class UsersModule {}
User versioning
For security purpose we need to be able to version our users credentials (password changes for example), so in case they change any credential we can revoke all refresh tokens.
We do this by creating a Credentials
JSON parameter on our users. Start by creating its interface:
// credentials.interface.ts
export interface ICredentials {
version: number;
lastPassword: string;
passwordUpdatedAt: number;
updatedAt: number;
}
On a new embeddables
folder for our JSON types add a credentials embeddable:
import { Embeddable, Property } from '@mikro-orm/core';
import dayjs from 'dayjs';
import { ICredentials } from '../interfaces/credentials.interface';
@Embeddable()
export class CredentialsEmbeddable implements ICredentials {
@Property({ default: 0 })
public version = 0;
@Property({ default: '' })
public lastPassword = '';
@Property({ default: dayjs().unix() })
public passwordUpdatedAt: number = dayjs().unix();
@Property({ default: dayjs().unix() })
public updatedAt: number = dayjs().unix();
public updatePassword(password: string): void {
this.version++;
this.lastPassword = password;
const now = dayjs().unix();
this.passwordUpdatedAt = now;
this.updatedAt = now;
}
public updateVersion(): void {
this.version++;
this.updatedAt = dayjs().unix();
}
}
And update our users interface and entity:
// user.interface.ts
import { ICredentials } from './credentials.interface';
export interface IUser {
// ...
credentials: ICredentials;
// ...
}
// user.entity.ts
import { Embedded, Entity, PrimaryKey, Property } from '@mikro-orm/core';
// ...
import { IUser } from '../interfaces/users.interface';
@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
// ...
@Embedded(() => CredentialsEmbeddable)
public credentials: CredentialsEmbeddable = new CredentialsEmbeddable();
// ...
}
User Service
User service will mosly cover our User CRUD operations, inject the usersRepository
with @InjectRepository
decorator and the CommonService
:
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { UserEntity } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(UserEntity)
private readonly usersRepository: EntityRepository<UserEntity>,
private readonly commonService: CommonService,
) {}
}
CRUD Operations
User Creations
To create a user we need three params:
- name: the name of the user;
- email: an all lowercased unique email;
- password: the user password (note this field will be optional when we add external providers).
// ...
@Injectable()
export class UsersService {
// ...
public async create(
email: string,
name: string,
password: string,
): Promise<UserEntity> {
const formattedEmail = email.toLowerCase();
await this.checkEmailUniqueness(formattedEmail);
const formattedName = this.commonService.formatName(name);
const user = this.usersRepository.create({
email: formattedEmail,
name: formattedName,
username: await this.generateUsername(formattedName),
password: await hash(password, 10),
});
await this.commonService.saveEntity(this.usersRepository, user, true);
return user;
}
// ...
private async checkEmailUniqueness(email: string): Promise<void> {
const count = await this.usersRepository.count({ email });
if (count > 0) {
throw new ConflictException('Email already in use');
}
}
/**
* Generate Username
*
* Generates a unique username using a point slug based on the name
* and if it's already in use, it adds the usernames count to the end
*/
private async generateUsername(name: string): Promise<string> {
const pointSlug = this.commonService.generatePointSlug(name);
const count = await this.usersRepository.count({
username: {
$like: `${pointSlug}%`,
},
});
if (count > 0) {
return `${pointSlug}${count}`;
}
return pointSlug;
}
// ...
}
User Reads
We need three read methods:
-
ID: the main read methods that fetches a user by ID
// ... @Injectable() export class UsersService { // ... public async findOneById(id: number): Promise<UserEntity> { const user = await this.usersRepository.findOne({ id }); this.commonService.checkEntityExistence(user, 'User'); return user; } // ... }
-
Email: mostly for authentication fetches a user by email
import { // ... UnauthorizedException, } from '@nestjs/common'; // ... import { isNull, isUndefined } from '../common/utils/validation.util'; @Injectable() export class UsersService { // ... public async findOneByEmail(email: string): Promise<UserEntity> { const user = await this.usersRepository.findOne({ email: email.toLowerCase(), }); this.throwUnauthorizedException(user); return user; } // necessary for password reset public async uncheckedUserByEmail(email: string): Promise<UserEntity> { return this.usersRepository.findOne({ email: email.toLowerCase(), }); } // ... private throwUnauthorizedException( user: undefined | null | UserEntity, ): void { if (isUndefined(user) || isNull(user)) { throw new UnauthorizedException('Invalid credentials'); } } // ... }
-
Credentials: for token generation and verification
// ... @Injectable() export class UsersService { // ... public async findOneByCredentials( id: number, version: number, ): Promise<UserEntity> { const user = await this.usersRepository.findOne({ id }); this.throwUnauthorizedException(user); if (user.credentials.version !== version) { throw new UnauthorizedException('Invalid credentials'); } return user; } // ... }
-
Username: for both fetching the user and for authentication
// ... @Injectable() export class UsersService { // ... public async findOneByUsername( username: string, forAuth = false, ): Promise<UserEntity> { const user = await this.usersRepository.findOne({ username: username.toLowerCase(), }); if (forAuth) { this.throwUnauthorizedException(user); } else { this.commonService.checkEntityExistence(user, 'User'); } return user; } // ... }
User Update
Before creating the updates we need to create some dtos
(Data Transfer Objects). One for changing the email:
// change-email.dto.ts
import { IsEmail, IsString, Length, MinLength } from 'class-validator';
export abstract class ChangeEmailDto {
@IsString()
@MinLength(1)
public password!: string;
@IsString()
@IsEmail()
@Length(5, 255)
public email: string;
}
And one for changing the rest of the user:
// update-user.dto.ts
import { IsString, Length, Matches, ValidateIf } from 'class-validator';
import { NAME_REGEX, SLUG_REGEX } from '../../common/consts/regex.const';
import { isNull, isUndefined } from '../../common/utils/validation.util';
export abstract class UpdateUserDto {
@IsString()
@Length(3, 106)
@Matches(SLUG_REGEX, {
message: 'Username must be a valid slugs',
})
@ValidateIf(
(o: UpdateUserDto) =>
!isUndefined(o.username) || isUndefined(o.name) || isNull(o.name),
)
public username?: string;
@IsString()
@Length(3, 100)
@Matches(NAME_REGEX, {
message: 'Name must not have special characters',
})
@ValidateIf(
(o: UpdateUserDto) =>
!isUndefined(o.name) || isUndefined(o.username) || isNull(o.username),
)
public name?: string;
}
Since user is a crucial entity on our API, I like to divide the updates in serveral methods, hence endpoints/mutations:
-
User Update:
// ... import { UsernameDto } from './dtos/username.dto'; @Injectable() export class UsersService { // ... public async update(userId: number, dto: UpdateUserDto): Promise<UserEntity> { const user = await this.findOneById(userId); const { name, username } = dto; if (!isUndefined(name) && !isNull(name)) { if (name === user.name) { throw new BadRequestException('Name must be different'); } user.name = this.commonService.formatName(name); } if (!isUndefined(username) && !isNull(username)) { const formattedUsername = dto.username.toLowerCase(); if (user.username === formattedUsername) { throw new BadRequestException('Username should be different'); } await this.checkUsernameUniqueness(formattedUsername); user.username = formattedUsername; } await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... private async checkUsernameUniqueness(username: string): Promise<void> { const count = await this.usersRepository.count({ username }); if (count > 0) { throw new ConflictException('Username already in use'); } } // ... }
-
Email Update:
// ... import { ChangeEmailDto } from './dtos/change-email.dto'; @Injectable() export class UsersService { // ... public async updateEmail( userId: number, dto: ChangeEmailDto, ): Promise<UserEntity> { const user = await this.userById(userId); const { email, password } = dto; if (!(await compare(password, user.password))) { throw new BadRequestException('Invalid password'); } const formattedEmail = email.toLowerCase(); await this.checkEmailUniqueness(formattedEmail); user.credentials.updateVersion(); user.email = formattedEmail; await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... }
-
Password Update and Reset:
// ... import { compare, hash } from 'bcrypt'; // ... @Injectable() export class UsersService { // ... public async updatePassword( userId: number, password: string, newPassword: string, ): Promise<UserEntity> { const user = await this.userById(userId); if (!(await compare(password, user.password))) { throw new BadRequestException('Wrong password'); } if (await compare(newPassword, user.password)) { throw new BadRequestException('New password must be different'); } user.credentials.updatePassword(user.password); user.password = await hash(newPassword, 10); await this.commonService.saveEntity(this.usersRepository, user); return user; } public async resetPassword( userId: number, version: number, password: string, ): Promise<UserEntity> { const user = await this.findOneByCredentials(userId, version); user.credentials.updatePassword(user.password); user.password = await hash(password, 10); await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... }
User Removal
Note that I still return the user with the function, in case we ever need to implement a notification system, feel free to return void:
// ...
@Injectable()
export class UsersService {
// ...
public async remove(userId: number): Promise<UserEntity> {
const user = await this.findOneById(userId);
await this.commonService.removeEntity(this.usersRepository, user);
return user;
}
// ...
}
JWT module
Although nestjs has its own JwtService
, we need a custom one for the various types of tokens we have:
$ nest g mo jwt
$ nest g s jwt
And export the service from the module:
import { Module } from '@nestjs/common';
import { JwtService } from './jwt.service';
@Module({
providers: [JwtService],
exports: [JwtService],
})
export class JwtModule {}
Token Types
Enum
Create a enums
folder and add the following enum:
export enum TokenTypeEnum {
ACCESS = 'access',
REFRESH = 'refresh',
CONFIRMATION = 'confirmation',
RESET_PASSWORD = 'resetPassword',
}
Interfaces
Each token extends from the previous, create an interfaces
folder and add one interface for each type of token.
Base Token
All tokens will have an iat
(issued at), exp
(expiration), iss
(issuer), aud
(audience) andsub
(subject) field so we need a base for all our tokens:
// token-base.interface.ts
export interface ITokenBase {
iat: number;
exp: number;
iss: string;
aud: string;
sub: string;
}
Access Token
The access token will only contain the id of an user:
// access-token.interface.ts
import { ITokenBase } from './token-base.interface';
export interface IAccessPayload {
id: number;
}
export interface IAccessToken extends IAccessPayload, ITokenBase {}
Email Token
The email token will contain the id and the version of an user:
// email-token.interface.ts
import { IAccessPayload } from './access-token.interface';
import { ITokenBase } from './token-base.interface';
export interface IEmailPayload extends IAccessPayload {
version: number;
}
export interface IEmailToken extends IEmailPayload, ITokenBase {}
Refresh Token
The refresh token will contain the id and the version of an user, as well as a uuid as the identifier of the token:
// refresh-token.interface.ts
import { IEmailPayload } from './email-token.interface';
import { ITokenBase } from './token-base.interface';
export interface IRefreshPayload extends IEmailPayload {
tokenId: string;
}
export interface IRefreshToken extends IRefreshPayload, ITokenBase {}
Service
Start by injecting the ConfigService
and CommonService
:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommonService } from '../common/common.service';
@Injectable()
export class JwtService {
private readonly jwtConfig: IJwt;
private readonly issuer: string;
private readonly domain: string;
constructor(
private readonly configService: ConfigService,
private readonly commonService: CommonService,
) {}
}
Since the jsonwebtoken library still uses callback for asynchronous behaviour lets create asynchronous sign and verify functions:
// ...
import * as jwt from 'jsonwebtoken';
import { IAccessPayload } from './interfaces/access-token.interface';
import { IEmailPayload } from './interfaces/email-token.interface';
import { IRefreshToken } from './interfaces/refresh-token.interface';
@Injectable()
export class JwtService {
// ...
private static async generateTokenAsync(
payload: IAccessPayload | IEmailPayload | IRefreshPayload,
secret: string,
options: jwt.SignOptions,
): Promise<string> {
return new Promise((resolve, rejects) => {
jwt.sign(payload, secret, options, (error, token) => {
if (error) {
rejects(error);
return;
}
resolve(token);
});
});
}
private static async verifyTokenAsync<T>(
token: string,
secret: string,
options: jwt.VerifyOptions,
): Promise<T> {
return new Promise((resolve, rejects) => {
jwt.verify(token, secret, options, (error, payload: T) => {
if (error) {
rejects(error);
return;
}
resolve(payload);
});
});
}
}
Start setting up the jwt configuration and domain:
// ...
import { IJwt } from '../config/interfaces/jwt.interface';
// ...
@Injectable()
export class JwtService {
private readonly jwtConfig: IJwt;
private readonly issuer: string;
private readonly domain: string;
constructor(
private readonly configService: ConfigService,
private readonly commonService: CommonService,
) {
this.jwtConfig = this.configService.get<IJwt>('jwt');
this.issuer = this.configService.get<string>('id');
this.domain = this.configService.get<string>('domain');
}
// ...
}
Create a method for generating tokens using IUser
and TokenTypesEnum
as params:
// ...
import { v4 } from 'uuid';
import { IJwt } from '../config/interfaces/jwt.interface';
import { IUser } from '../users/interfaces/user.interface';
// ...
@Injectable()
export class JwtService {
// ...
public async generateToken(
user: IUser,
tokenType: TokenTypeEnum,
domain?: string | null,
tokenId?: string,
): Promise<string> {
const jwtOptions: jwt.SignOptions = {
issuer: this.issuer,
subject: user.email,
audience: domain ?? this.domain,
algorithm: 'HS256', // only needs a secret
};
switch (tokenType) {
case TokenTypeEnum.ACCESS:
const { privateKey, time: accessTime } = this.jwtConfig.access;
return this.commonService.throwInternalError(
JwtService.generateTokenAsync({ id: user.id }, privateKey, {
...jwtOptions,
expiresIn: accessTime,
algorithm: 'RS256', // to use public and private key
}),
);
case TokenTypeEnum.REFRESH:
const { secret: refreshSecret, time: refreshTime } =
this.jwtConfig.refresh;
return this.commonService.throwInternalError(
JwtService.generateTokenAsync(
{
id: user.id,
version: user.credentials.version,
tokenId: tokenId ?? v4(),
},
refreshSecret,
{
...jwtOptions,
expiresIn: refreshTime,
},
),
);
case TokenTypeEnum.CONFIRMATION:
case TokenTypeEnum.RESET_PASSWORD:
const { secret, time } = this.jwtConfig[tokenType];
return this.commonService.throwInternalError(
JwtService.generateTokenAsync(
{ id: user.id, version: user.credentials.version },
secret,
{
...jwtOptions,
expiresIn: time,
},
),
);
}
}
}
And then create a method to verify and decode our tokens:
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
// ...
import {
// ...
IAccessToken,
} from './interfaces/access-token.interface';
import { IEmailPayload, IEmailToken } from './interfaces/email-token.interface';
import {
// ...
IRefreshToken,
} from './interfaces/refresh-token.interface';
// ...
@Injectable()
export class JwtService {
// ...
private static async throwBadRequest<
T extends IAccessToken | IRefreshToken | IEmailToken,
>(promise: Promise<T>): Promise<T> {
try {
return await promise;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new BadRequestException('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new BadRequestException('Invalid token');
}
throw new InternalServerErrorException(error);
}
}
// ...
public async verifyToken<
T extends IAccessToken | IRefreshToken | IEmailToken,
>(token: string, tokenType: TokenTypeEnum): Promise<T> {
const jwtOptions: jwt.VerifyOptions = {
issuer: this.issuer,
audience: new RegExp(this.domain),
};
switch (tokenType) {
case TokenTypeEnum.ACCESS:
const { publicKey, time: accessTime } = this.jwtConfig.access;
return JwtService.throwBadRequest(
JwtService.verifyTokenAsync(token, publicKey, {
...jwtOptions,
maxAge: accessTime,
algorithms: ['RS256'],
}),
);
case TokenTypeEnum.REFRESH:
case TokenTypeEnum.CONFIRMATION:
case TokenTypeEnum.RESET_PASSWORD:
const { secret, time } = this.jwtConfig[tokenType];
return JwtService.throwBadRequest(
JwtService.verifyTokenAsync(token, secret, {
...jwtOptions,
maxAge: time,
algorithms: ['HS256'],
}),
);
}
}
}
Mailer Module
To confirm our users identity, and to be able to reset passwords we need to send emails. Start by installing nodemailer and handlebars:
$ yarn add nodemailer handlebars
$ yarn add @types/nodemailer @types/handlebars
Create a mailer module and service:
$ nest g mo mailer
$ nest g s mailer
And export the service from the module:
import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';
@Module({
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {}
Templates
We will use handlebars to create templates for confirmation and password reseting.
Interfaces
Create an interfaces
folder and add an interface for the template data:
// template-data.interface.ts
export interface ITemplatedData {
name: string;
link: string;
}
And one for the templates we will have:
// templates.interface.ts
import { TemplateDelegate } from 'handlebars';
import { ITemplatedData } from './template-data.interface';
export interface ITemplates {
confirmation: TemplateDelegate<ITemplatedData>;
resetPassword: TemplateDelegate<ITemplatedData>;
}
HTML (hbs)
Create an email template for confirmation:
<!-- confirmation.hbs -->
<html lang='en'>
<body>
<p>Hello {{name}},</p>
<br />
<p>Welcome to [Your app],</p>
<p>
Click
<b><a href='{{link}}' target='_blank'>here</a></b>
to activate your acount or go to this link:
{{link}}
</p>
<p><small>This link will expire in an hour.</small></p>
<br />
<p>Best of luck,</p>
<p>[Your app] Team</p>
</body>
</html>
And one for password reseting:
<!-- reset-password.hbs -->
<html lang='en'>
<body>
<p>Hello {{name}},</p>
<br />
<p>Your password reset link:
<b><a href='{{link}}' target='_blank'>here</a></b></p>
<p>Or go to this link: ${{link}}</p>
<p><small>This link will expire in 30 minutes.</small></p>
<br />
<p>Best regards,</p>
<p>[Your app] Team</p>
</body>
</html>
To compile the templates you need to add an assets
on nest-cli.json
:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"mailer/templates/**/*"
],
"watchAssets": true
}
}
Service
Start by importing the ConfigService
:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MailerService {
constructor(private readonly configService: ConfigService) {}
}
And add the email client configuration, as well as a logger:
import { Injectable, Logger, LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createTransport, Transporter } from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
@Injectable()
export class MailerService {
private readonly loggerService: LoggerService;
private readonly transport: Transporter<SMTPTransport.SentMessageInfo>;
private readonly email: string;
private readonly domain: string;
constructor(private readonly configService: ConfigService) {
const emailConfig = this.configService.get<IEmailConfig>('emailService');
this.transport = createTransport(emailConfig);
this.email = `"My App" <${emailConfig.auth.user}>`;
this.domain = this.configService.get<string>('domain');
this.loggerService = new Logger(MailerService.name);
}
}
As you can see we have not added our templates yet, start by creating a parser method:
// ...
import { readFileSync } from 'fs';
import Handlebars from 'handlebars';
// ...
import { ITemplatedData } from './interfaces/template-data.interface';
@Injectable()
export class MailerService {
// ...
private static parseTemplate(
templateName: string,
): Handlebars.TemplateDelegate<ITemplatedData> {
const templateText = readFileSync(
join(__dirname, 'templates', templateName),
'utf-8',
);
return Handlebars.compile<ITemplatedData>(templateText, { strict: true });
}
}
And add our templates to the configuration:
// ...
import { ITemplates } from './interfaces/templates.interface';
@Injectable()
export class MailerService {
// ...
private readonly templates: ITemplates;
constructor(private readonly configService: ConfigService) {
this.templates = {
confirmation: MailerService.parseTemplate('confirmation.hbs'),
resetPassword: MailerService.parseTemplate('reset-password.hbs'),
};
}
// ...
}
Emails should be sent asynchronously so create a public method that uses the .then
notation:
// ...
@Injectable()
export class MailerService {
// ...
public sendEmail(
to: string,
subject: string,
html: string,
log?: string,
): void {
this.transport
.sendMail({
from: this.email,
to,
subject,
html,
})
.then(() => this.loggerService.log(log ?? 'A new email was sent.'))
.catch((error) => this.loggerService.error(error));
}
}
And a method for each of our two templates:
// ...
import { IUser } from '../users/interfaces/user.interface';
@Injectable()
export class MailerService {
// ...
public sendConfirmationEmail(user: IUser, token: string): void {
const { email, name } = user;
const subject = 'Confirm your email';
const html = this.templates.confirmation({
name,
link: `https://${this.domain}/auth/confirm/${token}`,
});
this.sendEmail(email, subject, html, 'A new confirmation email was sent.');
}
public sendResetPasswordEmail(user: IUser, token: string): void {
const { email, name } = user;
const subject = 'Reset your password';
const html = this.templates.resetPassword({
name,
link: `https://${this.domain}/auth/reset-password/${token}`,
});
this.sendEmail(
email,
subject,
html,
'A new reset password email was sent.',
);
}
// ...
}
Auth Module
Create the auth module:
$ nest g mo auth
$ nest g s auth
Entities
The auth module will only have one entity, the blacklisted tokens, create its interface on the interfaces
folder:
// blacklisted-token.interface.ts
import { IUser } from '../../users/interfaces/user.interface';
export interface IBlacklistedToken {
tokenId: string;
user: IUser;
createdAt: Date;
}
Now just implement it on a entity on the entities
folder:
// blacklisted-token.entity.ts
import {
Entity,
ManyToOne,
PrimaryKeyType,
Property,
Unique,
} from '@mikro-orm/core';
import { UserEntity } from '../../users/entities/user.entity';
import { IBlacklistedToken } from '../interfaces/blacklisted-token.interface';
@Entity({ tableName: 'blacklisted_tokens' })
@Unique({ properties: ['tokenId', 'user'] })
export class BlacklistedTokenEntity implements IBlacklistedToken {
@Property({
primary: true,
columnType: 'uuid',
})
public tokenId: string;
@ManyToOne({
entity: () => UserEntity,
onDelete: 'cascade',
primary: true,
})
public user: UserEntity;
@Property({ onCreate: () => new Date() })
public createdAt: Date;
[PrimaryKeyType]: [string, number];
}
It has a composite key of the tokenId
and the user
ID.
Add the entity to the module, as well as the UserModel
, JwtModule
and MailerModule
:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';
@Module({
imports: [
MikroOrmModule.forFeature([BlacklistedTokenEntity]),
UsersModule,
JwtModule,
MailerModule,
],
providers: [AuthService],
})
export class AuthModule {}
Service
Start by injecting the blacklistedTokensRepository
, CommonService
, UsersService
, JwtService
andMailerService
:
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { JwtService } from '../jwt/jwt.service';
import { MailerService } from '../mailer/mailer.service';
import { UsersService } from '../users/users.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(BlacklistedTokenEntity)
private readonly blacklistedTokensRepository: EntityRepository<BlacklistedTokenEntity>,
private readonly commonService: CommonService,
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly mailerService: MailerService,
) {}
}
DTOs
We need several dtos, so create a dtos
directory, with the following files:
Passwords DTO
We need two passwords paramenters one as the main password and a confirmation password for registration and password updates.
// passwords.dto.ts
import { IsString, Length, Matches, MinLength } from 'class-validator';
import { PASSWORD_REGEX } from '../../common/consts/regex.const';
export abstract class PasswordsDto {
@IsString()
@Length(8, 35)
@Matches(PASSWORD_REGEX, {
message:
'Password requires a lowercase letter, an uppercase letter, and a number or symbol',
})
public password1!: string;
@IsString()
@MinLength(1)
public password2!: string;
}
Sign-up DTO
For registration.
// sign-up.dto.ts
import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';
export abstract class SignUpDto extends PasswordsDto {
@IsString()
@Length(3, 100, {
message: 'Name has to be between 3 and 50 characters.',
})
@Matches(NAME_REGEX, {
message: 'Name can only contain letters, dtos, numbers and spaces.',
})
public name!: string;
@IsString()
@IsEmail()
@Length(5, 255)
public email!: string;
}
Sign-in DTO
For login, it can take the email or username.
// sign-in.dto.ts
import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';
export abstract class SignUpDto extends PasswordsDto {
@IsString()
@Length(3, 100, {
message: 'Name has to be between 3 and 50 characters.',
})
@Matches(NAME_REGEX, {
message: 'Name can only contain letters, dtos, numbers and spaces.',
})
public name!: string;
@IsString()
@IsEmail()
@Length(5, 255)
public email!: string;
}
Email DTO
A dto with only the email for sending password reset emails.
// email.dto.ts
export abstract class EmailDto {
@IsString()
@IsEmail()
@Length(5, 255)
public email: string;
}
Reset Password DTO
For reseting the password given a token.
// reset-password.dto.ts
import { IsJWT, IsString } from 'class-validator';
import { PasswordsDto } from './passwords.dto';
export abstract class ResetPasswordDto extends PasswordsDto {
@IsString()
@IsJWT()
public resetToken!: string;
}
Change Password DTO
For updating the user password.
// change-password.dto.ts
import { IsString, MinLength } from 'class-validator';
import { PasswordsDto } from './passwords.dto';
export abstract class ChangePasswordDto extends PasswordsDto {
@IsString()
@MinLength(1)
public password!: string;
}
Interfaces
Most authentication service methods will return the same three fields:
- User;
- Access Token;
- Refresh Token.
So create an interface for that called IAuthResult
:
// auth-result.interface.ts
import { IUser } from '../../users/interfaces/user.interface';
export interface IAuthResult {
user: IUser;
accessToken: string;
refreshToken: string;
}
Methods
We will start by creating a private method for faster generation of the access and refresh token by leveraging Promise.all
:
// ...
@Injectable()
export class AuthService {
// ...
private async generateAuthTokens(
user: UserEntity,
domain?: string,
tokenId?: string,
): Promise<[string, string]> {
return Promise.all([
this.jwtService.generateToken(
user,
TokenTypeEnum.ACCESS,
domain,
tokenId,
),
this.jwtService.generateToken(
user,
TokenTypeEnum.REFRESH,
domain,
tokenId,
),
]);
}
}
Sign Up Method
// ...
import { SignUpDto } from './dtos/sign-up.dto';
// ...
@Injectable()
export class AuthService {
// ...
public async signUp(dto: SignUpDto, domain?: string): Promise<IMessage> {
const { name, email, password1, password2 } = dto;
this.comparePasswords(password1, password2);
const user = await this.usersService.create(email, name, password1);
const confirmationToken = await this.jwtService.generateToken(
user,
TokenTypeEnum.CONFIRMATION,
domain,
);
this.mailerService.sendConfirmationEmail(user, confirmationToken);
return this.commonService.generateMessage('Registration successful');
}
private comparePasswords(password1: string, password2: string): void {
if (password1 !== password2) {
throw new BadRequestException('Passwords do not match');
}
}
// ...
}
Sign In Method
// ...
import {
// ...
UnauthorizedException,
} from '@nestjs/common';
import { compare } from 'bcrypt';
import { isEmail } from 'class-validator';
import { SLUG_REGEX } from '../common/consts/regex.const';
// ...
import { IAuthResult } from './interfaces/auth-result.interface';
@Injectable()
export class AuthService {
// ...
public async singIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
const { emailOrUsername, password } = dto;
const user = await this.userByEmailOrUsername(emailOrUsername);
if (!(await compare(password, user.password))) {
await this.checkLastPassword(user.credentials, password);
}
if (!user.confirmed) {
const confirmationToken = await this.jwtService.generateToken(
user,
TokenTypeEnum.CONFIRMATION,
domain,
);
this.mailerService.sendConfirmationEmail(user, confirmationToken);
throw new UnauthorizedException(
'Please confirm your email, a new email has been sent',
);
}
const [accessToken, refreshToken] = await this.generateAuthTokens(
user,
domain,
);
return { user, accessToken, refreshToken };
}
// validates the input and fetches the user by email or username
private async userByEmailOrUsername(
emailOrUsername: string,
): Promise<UserEntity> {
if (emailOrUsername.includes('@')) {
if (!isEmail(emailOrUsername)) {
throw new BadRequestException('Invalid email');
}
return this.usersService.userByEmail(emailOrUsername);
}
if (
emailOrUsername.length < 3 ||
emailOrUsername.length > 106 ||
!SLUG_REGEX.test(emailOrUsername)
) {
throw new BadRequestException('Invalid username');
}
return this.usersService.userByUsername(emailOrUsername, true);
}
// checks if your using your last password
private async checkLastPassword(
credentials: ICredentials,
password: string,
): Promise<void> {
const { lastPassword, passwordUpdatedAt } = credentials;
if (lastPassword.length === 0 || !(await compare(password, lastPassword))) {
throw new UnauthorizedException('Invalid credentials');
}
const now = dayjs();
const time = dayjs.unix(passwordUpdatedAt);
const months = now.diff(time, 'month');
const message = 'You changed your password ';
if (months > 0) {
throw new UnauthorizedException(
message + months + (months > 1 ? ' months ago' : ' month ago'),
);
}
const days = now.diff(time, 'day');
if (days > 0) {
throw new UnauthorizedException(
message + days + (days > 1 ? ' days ago' : ' day ago'),
);
}
const hours = now.diff(time, 'hour');
if (hours > 0) {
throw new UnauthorizedException(
message + hours + (hours > 1 ? ' hours ago' : ' hour ago'),
);
}
throw new UnauthorizedException(message + 'recently');
}
// ...
}
Refresh Token Access
// ...
@Injectable()
export class AuthService {
// ...
public async refreshTokenAccess(
refreshToken: string,
domain?: string,
): Promise<IAuthResult> {
const { id, version, tokenId } =
await this.jwtService.verifyToken<IRefreshToken>(
refreshToken,
TokenTypeEnum.REFRESH,
);
await this.checkIfTokenIsBlacklisted(id, tokenId);
const user = await this.usersService.userByCredentials(id, version);
const [accessToken, newRefreshToken] = await this.generateAuthTokens(
user,
domain,
tokenId,
);
return { user, accessToken, refreshToken: newRefreshToken };
}
// checks if a token given the ID of the user and ID of token exists on the database
private async checkIfTokenIsBlacklisted(
userId: number,
tokenId: string,
): Promise<void> {
const count = await this.blacklistedTokensRepository.count({
user: userId,
tokenId,
});
if (count > 0) {
throw new UnauthorizedException('Token is invalid');
}
}
// ...
}
Logout
// ...
@Injectable()
export class AuthService {
// ...
public async logout(refreshToken: string): Promise<IMessage> {
const { id, tokenId } = await this.jwtService.verifyToken<IRefreshToken>(
refreshToken,
TokenTypeEnum.REFRESH,
);
await this.blacklistToken(id, tokenId);
return this.commonService.generateMessage('Logout successful');
}
// creates a new blacklisted token in the database with the
// ID of the refresh token that was removed with the logout
private async blacklistToken(userId: number, tokenId: string): Promise<void> {
const blacklistedToken = this.blacklistedTokensRepository.create({
user: userId,
tokenId,
});
await this.commonService.saveEntity(
this.blacklistedTokensRepository,
blacklistedToken,
true,
);
}
// ...
}
Reset Password Email
// ...
import { isNull, isUndefined } from '../common/utils/validation.util';
@Injectable()
export class AuthService {
// ...
public async resetPasswordEmail(
dto: EmailDto,
domain?: string,
): Promise<IMessage> {
const user = await this.usersService.uncheckedUserByEmail(dto.email);
if (!isUndefined(user) && !isNull(user)) {
const resetToken = await this.jwtService.generateToken(
user,
TokenTypeEnum.RESET_PASSWORD,
domain,
);
this.mailerService.sendResetPasswordEmail(user, resetToken);
}
return this.commonService.generateMessage('Reset password email sent');
}
// ...
}
Reset Password
// ...
@Injectable()
export class AuthService {
// ...
public async resetPassword(dto: ResetPasswordDto): Promise<IMessage> {
const { password1, password2, resetToken } = dto;
const { id, version } = await this.jwtService.verifyToken<IEmailToken>(
resetToken,
TokenTypeEnum.RESET_PASSWORD,
);
this.comparePasswords(password1, password2);
await this.usersService.resetPassword(id, version, password1);
return this.commonService.generateMessage('Password reset successful');
}
// ...
}
Change Password
// ...
@Injectable()
export class AuthService {
// ...
public async changePassword(
userId: number,
dto: ChangePasswordDto,
): Promise<IAuthResult> {
const { password1, password2, password } = dto;
this.comparePasswords(password1, password2);
const user = await this.usersService.updatePassword(
userId,
password,
password1,
);
const [accessToken, refreshToken] = await this.generateAuthTokens(user);
return { user, accessToken, refreshToken };
}
// ...
}
Optional Section
Optimizations with Redis Cache
Since tokens expire saving them on disk permanently can be quite wasteful, so it is better to save them on cache. NestJS comes with its own CacheModule
so start by installing the following packages:
$ yarn add cache-manager ioredis cache-manager-redis-yet
Configuration
On the config.interface.ts
add the ioredis options:
// ...
import { RedisOptions } from 'ioredis';
export interface IConfig {
// ...
redis: RedisOptions;
}
Now most managed redis services (AWS ElastiCache, Digital Ocean Redis DB, etc) will actually give you an URL and not the options so we need to build a parser, on a new utils
folder add:
// redis-url-parser.util.ts
import { RedisOptions } from 'ioredis';
export const redisUrlParser = (url: string): RedisOptions => {
if (url.includes('://:')) {
const arr = url.split('://:')[1].split('@');
const secondArr = arr[1].split(':');
return {
password: arr[0],
host: secondArr[0],
port: parseInt(secondArr[1], 10),
};
}
const connectionString = url.split('://')[1];
const arr = connectionString.split(':');
return {
host: arr[0],
port: parseInt(arr[1], 10),
};
};
Add the redis URL to the .env
file (and on docker-compose if you are using it), the schema and the index
file:
REDIS_URL='redis://localhost:6379'
// index.ts
// ...
import { redisUrlParser } from './utils/redis-url-parser.util';
export function config(): IConfig {
// ...
return {
// ...
redis: redisUrlParser(process.env.REDIS_URL),
};
}
// config.schema.ts
import Joi from 'joi';
export const validationSchema = Joi.object({
// ...
REDIS_URL: Joi.string().required(),
});
Finaly to be able to use the CacheModule
we need to create a config class for the cache:
// cache.config.ts
import {
CacheModuleOptions,
CacheOptionsFactory,
Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet';
@Injectable()
export class CacheConfig implements CacheOptionsFactory {
constructor(private readonly configService: ConfigService) {}
async createCacheOptions(): Promise<CacheModuleOptions> {
return {
store: await redisStore({
...this.configService.get('redis'),
ttl: this.configService.get<number>('jwt.refresh.time') * 1000,
}),
};
}
}
And add it to the app.module.ts
:
// ...
import { CacheModule, Module } from '@nestjs/common';
// ...
@Module({
imports: [
// ...
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
useClass: CacheConfig,
}),
// ...
],
// ...
})
export class AppModule {}
Auth Module
Start by deleting the entities
directory, the blacklisted-token.interface.ts
file, and remove it from the module:
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
@Module({
imports: [UsersModule, JwtModule, MailerModule],
providers: [AuthService],
})
export class AuthModule {}
Auth Service
Start by changing the blacklistedTokensRepository
for the cache manager:
import {
// ...
CACHE_MANAGER,
// ...
} from '@nestjs/common';
import { Cache } from 'cache-manager';
// ...
@Injectable()
export class AuthService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cacheManager: Cache,
// ...
) {}
// ...
}
Now update the blacklistToken
method and logout
as it is dependent on it:
// ...
import dayjs from 'dayjs';
// ...
@Injectable()
export class AuthService {
// ...
public async logout(refreshToken: string): Promise<IMessage> {
const { id, tokenId, exp } =
await this.jwtService.verifyToken<IRefreshToken>(
refreshToken,
TokenTypeEnum.REFRESH,
);
await this.blacklistToken(id, tokenId, exp);
return this.commonService.generateMessage('Logout successful');
}
// ...
// checks if a blacklist token given a redis key exist on cache
private async blacklistToken(
userId: number,
tokenId: string,
exp: number,
): Promise<void> {
const now = dayjs().unix();
const ttl = (exp - now) * 1000;
if (ttl > 0) {
await this.commonService.throwInternalError(
this.cacheManager.set(`blacklist:${userId}:${tokenId}`, now, ttl),
);
}
}
// ...
}
As you can see, we use a typical redis key divided in three parts by a colon:
- Title of key: "blacklist"
- User ID: the id of the user that the token belongs;
- Token ID: the id of the token that is blacklisted.
I save the date of when it was created, but you cant just save 0 or 1 (binary) as true or false.
Also, the way we check for if the token exists changes as well:
// ...
@Injectable()
export class AuthService {
// ...
private async checkIfTokenIsBlacklisted(
userId: number,
tokenId: string,
): Promise<void> {
const time = await this.cacheManager.get<number>(
`blacklist:${userId}:${tokenId}`,
);
if (!isUndefined(time) && !isNull(time)) {
throw new UnauthorizedException('Invalid token');
}
}
// ...
}
Conclusion
With this you can create the base for a full local authentication type of API.
The full code of this tutorial can be find on this repo.
About the Author
Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?
Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.
Do not miss out on any of my latest articles – follow me here on dev and LinkedIn to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!
Posted on January 14, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.