mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 03:30:57 +00:00
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:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user