7 min read
Original source

Using server-side sessions instead of JSON Web Tokens

So far, in this series, we’ve used JSON Web Tokens (JWT) to implement authentication. While this is a fitting choice for many applications, this is not the…

So far, in this series, we’ve used JSON Web Tokens (JWT) to implement authentication. While this is a fitting choice for many applications, this is not the only choice out there. In this article, we look into server-side sessions and implement them with NestJS. You can find the code from this article in this repository The idea behind server-side sessions At its core, HTTP is stateless, and so are the HTTP requests. Even though that’s the case, we need to implement a mechanism to recognize if a person performing the request is authenticated. So far, we’ve been using JSON Web Tokens for that. We send them to the users when they log in and expect them to send them back when making subsequent requests to our API. This encrypted token contains the user’s id, and thanks to that, we can assume that the request is valid. If you want to know more about JSON Web Tokens, check outAPI with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies With the above solution, our application is still stateless. We can’t change the JWT token or make it invalid in a straightforward way. The server-side sessions work differently. We create a session for the users with server-side sessions when they log in and keep this information in the memory. We send the session’s id to the user and expect them to send it back when making further requests. When that happens, we can compare the received id of the session with the data stored in memory. The advantages and disadvantages The above change in approach has a set of consequences. Since we are storing the information about the session server-side, it might become tricky to scale. The more users we have logged in, the more significant strain it puts on our server’s memory. Also, if we have multiple instances of our web server, they don’t share memory. When due to load balancing, the user authenticates through the first instance and then accesses resources through the second instance, the server won’t recognize the user. In this article, we solve this issue with Redis. Keeping the session in memory has its advantages, too. Since we have easy access to the session data, we can quickly invalidate it. If we know that an attacker stole a particular cookie and can impersonate a user, we can easily remove one session from our memory. Also, if we don’t want the user to log in through multiple devices simultaneously, we can easily prevent that. If a user changes a password, we can also remove the old session from memory. All of the above use-cases are not easily achievable with JWT. We could create a blacklist of tokens to make tokens invalid, but unfortunately, it wouldn’t be straightforward. Defining the user data The first thing we do to implement authentication is to register our users. To do that, we need to define an entity for our users. In this article, we use TypeORM. Another suitable alternative is Prisma. If you want to know more, check out API with NestJS #32. Introduction to Prisma with PostgreSQL user.entity.ts import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import { Exclude } from 'class-transformer'; @Entity() class User { @PrimaryGeneratedColumn() public id?: number; @Column({ unique: true }) public email: string; @Column() public name: string; @Column() @Exclude() public password: string; } export default User; Above, we use @Exclude() to make sure that we don’t respond with the user’s password. If you want to dive deeper into this, check out API with NestJS #5. Serializing the response with interceptors We also need to be able to perform a few operations on the collection of users. To that, we create the UsersService. users.service.ts import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import User from './user.entity'; import CreateUserDto from './dto/createUser.dto'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository, ) {} async getByEmail(email: string) { const user = await this.usersRepository.findOne({ email }); if (user) { return user; } throw new HttpException('User with this email does not exist', HttpStatus.NOT_FOUND); } async getById(id: number) { const user = await this.usersRepository.findOne({ id }); if (user) { return user; } throw new HttpException('User with this id does not exist', HttpStatus.NOT_FOUND); } async create(userData: CreateUserDto) { const newUser = await this.usersRepository.create(userData); await this.usersRepository.save(newUser); return newUser; } } createUser.dto.ts export class CreateUserDto { email: string; name: string; password: string; } export default CreateUserDto; Managing passwords The crucial thing about the registration process is that we shouldn’t save the passwords in plain text. If a database breach happened, this would expose the passwords of our users. To deal with the above issue, we hash the passwords. During this process, the hashing algorithm converts one string into another string. Changing just one character in the passwords completely changes the outcome of hashing. The above process works only one way and, therefore, can’t be reversed straightforwardly. Thanks to that, we don’t know the exact passwords of our users. When they attempt to log in, we need to perform the hashing operation one more time. By comparing the hash of the provided credentials with the one stored in the database, we can determine if the user provided a valid password. Using the bcrypt algorithm One of the most popular hashing algorithms is bcrypt, implemented by the bcrypt npm package. It hashes the strings and compares the plain strings with hashes to validate the credentials.npm install @types/bcrypt bcryptThe bcrypt library is rather straightforward to use. We only need the hash and compare functions.const passwordInPlaintext = 'myStrongPassword'; const hash = await bcrypt.hash(passwordInPlaintext, 10); const isPasswordMatching = await bcrypt.compare(passwordInPlaintext, hashedPassword); console.log(isPasswordMatching); // true The authentication service We now have everything we need to implement the feature of registering users and validating their credentials. Let’s put this logic into the AuthenticationService. authentication.service.ts import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import RegisterDto from './dto/register.dto'; import * as bcrypt from 'bcrypt'; import PostgresErrorCode from '../database/postgresErrorCode.enum'; @Injectable() export class AuthenticationService { constructor( private readonly usersService: UsersService ) {} public async register(registrationData: RegisterDto) { const hashedPassword = await bcrypt.hash(registrationData.password, 10); try { return this.usersService.create({ ...registrationData, password: hashedPassword }); } catch (error) { if (error?.code === PostgresErrorCode.UniqueViolation) { throw new HttpException('User with that email already exists', HttpStatus.BAD_REQUEST); } throw new HttpException('Something went wrong', HttpStatus.INTERNAL_SERVER_ERROR); } } public async getAuthenticatedUser(email: string, plainTextPassword: string) { try { const user = await this.usersService.getByEmail(email); await this.verifyPassword(plainTextPassword, user.password); return user; } catch (error) { throw new HttpException('Wrong credentials provided', HttpStatus.BAD_REQUEST); } } private async verifyPassword(plainTextPassword: string, hashedPassword: string) { const isPasswordMatching = await bcrypt.compare( plainTextPassword, hashedPassword ); if (!isPasswordMatching) { throw new HttpException('Wrong credentials provided', HttpStatus.BAD_REQUEST); } } }There are quite a lot of things happening above. We break it down part by part in API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies Using server-side sessions with NestJS To implement authentication with server-side sessions, we need a few libraries. The first of them is express-session.npm install express-session @types/express-sessionIn this article, we also use the passport package. It provides an abstraction over the authentication and does quite a bit of the heavy lifting for us. Different applications need various approaches to authentication. Passport refers to those mechanisms as strategies. The one we need is called passport-local. It allows us to authenticate with a username and a password.npm install @nestjs/passport passport @types/passport-local passport-localWhen our users authenticate, we respond with a session ID cookie, and for security reasons, we need to encrypt it. To do that, we need a secret key. It is a string used to encrypt and decrypt the session ID. Changing the secret invalidates all existing sessions. We should never hardcode the secret key into our codebase. A fitting solution is to add it to environment variables. app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from '@hapi/joi'; @Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ POSTGRES_HOST: Joi.string().required(), POSTGRES_PORT: Joi.number().required(), POSTGRES_USER: Joi.string().required(), POSTGRES_PASSWORD: Joi.string().required(), POSTGRES_DB: Joi.string().required(), SESSION_SECRET: Joi.string().required() }) }) // ... ], controllers: [], providers: [], }) export class AppModule {}

Using server-side sessions instead of JSON Web Tokens | NestJS.io