feat: Implement a comprehensive two-factor authentication system including setup, verification, backup codes, and integration into the login flow.
This commit is contained in:
@@ -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,
|
||||
|
||||
266
src/app/(auth)/login/verify-2fa/page.tsx
Normal file
266
src/app/(auth)/login/verify-2fa/page.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
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 */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-[#f0f5fc] rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-[#e58625]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-[30px] font-normal text-[#00293d] mb-3">Two-Factor Authentication</h2>
|
||||
<p className="text-sm text-[#00293d] px-4 leading-relaxed">
|
||||
{useBackup
|
||||
? 'Enter one of your backup codes to continue'
|
||||
: 'Enter the 6-digit code from your authenticator app'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleFormSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-[20px] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!useBackup ? (
|
||||
/* 6-digit code input */
|
||||
<div className="flex justify-center gap-3" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Backup code input */
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter backup code"
|
||||
value={backupCode}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch between code and backup */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackup(!useBackup);
|
||||
setError('');
|
||||
setCode(['', '', '', '', '', '']);
|
||||
setBackupCode('');
|
||||
}}
|
||||
className="text-[#00293d] text-sm font-light hover:underline"
|
||||
>
|
||||
{useBackup ? 'Use authenticator code instead' : 'Use a backup code'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Verify Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-6 bg-[#e58625] hover:bg-[#d47a1f] text-[#00293d] font-bold text-sm rounded-[20px] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Back to Login */}
|
||||
<div className="text-center mt-8">
|
||||
<Link href="/login" className="text-[#00293d] text-sm font-light hover:underline">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="mt-6 p-4 bg-[#f0f5fc] rounded-[15px]">
|
||||
<p className="text-xs text-[#00293d]/70 text-center">
|
||||
{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.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user