Spaces:
Running
on
Zero
Running
on
Zero
Commit
Β·
4f454b4
1
Parent(s):
f85e1e8
fix: implement authentication security hardening
Browse files- Add password reset flow with PKCE token verification and email links
- Add educational disclaimer on signup page
- Clear global state on logout to prevent data leakage between sessions
- Invalidate user-specific cache entries on logout
- Add app_url configuration for password reset redirects
Security fixes:
- Prevents User B from accessing User A's portfolio data after logout
- Clears LAST_ANALYSIS_STATE, HISTORY_RECORDS, LAST_STRESS_TEST globals
- Implements proper session cleanup with cache invalidation
- Adds forgot password modal workflow with token detection
- app.py +232 -17
- backend/auth.py +86 -0
- backend/config.py +5 -0
app.py
CHANGED
|
@@ -1648,6 +1648,12 @@ def create_interface() -> gr.Blocks:
|
|
| 1648 |
placeholder="Enter your password",
|
| 1649 |
type="password"
|
| 1650 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1651 |
login_btn = gr.Button("Sign In", variant="primary", size="lg")
|
| 1652 |
login_message = gr.Markdown("")
|
| 1653 |
|
|
@@ -1672,6 +1678,10 @@ def create_interface() -> gr.Blocks:
|
|
| 1672 |
placeholder="Re-enter your password",
|
| 1673 |
type="password"
|
| 1674 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1675 |
signup_btn = gr.Button("Create Account", variant="primary", size="lg")
|
| 1676 |
signup_message = gr.Markdown("")
|
| 1677 |
|
|
@@ -1686,6 +1696,50 @@ def create_interface() -> gr.Blocks:
|
|
| 1686 |
gr.Markdown("*1 free analysis per day β’ No account required*", elem_classes="demo-subtitle")
|
| 1687 |
demo_message = gr.Markdown("")
|
| 1688 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1689 |
# Input Page with side-by-side layout (hidden until authenticated)
|
| 1690 |
with gr.Group(visible=False) as input_page:
|
| 1691 |
# Side-by-side input and preview (2:3 ratio for wider preview)
|
|
@@ -1750,6 +1804,33 @@ def create_interface() -> gr.Blocks:
|
|
| 1750 |
scale=1
|
| 1751 |
)
|
| 1752 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1753 |
# Add click handlers for example buttons
|
| 1754 |
tech_btn.click(
|
| 1755 |
fn=lambda: "AAPL 50 shares\nTSLA 25 shares\nNVDA 30 shares\nMETA 20 shares",
|
|
@@ -2415,31 +2496,112 @@ Please try again with different parameters.
|
|
| 2415 |
return current_session, f"β {message}"
|
| 2416 |
|
| 2417 |
async def handle_logout(current_session: Dict):
|
| 2418 |
-
"""Handle user logout.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2419 |
session = UserSession.from_dict(current_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2420 |
success, message = await auth.logout(session)
|
| 2421 |
|
| 2422 |
-
# Clear
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2423 |
return (
|
| 2424 |
-
{},
|
| 2425 |
-
f"β
{message}",
|
| 2426 |
-
gr.update(visible=True), # Show login
|
| 2427 |
-
gr.update(visible=False), # Hide
|
| 2428 |
-
gr.update(value="Not logged in") # Clear user info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2429 |
)
|
| 2430 |
|
| 2431 |
def sync_login(email: str, password: str, current_session: Dict):
|
| 2432 |
"""Synchronous wrapper for login."""
|
| 2433 |
return asyncio.run(handle_login(email, password, current_session))
|
| 2434 |
|
| 2435 |
-
def sync_signup(email: str, password: str, confirm_password: str, username: str, current_session: Dict):
|
| 2436 |
"""Synchronous wrapper for signup."""
|
| 2437 |
-
return asyncio.run(handle_signup(email, password, confirm_password, username, current_session))
|
| 2438 |
|
| 2439 |
def sync_logout(current_session: Dict):
|
| 2440 |
"""Synchronous wrapper for logout."""
|
| 2441 |
return asyncio.run(handle_logout(current_session))
|
| 2442 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2443 |
# Connect authentication handlers
|
| 2444 |
login_btn.click(
|
| 2445 |
sync_login,
|
|
@@ -2456,6 +2618,53 @@ Please try again with different parameters.
|
|
| 2456 |
outputs=[session_state, signup_message]
|
| 2457 |
)
|
| 2458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2459 |
def handle_demo_mode(current_session: Dict):
|
| 2460 |
"""Handle demo mode activation (anonymous with rate limiting)."""
|
| 2461 |
# Create a demo session (not authenticated)
|
|
@@ -2486,10 +2695,13 @@ Please try again with different parameters.
|
|
| 2486 |
logout_btn.click(
|
| 2487 |
sync_logout,
|
| 2488 |
inputs=[session_state],
|
| 2489 |
-
outputs=[
|
| 2490 |
-
|
| 2491 |
-
|
| 2492 |
-
|
|
|
|
|
|
|
|
|
|
| 2493 |
)
|
| 2494 |
|
| 2495 |
# Navigation event handlers
|
|
@@ -2511,10 +2723,13 @@ Please try again with different parameters.
|
|
| 2511 |
nav_signout_btn.click(
|
| 2512 |
sync_logout,
|
| 2513 |
inputs=[session_state],
|
| 2514 |
-
outputs=[
|
| 2515 |
-
|
| 2516 |
-
|
| 2517 |
-
|
|
|
|
|
|
|
|
|
|
| 2518 |
)
|
| 2519 |
|
| 2520 |
return demo
|
|
|
|
| 1648 |
placeholder="Enter your password",
|
| 1649 |
type="password"
|
| 1650 |
)
|
| 1651 |
+
forgot_password_btn = gr.Button(
|
| 1652 |
+
"Forgot password?",
|
| 1653 |
+
variant="secondary",
|
| 1654 |
+
size="sm",
|
| 1655 |
+
elem_classes="forgot-password-link"
|
| 1656 |
+
)
|
| 1657 |
login_btn = gr.Button("Sign In", variant="primary", size="lg")
|
| 1658 |
login_message = gr.Markdown("")
|
| 1659 |
|
|
|
|
| 1678 |
placeholder="Re-enter your password",
|
| 1679 |
type="password"
|
| 1680 |
)
|
| 1681 |
+
gr.Markdown(
|
| 1682 |
+
"*By signing up, you agree this is for educational purposes only and not financial advice.*",
|
| 1683 |
+
elem_classes="disclaimer-text"
|
| 1684 |
+
)
|
| 1685 |
signup_btn = gr.Button("Create Account", variant="primary", size="lg")
|
| 1686 |
signup_message = gr.Markdown("")
|
| 1687 |
|
|
|
|
| 1696 |
gr.Markdown("*1 free analysis per day β’ No account required*", elem_classes="demo-subtitle")
|
| 1697 |
demo_message = gr.Markdown("")
|
| 1698 |
|
| 1699 |
+
# Modal 1: Request password reset (only email input)
|
| 1700 |
+
with gr.Group(visible=False, elem_id="password-reset-modal") as password_reset_modal:
|
| 1701 |
+
gr.Markdown("## Request Password Reset")
|
| 1702 |
+
gr.Markdown("Enter your email address and we'll send you a password reset link.")
|
| 1703 |
+
|
| 1704 |
+
reset_email = gr.Textbox(
|
| 1705 |
+
label="Email",
|
| 1706 |
+
placeholder="[email protected]",
|
| 1707 |
+
type="email"
|
| 1708 |
+
)
|
| 1709 |
+
with gr.Row():
|
| 1710 |
+
reset_submit_btn = gr.Button("Send Reset Link", variant="primary", size="lg")
|
| 1711 |
+
reset_cancel_btn = gr.Button("Cancel", variant="secondary", size="sm")
|
| 1712 |
+
reset_message = gr.Markdown("")
|
| 1713 |
+
|
| 1714 |
+
# Modal 2: Set new password (only appears after clicking email link)
|
| 1715 |
+
with gr.Group(visible=False, elem_id="password-update-modal") as password_update_modal:
|
| 1716 |
+
gr.Markdown("## Set New Password")
|
| 1717 |
+
gr.Markdown("Enter your new password below.")
|
| 1718 |
+
|
| 1719 |
+
# Hidden fields to store recovery token_hash and email (populated by JavaScript)
|
| 1720 |
+
recovery_token_hash = gr.Textbox(visible=False, elem_id="recovery-token-hash")
|
| 1721 |
+
recovery_email = gr.Textbox(
|
| 1722 |
+
label="Email",
|
| 1723 |
+
placeholder="[email protected]",
|
| 1724 |
+
type="email",
|
| 1725 |
+
info="Enter the email address you used to request the password reset"
|
| 1726 |
+
)
|
| 1727 |
+
|
| 1728 |
+
new_password = gr.Textbox(
|
| 1729 |
+
label="New Password",
|
| 1730 |
+
placeholder="At least 6 characters",
|
| 1731 |
+
type="password"
|
| 1732 |
+
)
|
| 1733 |
+
confirm_new_password = gr.Textbox(
|
| 1734 |
+
label="Confirm New Password",
|
| 1735 |
+
placeholder="Re-enter your new password",
|
| 1736 |
+
type="password"
|
| 1737 |
+
)
|
| 1738 |
+
with gr.Row():
|
| 1739 |
+
update_password_btn = gr.Button("Update Password", variant="primary", size="lg")
|
| 1740 |
+
update_cancel_btn = gr.Button("Cancel", variant="secondary", size="sm")
|
| 1741 |
+
update_password_message = gr.Markdown("")
|
| 1742 |
+
|
| 1743 |
# Input Page with side-by-side layout (hidden until authenticated)
|
| 1744 |
with gr.Group(visible=False) as input_page:
|
| 1745 |
# Side-by-side input and preview (2:3 ratio for wider preview)
|
|
|
|
| 1804 |
scale=1
|
| 1805 |
)
|
| 1806 |
|
| 1807 |
+
# JavaScript to detect password reset token_hash in URL and populate hidden fields
|
| 1808 |
+
# This runs on page load and checks for recovery token in query parameters
|
| 1809 |
+
demo.load(
|
| 1810 |
+
fn=None, # No Python processing needed - JavaScript directly sets the value
|
| 1811 |
+
inputs=[],
|
| 1812 |
+
outputs=[recovery_token_hash],
|
| 1813 |
+
js="""
|
| 1814 |
+
function() {
|
| 1815 |
+
// Check for recovery token_hash in URL query parameters (PKCE flow)
|
| 1816 |
+
const params = new URLSearchParams(window.location.search);
|
| 1817 |
+
const tokenHash = params.get('token_hash') || '';
|
| 1818 |
+
const typeParam = params.get('type') || '';
|
| 1819 |
+
|
| 1820 |
+
if (tokenHash && typeParam === 'recovery') {
|
| 1821 |
+
console.log('Password reset token detected');
|
| 1822 |
+
|
| 1823 |
+
// Clean up URL (remove query parameters)
|
| 1824 |
+
window.history.replaceState({}, document.title, window.location.pathname);
|
| 1825 |
+
|
| 1826 |
+
return tokenHash;
|
| 1827 |
+
}
|
| 1828 |
+
|
| 1829 |
+
return '';
|
| 1830 |
+
}
|
| 1831 |
+
"""
|
| 1832 |
+
)
|
| 1833 |
+
|
| 1834 |
# Add click handlers for example buttons
|
| 1835 |
tech_btn.click(
|
| 1836 |
fn=lambda: "AAPL 50 shares\nTSLA 25 shares\nNVDA 30 shares\nMETA 20 shares",
|
|
|
|
| 2496 |
return current_session, f"β {message}"
|
| 2497 |
|
| 2498 |
async def handle_logout(current_session: Dict):
|
| 2499 |
+
"""Handle user logout and reset all UI components.
|
| 2500 |
+
|
| 2501 |
+
Security: Clears all global state and user-specific cache to prevent
|
| 2502 |
+
data leakage between user sessions.
|
| 2503 |
+
"""
|
| 2504 |
+
global LAST_ANALYSIS_STATE, HISTORY_RECORDS, LAST_STRESS_TEST
|
| 2505 |
+
|
| 2506 |
session = UserSession.from_dict(current_session)
|
| 2507 |
+
|
| 2508 |
+
# Extract user ID before clearing session (for cache cleanup)
|
| 2509 |
+
user_id = session.user_id if session else None
|
| 2510 |
+
|
| 2511 |
+
# 1. Clear backend authentication
|
| 2512 |
success, message = await auth.logout(session)
|
| 2513 |
|
| 2514 |
+
# 2. Clear global state variables (CRITICAL for data security)
|
| 2515 |
+
LAST_ANALYSIS_STATE = None
|
| 2516 |
+
HISTORY_RECORDS = []
|
| 2517 |
+
LAST_STRESS_TEST = None
|
| 2518 |
+
|
| 2519 |
+
# 3. Clear user-specific cache entries
|
| 2520 |
+
if user_id:
|
| 2521 |
+
try:
|
| 2522 |
+
from backend.caching.factory import cache_manager
|
| 2523 |
+
# Clear user-specific data from cache
|
| 2524 |
+
cache_manager.invalidation.invalidate_by_event("user_update", user_id)
|
| 2525 |
+
logger.info(f"Cleared cache for user {user_id}")
|
| 2526 |
+
except Exception as e:
|
| 2527 |
+
logger.error(f"Failed to clear user cache on logout: {e}")
|
| 2528 |
+
|
| 2529 |
+
# 4. Clear session and reset ALL UI components to initial state
|
| 2530 |
return (
|
| 2531 |
+
{}, # Clear session state
|
| 2532 |
+
f"β
{message}", # Login message
|
| 2533 |
+
gr.update(visible=True), # Show login container
|
| 2534 |
+
gr.update(visible=False), # Hide input page
|
| 2535 |
+
gr.update(value="Not logged in"), # Clear user info
|
| 2536 |
+
gr.update(visible=False), # Hide logout button
|
| 2537 |
+
gr.update(visible=False), # Hide sidebar
|
| 2538 |
+
gr.update(visible=False), # Hide results page
|
| 2539 |
+
gr.update(visible=False), # Hide loading page
|
| 2540 |
+
gr.update(visible=False), # Hide history page
|
| 2541 |
+
# Clear all analysis outputs
|
| 2542 |
+
gr.update(value=""), # analysis_output
|
| 2543 |
+
gr.update(value=None), # allocation_plot
|
| 2544 |
+
gr.update(value=None), # risk_plot
|
| 2545 |
+
gr.update(value=None), # performance_plot
|
| 2546 |
+
gr.update(value=None), # correlation_plot
|
| 2547 |
+
gr.update(value=None), # optimization_plot
|
| 2548 |
+
# Clear history
|
| 2549 |
+
gr.update(value=[]), # history_table
|
| 2550 |
+
# Clear password reset modals
|
| 2551 |
+
gr.update(visible=False), # password_reset_modal
|
| 2552 |
+
gr.update(visible=False), # password_update_modal
|
| 2553 |
)
|
| 2554 |
|
| 2555 |
def sync_login(email: str, password: str, current_session: Dict):
|
| 2556 |
"""Synchronous wrapper for login."""
|
| 2557 |
return asyncio.run(handle_login(email, password, current_session))
|
| 2558 |
|
| 2559 |
+
def sync_signup(email: str, password: str, confirm_password: str, username: str, terms_accepted: bool, current_session: Dict):
|
| 2560 |
"""Synchronous wrapper for signup."""
|
| 2561 |
+
return asyncio.run(handle_signup(email, password, confirm_password, username, terms_accepted, current_session))
|
| 2562 |
|
| 2563 |
def sync_logout(current_session: Dict):
|
| 2564 |
"""Synchronous wrapper for logout."""
|
| 2565 |
return asyncio.run(handle_logout(current_session))
|
| 2566 |
|
| 2567 |
+
async def handle_password_reset(email: str):
|
| 2568 |
+
"""Handle password reset request."""
|
| 2569 |
+
if not email:
|
| 2570 |
+
return "", "β Please enter your email address"
|
| 2571 |
+
|
| 2572 |
+
success, message = await auth.request_password_reset(email)
|
| 2573 |
+
|
| 2574 |
+
# Simple success message
|
| 2575 |
+
if success:
|
| 2576 |
+
return "", f"{message}\n\nCheck your email and click the reset link to set your new password."
|
| 2577 |
+
|
| 2578 |
+
return "", message
|
| 2579 |
+
|
| 2580 |
+
def sync_password_reset(email: str):
|
| 2581 |
+
"""Synchronous wrapper for password reset."""
|
| 2582 |
+
return asyncio.run(handle_password_reset(email))
|
| 2583 |
+
|
| 2584 |
+
async def handle_password_update(password: str, confirm: str, email: str, token_hash: str):
|
| 2585 |
+
"""Handle password update after reset with recovery token_hash."""
|
| 2586 |
+
if not email:
|
| 2587 |
+
return "β Please enter your email address"
|
| 2588 |
+
|
| 2589 |
+
if not password or not confirm:
|
| 2590 |
+
return "β Please enter both password fields"
|
| 2591 |
+
|
| 2592 |
+
if password != confirm:
|
| 2593 |
+
return "β Passwords do not match"
|
| 2594 |
+
|
| 2595 |
+
if len(password) < 6:
|
| 2596 |
+
return "β Password must be at least 6 characters"
|
| 2597 |
+
|
| 2598 |
+
success, message = await auth.update_password(password, email, token_hash)
|
| 2599 |
+
return message
|
| 2600 |
+
|
| 2601 |
+
def sync_password_update(password: str, confirm: str, email: str, token_hash: str):
|
| 2602 |
+
"""Synchronous wrapper for password update."""
|
| 2603 |
+
return asyncio.run(handle_password_update(password, confirm, email, token_hash))
|
| 2604 |
+
|
| 2605 |
# Connect authentication handlers
|
| 2606 |
login_btn.click(
|
| 2607 |
sync_login,
|
|
|
|
| 2618 |
outputs=[session_state, signup_message]
|
| 2619 |
)
|
| 2620 |
|
| 2621 |
+
# Password reset handlers
|
| 2622 |
+
forgot_password_btn.click(
|
| 2623 |
+
lambda: (gr.update(visible=False), gr.update(visible=True)),
|
| 2624 |
+
outputs=[login_container, password_reset_modal]
|
| 2625 |
+
)
|
| 2626 |
+
|
| 2627 |
+
reset_cancel_btn.click(
|
| 2628 |
+
lambda: (gr.update(visible=True), gr.update(visible=False), ""),
|
| 2629 |
+
outputs=[login_container, password_reset_modal, reset_message]
|
| 2630 |
+
)
|
| 2631 |
+
|
| 2632 |
+
update_cancel_btn.click(
|
| 2633 |
+
lambda: (gr.update(visible=True), gr.update(visible=False), ""),
|
| 2634 |
+
outputs=[login_container, password_update_modal, update_password_message]
|
| 2635 |
+
)
|
| 2636 |
+
|
| 2637 |
+
reset_submit_btn.click(
|
| 2638 |
+
sync_password_reset,
|
| 2639 |
+
inputs=[reset_email],
|
| 2640 |
+
outputs=[reset_email, reset_message]
|
| 2641 |
+
)
|
| 2642 |
+
|
| 2643 |
+
# Show password update modal when recovery token_hash is detected
|
| 2644 |
+
recovery_token_hash.change(
|
| 2645 |
+
fn=lambda token: (
|
| 2646 |
+
gr.update(visible=False) if token else gr.update(visible=True),
|
| 2647 |
+
gr.update(visible=True) if token else gr.update(visible=False)
|
| 2648 |
+
),
|
| 2649 |
+
inputs=[recovery_token_hash],
|
| 2650 |
+
outputs=[login_container, password_update_modal]
|
| 2651 |
+
)
|
| 2652 |
+
|
| 2653 |
+
# Password update handler (separate modal after email link click)
|
| 2654 |
+
update_password_btn.click(
|
| 2655 |
+
sync_password_update,
|
| 2656 |
+
inputs=[new_password, confirm_new_password, recovery_email, recovery_token_hash],
|
| 2657 |
+
outputs=[update_password_message]
|
| 2658 |
+
).then(
|
| 2659 |
+
# On success, hide modal and show login form
|
| 2660 |
+
lambda msg: (
|
| 2661 |
+
gr.update(visible=False) if "β
" in msg else gr.update(visible=True),
|
| 2662 |
+
gr.update(visible=True) if "β
" in msg else gr.update(visible=False)
|
| 2663 |
+
),
|
| 2664 |
+
inputs=[update_password_message],
|
| 2665 |
+
outputs=[password_update_modal, login_container]
|
| 2666 |
+
)
|
| 2667 |
+
|
| 2668 |
def handle_demo_mode(current_session: Dict):
|
| 2669 |
"""Handle demo mode activation (anonymous with rate limiting)."""
|
| 2670 |
# Create a demo session (not authenticated)
|
|
|
|
| 2695 |
logout_btn.click(
|
| 2696 |
sync_logout,
|
| 2697 |
inputs=[session_state],
|
| 2698 |
+
outputs=[
|
| 2699 |
+
session_state, login_message, login_container, input_page, user_info,
|
| 2700 |
+
logout_btn, sidebar, results_page, loading_page, history_page,
|
| 2701 |
+
analysis_output, allocation_plot, risk_plot, performance_plot,
|
| 2702 |
+
correlation_plot, optimization_plot, history_table,
|
| 2703 |
+
password_reset_modal, password_update_modal
|
| 2704 |
+
]
|
| 2705 |
)
|
| 2706 |
|
| 2707 |
# Navigation event handlers
|
|
|
|
| 2723 |
nav_signout_btn.click(
|
| 2724 |
sync_logout,
|
| 2725 |
inputs=[session_state],
|
| 2726 |
+
outputs=[
|
| 2727 |
+
session_state, login_message, login_container, input_page, user_info,
|
| 2728 |
+
logout_btn, sidebar, results_page, loading_page, history_page,
|
| 2729 |
+
analysis_output, allocation_plot, risk_plot, performance_plot,
|
| 2730 |
+
correlation_plot, optimization_plot, history_table,
|
| 2731 |
+
password_reset_modal, password_update_modal
|
| 2732 |
+
]
|
| 2733 |
)
|
| 2734 |
|
| 2735 |
return demo
|
backend/auth.py
CHANGED
|
@@ -137,6 +137,92 @@ class SupabaseAuth:
|
|
| 137 |
|
| 138 |
return False, "Signup failed", None
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
async def login(
|
| 141 |
self,
|
| 142 |
email: str,
|
|
|
|
| 137 |
|
| 138 |
return False, "Signup failed", None
|
| 139 |
|
| 140 |
+
async def request_password_reset(self, email: str) -> Tuple[bool, str]:
|
| 141 |
+
"""Request password reset email.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
email: User email address
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Tuple of (success, message)
|
| 148 |
+
"""
|
| 149 |
+
if self.demo_mode:
|
| 150 |
+
logger.warning("Password reset not available in demo mode")
|
| 151 |
+
return False, "Password reset is not available in demo mode"
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
# Use Supabase auth to send reset email with redirect back to app
|
| 155 |
+
redirect_url = f"{settings.app_url}/"
|
| 156 |
+
|
| 157 |
+
self.client.auth.reset_password_for_email(
|
| 158 |
+
email,
|
| 159 |
+
options={
|
| 160 |
+
"redirect_to": redirect_url
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
logger.info(f"Password reset requested for {email}, redirect to {redirect_url}")
|
| 165 |
+
return True, "Email sent!"
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
error_msg = str(e)
|
| 169 |
+
logger.error(f"Password reset failed: {error_msg}")
|
| 170 |
+
|
| 171 |
+
# Handle rate limiting
|
| 172 |
+
if "you can only request this after" in error_msg.lower():
|
| 173 |
+
return False, "β Please wait a minute before requesting another reset link"
|
| 174 |
+
|
| 175 |
+
return False, f"β Failed to send reset email: {error_msg}"
|
| 176 |
+
|
| 177 |
+
async def update_password(self, new_password: str, email: str, token_hash: str) -> Tuple[bool, str]:
|
| 178 |
+
"""Update user password after reset using token_hash verification.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
new_password: New password to set
|
| 182 |
+
email: User email address
|
| 183 |
+
token_hash: Recovery token hash from email link
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Tuple of (success, message)
|
| 187 |
+
"""
|
| 188 |
+
if self.demo_mode:
|
| 189 |
+
logger.warning("Password update not available in demo mode")
|
| 190 |
+
return False, "Password update is not available in demo mode"
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
# Verify the recovery token and establish session
|
| 194 |
+
# Note: For recovery tokens, only token_hash and type are needed (email is embedded in token)
|
| 195 |
+
verify_response = self.client.auth.verify_otp({
|
| 196 |
+
"token_hash": token_hash,
|
| 197 |
+
"type": "recovery"
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
if not verify_response.user:
|
| 201 |
+
logger.error("Token verification failed - no user returned")
|
| 202 |
+
return False, "β Invalid or expired reset link. Please request a new password reset."
|
| 203 |
+
|
| 204 |
+
logger.info(f"Token verified for user {verify_response.user.id}")
|
| 205 |
+
|
| 206 |
+
# Update password for authenticated user
|
| 207 |
+
update_response = self.client.auth.update_user({
|
| 208 |
+
"password": new_password
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
logger.info("Password updated successfully")
|
| 212 |
+
return True, "β
Password updated successfully. You can now sign in with your new password."
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
error_msg = str(e)
|
| 216 |
+
logger.error(f"Password update failed: {error_msg}")
|
| 217 |
+
|
| 218 |
+
# Handle specific errors
|
| 219 |
+
if "expired" in error_msg.lower():
|
| 220 |
+
return False, "β Reset link has expired. Please request a new password reset."
|
| 221 |
+
elif "invalid" in error_msg.lower():
|
| 222 |
+
return False, "β Invalid reset link. Please request a new password reset."
|
| 223 |
+
else:
|
| 224 |
+
return False, f"β Failed to update password: {error_msg}"
|
| 225 |
+
|
| 226 |
async def login(
|
| 227 |
self,
|
| 228 |
email: str,
|
backend/config.py
CHANGED
|
@@ -82,6 +82,11 @@ class Settings(BaseSettings):
|
|
| 82 |
default="INFO",
|
| 83 |
validation_alias="LOG_LEVEL"
|
| 84 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# Rate Limiting Settings
|
| 87 |
redis_url: Optional[str] = Field(
|
|
|
|
| 82 |
default="INFO",
|
| 83 |
validation_alias="LOG_LEVEL"
|
| 84 |
)
|
| 85 |
+
app_url: str = Field(
|
| 86 |
+
default="http://localhost:7860",
|
| 87 |
+
validation_alias="APP_URL",
|
| 88 |
+
description="Public URL where the application is accessible"
|
| 89 |
+
)
|
| 90 |
|
| 91 |
# Rate Limiting Settings
|
| 92 |
redis_url: Optional[str] = Field(
|