From 7bd9de5d2558fe1be8bb469e923487739ff67ff8 Mon Sep 17 00:00:00 2001 From: Jhonathan M Date: Sat, 13 Jul 2024 17:44:46 -0300 Subject: [PATCH] feat: adding route for session logout (#6) --- .dockerignore | 9 +++ .env.example | 1 + Dockerfile | 19 ++++++ docker-compose.yml | 30 ++++++++- src/app/auth/auth.controller.ts | 28 ++++++++- src/app/auth/auth.service.ts | 11 +++- src/app/auth/jwt.strategy.ts | 5 +- src/app/auth/refresh.strategy.ts | 4 +- src/app/auth/tests/auth.service.spec.ts | 19 +----- src/app/session/session.service.ts | 61 +++++++++++-------- src/app/session/tests/session.service.spec.ts | 36 ++++++----- src/constants/index.ts | 4 +- src/main.ts | 2 +- src/schemas/session.schema.ts | 6 +- src/types/auth.types.ts | 3 +- 15 files changed, 158 insertions(+), 80 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..280f149 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +.gitignore +*.md +dist +coverage +.idea +.vscode + diff --git a/.env.example b/.env.example index ede45a7..fdefff7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT= DATABASE_URL="mongodb://user:password@mongo:27017/morgoth?directConnection=true&serverSelectionTimeoutMS=2000&authSource=admin&appName=mongosh+2.2.6" +SECRET_KEY= MONGO_INITDB_ROOT_USERNAME="user" MONGO_INITDB_ROOT_PASSWORD="password" diff --git a/Dockerfile b/Dockerfile index e69de29..1a89baa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-slim AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +COPY . /app +WORKDIR /app + +FROM base AS prod-deps +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile + +FROM base AS build +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN pnpm run build + +FROM base +COPY --from=prod-deps /app/node_modules /app/node_modules +COPY --from=build /app/dist /app/dist + +CMD [ "pnpm", "start" ] diff --git a/docker-compose.yml b/docker-compose.yml index 523431c..db2b858 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,34 @@ services: restart: unless-stopped env_file: - .env.production.local + volumes: + - database:/bin/database ports: - - '27017:27017' + - 27017:27017 healthcheck: - test: [ 'CMD', 'mongo', 'admin', '--port', '27017', '--eval', "db.adminCommand('ping').ok" ] + test: + [ + 'CMD', + 'mongo', + 'admin', + '--port', + '27017', + '--eval', + "db.adminCommand('ping').ok", + ] interval: 5s timeout: 2s - retries: 20 \ No newline at end of file + retries: 20 + api: + build: + context: . + dockerfile: ./Dockerfile + restart: always + ports: + - 3000:3000 + env_file: + - .env.production.local + depends_on: + - mongo +volumes: + database: diff --git a/src/app/auth/auth.controller.ts b/src/app/auth/auth.controller.ts index d885985..f5c5d40 100644 --- a/src/app/auth/auth.controller.ts +++ b/src/app/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, HttpStatus, + Patch, Post, Req, Res, @@ -10,18 +11,22 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; import { AuthGuard } from '@nestjs/passport'; +import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthService } from '~/app/auth/auth.service'; import { SignInDto, SignUpDto } from '~/app/auth/auth.dto'; import { JwtAuthGuard } from '~/app/auth/auth.guard'; import { AUTH_COOKIE } from '~/constants'; import { Session } from '~/schemas/session.schema'; +import { Entity } from '~/types'; @Controller('auth') +@ApiTags('Authentication') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('signup') + @ApiResponse({ status: 201, description: 'Create a new user' }) async signUp(@Body() body: SignUpDto, @Res() res: Response) { const data = await this.authService.signUp(body); @@ -33,12 +38,32 @@ export class AuthController { @Get() @UseGuards(JwtAuthGuard) + @ApiResponse({ + status: 200, + description: 'Get the authenticated user', + }) async auth(@Req() req: Request, @Res() res: Response) { return res.status(HttpStatus.OK).json(req.user); } - @Get('refresh') + @Post('signout') + @ApiResponse({ + status: 200, + description: 'Signs out the logged in user', + }) + @UseGuards(JwtAuthGuard) + async signOut(@Req() req: Request, @Res() res: Response) { + const session = req.user as Entity; + await this.authService.signOut(session); + return res.status(HttpStatus.OK).send(); + } + + @Patch('refresh') @UseGuards(AuthGuard('refresh')) + @ApiResponse({ + status: 200, + description: 'Update access token by refresh token', + }) async refresh(@Req() req: Request, @Res() res: Response) { const session = req.user as Session; const { accessToken, refreshToken } = session; @@ -49,6 +74,7 @@ export class AuthController { } @Post('signin') + @ApiResponse({ status: 200, description: 'Sign in user' }) async signIn(@Body() body: SignInDto, @Res() res: Response) { const data = await this.authService.signIn(body); diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 7d15ae5..dce3730 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -11,6 +11,8 @@ import { INVALID_CREDENTIALS, USER_NOT_FOUND, } from '~/errors'; +import { Session } from '~/schemas/session.schema'; +import { Entity } from '~/types'; @Injectable() export class AuthService { @@ -37,10 +39,13 @@ export class AuthService { user.password = undefined; - const session = await this.sessionService.findByUser(user.id); - if (session) return session; + const session = await this.sessionService.create(user.id); + + return session; + } - return await this.sessionService.create(user.id); + async signOut(session: Entity) { + await this.sessionService.expireSession(session._id); } async signUp(data: SignUpDto) { diff --git a/src/app/auth/jwt.strategy.ts b/src/app/auth/jwt.strategy.ts index 9bdc2f4..093a0c7 100644 --- a/src/app/auth/jwt.strategy.ts +++ b/src/app/auth/jwt.strategy.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { SessionService } from '~/app/session/session.service'; import { env } from '~/env'; import { AUTH_COOKIE } from '~/constants'; +import { JwtAuthPayload } from '~/types/auth.types'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -27,8 +28,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(payload: Record) { - const session = this.sessionService.findByUser(payload.user); + async validate(payload: JwtAuthPayload) { + const session = await this.sessionService.findById(payload.sessionId); if (session) return session; diff --git a/src/app/auth/refresh.strategy.ts b/src/app/auth/refresh.strategy.ts index 959bed6..8d11739 100644 --- a/src/app/auth/refresh.strategy.ts +++ b/src/app/auth/refresh.strategy.ts @@ -30,9 +30,9 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') { async validate(payload: JwtAuthPayload) { if (payload.exp < Math.floor(Date.now() / 1000)) { - await this.sessionService.expireSession(payload.user); + await this.sessionService.expireSession(payload.sessionId); } else { - const session = await this.sessionService.refresh(payload.user); + const session = await this.sessionService.refresh(payload); if (session) return session; } diff --git a/src/app/auth/tests/auth.service.spec.ts b/src/app/auth/tests/auth.service.spec.ts index 42aab4a..be92ec6 100644 --- a/src/app/auth/tests/auth.service.spec.ts +++ b/src/app/auth/tests/auth.service.spec.ts @@ -89,40 +89,27 @@ describe('AuthService', () => { const user = {} as unknown as Entity; const session = {} as unknown as Entity; - it('should return the logged in user is session', async () => { - jest.spyOn(userModel, 'findOne').mockResolvedValue(user); - jest.spyOn(sessionService, 'findByUser').mockResolvedValue(session); - jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true); - - const data = await authService.signIn(payload); - - expect(data).toBe(session); - expect(sessionService.findByUser).toHaveBeenCalledTimes(1); - expect(userModel.findOne).toHaveBeenCalledTimes(1); - }); - it('must log in user and create a new session', async () => { jest.spyOn(userModel, 'findOne').mockResolvedValue(user); - jest.spyOn(sessionService, 'findByUser').mockResolvedValue(null); + jest.spyOn(sessionService, 'findById').mockResolvedValue(null); jest.spyOn(sessionService, 'create').mockResolvedValue(session); jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true); const data = await authService.signIn(payload); expect(data).toBe(session); - expect(sessionService.findByUser).toHaveBeenCalledTimes(1); expect(userModel.findOne).toHaveBeenCalledTimes(1); }); it('should return error when password is invalid', async () => { jest.spyOn(userModel, 'findOne').mockResolvedValue(user); - jest.spyOn(sessionService, 'findByUser').mockResolvedValue(session); + jest.spyOn(sessionService, 'findById').mockResolvedValue(session); jest.spyOn(bcrypt, 'compare').mockImplementation(async () => false); await expect(authService.signIn(payload)).rejects.toThrow( new HttpException(INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED), ); - expect(sessionService.findByUser).toHaveBeenCalledTimes(0); + expect(sessionService.findById).toHaveBeenCalledTimes(0); expect(userModel.findOne).toHaveBeenCalledTimes(1); }); diff --git a/src/app/session/session.service.ts b/src/app/session/session.service.ts index 4cc8da1..77f57b4 100644 --- a/src/app/session/session.service.ts +++ b/src/app/session/session.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { ACCESS_TOKEN_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN } from '~/constants'; import { Session } from '~/schemas/session.schema'; +import { JwtAuthPayload } from '~/types/auth.types'; import { env } from '~/env'; @Injectable() @@ -14,39 +15,43 @@ export class SessionService { @InjectModel(Session.name) private readonly sessionModel: Model, ) {} - async create(user: string) { - const accessToken = await this.jwtService.signAsync( - { user }, - { secret: env.SECRET_KEY, expiresIn: ACCESS_TOKEN_EXPIRES_IN }, - ); + async create(userId: string) { + const session = await this.sessionModel.create({ + user: userId, + }); - const refreshToken = await this.jwtService.signAsync( - { user }, - { secret: env.SECRET_KEY, expiresIn: REFRESH_TOKEN_EXPIRES_IN }, - ); + const payload = { userId, sessionId: session.id }; - const session = await this.sessionModel.create({ - accessToken, - refreshToken, - user, + const accessToken = await this.jwtService.signAsync(payload, { + secret: env.SECRET_KEY, + expiresIn: ACCESS_TOKEN_EXPIRES_IN, + }); + const refreshToken = await this.jwtService.signAsync(payload, { + secret: env.SECRET_KEY, + expiresIn: REFRESH_TOKEN_EXPIRES_IN, }); + + session.refreshToken = refreshToken; + session.accessToken = accessToken; + + await session.save(); await session.populate('user'); return session; } - async expireSession(user: string) { - await this.sessionModel.updateOne({ user }, { isExpired: true }); + async expireSession(id: string | Types.ObjectId) { + await this.sessionModel.findByIdAndUpdate(id, { isExpired: true }); } - async refresh(user: string) { - const accessToken = await this.jwtService.signAsync( - { user }, - { secret: env.SECRET_KEY, expiresIn: ACCESS_TOKEN_EXPIRES_IN }, - ); + async refresh(payload: JwtAuthPayload) { + const accessToken = await this.jwtService.signAsync(payload, { + secret: env.SECRET_KEY, + expiresIn: ACCESS_TOKEN_EXPIRES_IN, + }); const session = await this.sessionModel.findOneAndUpdate( - { user, isExpired: false }, + { id: payload.sessionId, isExpired: false }, { accessToken }, { new: true }, ); @@ -58,11 +63,13 @@ export class SessionService { } } - async findByUser(user: string) { - const session = await this.sessionModel - .findOne({ user, isExpired: false }) - .populate('user'); + async findById(id: string) { + const session = await this.sessionModel.findById(id).populate('user'); - if (session) return session; + if (session) { + if (session.isExpired) return null; + + return session; + } } } diff --git a/src/app/session/tests/session.service.spec.ts b/src/app/session/tests/session.service.spec.ts index 5c354eb..4b28497 100644 --- a/src/app/session/tests/session.service.spec.ts +++ b/src/app/session/tests/session.service.spec.ts @@ -12,7 +12,8 @@ describe('AuthService', () => { let jwtService: JwtService; let sessionModel: Model; - const user = expect.anything(); + const userId = expect.anything(); + const sessionId = expect.anything(); afterEach(() => jest.clearAllMocks()); @@ -40,18 +41,19 @@ describe('AuthService', () => { const session = { populate: jest.fn(() => session), } as unknown as EntityQuery; + const payload = { userId, sessionId, exp: 0 }; jest.spyOn(jwtService, 'signAsync').mockResolvedValue(accessToken); jest.spyOn(sessionModel, 'findOneAndUpdate').mockResolvedValue(session); - const data = await sessionService.refresh(user); + const data = await sessionService.refresh(payload); expect(data).toBe(session); expect(session.populate).toHaveBeenCalledTimes(1); expect(session.populate).toHaveBeenCalledWith('user'); expect(sessionModel.findOneAndUpdate).toHaveBeenCalledTimes(1); expect(sessionModel.findOneAndUpdate).toHaveBeenCalledWith( - { user, isExpired: false }, + { id: userId, isExpired: false }, { accessToken }, { new: true }, ); @@ -61,6 +63,7 @@ describe('AuthService', () => { describe('create', () => { const session = { populate: jest.fn(), + save: jest.fn(), }; it('should create and return a new session', async () => { @@ -69,7 +72,7 @@ describe('AuthService', () => { .spyOn(sessionModel, 'create') .mockResolvedValue(session as unknown as Entity[]); - const data = await sessionService.create(user); + const data = await sessionService.create(userId); expect(session.populate).toHaveBeenCalledTimes(1); expect(session.populate).toHaveBeenCalledWith('user'); @@ -81,34 +84,29 @@ describe('AuthService', () => { describe('expireSession', () => { it('should expire a session', async () => { jest - .spyOn(sessionModel, 'updateOne') + .spyOn(sessionModel, 'findByIdAndUpdate') .mockResolvedValue(expect.anything()); + await sessionService.expireSession(sessionId); - await sessionService.expireSession(user); - - expect(sessionModel.updateOne).toHaveBeenCalledTimes(1); - expect(sessionModel.updateOne).toHaveBeenCalledWith( - { user }, - { isExpired: true }, - ); + expect(sessionModel.findByIdAndUpdate).toHaveBeenCalledTimes(1); + expect(sessionModel.findByIdAndUpdate).toHaveBeenCalledWith(sessionId, { + isExpired: true, + }); }); }); - describe('findByUser', () => { + describe('findById', () => { it('must return session with user data', async () => { const session = { populate: jest.fn(() => session), } as unknown as EntityQuery; - jest.spyOn(sessionModel, 'findOne').mockReturnValue(session); + jest.spyOn(sessionModel, 'findById').mockReturnValue(session); - const data = await sessionService.findByUser(user); + const data = await sessionService.findById(sessionId); expect(session.populate).toHaveBeenCalledWith('user'); expect(session.populate).toHaveBeenCalledTimes(1); - expect(sessionModel.findOne).toHaveBeenCalledWith({ - user, - isExpired: false, - }); + expect(sessionModel.findById).toHaveBeenCalledWith(sessionId); expect(data).toBe(session); }); }); diff --git a/src/constants/index.ts b/src/constants/index.ts index 31844c5..38b827e 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,3 @@ export const AUTH_COOKIE = 'auth'; -export const ACCESS_TOKEN_EXPIRES_IN = '30s'; -export const REFRESH_TOKEN_EXPIRES_IN = '1min'; +export const ACCESS_TOKEN_EXPIRES_IN = '6d'; +export const REFRESH_TOKEN_EXPIRES_IN = '30d'; diff --git a/src/main.ts b/src/main.ts index b9877b1..5fd1dee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,7 @@ import 'reflect-metadata'; const config = new DocumentBuilder() .addCookieAuth(AUTH_COOKIE) .setTitle('Morgoth') - .setDescription('Docs') + .setDescription('Library System Docs') .setVersion('1.0') .build(); diff --git a/src/schemas/session.schema.ts b/src/schemas/session.schema.ts index 68098a8..aba3c9a 100644 --- a/src/schemas/session.schema.ts +++ b/src/schemas/session.schema.ts @@ -7,14 +7,14 @@ export type SessionDocument = mongoose.HydratedDocument; @Schema() export class Session { - @Prop({ required: true }) + @Prop({ required: false }) accessToken: string; - @Prop({ required: true }) + @Prop({ required: false }) refreshToken: string; @Prop({ required: true, default: false }) - isExpired: string; + isExpired: boolean; @Prop({ required: true, type: mongoose.Schema.Types.ObjectId, ref: 'User' }) user: User; diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index c6acf1f..289f978 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -1,4 +1,5 @@ export type JwtAuthPayload = { exp: number; - user: string; + userId: string; + sessionId: string; };