feat(auth): actually ask for 2FA at login

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.
This commit is contained in:
2026-07-31 22:02:40 -07:00
parent a3fde0dc9d
commit e8dd9f6c99
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>
);
}