From 5eb09345706bfcf19b91ffdf7c4088772feb630e Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Fri, 6 Feb 2026 09:28:58 +0530 Subject: [PATCH] feat: Implement a comprehensive two-factor authentication system including setup, verification, backup codes, and integration into the login flow. --- public/assets/icons/settings-shield-icon.svg | 4 + src/app/(auth)/login/page.tsx | 11 + src/app/(auth)/login/verify-2fa/page.tsx | 266 ++++++++++ src/auth.ts | 41 ++ .../settings/PasswordSecurityForm.tsx | 17 +- src/components/settings/TwoFactorSettings.tsx | 458 ++++++++++++++++++ src/components/settings/index.ts | 1 + src/services/api.ts | 2 + src/services/index.ts | 12 + src/services/two-factor.service.ts | 148 ++++++ 10 files changed, 946 insertions(+), 14 deletions(-) create mode 100644 public/assets/icons/settings-shield-icon.svg create mode 100644 src/app/(auth)/login/verify-2fa/page.tsx create mode 100644 src/components/settings/TwoFactorSettings.tsx create mode 100644 src/services/two-factor.service.ts diff --git a/public/assets/icons/settings-shield-icon.svg b/public/assets/icons/settings-shield-icon.svg new file mode 100644 index 0000000..b5a32ad --- /dev/null +++ b/public/assets/icons/settings-shield-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index a66ca75..574b8be 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -35,6 +35,17 @@ export default function LoginPage() { return; } + // Check if 2FA is required (response is wrapped in { success, data }) + if (validateData.data?.requiresTwoFactor) { + // Redirect to 2FA verification page with tempToken + const params = new URLSearchParams({ + token: validateData.data.tempToken, + email: email, + }); + router.push(`/login/verify-2fa?${params.toString()}`); + return; + } + // If validation passed, use NextAuth to create session const result = await signIn('credentials', { email, diff --git a/src/app/(auth)/login/verify-2fa/page.tsx b/src/app/(auth)/login/verify-2fa/page.tsx new file mode 100644 index 0000000..60dcf5b --- /dev/null +++ b/src/app/(auth)/login/verify-2fa/page.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import Link from 'next/link'; +import { twoFactorService } from '@/services'; + +export default function VerifyTwoFactorPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [code, setCode] = useState(['', '', '', '', '', '']); + const [backupCode, setBackupCode] = useState(''); + const [useBackup, setUseBackup] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + // Get tempToken and email from URL params + const tempToken = searchParams.get('token') || ''; + const userEmail = searchParams.get('email') || ''; + + useEffect(() => { + // If no token, redirect to login + if (!tempToken) { + router.push('/login'); + } + }, [tempToken, router]); + + const handleCodeChange = (index: number, value: string) => { + // Only allow single digit + if (value.length > 1) { + value = value.slice(-1); + } + + // Only allow numbers + if (value && !/^\d$/.test(value)) { + return; + } + + const newCode = [...code]; + newCode[index] = value; + setCode(newCode); + + // Auto-focus next input + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit when all digits are entered + if (value && index === 5 && newCode.every((digit) => digit !== '')) { + handleSubmit(newCode.join('')); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + if (pastedData.length === 6) { + const newCode = pastedData.split(''); + setCode(newCode); + inputRefs.current[5]?.focus(); + // Auto-submit after paste + setTimeout(() => handleSubmit(pastedData), 100); + } + }; + + const handleSubmit = async (codeValue?: string) => { + const verificationCode = codeValue || code.join(''); + + if (!useBackup && verificationCode.length !== 6) { + setError('Please enter a 6-digit code'); + return; + } + + if (useBackup && !backupCode.trim()) { + setError('Please enter a backup code'); + return; + } + + setLoading(true); + setError(''); + + try { + let response; + + if (useBackup) { + response = await twoFactorService.verifyBackupCode({ + tempToken, + backupCode: backupCode.trim(), + }); + } else { + response = await twoFactorService.verifyLogin({ + tempToken, + token: verificationCode, + }); + } + + // Now sign in with NextAuth using the tokens we received + // We need to call the NextAuth credentials provider with the tokens + const result = await signIn('credentials', { + email: userEmail, + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + redirect: false, + }); + + if (result?.error) { + setError('Failed to complete login. Please try again.'); + setLoading(false); + } else { + router.push('/'); + router.refresh(); + } + } catch (err) { + setLoading(false); + if (err instanceof Error) { + setError(err.message); + } else { + setError('Invalid code. Please try again.'); + } + // Clear code on error + if (!useBackup) { + setCode(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + } + } + }; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSubmit(); + }; + + if (!tempToken) { + return null; // Will redirect in useEffect + } + + return ( + <> + {/* Heading */} +
+
+ + + +
+

Two-Factor Authentication

+

+ {useBackup + ? 'Enter one of your backup codes to continue' + : 'Enter the 6-digit code from your authenticator app'} +

+
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {!useBackup ? ( + /* 6-digit code input */ +
+ {code.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleCodeChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + className="w-12 h-14 text-center text-xl font-bold bg-[#f0f5fc] rounded-[15px] text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#e58625] transition-all" + disabled={loading} + /> + ))} +
+ ) : ( + /* Backup code input */ +
+ setBackupCode(e.target.value.toUpperCase())} + className="w-full px-6 py-6 bg-[#f0f5fc] rounded-[20px] text-[#00293d] text-sm font-light placeholder:text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#00293d]/20 transition-all text-center tracking-widest" + disabled={loading} + /> +
+ )} + + {/* Switch between code and backup */} +
+ +
+ + {/* Verify Button */} + +
+ + {/* Back to Login */} +
+ + ← Back to Login + +
+ + {/* Help text */} +
+

+ {useBackup ? ( + <> + Backup codes are single-use. After using one, it will be invalidated. If you've + used all your backup codes, you'll need to generate new ones from your account + settings. + + ) : ( + <> + Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit + code shown for Re-Quest. + + )} +

+
+ + ); +} diff --git a/src/auth.ts b/src/auth.ts index 8494396..171b877 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -30,8 +30,42 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, + accessToken: { label: "Access Token", type: "text" }, + refreshToken: { label: "Refresh Token", type: "text" }, }, async authorize(credentials) { + // Handle 2FA completion - tokens are passed directly + if (credentials?.accessToken && credentials?.refreshToken) { + // Verify the tokens by fetching current user + const verifyRes = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auth/me`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${credentials.accessToken}`, + }, + } + ); + + if (verifyRes.ok) { + const userData = await verifyRes.json(); + if (userData.success) { + return { + id: userData.data.id, + email: userData.data.email, + name: `${userData.data.firstName || ""} ${userData.data.lastName || ""}`.trim(), + role: userData.data.role, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + }; + } + } + + throw new Error("Invalid authentication tokens"); + } + + // Standard login flow const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { @@ -46,6 +80,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ const data = await res.json(); + // Check if 2FA is required + if (res.ok && data.data?.requiresTwoFactor) { + // Return null to indicate 2FA is required + // The frontend will handle the redirect + throw new Error("2FA_REQUIRED"); + } + if (res.ok && data.success) { return { id: data.data.user.id, diff --git a/src/components/settings/PasswordSecurityForm.tsx b/src/components/settings/PasswordSecurityForm.tsx index c926293..5662c06 100644 --- a/src/components/settings/PasswordSecurityForm.tsx +++ b/src/components/settings/PasswordSecurityForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { TwoFactorSettings } from './TwoFactorSettings'; interface PasswordSecurityFormProps { onSave?: (data: { currentPassword: string; newPassword: string }) => void; @@ -139,20 +140,8 @@ export function PasswordSecurityForm({ onSave }: PasswordSecurityFormProps) { {/* Two-Factor Authentication Section */} -
-
-
-

- Two-Factor Authentication -

-

- Add an extra layer of security to your account -

-
- -
+
+
); diff --git a/src/components/settings/TwoFactorSettings.tsx b/src/components/settings/TwoFactorSettings.tsx new file mode 100644 index 0000000..518c24b --- /dev/null +++ b/src/components/settings/TwoFactorSettings.tsx @@ -0,0 +1,458 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { twoFactorService, TwoFactorSetupResponse } from '@/services'; + +type Step = 'status' | 'setup' | 'verify' | 'backup-codes' | 'disable'; + +interface TwoFactorSettingsProps { + onStatusChange?: (enabled: boolean) => void; +} + +export function TwoFactorSettings({ onStatusChange }: TwoFactorSettingsProps) { + const [step, setStep] = useState('status'); + const [isEnabled, setIsEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Setup data + const [setupData, setSetupData] = useState(null); + const [verificationCode, setVerificationCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [backupCodesCopied, setBackupCodesCopied] = useState(false); + + // Disable form + const [disablePassword, setDisablePassword] = useState(''); + const [showDisablePassword, setShowDisablePassword] = useState(false); + + // Fetch 2FA status on mount + useEffect(() => { + fetchStatus(); + }, []); + + const fetchStatus = async () => { + try { + setIsLoading(true); + const response = await twoFactorService.getStatus(); + setIsEnabled(response.data.enabled); + } catch (err) { + console.error('Failed to fetch 2FA status:', err); + } finally { + setIsLoading(false); + } + }; + + const handleSetup = async () => { + try { + setIsLoading(true); + setError(null); + const response = await twoFactorService.setup(); + setSetupData(response.data); + setStep('setup'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to setup 2FA'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async () => { + if (verificationCode.length !== 6) { + setError('Please enter a 6-digit verification code'); + return; + } + + try { + setIsLoading(true); + setError(null); + const response = await twoFactorService.enable(verificationCode); + setBackupCodes(response.data.backupCodes); + setStep('backup-codes'); + setIsEnabled(true); + onStatusChange?.(true); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Invalid verification code'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleDisable = async () => { + if (!disablePassword) { + setError('Please enter your password'); + return; + } + + try { + setIsLoading(true); + setError(null); + await twoFactorService.disable(disablePassword); + setIsEnabled(false); + setStep('status'); + setDisablePassword(''); + setSuccess('Two-factor authentication has been disabled'); + onStatusChange?.(false); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to disable 2FA'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleRegenerateBackupCodes = async () => { + const password = prompt('Enter your password to regenerate backup codes:'); + if (!password) return; + + try { + setIsLoading(true); + setError(null); + const response = await twoFactorService.regenerateBackupCodes(password); + setBackupCodes(response.data.backupCodes); + setStep('backup-codes'); + setSuccess('New backup codes generated successfully'); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to regenerate backup codes'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const copyBackupCodes = () => { + const codesText = backupCodes.join('\n'); + navigator.clipboard.writeText(codesText); + setBackupCodesCopied(true); + setTimeout(() => setBackupCodesCopied(false), 2000); + }; + + const downloadBackupCodes = () => { + const codesText = `Re-Quest 2FA Backup Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}\n\nGenerated: ${new Date().toISOString()}`; + const blob = new Blob([codesText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 're-quest-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + }; + + // Clear messages after timeout + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(null), 5000); + return () => clearTimeout(timer); + } + }, [success]); + + if (isLoading && step === 'status') { + return ( +
+
+
+
+
+
+ ); + } + + // Status view - show current 2FA status + if (step === 'status') { + return ( +
+ {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+
+
+ Security +
+
+

+ Two-Factor Authentication +

+

+ Add an extra layer of security to your account using an authenticator app +

+
+ + {isEnabled ? 'Enabled' : 'Not Enabled'} +
+
+
+ + {isEnabled ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + } + + // Setup view - show QR code + if (step === 'setup' && setupData) { + return ( +
+
+

+ Setup Two-Factor Authentication +

+

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) +

+
+ +
+ {/* QR Code */} +
+
+ 2FA QR Code +
+

+ Can't scan? Enter this code manually: +

+ + {setupData.manualEntry} + +
+ + {/* Verification */} +
+
+

+ Verify Setup +

+

+ Enter the 6-digit code from your authenticator app to verify setup +

+ + {error && ( +
+ {error} +
+ )} + +
+ { + const value = e.target.value.replace(/\D/g, '').slice(0, 6); + setVerificationCode(value); + setError(null); + }} + placeholder="000000" + maxLength={6} + className="w-32 h-[40px] px-4 border border-[#00293D]/20 rounded-[15px] text-[18px] font-mono text-center text-[#00293D] placeholder:text-[#00293D]/30 focus:outline-none focus:border-[#E58625] tracking-[0.3em]" + /> +
+
+ +
+ + +
+
+
+
+ ); + } + + // Backup codes view + if (step === 'backup-codes') { + return ( +
+
+
+ +

+ Two-Factor Authentication Enabled +

+
+

+ Save these backup codes in a secure place. You can use them to access your account if you lose your phone. +

+
+ +
+
+ {backupCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ +
+

+ Each code can only be used once. Store them safely! +

+
+ + +
+
+ + +
+ ); + } + + // Disable view + if (step === 'disable') { + return ( +
+
+

+ Disable Two-Factor Authentication +

+

+ Enter your password to confirm you want to disable 2FA. This will make your account less secure. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ { + setDisablePassword(e.target.value); + setError(null); + }} + placeholder="Enter your password" + className="w-full h-[40px] px-4 pr-12 border border-[#00293D]/20 rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625]" + /> + +
+
+ +
+ + +
+
+ ); + } + + return null; +} diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index bca3ea7..8b36518 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -4,3 +4,4 @@ export { ProfileSettingsForm } from './ProfileSettingsForm'; export { PasswordSecurityForm } from './PasswordSecurityForm'; export { NotificationsForm } from './NotificationsForm'; export { PrivacyForm } from './PrivacyForm'; +export { TwoFactorSettings } from './TwoFactorSettings'; diff --git a/src/services/api.ts b/src/services/api.ts index 538e884..366a9ac 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -77,6 +77,8 @@ const publicEndpoints = [ '/auth/forgot-password', '/auth/reset-password', '/auth/verify-email', + '/auth/2fa/verify', + '/auth/2fa/verify-backup', '/agent-types', ]; diff --git a/src/services/index.ts b/src/services/index.ts index cca5a10..2e217a8 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -35,3 +35,15 @@ export type { AgentProfile, AgentType } from './agents.service'; // Upload Service export { uploadService } from './upload.service'; export type { UploadedFile, UploadProgress } from './upload.service'; + +// Two-Factor Authentication Service +export { twoFactorService, TwoFactorService } from './two-factor.service'; +export type { + TwoFactorSetupResponse, + TwoFactorEnableResponse, + TwoFactorStatusResponse, + TwoFactorLoginRequest, + TwoFactorBackupCodeRequest, + TwoFactorLoginResponse, + TwoFactorRequiredResponse, +} from './two-factor.service'; diff --git a/src/services/two-factor.service.ts b/src/services/two-factor.service.ts new file mode 100644 index 0000000..bac0263 --- /dev/null +++ b/src/services/two-factor.service.ts @@ -0,0 +1,148 @@ +import api, { ApiResponse } from './api'; + +// Types +export interface TwoFactorSetupResponse { + secret: string; + qrCode: string; + manualEntry: string; +} + +export interface TwoFactorEnableResponse { + backupCodes: string[]; +} + +export interface TwoFactorStatusResponse { + enabled: boolean; + verifiedAt: string | null; +} + +export interface TwoFactorLoginRequest { + tempToken: string; + token: string; + userAgent?: string; + ipAddress?: string; +} + +export interface TwoFactorBackupCodeRequest { + tempToken: string; + backupCode: string; + userAgent?: string; + ipAddress?: string; +} + +export interface TwoFactorLoginResponse { + user: { + id: string; + email: string; + role: string; + firstName?: string; + lastName?: string; + avatar?: string; + }; + accessToken: string; + refreshToken: string; +} + +export interface TwoFactorRequiredResponse { + requiresTwoFactor: true; + tempToken: string; + user: { + id: string; + email: string; + }; +} + +// Two-Factor Service +class TwoFactorService { + private basePath = '/auth/2fa'; + + /** + * Setup 2FA - generates secret and QR code + */ + async setup(): Promise> { + const response = await api.post>( + `${this.basePath}/setup` + ); + return response.data; + } + + /** + * Enable 2FA - verify token and enable + */ + async enable(token: string): Promise> { + const response = await api.post>( + `${this.basePath}/enable`, + { token } + ); + return response.data; + } + + /** + * Disable 2FA - requires password confirmation + */ + async disable(password: string): Promise> { + const response = await api.post>( + `${this.basePath}/disable`, + { password } + ); + return response.data; + } + + /** + * Verify 2FA token during login + */ + async verifyLogin(data: TwoFactorLoginRequest): Promise> { + const response = await api.post>( + `${this.basePath}/verify`, + data + ); + return response.data; + } + + /** + * Verify backup code during login + */ + async verifyBackupCode(data: TwoFactorBackupCodeRequest): Promise> { + const response = await api.post>( + `${this.basePath}/verify-backup`, + data + ); + return response.data; + } + + /** + * Get 2FA status + */ + async getStatus(): Promise> { + const response = await api.get>( + `${this.basePath}/status` + ); + return response.data; + } + + /** + * Regenerate backup codes - requires password confirmation + */ + async regenerateBackupCodes(password: string): Promise> { + const response = await api.post>( + `${this.basePath}/backup-codes`, + { password } + ); + return response.data; + } + + /** + * Check if response indicates 2FA is required + */ + static isTwoFactorRequired(response: unknown): response is TwoFactorRequiredResponse { + return ( + typeof response === 'object' && + response !== null && + 'requiresTwoFactor' in response && + (response as TwoFactorRequiredResponse).requiresTwoFactor === true + ); + } +} + +export const twoFactorService = new TwoFactorService(); +export { TwoFactorService };