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

Uabc 248 create forgot password flow #277

Merged
merged 11 commits into from
Oct 8, 2024
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.7",
"drizzle-zod": "^0.5.1",
"lru-cache": "^11.0.1",
"lucide-react": "^0.358.0",
"next": "^14.1.3",
"next-auth": "^4.24.7",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ export default async function AdminDashboardPage() {
<Heading>Dashboard</Heading>
</div>
<div className="flex flex-col gap-4 px-4">
<DashboardButton href="admin/view-sessions">
<DashboardButton href="/admin/view-sessions">
<CalendarDays size={24} className="min-w-6" />
View Sessions
</DashboardButton>
<DashboardButton href="admin/semesters">
<DashboardButton href="/admin/semesters">
<CalendarClock size={24} className="min-w-6" />
Edit Semester Schedules
</DashboardButton>
<DashboardButton href="admin/members" className="relative">
<DashboardButton href="/admin/members" className="relative">
<MemberApprovalPing />
<BsPersonFillCheck size={24} className="min-w-6" /> Members
</DashboardButton>
Expand Down
45 changes: 45 additions & 0 deletions src/app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type NextRequest } from "next/server";
import { and, eq, isNotNull } from "drizzle-orm";
import { z } from "zod";

import { sendForgotPasswordEmail } from "@/emails";
import { responses } from "@/lib/api/responses";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { rateLimit } from "@/lib/rate-limit";
import { routeWrapper } from "@/lib/wrappers";
import { insertForgotPasswordToken } from "@/services/forgot-password";

const postRequestSchema = z.object({
email: z.string().email(),
});

const limiter = rateLimit({
interval: 60 * 60 * 1000,
});

export const POST = routeWrapper(async function (req: NextRequest) {
const body = await req.json();

// validate email
const { email } = postRequestSchema.parse(body);

const isRateLimited = limiter.check(3, email);

if (isRateLimited)
return responses.tooManyRequests({
message: "Rate limit exceeded, try again in 1 hour.",
});

// to check if email exists
const user = await db.query.users.findFirst({
where: and(eq(users.email, email), isNotNull(users.password)),
});

if (user) {
const token = await insertForgotPasswordToken(email);
await sendForgotPasswordEmail(user, token);
}

return responses.success();
});
89 changes: 89 additions & 0 deletions src/app/api/auth/reset-password/route.ts
monoclonalAb marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createHash } from "crypto";
import type { NextRequest } from "next/server";
import { hash } from "bcrypt";
import { and, eq, gt } from "drizzle-orm";
import { z } from "zod";

import { responses } from "@/lib/api/responses";
import { db } from "@/lib/db";
import { forgotPasswordTokens, users } from "@/lib/db/schema";
import { rateLimit } from "@/lib/rate-limit";
import { routeWrapper } from "@/lib/wrappers";

const limiter = rateLimit({
interval: 60 * 60 * 1000, // 1 hour
});

const postRequestSchema = z.object({
newPassword: z
.string()
.min(8, { message: "Password must be at least 8 characters" })
.regex(/\d/, { message: "Password must contain a number" })
.regex(/[a-z]/, { message: "Password must contain a lowercase letter" })
.regex(/[A-Z]/, { message: "Password must contain an uppercase letter" }),
resetPasswordToken: z.string(),
});

export const POST = routeWrapper(async (req: NextRequest) => {
const isRateLimited = limiter.check(5);

if (isRateLimited)
return responses.tooManyRequests({
message: "Rate limit exceeded, try again in 1 hour.",
});

const body = await req.json();
const { newPassword, resetPasswordToken } = postRequestSchema.parse(body);

const hashedToken = createHash("sha256")
.update(resetPasswordToken)
.digest("hex");

const matchingResetPasswordToken =
await db.query.forgotPasswordTokens.findFirst({
where: and(
eq(forgotPasswordTokens.token, hashedToken),
gt(forgotPasswordTokens.expires, new Date())
),
});

if (!matchingResetPasswordToken) {
return responses.badRequest({
code: "INVALID_CODE",
message: "Invalid reset token provided",
});
}

const user = await db.query.users.findFirst({
where: eq(users.email, matchingResetPasswordToken.identifier),
});

if (!user) {
return responses.badRequest({
message: "User does not exist.",
});
}
const hashedPassword = await hash(newPassword, 12);

//transaction to update the user's password and delete the reset password token
await db.transaction(async (tx) => {
await tx
.update(users)
.set({
password: hashedPassword,
})
.where(eq(users.email, matchingResetPasswordToken.identifier));
await tx
.delete(forgotPasswordTokens)
.where(
eq(
forgotPasswordTokens.identifier,
matchingResetPasswordToken.identifier
)
);
});

return responses.success({
message: "Password changed successfully",
});
});
14 changes: 11 additions & 3 deletions src/app/api/bookings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ import {
users,
} from "@/lib/db/schema";
import { StatusError } from "@/lib/exceptions";
import { rateLimit } from "@/lib/rate-limit";
import { obfuscateId } from "@/lib/sqid";
import { nzstParse } from "@/lib/utils/dates";
import { userRouteWrapper } from "@/lib/wrappers";
import { userCache } from "@/services/user";
import type { User } from "@/types/next-auth";

/**
* Creates a booking for the current user
*/
const limiter = rateLimit({
interval: 5 * 60 * 1000, // 60 seconds
});

const bookingSchema = z.array(
z.object({
Expand All @@ -45,6 +46,13 @@ export const POST = userRouteWrapper(
return responses.forbidden();
}

const isRateLimited = limiter.check(5); // max 5 requests per 5 minutes per IP

if (isRateLimited)
return responses.tooManyRequests({
message: "Rate limit exceeded, try again in 5 minutes.",
});

// parse the input array of objects and check if the user has enough sessions
const body = bookingSchema.parse(await request.json());
const numOfSessions = body.length;
Expand Down
Loading
Loading