diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 9fa4323..136b0df 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -1,6 +1,12 @@ import { Request, Response } from "express"; import { StatusCodes } from "http-status-codes"; -import { sendOtp, verifyOtp, refreshAccessToken, logout } from "./auth.service"; +import { + sendOtp, + checkOtpStatus, + verifyOtp, + refreshAccessToken, + logout, +} from "./auth.service"; const sendOtpController = async (req: Request, res: Response) => { const { phoneNumber } = req.body; @@ -8,6 +14,14 @@ const sendOtpController = async (req: Request, res: Response) => { res.status(StatusCodes.OK).json({ message: "OTP was sent successfully." }); }; +const checkOtpStatusController = async (req: Request, res: Response) => { + const { phoneNumber } = req.body; + await checkOtpStatus(phoneNumber); + res + .status(StatusCodes.OK) + .json({ message: "OTP status checked successfully." }); +}; + const verifyOtpController = async (req: Request, res: Response) => { const { phoneNumber, otp } = req.body; const { accessToken, refreshToken } = await verifyOtp(phoneNumber, otp); @@ -48,6 +62,7 @@ const logoutController = async (req: Request, res: Response) => { export { sendOtpController, + checkOtpStatusController, verifyOtpController, refreshTokenController, logoutController, diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 3e0ea45..b38ff42 100644 --- a/apps/backend/src/modules/auth/auth.route.ts +++ b/apps/backend/src/modules/auth/auth.route.ts @@ -1,7 +1,9 @@ import { Router } from "express"; import validateHandler from "@repo/backend/middlewares/validation"; +import rateLimitHandler from "@repo/backend/middlewares/rate-limit"; import { sendOtpController, + checkOtpStatusController, verifyOtpController, refreshTokenController, logoutController, @@ -10,22 +12,48 @@ import { sendOtpSchema, verifyOtpSchema, refreshTokenSchema, + statusOtpSchema, } from "./auth.schema"; const authRouter = Router(); -authRouter.post("/send-otp", validateHandler(sendOtpSchema), sendOtpController); +const sendOtpLimiter = rateLimitHandler({ + endpoint: "/otp/send", + timeSpan: "5m", + limit: 5, + message: "Too many SMS messages have been sent to this number recently", +}); authRouter.post( - "/verify-otp", + "/otp/send", + sendOtpLimiter, + validateHandler(sendOtpSchema), + sendOtpController +); + +const verifyOtplimiter = rateLimitHandler({ + endpoint: "/otp/verify", + timeSpan: "5m", + limit: 5, +}); + +authRouter.post( + "/otp/status", + validateHandler(statusOtpSchema), + checkOtpStatusController +); + +authRouter.post( + "/otp/verify", + verifyOtplimiter, validateHandler(verifyOtpSchema), - verifyOtpController, + verifyOtpController ); authRouter.get( "/refresh-token", validateHandler(refreshTokenSchema), - refreshTokenController, + refreshTokenController ); authRouter.get("/logout", logoutController); diff --git a/apps/backend/src/modules/auth/auth.schema.ts b/apps/backend/src/modules/auth/auth.schema.ts index d8bbe7d..2077b6f 100644 --- a/apps/backend/src/modules/auth/auth.schema.ts +++ b/apps/backend/src/modules/auth/auth.schema.ts @@ -6,6 +6,12 @@ const sendOtpSchema = z.object({ }), }); +const statusOtpSchema = z.object({ + body: z.object({ + phoneNumber: z.string().min(10).max(15), + }), +}); + const verifyOtpSchema = sendOtpSchema.extend({ body: z.object({ otp: z.string().min(6).max(6), @@ -18,4 +24,4 @@ const refreshTokenSchema = z.object({ }), }); -export { sendOtpSchema, verifyOtpSchema, refreshTokenSchema }; +export { sendOtpSchema, statusOtpSchema, verifyOtpSchema, refreshTokenSchema }; diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index 7b5a14d..fefd6bd 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -13,7 +13,7 @@ import { const twilioClient = twilio( process.env.TWILIO_ACCOUNT_SID, - process.env.TWILIO_AUTH_TOKEN, + process.env.TWILIO_AUTH_TOKEN ); const sendOtp = async (phoneNumber: string) => { @@ -28,7 +28,7 @@ const sendOtp = async (phoneNumber: string) => { expiresAt, }) .onConflictDoUpdate({ - target: otps.phoneNumber, // The unique constraint column + target: otps.phoneNumber, set: { otp, expiresAt, updatedAt: new Date() }, }); @@ -39,6 +39,18 @@ const sendOtp = async (phoneNumber: string) => { }); }; +const checkOtpStatus = async (phoneNumber: string) => { + const otp = await db.query.otps.findFirst({ + where: eq(otps.phoneNumber, phoneNumber), + }); + + if (!otp || otp.expiresAt < new Date()) { + throw new AuthError("OTP expired or invalid."); + } + + return true; +}; + const verifyOtp = async (phoneNumber: string, otp: string) => { const existingOtp = await db.query.otps.findFirst({ where: eq(otps.phoneNumber, phoneNumber), @@ -71,13 +83,16 @@ const verifyOtp = async (phoneNumber: string, otp: string) => { const refreshToken = jwtSignRefreshToken({ userId: user.id }); const hashedToken = await argon2.hash(refreshToken); - - await db.delete(refreshTokens).where(eq(refreshTokens.userId, user.id)); - - await db.insert(refreshTokens).values({ - userId: user.id, - token: hashedToken, - }); + await db + .insert(refreshTokens) + .values({ + userId: user.id, + token: hashedToken, + }) + .onConflictDoUpdate({ + target: refreshTokens.userId, + set: { token: hashedToken, updatedAt: new Date() }, + }); return { accessToken, refreshToken }; }; @@ -120,4 +135,4 @@ const logout = async (refreshToken: string) => { } }; -export { sendOtp, verifyOtp, refreshAccessToken, logout }; +export { sendOtp, checkOtpStatus, verifyOtp, refreshAccessToken, logout }; diff --git a/apps/backend/src/modules/auth/auth.test.ts b/apps/backend/src/modules/auth/auth.test.ts index 573b276..5d55da6 100644 --- a/apps/backend/src/modules/auth/auth.test.ts +++ b/apps/backend/src/modules/auth/auth.test.ts @@ -31,12 +31,12 @@ describe("Auth API", () => { process.env.JWT_REFRESH_TOKEN_COOKIE_KEY = "refreshToken"; }); - describe("POST /send-otp", () => { + describe("POST /otp/send", () => { it("should send an OTP successfully", async () => { const phoneNumber = "1234567890"; const res = await supertest(createServer()) - .post("/api/v1/auth/send-otp") + .post("/api/v1/auth/otp/send") .set("Content-Type", "application/json") .send({ phoneNumber }); @@ -56,14 +56,14 @@ describe("Auth API", () => { it("should return 400 if phoneNumber is missing", async () => { const res = await supertest(createServer()) - .post("/api/v1/auth/send-otp") + .post("/api/v1/auth/otp/send") .send({}); expect(res.status).toBe(400); }); }); - describe("POST /auth/verify-otp", () => { + describe("POST /auth/otp/verify", () => { it("should verify OTP, create user if not exists, and return tokens", async () => { const phoneNumber = "1234567890"; const otpCode = "123456"; @@ -76,7 +76,7 @@ describe("Auth API", () => { }); const response = await supertest(createServer()) - .post("/api/v1/auth/verify-otp") + .post("/api/v1/auth/otp/verify") .send({ phoneNumber, otp: otpCode }); expect(response.status).toBe(201); @@ -118,7 +118,7 @@ describe("Auth API", () => { }); const response = await supertest(createServer()) - .post("/api/v1/auth/verify-otp") + .post("/api/v1/auth/otp/verify") .send({ phoneNumber, otp: otpCode }); expect(response.status).toBe(401); @@ -137,7 +137,7 @@ describe("Auth API", () => { }); const response = await supertest(createServer()) - .post("/api/v1/auth/verify-otp") + .post("/api/v1/auth/otp/verify") .send({ phoneNumber, otp: otpCode }); expect(response.status).toBe(401); @@ -156,7 +156,7 @@ describe("Auth API", () => { }); const response = await supertest(createServer()) - .post("/api/v1/auth/verify-otp") + .post("/api/v1/auth/otp/verify") .send({ phoneNumber, otp: "123456" }); expect(response.status).toBe(401); diff --git a/packages/database/src/schema/auth.ts b/packages/database/src/schema/auth.ts index 4f390f8..493cf9e 100644 --- a/packages/database/src/schema/auth.ts +++ b/packages/database/src/schema/auth.ts @@ -19,6 +19,7 @@ export const otps = pgTable("otps", { export const refreshTokens = pgTable("refresh_tokens", { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer() + .unique() .notNull() .references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" }), token: text().notNull().unique(),