mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 03:30:57 +00:00
Merge pull request #29 from pezkuwichain/feat/2fa-login-gate
feat(auth): actually ask for 2FA at login
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