Merge pull request #29 from pezkuwichain/feat/2fa-login-gate

feat(auth): actually ask for 2FA at login
This commit is contained in:
SatoshiQaziMuhammed
2026-07-31 23:07:22 -07:00
committed by GitHub
4 changed files with 143 additions and 3 deletions
+4
View File
@@ -27,6 +27,7 @@ const Login = lazy(() => import('@/pages/Login'));
const TelegramConnect = lazy(() => import('@/pages/TelegramConnect'));
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const EmailVerification = lazy(() => import('@/pages/EmailVerification'));
const TwoFactorChallenge = lazy(() => import('@/pages/TwoFactorChallenge'));
const PasswordReset = lazy(() => import('@/pages/PasswordReset'));
const ProfileSettings = lazy(() => import('@/pages/ProfileSettings'));
const AdminPanel = lazy(() => import('@/pages/AdminPanel'));
@@ -134,6 +135,9 @@ function App() {
<Route path="/login" element={<Login />} />
<Route path="/auth/telegram-connect" element={<TelegramConnect />} />
<Route path="/email-verification" element={<EmailVerification />} />
{/* Not wrapped in ProtectedRoute: that route redirects here
whenever a challenge is pending, which would loop. */}
<Route path="/two-factor" element={<TwoFactorChallenge />} />
<Route path="/reset-password" element={<PasswordReset />} />
<Route path="/" element={<Index />} />
<Route path="/explorer" element={<Explorer />} />
+13 -2
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Loader2, Wallet } from 'lucide-react';
@@ -40,7 +40,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
allowTelegramSession = false,
requireMultisigMember = false
}) => {
const { user, loading, isAdmin } = useAuth();
const { user, loading, isAdmin, twoFactorPending } = useAuth();
const location = useLocation();
const { api, isApiReady, selectedAccount, connectWallet } = usePezkuwi();
const [walletRestoreChecked, setWalletRestoreChecked] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
@@ -151,6 +152,16 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
return <Navigate to="/login" replace />;
}
// A correct password is not enough once 2FA is enabled. This is checked here
// rather than only at login so that a deep link or a restored tab cannot walk
// straight past the challenge.
//
// Telegram sessions are exempt: they authenticate through signed initData from
// Telegram, not a password, so there is no password to second-factor.
if (user && twoFactorPending) {
return <Navigate to="/two-factor" state={{ from: location.pathname }} replace />;
}
// NOTE: `isAdmin` here is a COSMETIC UX gate only (derived from a localStorage
// wallet and forgeable in DevTools). It prevents casual access to the admin UI
// but is NOT a security boundary. All privileged operations behind this route
+64 -1
View File
@@ -8,11 +8,15 @@ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
const LAST_ACTIVITY_KEY = 'last_activity_timestamp';
const REMEMBER_ME_KEY = 'remember_me';
const TWO_FACTOR_VERIFIED_KEY = 'two_factor_verified_user';
interface AuthContextType {
user: User | null;
loading: boolean;
isAdmin: boolean;
/** Signed in, has 2FA enabled, and has not passed it yet in this browser session. */
twoFactorPending: boolean;
markTwoFactorVerified: () => void;
signIn: (email: string, password: string, rememberMe?: boolean) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string, username: string, referralCode?: string) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
@@ -33,6 +37,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [twoFactorPending, setTwoFactorPending] = useState(false);
// ========================================
// SESSION TIMEOUT MANAGEMENT
@@ -44,9 +49,63 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
localStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
}, []);
// sessionStorage, not localStorage: 2FA is asked once per browser session and
// again after the tab is closed. Persisting it would mean enabling 2FA once and
// never being challenged again on that device.
const clearTwoFactorVerification = () => {
try {
sessionStorage.removeItem(TWO_FACTOR_VERIFIED_KEY);
} catch {
// private mode / storage disabled — the pending flag simply stays in memory
}
};
/**
* Whether this user still owes a 2FA challenge.
*
* 2FA is optional, so a user without it enabled is never blocked. A failure to
* ask is treated as "not required": a transient error while checking must not
* turn into a login wall for people who never enabled 2FA in the first place.
*/
const refreshTwoFactorState = useCallback(async (currentUser: User | null) => {
if (!currentUser) {
setTwoFactorPending(false);
return;
}
try {
if (sessionStorage.getItem(TWO_FACTOR_VERIFIED_KEY) === currentUser.id) {
setTwoFactorPending(false);
return;
}
} catch {
// storage unavailable; fall through and ask again
}
try {
const { data, error } = await supabase.functions.invoke('two-factor-auth', {
body: { action: 'check' },
});
setTwoFactorPending(!error && Boolean(data?.enabled));
} catch {
setTwoFactorPending(false);
}
}, []);
const markTwoFactorVerified = useCallback(() => {
try {
if (user) sessionStorage.setItem(TWO_FACTOR_VERIFIED_KEY, user.id);
} catch {
// storage unavailable — the in-memory flag below still unblocks this session
}
setTwoFactorPending(false);
}, [user]);
const signOut = useCallback(async () => {
setIsAdmin(false);
setUser(null);
setTwoFactorPending(false);
clearTwoFactorVerification();
localStorage.removeItem(LAST_ACTIVITY_KEY);
localStorage.removeItem(REMEMBER_ME_KEY);
await supabase.auth.signOut();
@@ -187,6 +246,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
checkAdminStatus(); // Check admin status regardless of Supabase session
refreshTwoFactorState(session?.user ?? null);
setLoading(false);
}).catch(() => {
// If Supabase is not available, still check wallet-based admin
@@ -197,6 +257,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// Listen for changes on auth state
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
refreshTwoFactorState(session?.user ?? null);
checkAdminStatus(); // Check admin status on auth change
setLoading(false);
});
@@ -222,7 +283,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
window.removeEventListener('walletChanged', handleWalletChange);
window.removeEventListener('pezkuwi-native-ready', handleNativeReady);
};
}, [checkAdminStatus, setupMobileWallet]);
}, [checkAdminStatus, setupMobileWallet, refreshTwoFactorState]);
const signIn = async (email: string, password: string, rememberMe: boolean = false) => {
try {
@@ -296,6 +357,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
user,
loading,
isAdmin,
twoFactorPending,
markTwoFactorVerified,
signIn,
signUp,
signOut,
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { TwoFactorVerify } from '@/components/auth/TwoFactorVerify';
import { Loader2 } from 'lucide-react';
/**
* The 2FA challenge shown after a correct password when the account has 2FA on.
*
* TwoFactorVerify already existed but was referenced from nowhere, so enabling
* 2FA protected nothing — the setup screen worked and login never asked. This
* page is what connects the two.
*
* Deliberately outside ProtectedRoute: that route redirects here whenever a
* challenge is pending, so guarding this page with it would loop.
*
* Scope, stated plainly: this is a client-side gate. It stops someone who has
* the password from reaching the app in a browser, which is what 2FA is for
* here. It is not server-side enforcement — privileged operations verify
* authority independently (wallet signature for admin actions, JWT for the
* account's own data), and that is what actually protects funds and state.
*/
export default function TwoFactorChallenge() {
const { user, loading, twoFactorPending, markTwoFactorVerified, signOut } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo = (location.state as { from?: string } | null)?.from ?? '/dashboard';
useEffect(() => {
if (loading) return;
// Nothing to challenge: either signed out, or already past it.
if (!user) navigate('/login', { replace: true });
else if (!twoFactorPending) navigate(returnTo, { replace: true });
}, [loading, user, twoFactorPending, navigate, returnTo]);
if (loading || !user || !twoFactorPending) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 p-4">
<TwoFactorVerify
userId={user.id}
onSuccess={() => {
markTwoFactorVerified();
navigate(returnTo, { replace: true });
}}
// Backing out of the challenge has to end the session. Leaving the user
// signed in but unchallenged would defeat the point of asking.
onCancel={async () => {
await signOut();
navigate('/login', { replace: true });
}}
/>
</div>
);
}