From e8dd9f6c994d0388843381849b8a237f827e0ad5 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 31 Jul 2026 22:02:40 -0700 Subject: [PATCH] feat(auth): actually ask for 2FA at login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2FA could be enabled but was never enforced. TwoFactorSetup wrote a secret and backup codes, and login never checked any of it — TwoFactorVerify existed as a component referenced from nowhere. Turning 2FA on protected nothing. Optional, as decided: a user without 2FA is never blocked, and a failure while checking is treated as "not required" so a transient error cannot become a login wall for people who never enabled it. The challenge is enforced in ProtectedRoute rather than only after the password form, so a deep link or a restored tab cannot walk straight past it. Telegram sessions are exempt — they authenticate through signed initData, so there is no password to second-factor. Recovery is by backup code, which the verify endpoint already supported and consumes on use. Cancelling the challenge signs the user out; leaving them signed in but unchallenged would defeat the point of asking. Verification is remembered in sessionStorage, not localStorage: 2FA is asked once per browser session and again after the tab closes. Persisting it would mean being challenged once per device, ever. Scope, stated plainly rather than implied: this is a client-side gate. It stops someone holding the password from reaching the app in a browser, which is what 2FA is for here. It is not server-side enforcement — privileged operations still verify authority independently (wallet signature for admin actions, JWT for an account's own data), and those remain what actually protects funds and state. --- web/src/App.tsx | 4 ++ web/src/components/ProtectedRoute.tsx | 15 ++++++- web/src/contexts/AuthContext.tsx | 65 ++++++++++++++++++++++++++- web/src/pages/TwoFactorChallenge.tsx | 62 +++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/TwoFactorChallenge.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 22096a63..f2c4ed44 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + {/* Not wrapped in ProtectedRoute: that route redirects here + whenever a challenge is pending, which would loop. */} + } /> } /> } /> } /> diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 5c89f7ca..acaf8900 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -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 = ({ 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 = ({ return ; } + // 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 ; + } + // 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 diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 09d9ec3e..5b2d1f35 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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; @@ -33,6 +37,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [user, setUser] = useState(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, diff --git a/web/src/pages/TwoFactorChallenge.tsx b/web/src/pages/TwoFactorChallenge.tsx new file mode 100644 index 00000000..c2ca06b5 --- /dev/null +++ b/web/src/pages/TwoFactorChallenge.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ { + 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 }); + }} + /> +
+ ); +}