From 67527c2f52e76725ad78719d4b0307e702bd0da1 Mon Sep 17 00:00:00 2001 From: schererleander Date: Fri, 26 Dec 2025 16:24:36 +0100 Subject: feat(2fa): implement google authenticator 2fa - add otplib and qrcode dependencies - update user model with 2fa fields - add twoFactorCode to validation schema - implement api routes for setup, enable, disable - add 2fa verification in auth flow - add 2fa management ui in settings - implement 2fa challenge in login page --- src/app/api/user/2fa/route.ts | 97 ++++++ src/app/login/page.tsx | 176 +++++++---- src/app/settings/page.tsx | 32 +- src/app/settings/settings-content.tsx | 537 ++++++++++++++++++++++++++++++++++ src/components/ui/dialog.tsx | 143 +++++++++ src/lib/auth.ts | 21 +- src/lib/validation.ts | 6 +- src/model/User.ts | 5 +- 8 files changed, 940 insertions(+), 77 deletions(-) create mode 100644 src/app/api/user/2fa/route.ts create mode 100644 src/app/settings/settings-content.tsx create mode 100644 src/components/ui/dialog.tsx (limited to 'src') diff --git a/src/app/api/user/2fa/route.ts b/src/app/api/user/2fa/route.ts new file mode 100644 index 0000000..c5fcf83 --- /dev/null +++ b/src/app/api/user/2fa/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authenticator } from "otplib" +import QRCode from "qrcode" +import dbConnect from "@/lib/mongodb" +import User from "@/model/User" +import { authOptions } from "@/lib/auth" + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { code, secret } = await req.json() + + if (!code || !secret) { + return NextResponse.json( + { error: "Code and secret are required" }, + { status: 400 } + ) + } + + const isValid = authenticator.check(code, secret) + + if (!isValid) { + return NextResponse.json( + { error: "Invalid two-factor code" }, + { status: 400 } + ) + } + + await dbConnect() + await User.findByIdAndUpdate(session.user.id, { + twoFactorEnabled: true, + twoFactorSecret: secret, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("2FA enable error:", error) + return NextResponse.json( + { error: "Failed to enable two-factor authentication" }, + { status: 500 } + ) + } +} + +export async function DELETE() { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + await dbConnect() + await User.findByIdAndUpdate(session.user.id, { + twoFactorEnabled: false, + $unset: { twoFactorSecret: 1 }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("2FA disable error:", error) + return NextResponse.json( + { error: "Failed to disable two-factor authentication" }, + { status: 500 } + ) + } +} + +// Generate new secret and QR code for setup +export async function PUT() { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const secret = authenticator.generateSecret() + const otpauth = authenticator.keyuri( + session.user.email, + "Next-Boilerplate", + secret + ) + const qrCode = await QRCode.toDataURL(otpauth) + + return NextResponse.json({ secret, qrCode }) + } catch (error) { + console.error("2FA setup error:", error) + return NextResponse.json( + { error: "Failed to generate two-factor setup" }, + { status: 500 } + ) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ae89e63..c8a80f1 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -22,11 +22,14 @@ export default function SignInPage() { const [error, setError] = useState("") const router = useRouter() + const [showTwoFactor, setShowTwoFactor] = useState(false) + const form = useForm({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "", + twoFactorCode: "", }, }) @@ -38,11 +41,17 @@ export default function SignInPage() { const result = await signIn("credentials", { email: data.email, password: data.password, + twoFactorCode: data.twoFactorCode, redirect: false, }) if (result?.error) { - setError("Invalid email or password") + if (result.error === "2FA_REQUIRED") { + setShowTwoFactor(true) + // Don't clear password here so user can just enter code + } else { + setError(result.error) + } } else if (result?.ok) { router.push("/") router.refresh() @@ -62,7 +71,9 @@ export default function SignInPage() { Sign In - Enter your email and password to access your account + {showTwoFactor + ? "Enter the code from your authenticator app" + : "Enter your email and password to access your account"} @@ -74,76 +85,117 @@ export default function SignInPage() {
- ( - - Email - - - - - - )} - /> - ( - - Password - -
+ {!showTwoFactor ? ( + <> + ( + + Email + + + + + + )} + /> + ( + + Password + +
+ + +
+
+ +
+ )} + /> + + ) : ( + ( + + Two-Factor Code + - -
-
- -
- )} - /> + + + + )} + /> + )} + -
- Don't have an account? - - Sign up - -
+ {!showTwoFactor && ( + <> +
+ Don't have an account? + + Sign up + +
-
- -
+
+ +
+ + )} + + {showTwoFactor && ( +
+ +
+ )}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 5d5fd92..75d9d3d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,21 +1,31 @@ -import { redirect } from "next/navigation" import { getServerSession } from "next-auth" - -import Navbar from "@/components/Navbar" +import { redirect } from "next/navigation" +import dbConnect from "@/lib/mongodb" +import User from "@/model/User" import { authOptions } from "@/lib/auth" -import { SettingsForm } from "@/app/settings/settings-form" +import SettingsContent from "./settings-content" export default async function SettingsPage() { const session = await getServerSession(authOptions) - if (!session?.user) { + if (!session?.user?.email) { redirect("/login") } - return ( -
- - -
- ) + await dbConnect() + const user = await User.findOne({ email: session.user.email }).lean() as any + + if (!user) { + redirect("/login") + } + + // Sanitize user object for client component + const initialUser = { + name: user.name, + email: user.email, + image: user.profileImage?.url || null, + twoFactorEnabled: !!user.twoFactorEnabled, + } + + return } diff --git a/src/app/settings/settings-content.tsx b/src/app/settings/settings-content.tsx new file mode 100644 index 0000000..8916b9e --- /dev/null +++ b/src/app/settings/settings-content.tsx @@ -0,0 +1,537 @@ +"use client" + +import { useState } from "react" +import { useSession } from "next-auth/react" +import { useRouter } from "next/navigation" +import { Shield, Loader2, Copy } from "lucide-react" +import { toast } from "sonner" +import Image from "next/image" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Separator } from "@/components/ui/separator" +import Navbar from "@/components/Navbar" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Camera, Lock, Save, Trash2, Upload, User } from "lucide-react" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { updateProfileSchema, updatePasswordSchema, type UpdateProfileInput } from "@/lib/validation" +import { z } from "zod" + +// Re-using existing types and schemas from previous implementation +const passwordChangeSchema = updatePasswordSchema.extend({ + confirmPassword: z.string() +}).refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], +}) + +type ProfileFormData = UpdateProfileInput +type PasswordFormData = z.infer + +interface SettingsContentProps { + initialUser: { + name: string + email: string + image: string | null + twoFactorEnabled: boolean + } +} + +export default function SettingsContent({ initialUser }: SettingsContentProps) { + const { update } = useSession() + const router = useRouter() + const [twoFactorEnabled, setTwoFactorEnabled] = useState(initialUser.twoFactorEnabled) + const [is2FALoading, setIs2FALoading] = useState(false) + const [setupData, setSetupData] = useState<{ secret: string;qrCode: string } | null>(null) + const [verificationCode, setVerificationCode] = useState("") + const [isDialogOpen, setIsDialogOpen] = useState(false) + + // Existing state for other forms + const [isLoading, setIsLoading] = useState(false) + const [isImageLoading, setIsImageLoading] = useState(false) + const [profileImageUrl, setProfileImageUrl] = useState(initialUser.image) + + const profileForm = useForm({ + resolver: zodResolver(updateProfileSchema), + defaultValues: { + name: initialUser.name, + email: initialUser.email, + }, + }) + + const passwordForm = useForm({ + resolver: zodResolver(passwordChangeSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + }) + + // 2FA Handlers + const start2FASetup = async () => { + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { method: "PUT" }) + const data = await res.json() + if (data.error) throw new Error(data.error) + setSetupData(data) + setIsDialogOpen(true) + } catch (error) { + toast.error("Failed to start 2FA setup") + } finally { + setIs2FALoading(false) + } + } + + const verifyAndEnable2FA = async () => { + if (verificationCode.length !== 6) { + toast.error("Please enter a 6-digit code") + return + } + + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: verificationCode, + secret: setupData?.secret, + }), + }) + const data = await res.json() + + if (data.error) throw new Error(data.error) + + setTwoFactorEnabled(true) + setIsDialogOpen(false) + toast.success("Two-factor authentication enabled") + router.refresh() + } catch (error) { + toast.error("Invalid verification code") + } finally { + setIs2FALoading(false) + setVerificationCode("") + } + } + + const disable2FA = async () => { + if (!confirm("Are you sure you want to disable 2FA? This will make your account less secure.")) return + + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { method: "DELETE" }) + const data = await res.json() + + if (data.error) throw new Error(data.error) + + setTwoFactorEnabled(false) + toast.success("Two-factor authentication disabled") + router.refresh() + } catch (error) { + toast.error("Failed to disable 2FA") + } finally { + setIs2FALoading(false) + } + } + + const copyToClipboard = () => { + if (setupData?.secret) { + navigator.clipboard.writeText(setupData.secret) + toast.success("Secret copied to clipboard") + } + } + + // Existing Handlers (Profile, Password, Image) + const onProfileSubmit = async (data: ProfileFormData) => { + setIsLoading(true) + try { + const response = await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || "Failed to update profile") + return + } + await update({ name: data.name, email: data.email }) + toast.success("Profile updated successfully!") + } catch { + toast.error("An unexpected error occurred") + } finally { + setIsLoading(false) + } + } + + const onPasswordSubmit = async (data: PasswordFormData) => { + setIsLoading(true) + try { + const response = await fetch("/api/user/password", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }), + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || "Failed to update password") + return + } + toast.success("Password updated successfully!") + passwordForm.reset() + } catch { + toast.error("An unexpected error occurred") + } finally { + setIsLoading(false) + } + } + + const handleImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + setIsImageLoading(true) + try { + const formData = new FormData() + formData.append('image', file) + const response = await fetch('/api/user/profile-image', { + method: 'POST', + body: formData, + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || 'Failed to upload image') + return + } + setProfileImageUrl(result.profileImage.url) + toast.success('Profile image uploaded successfully!') + await update({ image: result.profileImage.url }) + } catch { + toast.error('An unexpected error occurred') + } finally { + setIsImageLoading(false) + } + } + + const handleImageDelete = async () => { + setIsImageLoading(true) + try { + const response = await fetch('/api/user/profile-image', { method: 'DELETE' }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || 'Failed to delete image') + return + } + setProfileImageUrl(null) + toast.success('Profile image deleted successfully!') + await update({ image: null }) + } catch { + toast.error('An unexpected error occurred') + } finally { + setIsImageLoading(false) + } + } + + return ( +
+ + +
+
+
+

Account Settings

+

+ Manage your account information and security settings +

+
+ + {/* Profile Information */} + + + + + Profile Information + + Update your personal information + + +
+ + ( + + Full Name + + + + + + )} + /> + ( + + Email Address + + + + + + )} + /> + + + +
+
+ + + + {/* Profile Image */} + + + + + Profile Image + + Upload or update your profile picture + + +
+ + + + {initialUser.name.charAt(0).toUpperCase()} + + + +
+
+
+ +
+ + {profileImageUrl && ( + + )} +
+

+ Supported formats: JPEG, PNG, WebP, GIF. Maximum size: 10MB. +

+
+
+
+
+ + + + {/* Two-Factor Authentication */} + + + + + Two-Factor Authentication + + + Add an extra layer of security to your account + + + +
+
+

Status: {twoFactorEnabled ? "Enabled" : "Disabled"}

+

+ {twoFactorEnabled + ? "Your account is secured with 2FA." + : "Protect your account by enabling 2FA."} +

+
+ {twoFactorEnabled ? ( + + ) : ( + + + + + + + Set up Two-Factor Authentication + + Scan the QR code with your authenticator app (like Google Authenticator or Authy). + + + + {setupData && ( +
+
+ 2FA QR Code +
+ +
+ + {setupData.secret} + + +
+ +
+ + setVerificationCode(e.target.value.slice(0, 6))} + maxLength={6} + /> +
+ + +
+ )} +
+
+ )} +
+
+
+ + + + {/* Password Change */} + + + + + Change Password + + + Update your password to keep your account secure + + + +
+ + ( + + Current Password + + + + + + )} + /> + ( + + New Password + + + + + + )} + /> + ( + + Confirm New Password + + + + + + )} + /> + + + +
+
+
+
+
+ ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..a6f1cfb --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0ed9d12..ad47d5f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { type NextAuthOptions } from "next-auth" import CredentialsProvider from "next-auth/providers/credentials" import bcrypt from "bcryptjs" +import { authenticator } from "otplib" import dbConnect from "./mongodb" import User from "@/model/User" import { loginSchema } from "./validation" @@ -11,7 +12,8 @@ export const authOptions: NextAuthOptions = { name: "credentials", credentials: { email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" } + password: { label: "Password", type: "password" }, + twoFactorCode: { label: "2FA Code", type: "text" } }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null @@ -19,7 +21,7 @@ export const authOptions: NextAuthOptions = { const result = loginSchema.safeParse(credentials) if (!result.success) return null - const { email, password } = result.data + const { email, password, twoFactorCode } = result.data try { await dbConnect() @@ -30,6 +32,17 @@ export const authOptions: NextAuthOptions = { const isPasswordValid = await bcrypt.compare(password, user.password) if (!isPasswordValid) return null + if (user.twoFactorEnabled) { + if (!twoFactorCode) { + throw new Error("2FA_REQUIRED") + } + + const isValid = authenticator.check(twoFactorCode, user.twoFactorSecret) + if (!isValid) { + throw new Error("Invalid 2FA Code") + } + } + return { id: user._id.toString(), email: user.email, @@ -38,6 +51,10 @@ export const authOptions: NextAuthOptions = { } } catch (error) { console.error("Auth error:", error) + // Rethrow specific 2FA errors so they reach the client + if (error instanceof Error && (error.message === "2FA_REQUIRED" || error.message === "Invalid 2FA Code")) { + throw error + } return null } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ab9416e..bc5a440 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -30,7 +30,11 @@ export const loginSchema = z.object({ .max(254, 'Email must be at most 254 characters'), password: z .string() - .max(128, 'Password must be at most 128 characters') + .max(128, 'Password must be at most 128 characters'), + twoFactorCode: z + .string() + .length(6, 'Code must be 6 digits') + .optional() }) // Profile update schema (reusing name and email from registerSchema) diff --git a/src/model/User.ts b/src/model/User.ts index e1784f2..c5c81de 100644 --- a/src/model/User.ts +++ b/src/model/User.ts @@ -8,7 +8,9 @@ const UserSchema = new Schema({ url: { type: String }, key: { type: String }, uploadedAt: { type: Date } - } + }, + twoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: { type: String } }, { timestamps: true }); @@ -16,6 +18,7 @@ const UserSchema = new Schema({ UserSchema.set('toJSON', { transform: (_doc: Document, ret: Record) => { delete ret.password; + delete ret.twoFactorSecret; delete ret.__v; return ret; } -- cgit v1.3.1