Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore/auth #4

Merged
merged 6 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"collection": "@nestjs/schematics",
"compilerOptions": {
"deleteOutDir": true
}
},
"entryFile": "api/src/main"
}
20 changes: 16 additions & 4 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,40 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:prod": "node dist/api/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --config ./test/jest-config.json -i --detectOpenHandles"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "catalog:",
"class-transformer": "catalog:",
"lodash": "^4.17.21",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"typeorm": "catalog:"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
Expand All @@ -43,7 +55,7 @@
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
Expand Down
22 changes: 0 additions & 22 deletions api/src/app.controller.spec.ts

This file was deleted.

40 changes: 6 additions & 34 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,14 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'path';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as process from 'node:process';
import { ApiConfigModule } from '@api/modules/config/app-config.module';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from '@api/modules/auth/auth.module';
import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: [
join(__dirname, `../../shared/config/.env.${process.env.NODE_ENV}`),
join(__dirname, '../../shared/config/.env'),
],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
// TODO: Move this to config service method
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
entities: [join(__dirname, '**', '*.entity.{ts,js}')],
synchronize: true,
ssl:
process.env.NODE_ENV === 'production'
? { require: true, rejectUnauthorized: false }
: false,
}),
inject: [ConfigService],
}),
],
imports: [ApiConfigModule, AuthModule],
controllers: [AppController],
providers: [AppService],
providers: [AppService, { provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}
1 change: 1 addition & 0 deletions api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(4000);
}
void bootstrap();
10 changes: 10 additions & 0 deletions api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthenticationModule } from '@api/modules/auth/authentication/authentication.module';
import { AuthorisationModule } from '@api/modules/auth/authorisation/authorisation.module';

@Module({
imports: [AuthenticationModule, AuthorisationModule],
controllers: [],
providers: [],
})
export class AuthModule {}
25 changes: 25 additions & 0 deletions api/src/modules/auth/authentication/authentication.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { User } from '@shared/entities/users/user.entity';
import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service';
import { LoginDto } from '@api/modules/auth/dtos/login.dto';
import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard';
import { GetUser } from '@api/modules/auth/decorators/get-user.decorator';
import { Public } from '@api/modules/auth/decorators/is-public.decorator';

@Controller('authentication')
export class AuthenticationController {
constructor(private authService: AuthenticationService) {}

@Public()
@Post('signup')
async signup(@Body() signupDto: LoginDto) {
return this.authService.signup(signupDto);
}

@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@GetUser() user: User) {
return this.authService.signIn(user);
}
}
39 changes: 39 additions & 0 deletions api/src/modules/auth/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { AuthenticationService } from './authentication.service';
import { AuthenticationController } from './authentication.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ApiConfigModule } from '@api/modules/config/app-config.module';
import { ApiConfigService } from '@api/modules/config/app-config.service';
import { UsersService } from '@api/modules/users/users.service';
import { UsersModule } from '@api/modules/users/users.module';
import { LocalStrategy } from '@api/modules/auth/strategies/local.strategy';
import { JwtStrategy } from '@api/modules/auth/strategies/jwt.strategy';

@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ApiConfigModule],
inject: [ApiConfigService],
useFactory: (config: ApiConfigService) => ({
secret: config.getJWTConfig().secret,
signOptions: { expiresIn: config.getJWTConfig().expiresIn },
}),
}),
UsersModule,
],
providers: [
AuthenticationService,
LocalStrategy,
{
provide: JwtStrategy,
useFactory: (users: UsersService, config: ApiConfigService) => {
return new JwtStrategy(users, config);
},
inject: [UsersService, ApiConfigService],
},
],
controllers: [AuthenticationController],
})
export class AuthenticationModule {}
42 changes: 42 additions & 0 deletions api/src/modules/auth/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '@api/modules/users/users.service';
import { User } from '@shared/entities/users/user.entity';
import * as bcrypt from 'bcrypt';
import { LoginDto } from '@api/modules/auth/dtos/login.dto';
import { JwtPayload } from '@api/modules/auth/strategies/jwt.strategy';

@Injectable()
export class AuthenticationService {
constructor(
private readonly usersService: UsersService,
private readonly jwt: JwtService,
) {}
async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
if (user && (await bcrypt.compare(password, user.password))) {
return user;
}
throw new UnauthorizedException(`Invalid credentials`);
}

async signup(signupDto: LoginDto): Promise<void> {
const passwordHash = await bcrypt.hash(signupDto.password, 10);
await this.usersService.createUser({
email: signupDto.email,
password: passwordHash,
});
}

async login(loginDto: LoginDto): Promise<{ access_token: string }> {
const user = await this.validateUser(loginDto.email, loginDto.password);
return {
access_token: this.jwt.sign({ id: user.id }),
};
}
async signIn(user: User): Promise<{ user: User; accessToken: string }> {
const payload: JwtPayload = { id: user.id };
const accessToken: string = this.jwt.sign(payload);
return { user, accessToken };
}
}
4 changes: 4 additions & 0 deletions api/src/modules/auth/authorisation/authorisation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';

@Module({})
export class AuthorisationModule {}
9 changes: 9 additions & 0 deletions api/src/modules/auth/decorators/get-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '@shared/entities/users/user.entity';

export const GetUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
8 changes: 8 additions & 0 deletions api/src/modules/auth/decorators/is-public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';

/**
* @description Decorator to inject a IS_PUBLIC_KEY metadata to the handler, which will be read by the JwtAuthGuard to allow public access to the handler.
*/

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
8 changes: 8 additions & 0 deletions api/src/modules/auth/dtos/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @note: Depending on how we will proceed with the repo structure, we might need to move this file to the shared module.
*/

export class LoginDto {
email: string;
password: string;
}
27 changes: 27 additions & 0 deletions api/src/modules/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '@api/modules/auth/decorators/is-public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}

canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic: boolean = this.reflector.get<boolean>(
IS_PUBLIC_KEY,
context.getHandler(),
);

if (isPublic) {
return true;
}

return super.canActivate(context);
}
}
5 changes: 5 additions & 0 deletions api/src/modules/auth/guards/local-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
30 changes: 30 additions & 0 deletions api/src/modules/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '@api/modules/users/users.service';
import { ApiConfigService } from '@api/modules/config/app-config.service';

export type JwtPayload = { id: string };

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly userService: UsersService,
private readonly config: ApiConfigService,
) {
const { secret } = config.getJWTConfig();
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: secret,
});
}

async validate(payload: JwtPayload) {
const { id } = payload;
const user = await this.userService.findOneBy(id);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
29 changes: 29 additions & 0 deletions api/src/modules/auth/strategies/local.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';

import { Strategy } from 'passport-local';
import { User } from '@shared/entities/users/user.entity';
import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service';

/**
* @description: LocalStrategy is used by passport to authenticate by email and password rather than a token.
*/

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthenticationService) {
super({ usernameField: 'email' });
}

async validate(email: string, password: string): Promise<User> {
const user: User | null = await this.authService.validateUser(
email,
password,
);

if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
Loading
Loading