-
-
Save culttm/48ccef7195c3532dbac4844c1c75b22d to your computer and use it in GitHub Desktop.
NestJS - Implementing Access & Refresh Token Authentication
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/authentication.controller.ts | |
import { Body, Controller, Post } from '@nestjs/common' | |
import { RegisterRequest } from './requests' | |
import { User } from '../../modules/user' | |
import { UsersService } from '../users/users.service' | |
export interface AuthenticationPayload { | |
user: User | |
payload: { | |
type: string | |
token: string | |
refresh_token?: string | |
} | |
} | |
@Controller('/api/auth') | |
export class AuthenticationController { | |
private readonly users: UsersService | |
private readonly tokens: TokensService | |
public constructor (users: UsersService, tokens: TokensService) { | |
this.users = users | |
this.tokens = tokens | |
} | |
@Post('/register') | |
public async register (@Body() body: RegisterRequest) { | |
const user = await this.users.createUserFromRequest(body) | |
const token = await this.tokens.generateAccessToken(user) | |
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30) | |
const payload = this.buildResponsePayload(user, token, refresh) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload { | |
return { | |
user: user, | |
payload: { | |
type: 'bearer', | |
token: accessToken, | |
...(refreshToken ? { refresh_token: refreshToken } : {}), | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/authentication.controller.ts | |
import { Body, Controller, Post, UnauthorizedException } from '@nestjs/common' | |
import { RegisterRequest, LoginRequest } from '../requests' | |
import { User } from '../../models/user.model' | |
import { UsersService } from '../users/users.service' | |
export interface AuthenticationPayload { | |
user: User | |
payload: { | |
type: string | |
token: string | |
refresh_token?: string | |
} | |
} | |
@Controller('/api/auth') | |
export class AuthenticationController { | |
private readonly users: UsersService | |
private readonly tokens: TokensService | |
public constructor (users: UsersService, tokens: TokensService) { | |
this.users = users | |
this.tokens = tokens | |
} | |
@Post('/register') | |
public async register (@Body() body: RegisterRequest) { | |
const user = await this.users.createUserFromRequest(body) | |
const token = await this.tokens.generateAccessToken(user) | |
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30) | |
const payload = this.buildResponsePayload(user, token, refresh) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
@Post('/login') | |
public async login (@Body() body: LoginRequest) { | |
const { username, password } = body | |
const user = await this.users.findForUsername(username) | |
const valid = user ? await this.users.validateCredentials(user, password) : false | |
if (!valid) { | |
throw new UnauthorizedException('The login is invalid') | |
} | |
const token = await this.tokens.generateAccessToken(user) | |
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30) | |
const payload = this.buildResponsePayload(user, token, refresh) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload { | |
return { | |
user: user, | |
payload: { | |
type: 'bearer', | |
token: accessToken, | |
...(refreshToken ? { refresh_token: refreshToken } : {}), | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/authentication.controller.ts | |
import { Body, Controller, Post, UnauthorizedException } from '@nestjs/common' | |
import { RegisterRequest, LoginRequest, RefreshRequest } from '../requests' | |
import { User } from '../../models/user.model' | |
import { UsersService } from '../users/users.service' | |
export interface AuthenticationPayload { | |
user: User | |
payload: { | |
type: string | |
token: string | |
refresh_token?: string | |
} | |
} | |
@Controller('/api/auth') | |
export class AuthenticationController { | |
private readonly users: UsersService | |
private readonly tokens: TokensService | |
public constructor (users: UsersService, tokens: TokensService) { | |
this.users = users | |
this.tokens = tokens | |
} | |
@Post('/register') | |
public async register (@Body() body: RegisterRequest) { | |
const user = await this.users.createUserFromRequest(body) | |
const token = await this.tokens.generateAccessToken(user) | |
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30) | |
const payload = this.buildResponsePayload(user, token, refresh) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
@Post('/login') | |
public async login (@Body() body: LoginRequest) { | |
const { username, password } = body | |
const user = await this.users.findForUsername(username) | |
const valid = user ? await this.users.validateCredentials(user, password) : false | |
if (!valid) { | |
throw new UnauthorizedException('The login is invalid') | |
} | |
const token = await this.tokens.generateAccessToken(user) | |
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30) | |
const payload = this.buildResponsePayload(user, token, refresh) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
@Post('/refresh') | |
public async refresh (@Body() body: RefreshRequest) { | |
const { user, token } = await this.tokens.createAccessTokenFromRefreshToken(body.refresh_token) | |
const payload = this.buildResponsePayload(user, token) | |
return { | |
status: 'success', | |
data: payload, | |
} | |
} | |
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload { | |
return { | |
user: user, | |
payload: { | |
type: 'bearer', | |
token: accessToken, | |
...(refreshToken ? { refresh_token: refreshToken } : {}), | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/authentication.controller.ts | |
// ... | |
import { Controller, UseGuards, Get, Req } from '@nestjs/common' | |
import { JWTGuard } from './jwt.guard.ts' | |
// ... | |
@Controller('/api/auth') | |
export class AuthenticationController { | |
// ... | |
@Get('/me') | |
@UseGuards(JWTGuard) | |
public async getUser (@Req() request) { | |
const userId = request.user.id | |
const user = await this.users.findForId(userId) | |
return { | |
status: 'success', | |
data: user, | |
} | |
} | |
// ... | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/authentication.module.ts | |
import { Module } from '@nestjs/common' | |
import { JwtModule } from '@nestjs/jwt' | |
import { SequelizeModule } from '@nestjs/sequelize' | |
import { UsersModule } from '../users/users.module' | |
import { RefreshToken } from '../../models/RefreshToken' | |
import { TokensService } from './tokens.service' | |
import { RefreshTokensRepository } from './refresh-tokens.repository' | |
import { AuthenticationController } from './authentication.controller' | |
@Module({ | |
imports: [ | |
SequelizeModule.forFeature([ | |
RefreshToken, | |
]), | |
JwtModule.register({ | |
secret: '<SECRET KEY>', | |
signOptions: { | |
expiresIn: '5m', | |
} | |
}), | |
UsersModule, | |
], | |
controllers: [ | |
AuthenticationController, | |
], | |
providers: [ | |
TokensService, | |
RefreshTokensRepository, | |
], | |
}) | |
export class AuthenticationModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/jwt.guard.ts | |
import { Injectable, UnauthorizedException } from '@nestjs/common' | |
import { AuthGuard } from '@nestjs/passport' | |
@Injectable() | |
export class JWTGuard extends AuthGuard('jwt') { | |
handleRequest (err, user, info: Error) { | |
if (err || info || !user) { | |
throw err || info || new UnauthorizedException() | |
} | |
return user | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/jwt.strategy.ts | |
import { Injectable } from '@nestjs/common' | |
import { PassportStrategy } from '@nestjs/passport' | |
import { ExtractJwt, Strategy } from 'passport-jwt' | |
import { UsersService } from '../../users/users.service' | |
import { User } from '../../models' | |
export interface AccessTokenPayload { | |
sub: number; | |
} | |
@Injectable() | |
export class JwtStrategy extends PassportStrategy(Strategy) { | |
private users: UsersService | |
public constructor (users: UsersService) { | |
super({ | |
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), | |
ignoreExpiration: false, | |
secretOrKey: '<SECRET KEY>', | |
signOptions: { | |
expiresIn: '5m', | |
}, | |
}) | |
this.users = users | |
} | |
async validate (payload: AccessTokenPayload): Promise<User> { | |
const { sub: id } = payload | |
const user = await this.users.findForId(id) | |
if (!user) { | |
return null | |
} | |
return user | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/models/refresh-token.model.ts | |
import { Table, Column, Model } from 'sequelize-typescript' | |
@Table({ tableName: 'refresh_tokens', underscored: true }) | |
export class RefreshToken extends Model<RefreshToken> { | |
@Column | |
user_id: number | |
@Column | |
is_revoked: boolean | |
@Column | |
expires: Date | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/refresh-tokens.repository.ts | |
import { Injectable } from '@nestjs/common' | |
import { User } from '../../../models/user.model' | |
import { RefreshToken } from '../../../models/refresh-token.model' | |
@Injectable() | |
export class RefreshTokensRepository { | |
public async createRefreshToken (user: User, ttl: number): Promise<RefreshToken> { | |
const token = new RefreshToken() | |
token.user_id = user.id | |
token.is_revoked = false | |
const expiration = new Date() | |
expiration.setTime(expiration.getTime() + ttl) | |
token.expires = expiration | |
return token.save() | |
} | |
public async findTokenById (id: number): Promise<RefreshToken | null> { | |
return RefreshToken.findOne({ | |
where: { | |
id, | |
} | |
}) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/requests.ts | |
import { IsNotEmpty, MinLength } from 'class-validator' | |
export class LoginRequest { | |
@IsNotEmpty({ message: 'A username is required' }) | |
readonly username: string | |
@IsNotEmpty({ message: 'A password is required to login' }) | |
readonly password: string | |
} | |
export class RegisterRequest { | |
@IsNotEmpty({ message: 'An username is required' }) | |
readonly username: string | |
@IsNotEmpty({ message: 'A password is required' }) | |
@MinLength(6, { message: 'Your password must be at least 6 characters' }) | |
readonly password: string | |
} | |
export class RefreshRequest { | |
@IsNotEmpty({ message: 'The refresh token is required' }) | |
readonly refresh_token: string | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/tokens.service.ts | |
import { Injectable } from '@nestjs/common' | |
import { JwtService } from '@nestjs/jwt' | |
import { SignOptions } from 'jsonwebtoken' | |
import { User } from '../../models' | |
import { RefreshTokensRepository } from './refresh-tokens.repository' | |
const BASE_OPTIONS: SignOptions = { | |
issuer: 'https://my-app.com', | |
audience:'https://my-app.com', | |
} | |
@Injectable() | |
export class TokensService { | |
private readonly tokens: RefreshTokensRepository | |
private readonly jwt: JwtService | |
public constructor (tokens: RefreshTokensRepository, jwt: JwtService) { | |
this.tokens = tokens | |
this.jwt = jwt | |
} | |
public async generateAccessToken (user: User): Promise<string> { | |
const opts: SignOptions = { | |
...BASE_OPTIONS, | |
subject: String(user.id), | |
} | |
return this.jwt.signAsync({}, opts) | |
} | |
public async generateRefreshToken (user: User, expiresIn: number): Promise<string> { | |
const token = await this.tokens.createRefreshToken(user, expiresIn) | |
const opts: SignOptions = { | |
...BASE_OPTIONS, | |
expiresIn, | |
subject: String(user.id), | |
jwtid: String(token.id), | |
} | |
return this.jwt.signAsync({}, opts) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/authentication/tokens.service.ts | |
import { UnprocessableEntityException, Injectable } from '@nestjs/common' | |
import { JwtService } from '@nestjs/jwt' | |
import { SignOptions, TokenExpiredError } from 'jsonwebtoken' | |
import { User } from '../../models' | |
import { RefreshToken } from '../../models/refresh-token.model' | |
import { UsersRepository } from '../users/users.repository' | |
import { RefreshTokensRepository } from './refresh-tokens.repository' | |
const BASE_OPTIONS: SignOptions = { | |
issuer: 'https://my-app.com', | |
audience:'https://my-app.com', | |
} | |
export interface RefreshTokenPayload { | |
jti: number; | |
sub: number | |
} | |
@Injectable() | |
export class TokensService { | |
private readonly tokens: RefreshTokensRepository | |
private readonly users: UsersRepository | |
private readonly jwt: JwtService | |
public constructor (tokens: RefreshTokensRepository, users: UsersRepository, jwt: JwtService) { | |
this.tokens = tokens | |
this.users = users | |
this.jwt = jwt | |
} | |
public async generateAccessToken (user: User): Promise<string> { | |
const opts: SignOptions = { | |
...BASE_OPTIONS, | |
subject: String(user.id), | |
} | |
return this.jwt.signAsync({}, opts) | |
} | |
public async generateRefreshToken (user: User, expiresIn: number): Promise<string> { | |
const token = await this.tokens.createRefreshToken(user, expiresIn) | |
const opts: SignOptions = { | |
...BASE_OPTIONS, | |
expiresIn, | |
subject: String(user.id), | |
jwtid: String(token.id), | |
} | |
return this.jwt.signAsync({}, opts) | |
} | |
public async resolveRefreshToken (encoded: string): Promise<{ user: User, token: RefreshToken }> { | |
const payload = await this.decodeRefreshToken(encoded) | |
const token = await this.getStoredTokenFromRefreshTokenPayload(payload) | |
if (!token) { | |
throw new UnprocessableEntityException('Refresh token not found') | |
} | |
if (token.is_revoked) { | |
throw new UnprocessableEntityException('Refresh token revoked') | |
} | |
const user = await this.getUserFromRefreshTokenPayload(payload) | |
if (!user) { | |
throw new UnprocessableEntityException('Refresh token malformed') | |
} | |
return { user, token } | |
} | |
public async createAccessTokenFromRefreshToken (refresh: string): Promise<{ token: string, user: User }> { | |
const { user } = await this.resolveRefreshToken(refresh) | |
const token = await this.generateAccessToken(user) | |
return { user, token } | |
} | |
private async decodeRefreshToken (token: string): Promise<RefreshTokenPayload> { | |
try { | |
return this.jwt.verifyAsync(token) | |
} catch (e) { | |
if (e instanceof TokenExpiredError) { | |
throw new UnprocessableEntityException('Refresh token expired') | |
} else { | |
throw new UnprocessableEntityException('Refresh token malformed') | |
} | |
} | |
} | |
private async getUserFromRefreshTokenPayload (payload: RefreshTokenPayload): Promise<User> { | |
const subId = payload.sub | |
if (!subId) { | |
throw new UnprocessableEntityException('Refresh token malformed') | |
} | |
return this.users.findForId(subId) | |
} | |
private async getStoredTokenFromRefreshTokenPayload (payload: RefreshTokenPayload): Promise<RefreshToken | null> { | |
const tokenId = payload.jti | |
if (!tokenId) { | |
throw new UnprocessableEntityException('Refresh token malformed') | |
} | |
return this.tokens.findTokenById(tokenId) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/models/user.model.ts | |
import { Column, Model, Table } from 'sequelize-typescript' | |
@Table({ tableName: 'users', underscored: true }) | |
export class User extends Model<User> { | |
@Column | |
username: string | |
@Column | |
password: string | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/users/users.module.ts | |
import { Module } from '@nestjs/common' | |
import { SequelizeModule } from '@nestjs/sequelize' | |
import { User } from '../../models/user' | |
import { UsersService } from './users.service' | |
import { UsersRepository } from './users.repository' | |
@Module({ | |
imports: [ | |
SequelizeModule.forFeature([ | |
User, | |
]), | |
], | |
providers: [ | |
UsersService, | |
UsersRepository, | |
], | |
exports: [ | |
UsersService, | |
UsersRepository, | |
], | |
}) | |
export class UsersModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/users/users.repository.ts | |
import { Injectable } from '@nestjs/common' | |
import { InjectModel } from '@nestjs/sequelize' | |
import { hash } from 'bcrypt' | |
import { col, fn, where } from 'sequelize' | |
import { User } from '../../models' | |
@Injectable() | |
export class UsersRepository { | |
private readonly users: typeof User | |
public constructor (@InjectModel(User) users: typeof User) { | |
this.users = users | |
} | |
public async findForId (id: number): Promise<User | null> { | |
return this.users.findOne({ | |
where: { | |
id, | |
}, | |
}) | |
} | |
public async findForUsername (username: string): Promise<User | null> { | |
return this.users.findOne({ | |
where: { | |
username: where(fn('lower', col('username')), username), | |
}, | |
}) | |
} | |
public async create (username: string, password: string): Promise<User> { | |
const user = new User() | |
user.username = username | |
user.password = await hash(password, 10) | |
return user.save() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// app/modules/user/users.service.ts | |
import { UnprocessableEntityException, Injectable } from '@nestjs/common' | |
import { compare } from 'bcrypt' | |
import { RegisterRequest } from '../../requests' | |
import { User } from '../../models' | |
import { UsersRepository } from '../users/users.repository' | |
@Injectable() | |
export class UsersService { | |
private readonly users: UsersRepository | |
public constructor (users: UsersRepository) { | |
this.users = users | |
} | |
public async validateCredentials (user: User, password: string): Promise<boolean> { | |
return compare(password, user.password) | |
} | |
public async createUserFromRequest (request: RegisterRequest): Promise<User> { | |
const { username, password } = request | |
const existingFromUsername = await this.findForUsername(request.username) | |
if (existingFromUsername) { | |
throw new UnprocessableEntityException('Username already in use') | |
} | |
return this.users.create(username, password) | |
} | |
public async findForId (id: number): Promise<User | null> { | |
return this.users.findForId(id) | |
} | |
public async findForUsername (username: string): Promise<User | null> { | |
return this.users.findForUsername(username) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment