diff --git a/apps/backend/src/app/auth/auth.controller.ts b/apps/backend/src/app/auth/auth.controller.ts new file mode 100644 index 0000000..9ee7454 --- /dev/null +++ b/apps/backend/src/app/auth/auth.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, Patch, + Post, + UnauthorizedException, + UseGuards +} from "@nestjs/common"; +import { UserGuard } from "./auth.guard"; +import { AuthService } from 'apps/backend/src/app/auth/auth.service'; +import { SignInDto, SignUpDto } from 'apps/backend/src/app/auth/auth.dto'; + +@Controller("auth") +export class AuthController { + constructor(private readonly authService: AuthService) { } + + //TODO Initial account validation for admin privileges + //POST signup + @HttpCode(HttpStatus.CREATED) + @Post("signup") + async signUp(@Body() dto: SignUpDto) { + console.log(dto); + return this.authService.doRegister(dto); + } + + //POST signin + @HttpCode(HttpStatus.OK) + @Post("signin") + async signIn(@Body() dto: SignInDto) { + console.log(dto); + return this.authService.doLogin(dto); + } + //GET me -- Get current user data via jwt + @HttpCode(HttpStatus.OK) + @Get("me") + @UseGuards(UserGuard) + async getMe(@Body() body: object) { + // @ts-ignore + const targetId = body.sourceUserId + const userData = await this.authService.fetchUserById(targetId) + if (!userData) { + throw new UnauthorizedException(); + } + return userData; + } + //DELETE me + @HttpCode(HttpStatus.FOUND) + @Delete("me") + @UseGuards(UserGuard) + async deleteMe(@Body() body: object) { + // @ts-ignore + const targetId = body.sourceUserId + try { + await this.authService.deleteUser(targetId) + } catch (err) { + throw new UnauthorizedException(); + } + } + + /* + //PATCH me + @HttpCode(HttpStatus.OK) + @Patch("me") + @UseGuards(UserGuard) + async patchMe(@Body() body: UpdateUserDto) { + console.log(body); + // @ts-ignore + const targetId = body.sourceUserId; + await this.authService.updateUser(targetId, body); + return await this.authService.fetchUserById(targetId) + } + */ +} diff --git a/apps/backend/src/app/auth/auth.dto.ts b/apps/backend/src/app/auth/auth.dto.ts new file mode 100644 index 0000000..6119775 --- /dev/null +++ b/apps/backend/src/app/auth/auth.dto.ts @@ -0,0 +1,68 @@ +import { + IsEmail, + IsNotEmpty, + IsString, + IsStrongPassword, + MaxLength, + MinLength, +} from "class-validator"; + +export class SignUpDto { + /* + @MinLength(1) + @MaxLength(24) + @IsNotEmpty() + @IsString() + firstName: string; + + @MinLength(1) + @MaxLength(24) + @IsNotEmpty() + @IsString() + lastName: string; + **/ + + @MaxLength(32) + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsNotEmpty() + @IsStrongPassword({ + minLength: 6, + minSymbols: 1, + }) + password: string; +} + +export class SignInDto { + @MaxLength(32) + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsNotEmpty() + @IsStrongPassword({ + minLength: 6, + minSymbols: 1, + }) + password: string; +} + +/* +export class UpdateUserDto { + @MinLength(1) + @MaxLength(24) + @IsNotEmpty() + @IsString() + firstName: string; + + @MinLength(1) + @MaxLength(24) + @IsNotEmpty() + @IsString() + lastName: string; +} +**/ diff --git a/apps/backend/src/app/auth/auth.guard.ts b/apps/backend/src/app/auth/auth.guard.ts new file mode 100644 index 0000000..53418eb --- /dev/null +++ b/apps/backend/src/app/auth/auth.guard.ts @@ -0,0 +1,87 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Inject } from "@nestjs/common"; +import type { Request } from "express"; +import { eq } from "drizzle-orm"; +import { Reflector } from "@nestjs/core"; +import { DbService } from 'apps/backend/src/app/db/db.service'; +import { UsersTable } from 'apps/backend/src/app/db/schema'; +import { CredentialsService } from 'apps/backend/src/app/credentials/credentials.service'; + +@Injectable() +export class UserGuard implements CanActivate { + + constructor( + @Inject(CredentialsService) private readonly credentialService: CredentialsService, + @Inject(DbService) private readonly databaseService: DbService, + ) { + } + + async canActivate( + context: ExecutionContext + ): Promise { + const request: Request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) + throw new UnauthorizedException("No authorization header found."); + + const token = authHeader.split(" ")[1]; + const vToken = await this.credentialService.verifyAuthToken(token); + + const user = await this.databaseService.use() + .select() + .from(UsersTable) + .where(eq(UsersTable.uuid, vToken.payload.sub)); + + if (user.length !== 1) + throw new UnauthorizedException("No such user found."); + /* + if (user[0].emailCode) + throw new UnauthorizedException("Email not verified."); + + */ + + // Inject user ID into request body + request.body.sourceUserId = vToken.payload.sub; + + return true; + } +} + +@Injectable() +export class AdminGuard implements CanActivate { + + constructor( + @Inject(CredentialsService) private readonly credentialService: CredentialsService, + @Inject(DbService) private readonly databaseService: DbService, + ) {} + async canActivate( + context: ExecutionContext + ): Promise { + const request: Request = context.switchToHttp().getRequest(); + + const authHeader = request.headers.authorization; + if (!authHeader) { + throw new UnauthorizedException("No authorization header found."); + + } + const token = authHeader.split(" ")[1]; + const vToken = await this.credentialService.verifyAuthToken(token); + + const user = await this.databaseService.use() + .select() + .from(UsersTable) + .where(eq(UsersTable.uuid, vToken.payload.sub)); + + if (user.length !== 1) + throw new UnauthorizedException("No such user found."); + + if (!user[0].isAdmin) { + throw new UnauthorizedException("Administrator only.."); + } + + // Inject user ID into request body + request.body.sourceUserId = vToken.payload.sub; + + return true; + } +} \ No newline at end of file diff --git a/apps/backend/src/app/auth/auth.module.ts b/apps/backend/src/app/auth/auth.module.ts new file mode 100644 index 0000000..b76747e --- /dev/null +++ b/apps/backend/src/app/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { DbModule } from 'apps/backend/src/app/db/db.module'; +import { CredentialsModule } from 'apps/backend/src/app/credentials/credentials.module'; +import { CredentialsService } from 'apps/backend/src/app/credentials/credentials.service'; +import { AdminGuard, UserGuard } from 'apps/backend/src/app/auth/auth.guard'; + +@Module({ + imports: [DbModule, CredentialsModule], + providers: [AuthService, CredentialsService, AdminGuard, UserGuard], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/apps/backend/src/app/auth/auth.service.ts b/apps/backend/src/app/auth/auth.service.ts new file mode 100644 index 0000000..969661f --- /dev/null +++ b/apps/backend/src/app/auth/auth.service.ts @@ -0,0 +1,159 @@ +import { + Injectable, + OnModuleInit, + UnauthorizedException, +} from "@nestjs/common"; +import { eq } from "drizzle-orm"; +import { DbService } from 'apps/backend/src/app/db/db.service'; +import { CredentialsService } from 'apps/backend/src/app/credentials/credentials.service'; +import { UsersTable } from 'apps/backend/src/app/db/schema'; +import { SignInDto, SignUpDto } from 'apps/backend/src/app/auth/auth.dto'; + +@Injectable() +export class AuthService implements OnModuleInit { + constructor( + private db: DbService, + private credentials: CredentialsService, + ) {} + + //TODO Initial account validation for admin privileges + async doRegister(data: SignUpDto) { + console.log(data); + const existingUser = await this.db + .use() + .select() + .from(UsersTable) + .where(eq(UsersTable.email, data.email)) + .prepare("userByEmail") + .execute(); + if (existingUser.length !== 0) + throw new UnauthorizedException("Already exist"); + const query = await this.db + .use() + .insert(UsersTable) + .values({ + //firstName: data.firstName, + //lastName: data.lastName, + email: data.email, + hash: await this.credentials.hash(data.password), + }) + .returning() + .prepare("insertUser") + .execute() + .catch((err) => { + console.error(err); + throw new UnauthorizedException( + "Error occurred while inserting user", + err, + ); + }); + return { + message: "User created, check your email for validation.", + token: await this.credentials.signAuthToken({ sub: query[0].uuid }), + }; + } + + async doLogin(data: SignInDto) { + const user = await this.db + .use() + .select() + .from(UsersTable) + .where(eq(UsersTable.email, data.email)) + .prepare("userByEmail") + .execute(); + if (user.length !== 1) + throw new UnauthorizedException("Invalid credentials"); + const passwordMatch = await this.credentials.check( + data.password, + user[0].hash, + ); + if (!passwordMatch) throw new UnauthorizedException("Invalid credentials"); + const token = await this.credentials.signAuthToken({ sub: user[0].uuid }); + return { + message: "Login successful", + token: token, + }; + } + + async fetchUserById(userId: string) { + const user = await this.db + .use() + .select() + .from(UsersTable) + .where(eq(UsersTable.uuid, userId)) + .prepare("userById") + .execute(); + if (user.length !== 1) { + throw new UnauthorizedException("User not found"); + } + delete user[0].hash; + //delete user[0].emailCode; + return user[0]; + } + + async fetchUsers() { + //TODO Pagination + const usersInDb = await this.db.use().select().from(UsersTable); + const result = { + total: usersInDb.length, + users: usersInDb.map((user) => { + delete user.hash; + return { + ...user, + }; + }), + }; + console.log(result); + return result; + } + + /* + async updateUser(targetId: string, userData: IUserUpdateData) { + const validationResult = UserUpdateSchema.safeParse(userData); + if (!validationResult.success) { + throw new UnauthorizedException(validationResult.error); + } + const updateQuery = await this.db + .use() + .update(UsersTable) + .set({ + ...userData + }) + .where(eq(UsersTable.uuid, targetId)) + .prepare("updateUserById") + .execute() + .catch((err) => { + console.error(err); + throw new UnauthorizedException( + "Error occurred while updating user", + err, + ); + }); + return true; + } + */ + + async deleteUser(targetId: string) { + await this.db + .use() + .delete(UsersTable) + .where(eq(UsersTable.uuid, targetId)) + .prepare("deleteUserById") + .execute() + .catch((err) => { + console.error(err); + throw new UnauthorizedException( + "Error occurred while deleting user", + err, + ); + }); + return true; + } + + async onModuleInit() { + setTimeout(() => { + this.fetchUsers(); + }, 2000); + } +} +