From 525cc13fc31bce6d09bcd571b984d21e0388028d Mon Sep 17 00:00:00 2001 From: Psami-wondah Date: Sat, 1 Feb 2025 22:24:07 +0100 Subject: [PATCH] feat: new auth --- docker-compose.yml | 1 + package.json | 1 + src/auth/auth.controller.ts | 73 +++++++++++----------------- src/auth/auth.service.ts | 58 +++++++++++++++++----- src/auth/dto/sign-up.dto.ts | 13 +---- src/auth/dto/verify-email.dto.ts | 7 ++- src/mail/clients/brevo.client.ts | 2 +- src/mail/clients/zeptomail.client.ts | 2 +- src/mail/mail.service.ts | 4 +- src/main.ts | 5 +- src/otp/otp.service.ts | 4 +- src/socket/socket.gateway.ts | 22 +++++++++ src/user/dto/create-user.dto.ts | 2 +- src/user/user.controller.ts | 19 ++++++-- src/user/user.module.ts | 2 + src/user/user.schema.ts | 5 +- src/user/user.service.ts | 28 ++++++++++- yarn.lock | 5 ++ 18 files changed, 170 insertions(+), 83 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index baeb677..0815b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: command: yarn start:prod ports: - 8000:8000 + - 8001:8001 env_file: - .env depends_on: diff --git a/package.json b/package.json index d2da021..5a5a7d2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "crypto": "^1.0.1", "handlebars": "^4.7.8", "jose": "^5.9.6", "lodash": "^4.17.21", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index f53f8b9..c14084d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -10,9 +10,7 @@ import { UseGuards, } from "@nestjs/common"; import { Request, Response } from "express"; -import { SignUpDto } from "./dto/sign-up.dto"; import { AuthService } from "./auth.service"; -import { SignInDto } from "./dto/sign-in.dto"; import { VerifyEmailDto } from "./dto/verify-email.dto"; import { ResendOtpEmailDto } from "./dto/resend-otp-email.dto"; import { AuthGuard } from "./auth.guard"; @@ -26,38 +24,14 @@ import { RefreshTokenDto } from "./dto/refresh-token.dto"; export class AuthController { constructor(private authService: AuthService) {} - @Post("/sign-up") - async signUp( - @Body() signUpData: SignUpDto, - @Res({ passthrough: true }) response: Response, - ): Promise<{ message: string; accessToken: string; user: UserDto }> { - const userAccess = await this.authService.signUpEmail(signUpData); - response - .status(HttpStatus.CREATED) - .cookie("refreshToken", userAccess.refreshToken, { - httpOnly: true, - }); - return { - message: "Signup Successful", - ...userAccess, - }; - } - - @Post("/sign-in") + @Post("/sign-in-passwordless") async signIn( - @Body() signInData: SignInDto, + @Body() signInData: ResendOtpEmailDto, @Res({ passthrough: true }) response: Response, - ): Promise<{ message: string; accessToken: string; user: UserDto }> { - const userAccess = await this.authService.signInEmail(signInData); - response - .status(HttpStatus.OK) - .cookie("refreshToken", userAccess.refreshToken, { - httpOnly: true, - }); - return { - message: "Signin Successful", - ...userAccess, - }; + ): Promise<{ message: string; data: { isNew: boolean } }> { + const userAccess = await this.authService.signInPasswordless(signInData); + response.status(HttpStatus.OK); + return userAccess; } @Post("/refresh-tokens") @@ -81,6 +55,9 @@ export class AuthController { .status(HttpStatus.OK) .cookie("refreshToken", userAccess.refreshToken, { httpOnly: true, + expires: new Date(userAccess.refreshTokenExpiry), + sameSite: "none", + secure: true, }); return { message: "Token refresh successful", @@ -93,9 +70,18 @@ export class AuthController { @Body() data: VerifyEmailDto, @Res({ passthrough: true }) response: Response, ): Promise<{ message: string }> { - const resp = await this.authService.verifyEmail(data); - response.status(HttpStatus.OK); - return resp; + const userAccess = await this.authService.verifyEmail(data); + const { refreshToken, refreshTokenExpiry, ...rest } = userAccess; + response.status(HttpStatus.OK).cookie("refreshToken", refreshToken, { + httpOnly: true, + expires: new Date(refreshTokenExpiry), + sameSite: "none", + secure: true, + }); + return { + message: "Signin Successful", + ...rest, + }; } @UseGuards(AuthGuard) @@ -116,17 +102,16 @@ export class AuthController { ): Promise<{ message: string }> { const userData = await this.authService.verifyGoogleAuthToken(data.token); const userAccess = await this.authService.signGoogle(userData); - response - .status(HttpStatus.OK) - .cookie("refreshToken", userAccess.refreshToken, { - httpOnly: true, - expires: new Date(userAccess.refreshTokenExpiry), - sameSite: "none", - secure: false, - }); + const { refreshToken, refreshTokenExpiry, ...rest } = userAccess; + response.status(HttpStatus.OK).cookie("refreshToken", refreshToken, { + httpOnly: true, + expires: new Date(refreshTokenExpiry), + sameSite: "none", + secure: true, + }); return { message: "Google Auth Successful", - ...userAccess, + ...rest, }; } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0f70512..6db9d90 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -116,9 +116,7 @@ export class AuthService { }; } - async refreshTokens( - refreshToken: string, - ): Promise<{ accessToken: string; refreshToken: string; user: UserDto }> { + async refreshTokens(refreshToken: string) { const storedToken = await this.refreshTokenModel.findOne({ token: refreshToken, }); @@ -139,7 +137,7 @@ export class AuthService { async signGoogle(userData: GoogleUser) { let user = await this.userService.findOne({ authId: userData.id, - authType: AuthType.GOOGLE, + email: userData.email, }); if (!user) { @@ -153,6 +151,10 @@ export class AuthService { }); } + if (user.authType !== AuthType.GOOGLE) { + throw new UnauthorizedException("Sign in with email"); + } + return await this.generateTokensForUser(user); } @@ -194,6 +196,37 @@ export class AuthService { return await this.generateTokensForUser(user); } + async signInPasswordless(data: ResendOtpEmailDto) { + let isNew = false; + let user = await this.userService.findOneByEmail(data.email); + if (!user) { + user = await this.userService.create({ + email: data.email, + authType: AuthType.EMAIL, + password: data.email, + }); + } + if (!user.name) { + isNew = true; + } + if (user.authType !== AuthType.EMAIL) { + throw new UnauthorizedException("Sign in with google"); + } + const otp = await this.otpService.generateOtpForUser(user); + this.mailService.send( + [{ name: user.name, email: user.email }], + "Verify your Email", + "verify-email.template.html", + { code: otp }, + ); + return { + message: "Email Sent", + data: { + isNew, + }, + }; + } + // TODO: Protect with Auth async resendEmailVerificationOtp(data: ResendOtpEmailDto): Promise<{ message: string; @@ -217,9 +250,7 @@ export class AuthService { }; } - async verifyEmail(verifyEmailData: VerifyEmailDto): Promise<{ - message: string; - }> { + async verifyEmail(verifyEmailData: VerifyEmailDto) { const validOtp = await this.otpService.verifyOTP( verifyEmailData.email, verifyEmailData.code, @@ -227,9 +258,14 @@ export class AuthService { if (!validOtp) { throw new UnauthorizedException(); } - await this.userService.verifyEmail(verifyEmailData.email); - return { - message: "Email Verified", - }; + await this.userService.verifyEmail( + verifyEmailData.email, + verifyEmailData.name, + ); + const user = await this.userService.findOneByEmail(verifyEmailData.email); + if (!user) { + throw new NotFoundException(); + } + return await this.generateTokensForUser(user); } } diff --git a/src/auth/dto/sign-up.dto.ts b/src/auth/dto/sign-up.dto.ts index 6571b54..e9454be 100644 --- a/src/auth/dto/sign-up.dto.ts +++ b/src/auth/dto/sign-up.dto.ts @@ -1,10 +1,4 @@ -import { - IsEmail, - IsNotEmpty, - IsString, - MinLength, - IsPhoneNumber, -} from "class-validator"; +import { IsEmail, IsNotEmpty, IsString, MinLength } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; export class SignUpDto { @@ -18,11 +12,6 @@ export class SignUpDto { @IsEmail() email: string; - @ApiProperty() - @IsNotEmpty() - @IsPhoneNumber() - phoneNumber: string; - @ApiProperty() @IsNotEmpty() @MinLength(8) diff --git a/src/auth/dto/verify-email.dto.ts b/src/auth/dto/verify-email.dto.ts index 62a7ca7..c3152f9 100644 --- a/src/auth/dto/verify-email.dto.ts +++ b/src/auth/dto/verify-email.dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsNotEmpty, IsNumber } from "class-validator"; +import { IsEmail, IsNotEmpty, IsNumber, IsString } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; export class VerifyEmailDto { @@ -7,6 +7,11 @@ export class VerifyEmailDto { @IsEmail() email: string; + @ApiProperty() + @IsNotEmpty() + @IsString() + name?: string; + @ApiProperty() @IsNotEmpty() @IsNumber() diff --git a/src/mail/clients/brevo.client.ts b/src/mail/clients/brevo.client.ts index a5c3222..bf992ee 100644 --- a/src/mail/clients/brevo.client.ts +++ b/src/mail/clients/brevo.client.ts @@ -9,7 +9,7 @@ export const sendEmailBrevo = async ( ) => { const url = "https://api.sendinblue.com/v3/smtp/email"; const body = { - sender: { email: "fastpoint@psami.com", name: "Fast Point" }, + sender: { email: "copyyt@psami.com", name: "Copyyt" }, to: addresses, subject, htmlContent: htmlString, diff --git a/src/mail/clients/zeptomail.client.ts b/src/mail/clients/zeptomail.client.ts index 9b75cd1..ddb4ff9 100644 --- a/src/mail/clients/zeptomail.client.ts +++ b/src/mail/clients/zeptomail.client.ts @@ -9,7 +9,7 @@ export const sendEmailZepto = async ( ) => { const url = "https://api.zeptomail.com/v1.1/email"; const body = JSON.stringify({ - from: { address: "fastpoint@psami.com", name: "Fast Point" }, + from: { address: "copyyt@psami.com", name: "Copyyt" }, to: addresses.map((address) => ({ email_address: { address: address.email, name: address.name }, })), diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index cd46736..7e85790 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; -import path from "path"; -import fs from "fs"; +import * as path from "path"; +import * as fs from "fs"; import handlebars from "handlebars"; import { MailAddress } from "./interfaces/mail-address.interface"; import { sendEmailZepto } from "./clients/zeptomail.client"; diff --git a/src/main.ts b/src/main.ts index 4e16be3..354e1dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()); app.enableCors({ - origin: ["chrome-extension://ophadgignfjigkbdcmicnklokjeknnbd"], + origin: [ + "chrome-extension://ophadgignfjigkbdcmicnklokjeknnbd", + "http://localhost:5173", + ], credentials: true, methods: "GET,HEAD,PUT,PATCH,POST,DELETE", }); diff --git a/src/otp/otp.service.ts b/src/otp/otp.service.ts index 769b7fd..d81f0ba 100644 --- a/src/otp/otp.service.ts +++ b/src/otp/otp.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { OTP } from "./otp.schema"; import { UserDocument } from "src/user/user.schema"; -import crypto from "crypto"; +import * as crypto from "crypto"; const otpExpiryMinutes = 10; @@ -27,8 +27,6 @@ export class OtpService { async verifyOTP(email: string, code: number): Promise { const record = await this.otpModel.findOne({ email, code }); - console.log(record); - if (!record) { return false; } diff --git a/src/socket/socket.gateway.ts b/src/socket/socket.gateway.ts index 07f289d..f485f3e 100644 --- a/src/socket/socket.gateway.ts +++ b/src/socket/socket.gateway.ts @@ -33,11 +33,30 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { private userService: UserService, ) {} + isSocketActive(socketId: string): boolean { + if (!this.server) { + throw new Error("Socket.IO server instance is not initialized."); + } + const socket = this.server.sockets.sockets.get(socketId); + return socket ? socket.connected : false; + } + private async initializeUserConnection( user: UserDocument, socket: Socket, ): Promise { socket.data.user = user; + + // delete disconnected connections + const connections = await this.userService.getConnections(user._id); + if (connections) { + const activeConnections = connections.filter((connection) => + this.isSocketActive(connection), + ); + if (activeConnections.length !== connections.length) { + await this.userService.setConnections(user._id, activeConnections); + } + } await this.userService.addConnection(user._id, socket.id); this.logger.log( `Client connected: ${socket.id} - User ID: ${user._id.toString()}`, @@ -81,6 +100,9 @@ export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { const connections = await this.userService.getConnections( currentUser._id, ); + if (message) { + this.userService.setLastMessage(currentUser._id, String(message)); + } uniq([...(connections ?? []), socket.id]).forEach((connection) => { this.server.to(connection).emit("message", message); }); diff --git a/src/user/dto/create-user.dto.ts b/src/user/dto/create-user.dto.ts index 549cbbc..85309b8 100644 --- a/src/user/dto/create-user.dto.ts +++ b/src/user/dto/create-user.dto.ts @@ -10,7 +10,7 @@ import { AuthType } from "../enum/auth-type.enum"; export class CreateUserDto { @IsNotEmpty() @IsString() - name: string; + name?: string; @IsNotEmpty() @IsEmail() diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index ad8c2a6..99a67c6 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,4 +1,17 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Get, Req, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "src/auth/auth.guard"; +import { ApiBearerAuth } from "@nestjs/swagger"; +import { AuthenticatedRequest } from "src/auth/interfaces/request.interface"; -@Controller('user') -export class UserController {} +@Controller("api/v1/user") +export class UserController { + @ApiBearerAuth() + @UseGuards(AuthGuard) + @Get("/last-message") + getProfile(@Req() req: AuthenticatedRequest) { + return { + messsage: "Last message", + data: req.user?.lastMessage ?? "", + }; + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 0c671bd..0bd9bc9 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -3,9 +3,11 @@ import { UserController } from "./user.controller"; import { UserService } from "./user.service"; import { MongooseModule } from "@nestjs/mongoose"; import { User, UserSchema } from "./user.schema"; +import { JwtModule } from "@nestjs/jwt"; @Module({ imports: [ + JwtModule, MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), ], controllers: [UserController], diff --git a/src/user/user.schema.ts b/src/user/user.schema.ts index a767428..8fcdea9 100644 --- a/src/user/user.schema.ts +++ b/src/user/user.schema.ts @@ -6,7 +6,7 @@ export type UserDocument = HydratedDocument; @Schema({ timestamps: true }) export class User { - @Prop({ required: true }) + @Prop({ required: false }) name: string; @Prop({ required: false, unique: true }) @@ -28,6 +28,9 @@ export class User { @Prop({ default: false }) emailVerified: boolean; + @Prop({ type: String, default: "" }) + lastMessage: string; + @Prop({ type: Array }) connections: []; } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index a3dc224..95ca406 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -45,8 +45,13 @@ export class UserService { return this.userModel.findOne({ email }); } - async verifyEmail(email: string): Promise { - return this.userModel.findOneAndUpdate({ email, emailVerified: true }); + async verifyEmail(email: string, name: string | null = null): Promise { + return this.userModel.findOneAndUpdate( + { + email, + }, + { $set: name ? { emailVerified: true, name } : { emailVerified: true } }, + ); } async getConnections(id: Types.ObjectId) { @@ -74,5 +79,24 @@ export class UserService { ); } + async setConnections(id: Types.ObjectId, connections: string[]) { + return this.userModel.findOneAndUpdate( + { _id: id }, + { $set: { connections } }, + ); + } + + async setLastMessage(id: Types.ObjectId, message: string) { + return this.userModel.findOneAndUpdate( + { _id: id }, + { $set: { lastMessage: message } }, + ); + } + + async getLastMessage(id: Types.ObjectId) { + const user = await this.userModel.findById(id); + return user?.lastMessage; + } + // TODO: delete disconnected connections: probably find a way to get all the current sockets which might be slow though } diff --git a/yarn.lock b/yarn.lock index 6bd4c55..04906e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2125,6 +2125,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" + integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"