فهرست منبع

Merge pull request #28 from cadtoolbox/copilot/fix-password-reset-template-usage

Fix Advanced Auth: missing translation key and email template integration
Thomas Rambach 3 ماه پیش
والد
کامیت
94aa865602

+ 13 - 9
backend/app/api/routes/auth.py

@@ -5,6 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
     authenticate_user,
@@ -35,13 +36,12 @@ from backend.app.schemas.auth import (
     UserResponse,
 )
 from backend.app.services.email_service import (
-    create_password_reset_email,
+    create_password_reset_email_from_template,
     generate_secure_password,
     get_smtp_settings,
     save_smtp_settings,
     send_email,
 )
-from backend.app.api.routes.settings import get_external_login_url
 
 
 def _user_to_response(user: User) -> UserResponse:
@@ -263,7 +263,7 @@ async def disable_auth(
 @router.post("/login", response_model=LoginResponse)
 async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
     """Login and get access token.
-    
+
     Supports username or email-based login. Username lookup is case-insensitive.
     """
     # Check if auth is enabled
@@ -276,13 +276,13 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
 
     # Try username-based authentication first
     user = await authenticate_user(db, request.username, request.password)
-    
+
     # If username auth failed and advanced auth is enabled, try email-based authentication
     if not user:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
-    
+
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
@@ -437,7 +437,7 @@ async def enable_advanced_auth(
     db: AsyncSession = Depends(get_db),
 ):
     """Enable advanced authentication (admin only).
-    
+
     Requires SMTP settings to be configured and tested first.
     """
     import logging
@@ -546,7 +546,7 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
 
     # Find user by email
     user = await get_user_by_email(db, request.email)
-    
+
     # Always return success message to prevent email enumeration
     # but only send email if user exists
     if user and user.is_active:
@@ -559,7 +559,9 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             login_url = await get_external_login_url(db)
 
             # Send password reset email
-            subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
+            subject, text_body, html_body = await create_password_reset_email_from_template(
+                db, user.username, new_password, login_url
+            )
             send_email(smtp_settings, user.email, subject, text_body, html_body)
 
             logger.info(f"Password reset email sent to {user.email}")
@@ -633,7 +635,9 @@ async def reset_user_password(
         login_url = await get_external_login_url(db)
 
         # Send password reset email
-        subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
+        subject, text_body, html_body = await create_password_reset_email_from_template(
+            db, user.username, new_password, login_url
+        )
         send_email(smtp_settings, user.email, subject, text_body, html_body)
 
         logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")

+ 6 - 4
backend/app/api/routes/users.py

@@ -3,6 +3,7 @@ from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
@@ -19,12 +20,11 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 from backend.app.services.email_service import (
-    create_welcome_email,
+    create_welcome_email_from_template,
     generate_secure_password,
     get_smtp_settings,
     send_email,
 )
-from backend.app.api.routes.settings import get_external_login_url
 
 router = APIRouter(prefix="/users", tags=["users"])
 
@@ -64,7 +64,7 @@ async def create_user(
     db: AsyncSession = Depends(get_db),
 ):
     """Create a new user.
-    
+
     When advanced authentication is enabled:
     - Email is required
     - Password is auto-generated and emailed to user
@@ -153,7 +153,9 @@ async def create_user(
             smtp_settings = await get_smtp_settings(db)
             if smtp_settings:
                 login_url = await get_external_login_url(db)
-                subject, text_body, html_body = create_welcome_email(new_user.username, password, login_url)
+                subject, text_body, html_body = await create_welcome_email_from_template(
+                    db, new_user.username, password, login_url
+                )
                 send_email(smtp_settings, new_user.email, subject, text_body, html_body)
                 logger.info(f"Welcome email sent to {new_user.email}")
             else:

+ 212 - 44
backend/app/services/email_service.py

@@ -2,16 +2,20 @@
 
 from __future__ import annotations
 
+import html
 import logging
+import re
 import secrets
 import smtplib
 import string
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
+from typing import Any
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.settings import Settings
 from backend.app.schemas.auth import SMTPSettings
 
@@ -20,21 +24,21 @@ logger = logging.getLogger(__name__)
 
 def generate_secure_password(length: int = 16) -> str:
     """Generate a secure random password.
-    
+
     Args:
         length: Length of the password (default: 16)
-        
+
     Returns:
         A secure random password containing uppercase, lowercase, digits, and special characters
     """
     import random
-    
+
     # Define character sets
     lowercase = string.ascii_lowercase
     uppercase = string.ascii_uppercase
     digits = string.digits
     special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
-    
+
     # Ensure at least one character from each set
     password_chars = [
         secrets.choice(lowercase),
@@ -42,23 +46,57 @@ def generate_secure_password(length: int = 16) -> str:
         secrets.choice(digits),
         secrets.choice(special),
     ]
-    
+
     # Fill the rest with random characters from all sets
     all_chars = lowercase + uppercase + digits + special
     password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
-    
+
     # Shuffle to avoid predictable patterns
     random.shuffle(password_chars)
-    
+
     return "".join(password_chars)
 
 
+async def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None:
+    """Get a notification template by event type from database.
+
+    Args:
+        db: Database session
+        event_type: Type of event (e.g., 'user_created', 'password_reset')
+
+    Returns:
+        NotificationTemplate object or None if not found
+    """
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
+    )
+    return result.scalar_one_or_none()
+
+
+def render_template(template_str: str, variables: dict[str, Any]) -> str:
+    """Render a template string with variables.
+
+    Args:
+        template_str: Template string with {variable} placeholders
+        variables: Dictionary of variables to substitute
+
+    Returns:
+        Rendered template string
+    """
+    result = template_str
+    for key, value in variables.items():
+        result = result.replace("{" + key + "}", str(value) if value is not None else "")
+    # Remove any remaining unreplaced placeholders (case-insensitive, alphanumeric + underscore)
+    result = re.sub(r"\{[a-zA-Z0-9_]+\}", "", result)
+    return result
+
+
 async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
     """Get SMTP settings from database.
-    
+
     Args:
         db: Database session
-        
+
     Returns:
         SMTPSettings object or None if not configured
     """
@@ -79,21 +117,21 @@ async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
         )
     )
     settings_dict = {s.key: s.value for s in result.scalars().all()}
-    
+
     # Check if minimum required settings are present
     required_keys = ["smtp_host", "smtp_port", "smtp_from_email"]
     if not all(key in settings_dict for key in required_keys):
         return None
-    
+
     # Handle migration: convert old smtp_use_tls to smtp_security if needed
     smtp_security = settings_dict.get("smtp_security")
     if not smtp_security:
         # Migrate from old smtp_use_tls format
         smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true"
         smtp_security = "starttls" if smtp_use_tls else "ssl"
-    
+
     smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true"
-    
+
     return SMTPSettings(
         smtp_host=settings_dict["smtp_host"],
         smtp_port=int(settings_dict["smtp_port"]),
@@ -108,14 +146,14 @@ async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
 
 async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
     """Save SMTP settings to database.
-    
+
     Args:
         db: Database session
         smtp_settings: SMTP settings to save
     """
     from sqlalchemy import func
     from sqlalchemy.dialects.sqlite import insert as sqlite_insert
-    
+
     settings_data = {
         "smtp_host": smtp_settings.smtp_host,
         "smtp_port": str(smtp_settings.smtp_port),
@@ -124,15 +162,15 @@ async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> N
         "smtp_from_email": smtp_settings.smtp_from_email,
         "smtp_from_name": smtp_settings.smtp_from_name,
     }
-    
+
     # Only save username if auth is enabled or if provided
     if smtp_settings.smtp_username:
         settings_data["smtp_username"] = smtp_settings.smtp_username
-    
+
     # Only save password if provided
     if smtp_settings.smtp_password:
         settings_data["smtp_password"] = smtp_settings.smtp_password
-    
+
     for key, value in settings_data.items():
         stmt = sqlite_insert(Settings).values(key=key, value=value)
         stmt = stmt.on_conflict_do_update(
@@ -150,14 +188,14 @@ def send_email(
     body_html: str | None = None,
 ) -> None:
     """Send an email using SMTP.
-    
+
     Args:
         smtp_settings: SMTP configuration
         to_email: Recipient email address
         subject: Email subject
         body_text: Plain text body
         body_html: Optional HTML body
-        
+
     Raises:
         Exception: If email sending fails
     """
@@ -165,24 +203,24 @@ def send_email(
     msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
     msg["To"] = to_email
     msg["Subject"] = subject
-    
+
     # Attach plain text part
     msg.attach(MIMEText(body_text, "plain"))
-    
+
     # Attach HTML part if provided
     if body_html:
         msg.attach(MIMEText(body_html, "html"))
-    
+
     # Send email
     try:
         security = smtp_settings.smtp_security
         auth_enabled = smtp_settings.smtp_auth_enabled
-        
+
         # Validate username is provided when authentication is enabled
         if auth_enabled and smtp_settings.smtp_password:
             if not smtp_settings.smtp_username:
                 raise ValueError("SMTP username is required when authentication is enabled")
-        
+
         if security == "ssl":
             # Direct SSL connection (typically port 465)
             with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
@@ -210,17 +248,17 @@ def send_email(
 
 def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
     """Create welcome email content for new user.
-    
+
     Args:
         username: Username of the new user
         password: Auto-generated password
         login_url: URL to login page
-        
+
     Returns:
         Tuple of (subject, text_body, html_body)
     """
     subject = "Welcome to BamBuddy - Your Account Details"
-    
+
     text_body = f"""Welcome to BamBuddy!
 
 Your account has been created. Here are your login details:
@@ -235,7 +273,7 @@ For security reasons, please change your password after your first login.
 Best regards,
 BamBuddy Team
 """
-    
+
     html_body = f"""<!DOCTYPE html>
 <html>
 <head>
@@ -248,20 +286,20 @@ BamBuddy Team
     </div>
     <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
         <p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
-        
+
         <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
             <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
             <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
         </div>
-        
+
         <div style="text-align: center; margin: 30px 0;">
             <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
         </div>
-        
+
         <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
             <strong>Security Note:</strong> For security reasons, please change your password after your first login.
         </p>
-        
+
         <p style="font-size: 14px; color: #999; margin-top: 30px;">
             Best regards,<br>
             BamBuddy Team
@@ -270,23 +308,23 @@ BamBuddy Team
 </body>
 </html>
 """
-    
+
     return subject, text_body, html_body
 
 
 def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
     """Create password reset email content.
-    
+
     Args:
         username: Username of the user
         password: New auto-generated password
         login_url: URL to login page
-        
+
     Returns:
         Tuple of (subject, text_body, html_body)
     """
     subject = "BamBuddy - Your Password Has Been Reset"
-    
+
     text_body = f"""Your BamBuddy password has been reset.
 
 Your login details:
@@ -303,7 +341,7 @@ If you did not request this password reset, please contact your administrator im
 Best regards,
 BamBuddy Team
 """
-    
+
     html_body = f"""<!DOCTYPE html>
 <html>
 <head>
@@ -316,26 +354,26 @@ BamBuddy Team
     </div>
     <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
         <p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
-        
+
         <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
             <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
             <p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
         </div>
-        
+
         <div style="text-align: center; margin: 30px 0;">
             <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
         </div>
-        
+
         <div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
             <p style="margin: 0; font-size: 14px; color: #856404;">
                 <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
             </p>
         </div>
-        
+
         <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
             <strong>Security Note:</strong> For security reasons, please change your password after logging in.
         </p>
-        
+
         <p style="font-size: 14px; color: #999; margin-top: 30px;">
             Best regards,<br>
             BamBuddy Team
@@ -344,5 +382,135 @@ BamBuddy Team
 </body>
 </html>
 """
-    
+
     return subject, text_body, html_body
+
+
+async def create_welcome_email_from_template(
+    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
+) -> tuple[str, str, str]:
+    """Create welcome email content using notification template from database.
+
+    Args:
+        db: Database session
+        username: Username of the new user
+        password: Auto-generated password
+        login_url: URL to login page
+        app_name: Application name (default: BamBuddy)
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    # Try to get template from database
+    template = await get_notification_template(db, "user_created")
+
+    if template:
+        # Render template with variables
+        variables = {
+            "app_name": app_name,
+            "username": username,
+            "password": password,
+            "login_url": login_url,
+        }
+
+        subject = render_template(template.title_template, variables)
+        text_body = render_template(template.body_template, variables)
+
+        # Create HTML version with embedded login button
+        # Escape text_body to prevent XSS vulnerabilities
+        escaped_text_body = html.escape(text_body)
+        html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: white; margin: 0; font-size: 24px;">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="white-space: pre-wrap; font-size: 16px;">{escaped_text_body}</div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+    </div>
+</body>
+</html>
+"""
+
+        logger.info("Using custom welcome email template from database")
+        return subject, text_body, html_body
+    else:
+        # Fallback to hardcoded template
+        logger.warning("No welcome email template found in database, using default")
+        return create_welcome_email(username, password, login_url)
+
+
+async def create_password_reset_email_from_template(
+    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
+) -> tuple[str, str, str]:
+    """Create password reset email content using notification template from database.
+
+    Args:
+        db: Database session
+        username: Username of the user
+        password: New auto-generated password
+        login_url: URL to login page
+        app_name: Application name (default: BamBuddy)
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
+    """
+    # Try to get template from database
+    template = await get_notification_template(db, "password_reset")
+
+    if template:
+        # Render template with variables
+        variables = {
+            "app_name": app_name,
+            "username": username,
+            "password": password,
+            "login_url": login_url,
+        }
+
+        subject = render_template(template.title_template, variables)
+        text_body = render_template(template.body_template, variables)
+
+        # Create HTML version with embedded login button
+        # Escape text_body to prevent XSS vulnerabilities
+        escaped_text_body = html.escape(text_body)
+        html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: white; margin: 0; font-size: 24px;">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="white-space: pre-wrap; font-size: 16px;">{escaped_text_body}</div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
+        </div>
+
+        <div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
+            <p style="margin: 0; font-size: 14px; color: #856404;">
+                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
+            </p>
+        </div>
+    </div>
+</body>
+</html>
+"""
+
+        logger.info("Using custom password reset email template from database")
+        return subject, text_body, html_body
+    else:
+        # Fallback to hardcoded template
+        logger.warning("No password reset email template found in database, using default")
+        return create_password_reset_email(username, password, login_url)

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -1642,6 +1642,8 @@ export default {
       optional: 'optional',
       autoGeneratedPassword: 'Ein sicheres Passwort wird automatisch generiert und per E-Mail an den Benutzer gesendet.',
       passwordManagedByAdvancedAuth: 'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie "Passwort zurücksetzen", um ein neues Passwort per E-Mail an den Benutzer zu senden.',
+      resetPassword: 'Passwort zurücksetzen',
+      resettingPassword: 'Passwort wird zurückgesetzt...',
     },
     deleteModal: {
       title: 'Benutzer löschen',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -1642,6 +1642,8 @@ export default {
       optional: 'optional',
       autoGeneratedPassword: 'A secure password will be automatically generated and emailed to the user.',
       passwordManagedByAdvancedAuth: 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.',
+      resetPassword: 'Reset Password',
+      resettingPassword: 'Resetting Password...',
     },
     deleteModal: {
       title: 'Delete User',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -2174,6 +2174,8 @@ export default {
       optional: 'オプション',
       autoGeneratedPassword: '安全なパスワードが自動的に生成され、ユーザーにメールで送信されます。',
       passwordManagedByAdvancedAuth: 'パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、メールで新しいパスワードをユーザーに送信してください。',
+      resetPassword: 'パスワードのリセット',
+      resettingPassword: 'パスワードをリセット中...',
     },
     modal: {
       createUser: 'ユーザーを作成',