feat: added fastify, add authentication with passport

This commit is contained in:
Hackntosh 2024-01-25 16:17:13 +00:00
parent 7d3afa3dcd
commit b5911c712c
32 changed files with 1743 additions and 158 deletions

View file

@ -16,9 +16,9 @@ DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES
# Redis # Redis
REDIS_HOST=127.0.0.1 # localhost (for docker use redis) REDIS_HOST=127.0.0.1 # localhost (for docker use redis)
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<placehoder> REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express # Fastify
SERVER_PORT=<placeholder> SERVER_PORT=<placeholder>
CLIENT_URL=<placeholder> CLIENT_URL=<placeholder>

View file

@ -13,9 +13,9 @@ DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:<placeholder>/$
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=<placeholder> REDIS_PORT=<placeholder>
REDIS_PASSWORD=<placeholder> REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express # Fastify
SERVER_PORT=<placeholder> SERVER_PORT=<placeholder>
# Security # Security

1300
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,11 +28,21 @@
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@fastify/static": "^6.12.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-fastify": "^10.3.1",
"@nestjs/swagger": "^7.2.0", "@nestjs/swagger": "^7.2.0",
"@prisma/client": "^5.8.1",
"bcrypt": "^5.1.1",
"nestjs-zod": "^3.0.0", "nestjs-zod": "^3.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -41,9 +51,12 @@
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
@ -52,6 +65,7 @@
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"prisma": "^5.8.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",

View file

@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View file

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View file

@ -1,19 +1,30 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { UserModule } from "./user/user.module"; import { UserModule } from "./user/user.module";
import { APP_PIPE } from "@nestjs/core"; import { APP_GUARD, APP_PIPE } from "@nestjs/core";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { PostModule } from "./post/post.module";
import { AuthModule } from "./auth/auth.module";
import { ConfigModule } from "@nestjs/config";
import { JwtAuthGuard } from "./auth/jwt-auth.guard";
@Module({ @Module({
imports: [UserModule], imports: [
controllers: [AppController], UserModule,
PostModule,
AuthModule,
ConfigModule.forRoot({
isGlobal: true,
}),
],
providers: [ providers: [
AppService,
{ {
provide: APP_PIPE, provide: APP_PIPE,
useClass: ZodValidationPipe, useClass: ZodValidationPipe,
}, },
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
], ],
}) })
export class AppModule {} export class AppModule {}

View file

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View file

@ -0,0 +1,35 @@
import {
Controller,
Request,
Post,
UseGuards,
Body,
HttpCode,
} from "@nestjs/common";
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { LocalAuthGuard } from "./local-auth.guard";
import { AuthService } from "./auth.service";
import { LoginUserDTO } from "./dto/login.dto";
import { Public } from "src/public.decorator";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post("/login")
@ApiOperation({ summary: "Authenticates a user" })
@ApiOkResponse({ status: 200, description: "Authenticated successfully" })
@ApiUnauthorizedResponse({ description: "Wrong username or password" })
@HttpCode(200)
async login(@Request() req, @Body() _: LoginUserDTO) {
return this.authService.login(req.user);
}
}

22
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,22 @@
import { Module } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { LocalStrategy } from "./local.strategy";
import { UserModule } from "src/user/user.module";
import { AuthController } from "./auth.controller";
import { JwtModule } from "@nestjs/jwt";
import { JwtStrategy } from "./jwt.strategy";
@Module({
controllers: [AuthController],
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET,
signOptions: { expiresIn: "1d" }, // TODO: add refresh tokens
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

46
src/auth/auth.service.ts Normal file
View file

@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { UserService } from "src/user/user.service";
import * as bcrypt from "bcrypt";
import { UserModel } from "src/user/models/user.model";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<UserModel | null> {
const user = await this.userService.search(username);
if (user === undefined) {
return null;
}
const validation = await bcrypt.compare(password, user.password);
if (user && validation) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: UserModel): Promise<{ token: string }> {
const payload = {
displayName: user.displayName,
username: user.username,
profileImage: user.profileImage,
sub: user.id,
};
return {
token: this.jwtService.sign(payload),
};
}
}

29
src/auth/dto/login.dto.ts Normal file
View file

@ -0,0 +1,29 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const LoginUserSchema = z
.object({
username: z
.string({
required_error: "Username is required",
})
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase(),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
export class LoginUserDTO extends createZodDto(LoginUserSchema) {}

View file

@ -0,0 +1,25 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";
import { IS_PUBLIC_KEY } from "src/public.decorator";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

28
src/auth/jwt.strategy.ts Normal file
View file

@ -0,0 +1,28 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
type Payload = {
displayName: string;
username: string;
sub: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET,
});
}
async validate(payload: Payload) {
return {
displayName: payload.displayName,
username: payload.username,
id: payload.sub,
};
}
}

View file

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}

View file

@ -0,0 +1,20 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService } from "./auth.service";
import { UserModel } from "src/user/models/user.model";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<UserModel> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException("Wrong username or password");
}
return user;
}
}

View file

@ -2,9 +2,16 @@ import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { patchNestJsSwagger } from "nestjs-zod"; import { patchNestJsSwagger } from "nestjs-zod";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);
patchNestJsSwagger(); patchNestJsSwagger();
@ -14,15 +21,27 @@ async function bootstrap() {
.setTitle("Project Knedita") .setTitle("Project Knedita")
.setDescription("An open-source social media") .setDescription("An open-source social media")
.setVersion("1.0") .setVersion("1.0")
.addTag("User") .addBearerAuth(
.addTag("Post") {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
name: "JWT",
description: "Enter JWT Token",
in: "header",
},
"JWT",
)
.addTag("Auth")
.addTag("Comment") .addTag("Comment")
.addTag("Post")
.addTag("User")
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/", app, document); SwaggerModule.setup("/", app, document);
await app.listen(3000); await app.listen(3000, "0.0.0.0");
} }
bootstrap(); bootstrap();

View file

@ -0,0 +1 @@
export class CreatePostDto {}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePostDto } from './create-post.dto';
export class UpdatePostDto extends PartialType(CreatePostDto) {}

View file

@ -0,0 +1 @@
export class Post {}

View file

@ -0,0 +1,44 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from "@nestjs/common";
import { PostService } from "./post.service";
import { CreatePostDto } from "./dto/create-post.dto";
import { UpdatePostDto } from "./dto/update-post.dto";
import { ApiTags } from "@nestjs/swagger";
@ApiTags("Post")
@Controller("post")
export class PostController {
constructor(private readonly postService: PostService) {}
@Post()
create(@Body() createPostDto: CreatePostDto) {
return this.postService.create(createPostDto);
}
@Get()
findAll() {
return this.postService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.postService.findOne(+id);
}
@Patch(":id")
update(@Param("id") id: string, @Body() updatePostDto: UpdatePostDto) {
return this.postService.update(+id, updatePostDto);
}
@Delete(":id")
remove(@Param("id") id: string) {
return this.postService.remove(+id);
}
}

9
src/post/post.module.ts Normal file
View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PostService } from './post.service';
import { PostController } from './post.controller';
@Module({
controllers: [PostController],
providers: [PostService],
})
export class PostModule {}

26
src/post/post.service.ts Normal file
View file

@ -0,0 +1,26 @@
import { Injectable } from "@nestjs/common";
import { CreatePostDto } from "./dto/create-post.dto";
import { UpdatePostDto } from "./dto/update-post.dto";
@Injectable()
export class PostService {
create(createPostDto: CreatePostDto) {
return "This action adds a new post";
}
findAll() {
return "This action returns all post";
}
findOne(id: number) {
return `This action returns a #${id} post`;
}
update(id: number, updatePostDto: UpdatePostDto) {
return `This action updates a #${id} post`;
}
remove(id: number) {
return `This action removes a #${id} post`;
}
}

9
src/prisma.service.ts Normal file
View file

@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

4
src/public.decorator.ts Normal file
View file

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -9,21 +9,25 @@ export const CreateUserSchema = z
}) })
.regex( .regex(
/^[a-zA-Z0-9_.]{5,15}$/, /^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters (uppercase and lowercase words), underscore, dots and it must be between 5 and 15 characters", "The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
), )
.toLowerCase(),
email: z email: z
.string({ .string({
required_error: "Email is required", required_error: "Email is required",
}) })
.email("Invalid email"), .email("Invalid email"),
password: z password: z
.string({ .password({
required_error: "Password is required", required_error: "Password is required",
}) })
.regex( .min(8)
/^(?=.*[0-9])(?=.*[!@#$%^&*_])[a-zA-Z0-9!@#$%^&*_]{8,}$/, .max(32)
"Password must have at least 8 characters, one number and one special character.", .atLeastOne("digit")
), .atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
}) })
.required(); .required();

View file

@ -0,0 +1,18 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
// TODO: Add posts, liked_posts, liked_comments, followers, following, post_comments and notifications field
export const UserSchema = z
.object({
id: z.string().uuid(),
displayName: z.string(),
username: z.string(),
email: z.string().email(),
password: z.password(),
profileImage: z.string().url(),
createdAt: z.date(),
})
.required();
export class UserModel extends createZodDto(UserSchema) {}

View file

@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from './user.controller';
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View file

@ -1,22 +1,43 @@
import { Body, Controller, Post, UsePipes } from "@nestjs/common"; import { Body, Controller, Get, Post, Request } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiCreatedResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { UserService } from "./user.service"; import { UserService } from "./user.service";
import { CreateUserDTO } from "./dto/create-user.dto"; import { CreateUserDTO } from "./dto/create-user.dto";
import { Public } from "src/public.decorator";
@ApiTags("User") @ApiTags("User")
@Controller("user") @Controller("user")
export class UserController { export class UserController {
constructor(private readonly userService: UserService) {} constructor(private readonly userService: UserService) {}
// GET
@Get("/me")
@ApiOperation({ summary: "Returns information about the logged user" })
@ApiBearerAuth("JWT")
@ApiUnauthorizedResponse({
description: "Not authenticated / Invalid JWT Token",
})
async me(@Request() req) {
return req.user; // TODO: Add typing to req.user
}
// POST
@Public()
@Post("/signup") @Post("/signup")
@ApiOperation({ summary: "Creates a new account" }) @ApiOperation({ summary: "Creates a new account" })
@ApiResponse({ status: 200, description: "Account created successfully" }) @ApiCreatedResponse({ description: "Account created successfully" })
@ApiResponse({ @ApiBadRequestResponse({
status: 400,
description: description:
"Missing field / Invalid username / Invalid email / Weak password", "Missing field / Invalid username / Invalid email / Weak password",
}) })
async create(@Body() createUserDTO: CreateUserDTO) { async create(@Body() createUserDTO: CreateUserDTO) {
return this.userService.create(createUserDTO); return this.userService.create(createUserDTO);
} }
// PUT
} }

View file

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { UserController } from './user.controller'; import { UserController } from "./user.controller";
import { UserService } from './user.service'; import { UserService } from "./user.service";
import { PrismaService } from "src/prisma.service";
@Module({ @Module({
controllers: [UserController], controllers: [UserController],
providers: [UserService] providers: [UserService, PrismaService],
exports: [UserService],
}) })
export class UserModule {} export class UserModule {}

View file

@ -1,18 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { UserService } from "./user.service";
describe("UserService", () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UserService],
}).compile();
service = module.get<UserService>(UserService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View file

@ -1,12 +1,66 @@
import { Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { CreateUserDTO } from "./dto/create-user.dto"; import { CreateUserDTO } from "./dto/create-user.dto";
import { PrismaService } from "src/prisma.service";
// TODO: Add prisma client import { UserModel } from "./models/user.model";
import * as bcrypt from "bcrypt";
@Injectable() @Injectable()
export class UserService { export class UserService {
create(user: CreateUserDTO): string { constructor(private prisma: PrismaService) {}
console.log(user);
return "Created successfully"; async create({
username,
email,
password,
}: CreateUserDTO): Promise<
Pick<UserModel, "displayName" | "username" | "createdAt">
> {
if ((await this.prisma.user.findFirst({ where: { username } })) != null) {
throw new BadRequestException("Username already in use");
}
if ((await this.prisma.user.findFirst({ where: { email } })) != null) {
throw new BadRequestException("Email already in use");
}
// Password encryption
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(password, salt);
const user = await this.prisma.user.create({
data: {
username,
email,
password: hash,
},
select: {
displayName: true,
username: true,
createdAt: true,
},
});
return user;
}
async search(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({
where: {
username,
},
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
password: true,
},
});
if (user == null) {
return undefined;
}
return user;
} }
} }