From 77f4af51e274da72a1c007f2a7c17eb5b88a9056 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 19 Dec 2025 09:29:01 +0530 Subject: [PATCH 1/4] feat(auth): add email-password authentication - Add passwordHash field to User model and PasswordResetToken table - Configure NextAuth with JWT sessions and CredentialsProvider - Create password validation utilities with Zod schemas - Add signup API route with OAuth user password linking support - Update login page with password/magic link toggle - Create dedicated signup page with password registration - Implement complete password reset flow: - Forgot password API and page - Reset password API and page - Password reset email template - Add sendPasswordResetEmail to mailer This enables users to sign up and login with email/password alongside existing OAuth (Google, GitHub) authentication. --- .../migration.sql | 22 ++ apps/web/prisma/schema.prisma | 12 + .../src/app/api/auth/forgot-password/route.ts | 55 +++ .../src/app/api/auth/reset-password/route.ts | 61 ++++ apps/web/src/app/api/auth/signup/route.ts | 90 +++++ apps/web/src/app/forgot-password/page.tsx | 139 ++++++++ apps/web/src/app/login/login-page.tsx | 210 +++++++++--- apps/web/src/app/reset-password/page.tsx | 244 +++++++++++++ apps/web/src/app/signup/page.tsx | 6 +- apps/web/src/app/signup/signup-page.tsx | 320 ++++++++++++++++++ apps/web/src/server/auth.ts | 66 +++- .../email-templates/PasswordResetEmail.tsx | 87 +++++ apps/web/src/server/email-templates/index.ts | 4 + apps/web/src/server/mailer.ts | 29 +- apps/web/src/server/password-utils.ts | 66 ++++ 15 files changed, 1358 insertions(+), 53 deletions(-) create mode 100644 apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql create mode 100644 apps/web/src/app/api/auth/forgot-password/route.ts create mode 100644 apps/web/src/app/api/auth/reset-password/route.ts create mode 100644 apps/web/src/app/api/auth/signup/route.ts create mode 100644 apps/web/src/app/forgot-password/page.tsx create mode 100644 apps/web/src/app/reset-password/page.tsx create mode 100644 apps/web/src/app/signup/signup-page.tsx create mode 100644 apps/web/src/server/email-templates/PasswordResetEmail.tsx create mode 100644 apps/web/src/server/password-utils.ts diff --git a/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql b/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql new file mode 100644 index 00000000..0c133113 --- /dev/null +++ b/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT; + +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_email_idx" ON "PasswordResetToken"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_email_token_key" ON "PasswordResetToken"("email", "token"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 772d23b1..29b72753 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -84,6 +84,7 @@ model User { email String? @unique emailVerified DateTime? image String? + passwordHash String? // nullable for OAuth-only users isBetaUser Boolean @default(false) isWaitlisted Boolean @default(false) createdAt DateTime @default(now()) @@ -92,6 +93,17 @@ model User { teamUsers TeamUser[] } +model PasswordResetToken { + id String @id @default(cuid()) + email String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + + @@unique([email, token]) + @@index([email]) +} + enum Plan { FREE BASIC diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 00000000..8126d50a --- /dev/null +++ b/apps/web/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { randomBytes } from "crypto"; +import { db } from "~/server/db"; +import { sendPasswordResetEmail } from "~/server/mailer"; + +const schema = z.object({ + email: z.string().email(), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = schema.safeParse(body); + + if (!result.success) { + // Always return success to prevent email enumeration + return NextResponse.json({ success: true }); + } + + const normalizedEmail = result.data.email.toLowerCase().trim(); + + const user = await db.user.findUnique({ + where: { email: normalizedEmail }, + }); + + // Only send reset email if user exists AND has a password + if (user && user.passwordHash) { + // Delete any existing tokens for this email + await db.passwordResetToken.deleteMany({ + where: { email: normalizedEmail }, + }); + + // Create new token (expires in 1 hour) + const token = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 60 * 60 * 1000); + + await db.passwordResetToken.create({ + data: { + email: normalizedEmail, + token, + expires, + }, + }); + + await sendPasswordResetEmail(normalizedEmail, token); + } + + // Always return success to prevent email enumeration + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Forgot password error:", error); + return NextResponse.json({ success: true }); + } +} diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts new file mode 100644 index 00000000..e942535a --- /dev/null +++ b/apps/web/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { db } from "~/server/db"; +import { createSecureHash } from "~/server/crypto"; +import { resetPasswordSchema } from "~/server/password-utils"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = resetPasswordSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { error: result.error.errors[0]?.message ?? "Invalid input" }, + { status: 400 } + ); + } + + const { token, password } = result.data; + + const resetToken = await db.passwordResetToken.findUnique({ + where: { token }, + }); + + if (!resetToken) { + return NextResponse.json( + { error: "Invalid or expired reset link" }, + { status: 400 } + ); + } + + if (resetToken.expires < new Date()) { + // Clean up expired token + await db.passwordResetToken.delete({ where: { token } }); + return NextResponse.json( + { error: "This reset link has expired. Please request a new one." }, + { status: 400 } + ); + } + + const passwordHash = await createSecureHash(password); + + await db.user.update({ + where: { email: resetToken.email }, + data: { + passwordHash, + emailVerified: new Date(), // Verify email on password reset + }, + }); + + // Delete used token + await db.passwordResetToken.delete({ where: { token } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Reset password error:", error); + return NextResponse.json( + { error: "Something went wrong. Please try again." }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts new file mode 100644 index 00000000..619fc762 --- /dev/null +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { db } from "~/server/db"; +import { createSecureHash } from "~/server/crypto"; +import { signupSchema } from "~/server/password-utils"; +import { env } from "~/env"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const result = signupSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { error: result.error.errors[0]?.message ?? "Invalid input" }, + { status: 400 } + ); + } + + const { email, password, name } = result.data; + const normalizedEmail = email.toLowerCase().trim(); + + // Check if user exists + const existingUser = await db.user.findUnique({ + where: { email: normalizedEmail }, + }); + + if (existingUser) { + if (existingUser.passwordHash) { + // User already has password authentication + return NextResponse.json( + { error: "An account with this email already exists" }, + { status: 400 } + ); + } + + // User exists via OAuth, allow adding password + const passwordHash = await createSecureHash(password); + await db.user.update({ + where: { id: existingUser.id }, + data: { passwordHash }, + }); + + return NextResponse.json({ + success: true, + message: + "Password added to your existing account. You can now sign in with email and password.", + }); + } + + // Create new user with password + const passwordHash = await createSecureHash(password); + + // Check for pending team invites + const pendingInvites = await db.teamInvite.findMany({ + where: { email: normalizedEmail }, + }); + + // Determine beta/waitlist status based on environment and invites + const isBetaUser = + !env.NEXT_PUBLIC_IS_CLOUD || + env.NODE_ENV === "development" || + pendingInvites.length > 0; + const isWaitlisted = + env.NEXT_PUBLIC_IS_CLOUD && + env.NODE_ENV !== "development" && + pendingInvites.length === 0; + + await db.user.create({ + data: { + email: normalizedEmail, + name: name ?? null, + passwordHash, + isBetaUser, + isWaitlisted, + emailVerified: new Date(), // Mark as verified since they're signing up directly + }, + }); + + return NextResponse.json({ + success: true, + message: "Account created successfully. You can now sign in.", + }); + } catch (error) { + console.error("Signup error:", error); + return NextResponse.json( + { error: "Something went wrong. Please try again." }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/forgot-password/page.tsx b/apps/web/src/app/forgot-password/page.tsx new file mode 100644 index 00000000..89a75ba6 --- /dev/null +++ b/apps/web/src/app/forgot-password/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Button } from "@usesend/ui/src/button"; +import Image from "next/image"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import Link from "next/link"; + +const emailSchema = z.object({ + email: z + .string({ required_error: "Email is required" }) + .email({ message: "Invalid email" }), +}); + +export default function ForgotPasswordPage() { + const [status, setStatus] = useState<"idle" | "sending" | "success">("idle"); + + const form = useForm>({ + resolver: zodResolver(emailSchema), + }); + + async function onSubmit(values: z.infer) { + setStatus("sending"); + try { + await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: values.email }), + }); + // Always show success to prevent email enumeration + setStatus("success"); + } catch { + // Still show success to prevent email enumeration + setStatus("success"); + } + } + + return ( +
+
+ useSend +
+

+ Reset your password +

+

+ Remember your password? + + Sign in + +

+
+ +
+ {status === "success" ? ( +
+
+

+ If an account exists with that email, we've sent you a + password reset link. Please check your inbox. +

+
+

+ Didn't receive the email? Check your spam folder or try again. +

+ +
+ ) : ( + <> +

+ Enter your email address and we'll send you a link to reset your + password. +

+
+ + ( + + + + + + + )} + /> + + + + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx index 46617f16..6a3f0b34 100644 --- a/apps/web/src/app/login/login-page.tsx +++ b/apps/web/src/app/login/login-page.tsx @@ -27,6 +27,7 @@ import Spinner from "@usesend/ui/src/spinner"; import Link from "next/link"; import { useTheme } from "@usesend/ui"; import { useSearchParams as useNextSearchParams } from "next/navigation"; +import { passwordLoginSchema } from "~/server/password-utils"; const emailSchema = z.object({ email: z @@ -72,6 +73,13 @@ export default function LoginPage({ "idle" | "sending" | "success" >("idle"); + const [authMode, setAuthMode] = useState<"password" | "otp">("password"); + const [loginError, setLoginError] = useState(null); + + const searchParams = useNextSearchParams(); + const inviteId = searchParams.get("inviteId"); + const callbackUrl = typeof window !== "undefined" ? window.location.origin : ""; + const emailForm = useForm>({ resolver: zodResolver(emailSchema), }); @@ -80,6 +88,14 @@ export default function LoginPage({ resolver: zodResolver(otpSchema), }); + const passwordForm = useForm>({ + resolver: zodResolver(passwordLoginSchema), + defaultValues: { + email: "", + password: "", + }, + }); + async function onEmailSubmit(values: z.infer) { setEmailStatus("sending"); await signIn("email", { @@ -90,18 +106,41 @@ export default function LoginPage({ } async function onOTPSubmit(values: z.infer) { - const { origin: callbackUrl } = window.location; + const { origin: baseUrl } = window.location; const email = emailForm.getValues().email; console.log("email", email); const finalCallbackUrl = inviteId ? `/join-team?inviteId=${inviteId}` - : `${callbackUrl}/dashboard`; + : `${baseUrl}/dashboard`; window.location.href = `/api/auth/callback/email?email=${encodeURIComponent( email.toLowerCase() )}&token=${values.otp.toLowerCase()}&callbackUrl=${encodeURIComponent(finalCallbackUrl)}`; } + async function onPasswordSubmit(values: z.infer) { + setLoginError(null); + setSubmittedProvider("credentials"); + + const result = await signIn("credentials", { + email: values.email.toLowerCase(), + password: values.password, + redirect: false, + }); + + setSubmittedProvider(null); + + if (result?.error) { + setLoginError("Invalid email or password"); + return; + } + + const finalCallbackUrl = inviteId + ? `/join-team?inviteId=${inviteId}` + : `${callbackUrl}/dashboard`; + window.location.href = finalCallbackUrl; + } + const emailProvider = providers?.find( (provider) => provider.type === "email" ); @@ -109,15 +148,12 @@ export default function LoginPage({ const [submittedProvider, setSubmittedProvider] = useState | null>(null); - const searchParams = useNextSearchParams(); - const inviteId = searchParams.get("inviteId"); - const handleSubmit = (provider: LiteralUnion) => { setSubmittedProvider(provider); - const callbackUrl = inviteId + const redirectUrl = inviteId ? `/join-team?inviteId=${inviteId}` : "/dashboard"; - signIn(provider, { callbackUrl }); + signIn(provider, { callbackUrl: redirectUrl }); }; const { resolvedTheme } = useTheme(); @@ -178,7 +214,96 @@ export default function LoginPage({

- {emailStatus === "success" ? ( + + {/* Auth mode toggle */} +
+ + +
+ + {/* Error message display */} + {loginError && ( +

{loginError}

+ )} + + {authMode === "password" ? ( + /* Password login form */ +
+ + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ + Forgot password? + +
+ + + + ) : emailStatus === "success" ? ( + /* OTP verification form */ <>

We have sent an email with the OTP. Please check your inbox @@ -238,42 +363,41 @@ export default function LoginPage({ ) : ( - <> -

- + + ( + + + + + + + + )} + /> + - - - + {emailStatus === "sending" + ? "Sending..." + : "Continue with email"} + + + )} )} diff --git a/apps/web/src/app/reset-password/page.tsx b/apps/web/src/app/reset-password/page.tsx new file mode 100644 index 00000000..9c8e2a20 --- /dev/null +++ b/apps/web/src/app/reset-password/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Button } from "@usesend/ui/src/button"; +import Image from "next/image"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState, Suspense } from "react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; + +const passwordSchema = z + .object({ + password: z + .string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be less than 128 characters") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + + const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">( + "idle" + ); + const [errorMessage, setErrorMessage] = useState(""); + + const form = useForm>({ + resolver: zodResolver(passwordSchema), + defaultValues: { + password: "", + confirmPassword: "", + }, + }); + + async function onSubmit(values: z.infer) { + if (!token) { + setErrorMessage("Invalid reset link"); + setStatus("error"); + return; + } + + setStatus("sending"); + setErrorMessage(""); + + try { + const response = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password: values.password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setErrorMessage(data.error || "Something went wrong"); + setStatus("error"); + return; + } + + setStatus("success"); + } catch { + setErrorMessage("Something went wrong. Please try again."); + setStatus("error"); + } + } + + if (!token) { + return ( +
+
+ useSend +
+
+

+ Invalid Reset Link +

+

+ This password reset link is invalid or has expired. +

+ + + +
+
+
+
+ ); + } + + return ( +
+
+ useSend +
+

+ Create new password +

+

+ Enter a new password for your account +

+
+ +
+ {status === "success" ? ( +
+
+

+ Your password has been reset successfully. +

+
+ + + +
+ ) : ( + <> + {status === "error" && errorMessage && ( +
+

+ {errorMessage} +

+
+ )} +
+ + ( + + New Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + + +

+ + Back to sign in + +

+ + )} +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + +
+ useSend +

Loading...

+
+ + } + > + +
+ ); +} diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx index ba9d53ba..949323b0 100644 --- a/apps/web/src/app/signup/page.tsx +++ b/apps/web/src/app/signup/page.tsx @@ -1,9 +1,9 @@ import { redirect } from "next/navigation"; import { getServerAuthSession } from "~/server/auth"; -import LoginPage from "../login/login-page"; +import SignupPage from "./signup-page"; import { getProviders } from "next-auth/react"; -export default async function Login() { +export default async function Signup() { const session = await getServerAuthSession(); if (session) { @@ -12,5 +12,5 @@ export default async function Login() { const providers = await getProviders(); - return ; + return ; } diff --git a/apps/web/src/app/signup/signup-page.tsx b/apps/web/src/app/signup/signup-page.tsx new file mode 100644 index 00000000..b813b13d --- /dev/null +++ b/apps/web/src/app/signup/signup-page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { Button } from "@usesend/ui/src/button"; +import Image from "next/image"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Input } from "@usesend/ui/src/input"; +import { BuiltInProviderType } from "next-auth/providers/index"; +import Spinner from "@usesend/ui/src/spinner"; +import Link from "next/link"; +import { useSearchParams as useNextSearchParams, useRouter } from "next/navigation"; +import { signupSchema } from "~/server/password-utils"; + +const providerSvgs = { + github: ( + + + + ), + google: ( + + + + ), +}; + +// Extended schema with confirm password for client-side validation +const signupFormSchema = signupSchema.extend({ + confirmPassword: z.string().min(1, "Please confirm your password"), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); + +type SignupFormValues = z.infer; + +export default function SignupPage({ + providers, +}: { + providers?: ClientSafeProvider[]; +}) { + const router = useRouter(); + const searchParams = useNextSearchParams(); + const inviteId = searchParams.get("inviteId"); + + const [signupStatus, setSignupStatus] = useState< + "idle" | "submitting" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [submittedProvider, setSubmittedProvider] = + useState | null>(null); + + const form = useForm({ + resolver: zodResolver(signupFormSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + }); + + async function onSubmit(values: SignupFormValues) { + setSignupStatus("submitting"); + setErrorMessage(""); + + try { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: values.email, + password: values.password, + name: values.name || undefined, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setSignupStatus("error"); + setErrorMessage(data.error || "Something went wrong"); + return; + } + + setSignupStatus("success"); + + // Auto-login with credentials after successful signup + const signInResult = await signIn("credentials", { + email: values.email.toLowerCase(), + password: values.password, + redirect: false, + }); + + if (signInResult?.ok) { + const callbackUrl = inviteId + ? `/join-team?inviteId=${inviteId}` + : "/dashboard"; + router.push(callbackUrl); + } else { + // If auto-login fails, redirect to login page + router.push("/login?message=Account created successfully. Please sign in."); + } + } catch { + setSignupStatus("error"); + setErrorMessage("Something went wrong. Please try again."); + } + } + + const handleOAuthSubmit = (provider: LiteralUnion) => { + setSubmittedProvider(provider); + const callbackUrl = inviteId + ? `/join-team?inviteId=${inviteId}` + : "/dashboard"; + signIn(provider, { callbackUrl }); + }; + + const oauthProviders = providers?.filter((p) => p.type !== "email" && p.type !== "credentials"); + + return ( +
+
+ useSend +
+

+ Create new account +

+

+ Already have an account? + + Sign in + +

+
+ +
+ {/* OAuth Buttons */} + {oauthProviders && oauthProviders.length > 0 && ( + <> + {oauthProviders.map((provider) => ( + + ))} + + {/* Divider */} +
+

+ or +

+
+
+ + )} + + {/* Signup Form */} + {signupStatus === "success" ? ( +
+

+ Account created successfully! +

+

+ Redirecting you to the dashboard... +

+
+ ) : ( +
+ + {/* Name Field (Optional) */} + ( + + Name (optional) + + + + + + )} + /> + + {/* Email Field */} + ( + + Email + + + + + + )} + /> + + {/* Password Field */} + ( + + Password + + + + + At least 8 characters with uppercase, lowercase, and number + + + + )} + /> + + {/* Confirm Password Field */} + ( + + Confirm Password + + + + + + )} + /> + + {/* Error Message */} + {errorMessage && ( +

+ {errorMessage} +

+ )} + + {/* Submit Button */} + + + + )} +
+
+
+ ); +} diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 1f53f4c5..57eb72d3 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -8,11 +8,13 @@ import { type Adapter } from "next-auth/adapters"; import GitHubProvider from "next-auth/providers/github"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; +import CredentialsProvider from "next-auth/providers/credentials"; import { Provider } from "next-auth/providers/index"; import { sendSignUpEmail } from "~/server/mailer"; import { env } from "~/env"; import { db } from "~/server/db"; +import { verifySecureHash } from "~/server/crypto"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -88,6 +90,47 @@ function getProviders() { ); } + // Credentials provider for email/password authentication + providers.push( + CredentialsProvider({ + id: "credentials", + name: "Email & Password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + return null; + } + + const email = (credentials.email as string).toLowerCase().trim(); + const password = credentials.password as string; + + const user = await db.user.findUnique({ where: { email } }); + + if (!user || !user.passwordHash) { + return null; + } + + const isValid = await verifySecureHash(password, user.passwordHash); + if (!isValid) { + return null; + } + + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + isBetaUser: user.isBetaUser, + isAdmin: user.email === env.ADMIN_EMAIL, + isWaitlisted: user.isWaitlisted, + }; + }, + }) + ); + if (providers.length === 0 && process.env.SKIP_ENV_VALIDATION !== "true") { throw new Error("No auth providers found, need atleast one"); } @@ -101,15 +144,28 @@ function getProviders() { * @see https://next-auth.js.org/configuration/options */ export const authOptions: NextAuthOptions = { + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, callbacks: { - session: ({ session, user }) => ({ + jwt: async ({ token, user }) => { + if (user) { + token.id = user.id; + token.isBetaUser = user.isBetaUser; + token.isWaitlisted = user.isWaitlisted; + token.isAdmin = user.email === env.ADMIN_EMAIL; + } + return token; + }, + session: ({ session, token }) => ({ ...session, user: { ...session.user, - id: user.id, - isBetaUser: user.isBetaUser, - isAdmin: user.email === env.ADMIN_EMAIL, - isWaitlisted: user.isWaitlisted, + id: token.id as number, + isBetaUser: token.isBetaUser as boolean, + isAdmin: token.isAdmin as boolean, + isWaitlisted: token.isWaitlisted as boolean, }, }), }, diff --git a/apps/web/src/server/email-templates/PasswordResetEmail.tsx b/apps/web/src/server/email-templates/PasswordResetEmail.tsx new file mode 100644 index 00000000..6c76f325 --- /dev/null +++ b/apps/web/src/server/email-templates/PasswordResetEmail.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { Container, Text } from "jsx-email"; +import { render } from "jsx-email"; +import { EmailLayout } from "./components/EmailLayout"; +import { EmailHeader } from "./components/EmailHeader"; +import { EmailFooter } from "./components/EmailFooter"; +import { EmailButton } from "./components/EmailButton"; + +interface PasswordResetEmailProps { + resetUrl: string; + logoUrl?: string; +} + +export function PasswordResetEmail({ + resetUrl, + logoUrl, +}: PasswordResetEmailProps) { + return ( + + + + + + Hi there, + + + + We received a request to reset your password for your useSend account. + Click the button below to create a new password: + + + + Reset password + + + + This link will expire in 1 hour for security reasons. + + + + If you didn't request a password reset, you can safely ignore this + email. Your password will remain unchanged. + + + + + + ); +} + +export async function renderPasswordResetEmail( + props: PasswordResetEmailProps +): Promise { + return render(); +} diff --git a/apps/web/src/server/email-templates/index.ts b/apps/web/src/server/email-templates/index.ts index 02e963a7..ebf16f2b 100644 --- a/apps/web/src/server/email-templates/index.ts +++ b/apps/web/src/server/email-templates/index.ts @@ -8,6 +8,10 @@ export { UsageLimitReachedEmail, renderUsageLimitReachedEmail, } from "./UsageLimitReachedEmail"; +export { + PasswordResetEmail, + renderPasswordResetEmail, +} from "./PasswordResetEmail"; export * from "./components/EmailLayout"; export * from "./components/EmailHeader"; diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts index bc360e62..45699014 100644 --- a/apps/web/src/server/mailer.ts +++ b/apps/web/src/server/mailer.ts @@ -5,7 +5,11 @@ import { db } from "./db"; import { getDomains } from "./service/domain-service"; import { sendEmail } from "./service/email-service"; import { logger } from "./logger/log"; -import { renderOtpEmail, renderTeamInviteEmail } from "./email-templates"; +import { + renderOtpEmail, + renderTeamInviteEmail, + renderPasswordResetEmail, +} from "./email-templates"; let usesend: UseSend | undefined; @@ -69,6 +73,27 @@ export async function sendTeamInviteEmail( await sendMail(email, subject, text, html); } +export async function sendPasswordResetEmail(email: string, token: string) { + const resetUrl = `${env.NEXTAUTH_URL}/reset-password?token=${token}`; + + if (env.NODE_ENV === "development") { + logger.info({ email, resetUrl, token }, "Sending password reset email"); + return; + } + + const subject = "Reset your password"; + + // Use jsx-email template for beautiful HTML + const html = await renderPasswordResetEmail({ + resetUrl, + }); + + // Fallback text version + const text = `Hey,\n\nWe received a request to reset your password for your useSend account.\n\nYou can reset your password by clicking the link below:\n${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you didn't request this, you can safely ignore this email.\n\nThanks,\nuseSend Team`; + + await sendMail(email, subject, text, html); +} + export async function sendSubscriptionConfirmationEmail(email: string) { if (!env.FOUNDER_EMAIL) { logger.error("FOUNDER_EMAIL not configured"); @@ -92,7 +117,7 @@ export async function sendMail( ) { if (isSelfHosted()) { logger.info("Sending email using self hosted"); - /* + /* Self hosted so checking if we can send using one of the available domain Assuming self hosted will have only one team TODO: fix this diff --git a/apps/web/src/server/password-utils.ts b/apps/web/src/server/password-utils.ts new file mode 100644 index 00000000..e789e772 --- /dev/null +++ b/apps/web/src/server/password-utils.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +/** + * Password validation schema with strong requirements: + * - Minimum 8 characters + * - Maximum 128 characters + * - At least one lowercase letter + * - At least one uppercase letter + * - At least one number + */ +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be less than 128 characters") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[0-9]/, "Password must contain at least one number"); + +/** + * Validates a password against the password schema + * @param password - The password to validate + * @returns SafeParseReturnType with success status and error messages + */ +export const validatePassword = (password: string) => { + return passwordSchema.safeParse(password); +}; + +/** + * Schema for signup form validation + */ +export const signupSchema = z.object({ + email: z.string().email("Invalid email address"), + password: passwordSchema, + name: z.string().min(1, "Name is required").optional(), +}); + +/** + * Schema for password login form validation + */ +export const passwordLoginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +/** + * Schema for password change (when user already has a password) + */ +export const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: passwordSchema, +}); + +/** + * Schema for setting password (when OAuth user adds password) + */ +export const setPasswordSchema = z.object({ + newPassword: passwordSchema, +}); + +/** + * Schema for password reset + */ +export const resetPasswordSchema = z.object({ + token: z.string().min(1, "Reset token is required"), + password: passwordSchema, +}); From 95eafbc82aebe3c426d1665ef610a5c195458df7 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 19 Dec 2025 09:32:35 +0530 Subject: [PATCH 2/4] feat(settings): add account settings with password management - Create user tRPC router with password management procedures: - hasPassword: check if user has password set - getLinkedAccounts: list OAuth providers linked - changePassword: change existing password - setPassword: add password for OAuth-only users - getProfile: get user profile info - Create account settings page at /settings/account: - Profile info display - Linked OAuth accounts section - Change/set password forms - Add Account tab to settings navigation --- .../app/(dashboard)/settings/account/page.tsx | 499 ++++++++++++++++++ .../src/app/(dashboard)/settings/layout.tsx | 1 + apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/user.ts | 133 +++++ 4 files changed, 635 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/settings/account/page.tsx create mode 100644 apps/web/src/server/api/routers/user.ts diff --git a/apps/web/src/app/(dashboard)/settings/account/page.tsx b/apps/web/src/app/(dashboard)/settings/account/page.tsx new file mode 100644 index 00000000..b0427158 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/account/page.tsx @@ -0,0 +1,499 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@usesend/ui/src/button"; +import { Input } from "@usesend/ui/src/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@usesend/ui/src/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@usesend/ui/src/form"; +import { Separator } from "@usesend/ui/src/separator"; +import { Skeleton } from "@usesend/ui/src/skeleton"; +import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar"; +import { toast } from "@usesend/ui/src/toaster"; +import { api } from "~/trpc/react"; +import { passwordSchema } from "~/server/password-utils"; +import { CheckCircle2, Github, Mail } from "lucide-react"; + +// Schema for changing password (user already has a password) +const changePasswordFormSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: passwordSchema, + confirmPassword: z.string().min(1, "Please confirm your new password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +// Schema for setting password (OAuth-only user) +const setPasswordFormSchema = z + .object({ + newPassword: passwordSchema, + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +type ChangePasswordFormValues = z.infer; +type SetPasswordFormValues = z.infer; + +// Provider display configuration +const providerConfig: Record< + string, + { name: string; icon: React.ReactNode; color: string } +> = { + github: { + name: "GitHub", + icon: , + color: "bg-gray-900 dark:bg-gray-700", + }, + google: { + name: "Google", + icon: ( + + + + ), + color: "bg-white dark:bg-gray-200", + }, +}; + +function ChangePasswordForm({ onSuccess }: { onSuccess: () => void }) { + const changePasswordMutation = api.user.changePassword.useMutation(); + + const form = useForm({ + resolver: zodResolver(changePasswordFormSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + }); + + async function onSubmit(values: ChangePasswordFormValues) { + try { + await changePasswordMutation.mutateAsync({ + currentPassword: values.currentPassword, + newPassword: values.newPassword, + }); + toast.success("Password changed successfully"); + form.reset(); + onSuccess(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to change password"; + toast.error(message); + } + } + + return ( +
+ + ( + + Current Password + + + + + + )} + /> + + ( + + New Password + + + + + At least 8 characters with uppercase, lowercase, and number + + + + )} + /> + + ( + + Confirm New Password + + + + + + )} + /> + +
+ +
+ + + ); +} + +function SetPasswordForm({ onSuccess }: { onSuccess: () => void }) { + const setPasswordMutation = api.user.setPassword.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(setPasswordFormSchema), + defaultValues: { + newPassword: "", + confirmPassword: "", + }, + }); + + async function onSubmit(values: SetPasswordFormValues) { + try { + await setPasswordMutation.mutateAsync({ + newPassword: values.newPassword, + }); + toast.success("Password set successfully. You can now sign in with email and password."); + form.reset(); + await utils.user.hasPassword.invalidate(); + onSuccess(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to set password"; + toast.error(message); + } + } + + return ( +
+ + ( + + Password + + + + + At least 8 characters with uppercase, lowercase, and number + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + +
+ +
+ + + ); +} + +function LinkedAccountsSection({ providers }: { providers: string[] }) { + if (providers.length === 0) { + return ( +

+ No OAuth accounts linked. You can link accounts by signing in with them. +

+ ); + } + + return ( +
+ {providers.map((provider) => { + const config = providerConfig[provider.toLowerCase()]; + return ( +
+
+
+ {config?.icon || } +
+
+

{config?.name || provider}

+

Connected

+
+
+ +
+ ); + })} +
+ ); +} + +function ProfileSection({ + profile, +}: { + profile: { name: string | null; email: string | null; image: string | null }; +}) { + const initials = + profile.name + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) || profile.email?.charAt(0).toUpperCase() || "?"; + + return ( +
+ + + {initials} + +
+ {profile.name && ( +

{profile.name}

+ )} +

{profile.email}

+
+
+ ); +} + +export default function AccountSettingsPage() { + const [showPasswordForm, setShowPasswordForm] = useState(false); + + const { data: profile, isLoading: profileLoading } = + api.user.getProfile.useQuery(); + const { data: hasPasswordData, isLoading: hasPasswordLoading } = + api.user.hasPassword.useQuery(); + const { data: linkedAccounts, isLoading: linkedAccountsLoading } = + api.user.getLinkedAccounts.useQuery(); + + const isLoading = profileLoading || hasPasswordLoading || linkedAccountsLoading; + const hasPassword = hasPasswordData?.hasPassword ?? false; + + if (isLoading) { + return ( +
+ + + + + + +
+ +
+ + +
+
+
+
+ + + + + + + + + + + + + + + + +
+ ); + } + + return ( +
+ {/* Profile Info Section */} + + + Profile + Your account information + + + {profile && ( + + )} + + + + {/* Linked Accounts Section */} + + + Linked Accounts + + OAuth providers connected to your account + + + + + + + + {/* Password Section */} + + + Password + + {hasPassword + ? "Manage your password for email/password sign in" + : "Set up a password to enable email/password sign in"} + + + + {hasPassword ? ( +
+
+
+
+ +
+
+

Password is set

+

+ You can sign in with email and password +

+
+
+ +
+ + {showPasswordForm && ( + <> + + setShowPasswordForm(false)} + /> + + )} +
+ ) : ( +
+
+
+
+ +
+
+

No password set

+

+ Set a password to enable email/password sign in +

+
+
+ +
+ + {showPasswordForm && ( + <> + + setShowPasswordForm(false)} + /> + + )} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx index aac3fd18..22ffef6b 100644 --- a/apps/web/src/app/(dashboard)/settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -26,6 +26,7 @@ export default function ApiKeysPage({ ) : null} Team + Account
{children}
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index b9f7f033..69ff9ef8 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -14,6 +14,7 @@ import { suppressionRouter } from "./routers/suppression"; import { limitsRouter } from "./routers/limits"; import { waitlistRouter } from "./routers/waitlist"; import { feedbackRouter } from "./routers/feedback"; +import { userRouter } from "./routers/user"; /** * This is the primary router for your server. @@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({ limits: limitsRouter, waitlist: waitlistRouter, feedback: feedbackRouter, + user: userRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/user.ts b/apps/web/src/server/api/routers/user.ts new file mode 100644 index 00000000..785b8d15 --- /dev/null +++ b/apps/web/src/server/api/routers/user.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { createSecureHash, verifySecureHash } from "~/server/crypto"; +import { passwordSchema } from "~/server/password-utils"; + +export const userRouter = createTRPCRouter({ + /** + * Check if the current user has a password set + */ + hasPassword: protectedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { passwordHash: true }, + }); + return { hasPassword: !!user?.passwordHash }; + }), + + /** + * Get linked OAuth accounts for the current user + */ + getLinkedAccounts: protectedProcedure.query(async ({ ctx }) => { + const accounts = await ctx.db.account.findMany({ + where: { userId: ctx.session.user.id }, + select: { provider: true }, + }); + return accounts.map((a) => a.provider); + }), + + /** + * Change password for users who already have one + */ + changePassword: protectedProcedure + .input( + z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: passwordSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + if (!user.passwordHash) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You don't have a password set. Use 'Set Password' instead.", + }); + } + + const isValid = await verifySecureHash( + input.currentPassword, + user.passwordHash + ); + if (!isValid) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Current password is incorrect", + }); + } + + const newPasswordHash = await createSecureHash(input.newPassword); + + await ctx.db.user.update({ + where: { id: user.id }, + data: { passwordHash: newPasswordHash }, + }); + + return { success: true }; + }), + + /** + * Set password for OAuth-only users + */ + setPassword: protectedProcedure + .input( + z.object({ + newPassword: passwordSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + if (user.passwordHash) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You already have a password. Use 'Change Password' instead.", + }); + } + + const passwordHash = await createSecureHash(input.newPassword); + + await ctx.db.user.update({ + where: { id: user.id }, + data: { passwordHash }, + }); + + return { success: true }; + }), + + /** + * Get current user profile info + */ + getProfile: protectedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { + id: true, + name: true, + email: true, + image: true, + createdAt: true, + }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + return user; + }), +}); From 4901e305464a0bb52d63431d957018195e1adbc4 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 19 Dec 2025 09:34:32 +0530 Subject: [PATCH 3/4] feat(security): add rate limiting for credentials auth - Add rate limiting for credentials login (5 attempts/minute) - Add rate limiting for password reset (3 requests/hour) - Use existing Redis and IP detection patterns --- .../src/app/api/auth/[...nextauth]/route.ts | 36 ++++++++++- .../src/app/api/auth/forgot-password/route.ts | 64 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts index 0d089e71..466dbeea 100644 --- a/apps/web/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -50,8 +50,10 @@ function getClientIp(req: Request): string | null { } export async function POST(req: Request, ctx: any) { + const url = new URL(req.url); + + // Rate limiting for email signin if (env.AUTH_EMAIL_RATE_LIMIT > 0) { - const url = new URL(req.url); if (url.pathname.endsWith("/signin/email")) { try { const ip = getClientIp(req); @@ -81,5 +83,37 @@ export async function POST(req: Request, ctx: any) { } } } + + // Rate limiting for credentials signin + if (url.pathname.endsWith("/callback/credentials")) { + try { + const ip = getClientIp(req); + if (!ip) { + logger.warn("Auth credentials rate limit skipped: missing client IP"); + return handler(req, ctx); + } + const redis = getRedis(); + const key = `auth-credentials-rl:${ip}`; + const ttl = 60; + const limit = 5; + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, ttl); + if (count > limit) { + logger.warn({ ip }, "Auth credentials rate limit exceeded"); + return Response.json( + { + error: { + code: "RATE_LIMITED", + message: "Too many login attempts. Please try again later.", + }, + }, + { status: 429 } + ); + } + } catch (error) { + logger.error({ err: error }, "Auth credentials rate limit failed"); + } + } + return handler(req, ctx); } diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts index 8126d50a..fabb5e8e 100644 --- a/apps/web/src/app/api/auth/forgot-password/route.ts +++ b/apps/web/src/app/api/auth/forgot-password/route.ts @@ -3,12 +3,76 @@ import { z } from "zod"; import { randomBytes } from "crypto"; import { db } from "~/server/db"; import { sendPasswordResetEmail } from "~/server/mailer"; +import { getRedis } from "~/server/redis"; +import { logger } from "~/server/logger/log"; const schema = z.object({ email: z.string().email(), }); +function getClientIp(req: Request): string | null { + const h = req.headers; + const direct = + h.get("x-forwarded-for") ?? + h.get("x-real-ip") ?? + h.get("cf-connecting-ip") ?? + h.get("x-client-ip") ?? + h.get("true-client-ip") ?? + h.get("fastly-client-ip") ?? + h.get("x-cluster-client-ip") ?? + null; + + let ip = direct?.split(",")[0]?.trim() ?? ""; + + if (!ip) { + const fwd = h.get("forwarded"); + if (fwd) { + const first = fwd.split(",")[0]; + const match = first?.match(/for=([^;]+)/i); + if (match && match[1]) { + const raw = match[1].trim().replace(/^"|"$/g, ""); + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + ip = end !== -1 ? raw.slice(1, end) : raw; + } else { + const parts = raw.split(":"); + if (parts.length > 0 && parts[0]) { + ip = + parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0]) + ? parts[0] + : raw; + } + } + } + } + } + + return ip || null; +} + export async function POST(request: Request) { + // Rate limiting for password reset + try { + const ip = getClientIp(request); + if (ip) { + const redis = getRedis(); + const key = `password-reset-rl:${ip}`; + const ttl = 3600; // 1 hour + const limit = 3; + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, ttl); + if (count > limit) { + logger.warn({ ip }, "Password reset rate limit exceeded"); + // Still return success to prevent enumeration + return NextResponse.json({ success: true }); + } + } else { + logger.warn("Password reset rate limit skipped: missing client IP"); + } + } catch (error) { + logger.error({ err: error }, "Password reset rate limit failed"); + } + try { const body = await request.json(); const result = schema.safeParse(body); From 48e480c1228194d4cbb2836b3238499b77bee9e1 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 19 Dec 2025 21:08:12 +0530 Subject: [PATCH 4/4] fix(security): address PR review security and code quality issues P0: Remove account takeover vulnerability in signup route - Removed OAuth password-linking from /api/auth/signup endpoint - Attackers could previously add passwords to OAuth accounts without auth - OAuth users must now use authenticated settings page to add passwords P1: Fix timing attack vulnerability in credentials provider - Always run hash verification regardless of user existence - Use dummy hash for non-existent users to normalize response time - Prevents email enumeration via response timing analysis P1: Fix race condition in password reset flow - Wrap password update and token deletion in db.$transaction() - Add user existence verification before password update - Prevents concurrent token reuse and partial update failures P2: Code quality improvements - Import passwordSchema from shared utils (reset-password/page.tsx) - Clear loginError when switching auth modes (login-page.tsx) - Remove debug console.log statement (login-page.tsx) - Add defaultValues to useForm (forgot-password/page.tsx) --- .../src/app/api/auth/reset-password/route.ts | 43 ++++++++++++++----- apps/web/src/app/api/auth/signup/route.ts | 24 ++--------- apps/web/src/app/forgot-password/page.tsx | 3 ++ apps/web/src/app/login/login-page.tsx | 5 +-- apps/web/src/app/reset-password/page.tsx | 17 +++----- apps/web/src/server/auth.ts | 11 ++--- 6 files changed, 53 insertions(+), 50 deletions(-) diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts index e942535a..b88ffb58 100644 --- a/apps/web/src/app/api/auth/reset-password/route.ts +++ b/apps/web/src/app/api/auth/reset-password/route.ts @@ -39,23 +39,44 @@ export async function POST(request: Request) { const passwordHash = await createSecureHash(password); - await db.user.update({ - where: { email: resetToken.email }, - data: { - passwordHash, - emailVerified: new Date(), // Verify email on password reset - }, - }); + await db.$transaction(async (tx) => { + // Verify user exists + const user = await tx.user.findUnique({ + where: { email: resetToken.email }, + }); + + if (!user) { + throw new Error("User not found"); + } - // Delete used token - await db.passwordResetToken.delete({ where: { token } }); + // Update password + await tx.user.update({ + where: { email: resetToken.email }, + data: { + passwordHash, + emailVerified: new Date(), + }, + }); + + // Delete used token + await tx.passwordResetToken.delete({ where: { token } }); + }); return NextResponse.json({ success: true }); } catch (error) { console.error("Reset password error:", error); + const message = + error instanceof Error && error.message === "User not found" + ? "User account not found" + : "Something went wrong. Please try again."; return NextResponse.json( - { error: "Something went wrong. Please try again." }, - { status: 500 } + { error: message }, + { + status: + error instanceof Error && error.message === "User not found" + ? 400 + : 500, + } ); } } diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts index 619fc762..39fd90d3 100644 --- a/apps/web/src/app/api/auth/signup/route.ts +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -25,26 +25,10 @@ export async function POST(request: Request) { }); if (existingUser) { - if (existingUser.passwordHash) { - // User already has password authentication - return NextResponse.json( - { error: "An account with this email already exists" }, - { status: 400 } - ); - } - - // User exists via OAuth, allow adding password - const passwordHash = await createSecureHash(password); - await db.user.update({ - where: { id: existingUser.id }, - data: { passwordHash }, - }); - - return NextResponse.json({ - success: true, - message: - "Password added to your existing account. You can now sign in with email and password.", - }); + return NextResponse.json( + { error: "An account with this email already exists. Please sign in." }, + { status: 400 } + ); } // Create new user with password diff --git a/apps/web/src/app/forgot-password/page.tsx b/apps/web/src/app/forgot-password/page.tsx index 89a75ba6..9b1e8b8e 100644 --- a/apps/web/src/app/forgot-password/page.tsx +++ b/apps/web/src/app/forgot-password/page.tsx @@ -27,6 +27,9 @@ export default function ForgotPasswordPage() { const form = useForm>({ resolver: zodResolver(emailSchema), + defaultValues: { + email: "", + }, }); async function onSubmit(values: z.infer) { diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx index 6a3f0b34..297d19f2 100644 --- a/apps/web/src/app/login/login-page.tsx +++ b/apps/web/src/app/login/login-page.tsx @@ -108,7 +108,6 @@ export default function LoginPage({ async function onOTPSubmit(values: z.infer) { const { origin: baseUrl } = window.location; const email = emailForm.getValues().email; - console.log("email", email); const finalCallbackUrl = inviteId ? `/join-team?inviteId=${inviteId}` @@ -221,7 +220,7 @@ export default function LoginPage({ type="button" variant={authMode === "password" ? "default" : "outline"} className="flex-1" - onClick={() => setAuthMode("password")} + onClick={() => { setAuthMode("password"); setLoginError(null); }} > Password @@ -229,7 +228,7 @@ export default function LoginPage({ type="button" variant={authMode === "otp" ? "default" : "outline"} className="flex-1" - onClick={() => setAuthMode("otp")} + onClick={() => { setAuthMode("otp"); setLoginError(null); }} > Magic Link diff --git a/apps/web/src/app/reset-password/page.tsx b/apps/web/src/app/reset-password/page.tsx index 9c8e2a20..26826f22 100644 --- a/apps/web/src/app/reset-password/page.tsx +++ b/apps/web/src/app/reset-password/page.tsx @@ -17,16 +17,11 @@ import { import { Input } from "@usesend/ui/src/input"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; +import { passwordSchema } from "~/server/password-utils"; -const passwordSchema = z +const resetPasswordFormSchema = z .object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .max(128, "Password must be less than 128 characters") - .regex(/[a-z]/, "Password must contain at least one lowercase letter") - .regex(/[A-Z]/, "Password must contain at least one uppercase letter") - .regex(/[0-9]/, "Password must contain at least one number"), + password: passwordSchema, confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { @@ -43,15 +38,15 @@ function ResetPasswordForm() { ); const [errorMessage, setErrorMessage] = useState(""); - const form = useForm>({ - resolver: zodResolver(passwordSchema), + const form = useForm>({ + resolver: zodResolver(resetPasswordFormSchema), defaultValues: { password: "", confirmPassword: "", }, }); - async function onSubmit(values: z.infer) { + async function onSubmit(values: z.infer) { if (!token) { setErrorMessage("Invalid reset link"); setStatus("error"); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 57eb72d3..ab396989 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -109,12 +109,13 @@ function getProviders() { const user = await db.user.findUnique({ where: { email } }); - if (!user || !user.passwordHash) { - return null; - } + // Always run hash verification to prevent timing attacks + // Use a dummy hash when user doesn't exist to normalize response time + const DUMMY_HASH = "0000000000000000:0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + const hashToVerify = user?.passwordHash ?? DUMMY_HASH; + const isValid = await verifySecureHash(password, hashToVerify); - const isValid = await verifySecureHash(password, user.passwordHash); - if (!isValid) { + if (!user || !user.passwordHash || !isValid) { return null; }