Skip to content

Commit

Permalink
feat: adding session management (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
nathan2slime authored Jul 12, 2024
1 parent 1a5b2c4 commit 4c132e5
Show file tree
Hide file tree
Showing 14 changed files with 188 additions and 122 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,tests}/**/*.ts\" --fix",
"commit": "git-cz",
"test": "jest",
"test": "NODE_ENV=test jest",
"prepare": "husky",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:cov": "NODE_ENV=test jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./tests/jest-e2e.json"
"test:e2e": "NODE_ENV=test jest --config ./tests/jest-e2e.json"
},
"dependencies": {
"@commitlint/cli": "^19.3.0",
Expand Down
38 changes: 13 additions & 25 deletions src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,18 @@ 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';

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('signup')
async signUp(
@Body() body: SignUpDto,
@Res({ passthrough: true }) res: Response,
) {
async signUp(@Body() body: SignUpDto, @Res() res: Response) {
const data = await this.authService.signUp(body);

res.cookie(
AUTH_COOKIE,
{
accessToken: data.accessToken,
refreshToken: data.refreshToken,
},
{ httpOnly: true },
);
const { accessToken, refreshToken } = data;
res.cookie(AUTH_COOKIE, { accessToken, refreshToken }, { httpOnly: true });

return res.status(HttpStatus.CREATED).json(data);
}
Expand All @@ -48,24 +40,20 @@ export class AuthController {
@Get('refresh')
@UseGuards(AuthGuard('refresh'))
async refresh(@Req() req: Request, @Res() res: Response) {
return res.status(HttpStatus.OK).json(req.user);
const session = req.user as Session;
const { accessToken, refreshToken } = session;

res.cookie(AUTH_COOKIE, { accessToken, refreshToken }, { httpOnly: true });

return res.status(HttpStatus.OK).json(session);
}

@Post('signin')
async signIn(
@Body() body: SignInDto,
@Res({ passthrough: true }) res: Response,
) {
async signIn(@Body() body: SignInDto, @Res() res: Response) {
const data = await this.authService.signIn(body);

res.cookie(
AUTH_COOKIE,
{
accessToken: data.accessToken,
refreshToken: data.refreshToken,
},
{ httpOnly: true },
);
const { accessToken, refreshToken } = data;
res.cookie(AUTH_COOKIE, { accessToken, refreshToken }, { httpOnly: true });

return res.status(HttpStatus.OK).json(data);
}
Expand Down
15 changes: 13 additions & 2 deletions src/app/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import { AuthController } from '~/app/auth/auth.controller';
import { User, UserSchema } from '~/schemas/user.schema';
import { JwtStrategy } from '~/app/auth/jwt.strategy';
import { JwtRefreshStrategy } from '~/app/auth/refresh.strategy';
import { Session, SessionSchema } from '~/schemas/session.schema';
import { ACCESS_TOKEN_EXPIRES_IN } from '~/constants';
import { SessionService } from '~/app/session/session.service';
import { env } from '~/env';

@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Session.name, schema: SessionSchema },
]),
JwtModule.register({
global: true,
secret: env.SECRET_KEY,
Expand All @@ -22,7 +27,13 @@ import { env } from '~/env';
PassportModule,
],
controllers: [AuthController],
providers: [AuthService, JwtService, JwtStrategy, JwtRefreshStrategy],
providers: [
AuthService,
SessionService,
JwtService,
JwtStrategy,
JwtRefreshStrategy,
],
exports: [AuthService],
})
export class AuthModule {}
75 changes: 17 additions & 58 deletions src/app/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { JwtService } from '@nestjs/jwt';
import { compare } from 'bcrypt';

import { ACCESS_TOKEN_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN } from '~/constants';
import { SessionService } from '~/app/session/session.service';
import { SignInDto, SignUpDto } from '~/app/auth/auth.dto';
import { User } from '~/schemas/user.schema';
import { env } from '~/env';
import { compare } from 'bcrypt';
import {
EMAIL_IS_ALREDY_IN_USE,
INVALID_CREDENTIALS,
USER_NOT_FOUND,
} from '~/errors';

@Injectable()
export class AuthService {
constructor(
@InjectModel(User.name) private readonly userModel: Model<User>,
private readonly jwtService: JwtService,
private readonly sessionService: SessionService,
) {}

async signIn(data: SignInDto) {
Expand All @@ -26,76 +29,32 @@ export class AuthService {
role: true,
},
);
if (!user) throw new HttpException('User not found', HttpStatus.NOT_FOUND);
if (!user) throw new HttpException(USER_NOT_FOUND, HttpStatus.NOT_FOUND);

const isValidPassword = await compare(data.password, user.password);
if (!isValidPassword)
throw new HttpException(
'Credentials do not match',
HttpStatus.UNAUTHORIZED,
);
throw new HttpException(INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED);

user.password = undefined;

return {
user,
accessToken: await this.generateJwtAccess(user.id),
refreshToken: await this.generateJwtRefresh(user.id),
};
}

async generateJwtAccess(user: string) {
return await this.jwtService.signAsync(
{ user },
{ secret: env.SECRET_KEY, expiresIn: ACCESS_TOKEN_EXPIRES_IN },
);
}

async generateJwtRefresh(user: string) {
return await this.jwtService.signAsync(
{ user },
{
secret: env.SECRET_KEY,
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
},
);
}

async useJwtRefresh(jwt: Record<string, string | null>) {
if (jwt.refreshToken) {
const res = await this.jwtService.decode(jwt.refreshToken);
const session = await this.sessionService.findByUser(user.id);
if (session) return session;

const user = await this.userModel.findById(res.user);

if (user) {
return {
user,
refreshToken: jwt.refreshToken,
accessToken: this.generateJwtAccess(user.id),
};
}
}

throw new HttpException(
'No refresh token provided',
HttpStatus.UNAUTHORIZED,
);
return await this.sessionService.create(user.id);
}

async signUp(data: SignUpDto) {
const emailIsAlreadyInUse = await this.userModel.findOne({
email: data.email,
});
if (!!emailIsAlreadyInUse)
throw new HttpException('Email is already in use', HttpStatus.CONFLICT);
throw new HttpException(EMAIL_IS_ALREDY_IN_USE, HttpStatus.CONFLICT);

const user = await this.userModel.create(data);
user.password = undefined;

return {
user,
accessToken: await this.generateJwtAccess(user.id),
refreshToken: await this.generateJwtRefresh(user.id),
};
const session = await this.sessionService.create(user.id);

return session;
}
}
10 changes: 4 additions & 6 deletions src/app/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Model } from 'mongoose';
import { Request } from 'express';
import { InjectModel } from '@nestjs/mongoose';

import { SessionService } from '~/app/session/session.service';
import { env } from '~/env';
import { AUTH_COOKIE } from '~/constants';
import { User } from '~/schemas/user.schema';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(@InjectModel(User.name) private readonly userModel: Model<User>) {
constructor(private readonly sessionService: SessionService) {
super({
ignoreExpiration: false,
jwtFromRequest: ExtractJwt.fromExtractors([
Expand All @@ -30,9 +28,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}

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

if (user) return user;
if (session) return session;

throw new UnauthorizedException();
}
Expand Down
30 changes: 11 additions & 19 deletions src/app/auth/refresh.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { JwtService } from '@nestjs/jwt';

import { env } from '~/env';
import { ACCESS_TOKEN_EXPIRES_IN, AUTH_COOKIE } from '~/constants';
import { User } from '~/schemas/user.schema';
import { AUTH_COOKIE } from '~/constants';
import { SessionService } from '~/app/session/session.service';
import { JwtAuthPayload } from '~/types/auth.types';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
constructor(
@InjectModel(User.name) private readonly userModel: Model<User>,
private readonly jwtService: JwtService,
) {
constructor(private readonly sessionService: SessionService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => {
Expand All @@ -28,21 +23,18 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
return null;
},
]),
ignoreExpiration: false,
ignoreExpiration: true,
secretOrKey: env.SECRET_KEY,
});
}

async validate(payload: Record<string, string>) {
const user = await this.userModel.findById(payload.user);
async validate(payload: JwtAuthPayload) {
if (payload.exp < Math.floor(Date.now() / 1000)) {
await this.sessionService.expireSession(payload.user);
} else {
const session = await this.sessionService.refresh(payload.user);

if (user) {
const accessToken = await this.jwtService.signAsync(
{ user: user._id },
{ secret: env.SECRET_KEY, expiresIn: ACCESS_TOKEN_EXPIRES_IN },
);

return { user, accessToken };
if (session) return session;
}

throw new UnauthorizedException();
Expand Down
18 changes: 14 additions & 4 deletions src/app/auth/tests/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Response, Request } from 'express';
import { Model } from 'mongoose';
import { Document, Model, Types } from 'mongoose';
import { JwtService } from '@nestjs/jwt';
import { getModelToken } from '@nestjs/mongoose';

import { AuthController } from '~/app/auth/auth.controller';
import { AuthService } from '~/app/auth/auth.service';
import { SignInDto, SignUpDto } from '~/app/auth/auth.dto';
import { User } from '~/schemas/user.schema';
import { Session } from '~/schemas/session.schema';
import { SessionService } from '~/app/session/session.service';

describe('AuthController', () => {
let authController: AuthController;
Expand All @@ -28,10 +30,15 @@ describe('AuthController', () => {
providers: [
AuthService,
JwtService,
SessionService,
{
provide: getModelToken(User.name),
useValue: Model,
},
{
provide: getModelToken(Session.name),
useValue: Model,
},
],
}).compile();

Expand All @@ -49,12 +56,14 @@ describe('AuthController', () => {

it('must return user, access token and refresh token', async () => {
const data = {
_id: expect.anything(),
accessToken: expect.anything(),
user: expect.anything(),
refreshToken: expect.anything(),
};
} as Document<unknown, object, Session> &
Session & { _id: Types.ObjectId };

jest.spyOn(authService, 'signIn').mockResolvedValue(data);
jest.spyOn(authService, 'signIn').mockImplementation(async () => data);
const response = await authController.signIn(payload, res);

expect(response).toBe(data);
Expand All @@ -77,7 +86,8 @@ describe('AuthController', () => {
accessToken: expect.anything(),
user: expect.anything(),
refreshToken: expect.anything(),
};
} as Document<unknown, object, Session> &
Session & { _id: Types.ObjectId };

jest.spyOn(authService, 'signUp').mockResolvedValue(data);
const response = await authController.signUp(payload, res);
Expand Down
Loading

0 comments on commit 4c132e5

Please sign in to comment.