Skip to content

Commit

Permalink
resolve #9 handle multiple refresh token
Browse files Browse the repository at this point in the history
  • Loading branch information
AbdelrahmanBayoumi committed Sep 9, 2023
1 parent c346d3a commit 80c295d
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 83 deletions.
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@nestjs/mapped-types": "^2.0.1",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/schedule": "^3.0.3",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/swagger": "^7.0.3",
"@nestjs/throttler": "^4.1.0",
Expand Down
22 changes: 22 additions & 0 deletions prisma/migrations/20230909214106_rt_table/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `hashedRt` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "users" DROP COLUMN "hashedRt";

-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,

CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17 changes: 14 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ model User {
email String @unique
hash String
hashedRt String?
fullName String
birthday String
image String?
isVerified Boolean @default(false)
isVerified Boolean @default(false)
// One to Many Relationship between User and Birthday
// One User can have many Birthdays
Birthday Birthday[]
Birthday Birthday[]
refreshTokens RefreshToken[]
@@map("users")
}
Expand All @@ -50,3 +50,14 @@ model Birthday {
@@map("birthdays")
}

model RefreshToken {
id Int @id @default(autoincrement())
token String
expiresAt DateTime
createdAt DateTime @default(now())
// Many to One Relationship between RefreshToken and User and onDelete Cascade
// Many RefreshTokens can belong to one User
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import { UserSensitiveDataInterceptor } from './interceptors';
import { LoggerModule } from 'nestjs-pino';
import { ServeFaviconMiddleware } from '@nest-middlewares/serve-favicon';
import { join } from 'path';
import { ScheduleModule } from '@nestjs/schedule';
import { TasksModule } from './utils/tasks/tasks.module';

@Module({
imports: [
LoggerModule.forRoot(),
ScheduleModule.forRoot(),
TasksModule,
// ThrottlerModule.forRoot({
// ttl: 60,
// limit: 100,
Expand Down
8 changes: 5 additions & 3 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ export class AuthController {
user.createdAt = undefined;
user.updatedAt = undefined;
user.hash = undefined;
user.hashedRt = undefined;
return user;
}

@Post('logout')
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
logout(@GetUser('id') userId: number) {
this.authService.logout(userId);
logout(
@GetUser('id') userId: number,
@Body('refresh_token') refreshToken: string,
) {
this.authService.logout(userId, refreshToken);
return;
}

Expand Down
153 changes: 87 additions & 66 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ export class AuthService {
) {}

/**
* update the refresh token of the user
* @param userId user id to be updated
* @param refreshToken refresh token to be updated
* add a refresh token to the database
* @param userId user id to be added to the refresh token
* @param refreshToken refresh token to be added to
*/
async updateRefreshToken(userId: number, refreshToken: string) {
const hashedRefreshToken = await this.hashService.generateHash(
refreshToken,
);
await this.prisma.user.update({
where: { id: userId },
data: { hashedRt: hashedRefreshToken },
async addRefreshToken(userId: number, refreshToken: string) {
await this.prisma.refreshToken.create({
data: {
userId,
token: refreshToken,
// expiresAt: date of 30 days from now
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
}

Expand All @@ -62,7 +63,7 @@ export class AuthService {
this.sendVerificationEmail(user.id, user.email);

const tokens = await this.getTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refresh_token);
await this.addRefreshToken(user.id, tokens.refresh_token);
return tokens;
} catch (error) {
// if the error is because of a duplicate email
Expand All @@ -73,6 +74,39 @@ export class AuthService {
}
}

/**
* login a user and return a access token and a refresh token
* @param dto LoginDto object with user email and password
* @returns access token and refresh token pair
* @throws UnauthorizedException if the credentials are incorrect or the user does not exist
*/
async login(dto: LoginDto): Promise<Tokens> {
// find the user by email
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});

// if user does not exist throw exception
if (!user) {
throw new UnauthorizedException('Access denied');
}

// compare password
const pwMatches = await this.hashService.verifyHashed(
user.hash,
dto.password,
);

// if password incorrect throw exception
if (!pwMatches) {
throw new UnauthorizedException('Access denied');
}

const tokens = await this.getTokens(user.id, user.email);
await this.addRefreshToken(user.id, tokens.refresh_token);
return tokens;
}

/**
* verify the user by sending a verification email
* @param userId user id to be verified
Expand Down Expand Up @@ -128,39 +162,6 @@ export class AuthService {
}
}

/**
* login a user and return a access token and a refresh token
* @param dto LoginDto object with user email and password
* @returns access token and refresh token pair
* @throws UnauthorizedException if the credentials are incorrect or the user does not exist
*/
async login(dto: LoginDto): Promise<Tokens> {
// find the user by email
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});

// if user does not exist throw exception
if (!user) {
throw new UnauthorizedException('Access denied');
}

// compare password
const pwMatches = await this.hashService.verifyHashed(
user.hash,
dto.password,
);

// if password incorrect throw exception
if (!pwMatches) {
throw new UnauthorizedException('Access denied');
}

const tokens = await this.getTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refresh_token);
return tokens;
}

/**
* sign a access token and a refresh token for the user and return them
* @param userId user id to be signed
Expand All @@ -179,7 +180,7 @@ export class AuthService {
this.jwt.signAsync(
{ sub: userId, email },
{
expiresIn: '7d',
expiresIn: '30d',
secret: this.config.get('JWT_REFRESH_SECRET'),
},
),
Expand All @@ -196,40 +197,42 @@ export class AuthService {
* @param userId user id to be logged out
* @returns true if the user was logged out successfully
*/
async logout(userId: number): Promise<boolean> {
await this.prisma.user.updateMany({
where: {
id: userId,
hashedRt: {
not: null,
},
},
data: {
hashedRt: null,
},
});
async logout(userId: number, refreshToken: string): Promise<boolean> {
await this.revokeRefreshToken(userId, refreshToken);
return true;
}

/**
* refresh the access token and the refresh token
* refresh the access token and the refresh token by verifying the refresh token
* @param user user to be refreshed
* @returns access token and refresh token pair
* @throws ForbiddenException if the user does not have a refresh token
* @throws ForbiddenException if the refresh token does not match the hashed refresh token
*/
async refreshTokens(user: User): Promise<Tokens> {
if (!user || !user.hashedRt || !user['refreshToken'])
if (!user) throw new ForbiddenException('Access Denied');
// user['refreshToken'] is the token from RefreshTokenGuard which is added to the user object by the guard

// get refreshToken to check if it is valid
const refreshTokenToVerify = await this.prisma.refreshToken.findFirst({
where: {
userId: user.id,
token: user['refreshToken'],
},
});

if (!refreshTokenToVerify || refreshTokenToVerify.expiresAt < new Date())
throw new ForbiddenException('Access Denied');

const rtMatches = await this.hashService.verifyHashed(
user.hashedRt,
user['refreshToken'],
);
if (!rtMatches) throw new ForbiddenException('Access Denied');
// delete the refresh token from the db because we will add a new one
await this.prisma.refreshToken.delete({
where: {
id: refreshTokenToVerify.id,
},
});

const tokens = await this.getTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refresh_token);
await this.addRefreshToken(user.id, tokens.refresh_token);
return tokens;
}

Expand All @@ -253,9 +256,27 @@ export class AuthService {
where: { id: user.id },
data: {
hash,
hashedRt: null,
},
});
// remove all refresh tokens that user have
await this.revokeRefreshToken(user.id);
return await this.mailUtil.sendForgetPasswordMail(email, tempPassword);
}

async revokeRefreshToken(userId: number, token?: string): Promise<void> {
if (!token) {
await this.prisma.refreshToken.deleteMany({
where: {
userId: userId,
},
});
} else {
await this.prisma.refreshToken.deleteMany({
where: {
userId: userId,
token: token,
},
});
}
}
}
3 changes: 3 additions & 0 deletions src/auth/guard/refreshToken.guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { AuthGuard } from '@nestjs/passport';

/**
* guard to check if the refresh token is valid and belongs to the user and add the user to the request object
*/
export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {
constructor() {
super();
Expand Down
2 changes: 1 addition & 1 deletion src/birthday/birthday.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { BirthdayService } from './birthday.service';
import { BirthdayController } from './birthday.controller';
import { FirebaseService } from 'src/utils/firebase.service';
import { FirebaseService } from '../utils/firebase.service';

@Module({
providers: [BirthdayService, FirebaseService],
Expand Down
Loading

0 comments on commit 80c295d

Please sign in to comment.