Skip to content

Commit

Permalink
feat: adding route for session logout (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
nathan2slime committed Jul 13, 2024
1 parent 7959c33 commit 7bd9de5
Show file tree
Hide file tree
Showing 15 changed files with 158 additions and 80 deletions.
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
.git
.gitignore
*.md
dist
coverage
.idea
.vscode

1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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" ]
30 changes: 27 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
retries: 20
api:
build:
context: .
dockerfile: ./Dockerfile
restart: always
ports:
- 3000:3000
env_file:
- .env.production.local
depends_on:
- mongo
volumes:
database:
28 changes: 27 additions & 1 deletion src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@ import {
Controller,
Get,
HttpStatus,
Patch,
Post,
Req,
Res,
UseGuards,
} 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);

Expand All @@ -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<Session>;
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;
Expand All @@ -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);

Expand Down
11 changes: 8 additions & 3 deletions src/app/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Session>) {
await this.sessionService.expireSession(session._id);
}

async signUp(data: SignUpDto) {
Expand Down
5 changes: 3 additions & 2 deletions src/app/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -27,8 +28,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}

async validate(payload: Record<string, string>) {
const session = this.sessionService.findByUser(payload.user);
async validate(payload: JwtAuthPayload) {
const session = await this.sessionService.findById(payload.sessionId);

if (session) return session;

Expand Down
4 changes: 2 additions & 2 deletions src/app/auth/refresh.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
19 changes: 3 additions & 16 deletions src/app/auth/tests/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,40 +89,27 @@ describe('AuthService', () => {
const user = {} as unknown as Entity<User>;
const session = {} as unknown as Entity<Session>;

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);
});

Expand Down
61 changes: 34 additions & 27 deletions src/app/session/session.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -14,39 +15,43 @@ export class SessionService {
@InjectModel(Session.name) private readonly sessionModel: Model<Session>,
) {}

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 },
);
Expand All @@ -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;
}
}
}
Loading

0 comments on commit 7bd9de5

Please sign in to comment.