BrianIsaac commited on
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

Files changed (3) hide show
  1. app.py +232 -17
  2. backend/auth.py +86 -0
  3. 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 session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2423
  return (
2424
- {},
2425
- f"βœ… {message}",
2426
- gr.update(visible=True), # Show login form
2427
- gr.update(visible=False), # Hide main content
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=[session_state, login_message, login_container, input_page, user_info]
2490
- ).then(
2491
- lambda: (gr.update(visible=False), gr.update(visible=False)),
2492
- outputs=[logout_btn, sidebar]
 
 
 
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=[session_state, login_message, login_container, input_page, user_info]
2515
- ).then(
2516
- lambda: (gr.update(visible=False), gr.update(visible=False)),
2517
- outputs=[logout_btn, sidebar]
 
 
 
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(