diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 2605dee..f3e8be4 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -17,9 +17,16 @@ from app.schemas.users import ( UserResponse, Token, LoginRequest, - RefreshTokenRequest + RefreshTokenRequest, + PasswordResetRequest, + PasswordResetConfirm ) +from app.schemas.common import MessageResponse from app.services.auth_service import AuthService, AuthenticationError +from app.services.email_service import email_service +from app.utils.security import create_password_reset_token, verify_password_reset_token +from app.crud.user import user as user_crud +from app.core.auth import get_password_hash router = APIRouter() logger = logging.getLogger(__name__) @@ -204,7 +211,139 @@ async def get_current_user_info( ) -> Any: """ Get current user information. - + Requires authentication. """ return current_user + + +@router.post( + "/password-reset/request", + response_model=MessageResponse, + status_code=status.HTTP_200_OK, + summary="Request Password Reset", + description=""" + Request a password reset link. + + An email will be sent with a reset link if the email exists. + Always returns success to prevent email enumeration. + + **Rate Limit**: 3 requests/minute + """, + operation_id="request_password_reset" +) +@limiter.limit("3/minute") +async def request_password_reset( + request: Request, + reset_request: PasswordResetRequest, + db: Session = Depends(get_db) +) -> Any: + """ + Request a password reset. + + Sends an email with a password reset link. + Always returns success to prevent email enumeration. + """ + try: + # Look up user by email + user = user_crud.get_by_email(db, email=reset_request.email) + + # Only send email if user exists and is active + if user and user.is_active: + # Generate reset token + reset_token = create_password_reset_token(user.email) + + # Send password reset email + await email_service.send_password_reset_email( + to_email=user.email, + reset_token=reset_token, + user_name=user.first_name + ) + logger.info(f"Password reset requested for {user.email}") + else: + # Log attempt but don't reveal if email exists + logger.warning(f"Password reset requested for non-existent or inactive email: {reset_request.email}") + + # Always return success to prevent email enumeration + return MessageResponse( + success=True, + message="If your email is registered, you will receive a password reset link shortly" + ) + except Exception as e: + logger.error(f"Error processing password reset request: {str(e)}", exc_info=True) + # Still return success to prevent information leakage + return MessageResponse( + success=True, + message="If your email is registered, you will receive a password reset link shortly" + ) + + +@router.post( + "/password-reset/confirm", + response_model=MessageResponse, + status_code=status.HTTP_200_OK, + summary="Confirm Password Reset", + description=""" + Reset password using a token from email. + + **Rate Limit**: 5 requests/minute + """, + operation_id="confirm_password_reset" +) +@limiter.limit("5/minute") +def confirm_password_reset( + request: Request, + reset_confirm: PasswordResetConfirm, + db: Session = Depends(get_db) +) -> Any: + """ + Confirm password reset with token. + + Verifies the token and updates the user's password. + """ + try: + # Verify the reset token + email = verify_password_reset_token(reset_confirm.token) + + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired password reset token" + ) + + # Look up user + user = user_crud.get_by_email(db, email=email) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User account is inactive" + ) + + # Update password + user.password_hash = get_password_hash(reset_confirm.new_password) + db.add(user) + db.commit() + + logger.info(f"Password reset successful for {user.email}") + + return MessageResponse( + success=True, + message="Password has been reset successfully. You can now log in with your new password." + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error confirming password reset: {str(e)}", exc_info=True) + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while resetting your password" + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 06bb210..5d94ccd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -58,6 +58,12 @@ class Settings(BaseSettings): # CORS configuration BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000"] + # Frontend URL for email links + FRONTEND_URL: str = Field( + default="http://localhost:3000", + description="Frontend application URL for email links" + ) + # Admin user FIRST_SUPERUSER_EMAIL: Optional[str] = Field( default=None, diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index 562e875..3b05117 100644 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Optional, Dict, Any from uuid import UUID -from pydantic import BaseModel, EmailStr, field_validator, ConfigDict +from pydantic import BaseModel, EmailStr, field_validator, ConfigDict, Field class UserBase(BaseModel): @@ -166,3 +166,43 @@ class LoginRequest(BaseModel): class RefreshTokenRequest(BaseModel): refresh_token: str + + +class PasswordResetRequest(BaseModel): + """Schema for requesting a password reset.""" + email: EmailStr = Field(..., description="Email address of the account") + + model_config = { + "json_schema_extra": { + "example": { + "email": "user@example.com" + } + } + } + + +class PasswordResetConfirm(BaseModel): + """Schema for confirming a password reset with token.""" + token: str = Field(..., description="Password reset token from email") + new_password: str = Field(..., min_length=8, description="New password") + + @field_validator('new_password') + @classmethod + def password_strength(cls, v: str) -> str: + """Basic password strength validation""" + if len(v) < 8: + raise ValueError('Password must be at least 8 characters') + if not any(char.isdigit() for char in v): + raise ValueError('Password must contain at least one digit') + if not any(char.isupper() for char in v): + raise ValueError('Password must contain at least one uppercase letter') + return v + + model_config = { + "json_schema_extra": { + "example": { + "token": "eyJwYXlsb2FkIjp7ImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTcxMjM0NTY3OH19", + "new_password": "NewSecurePassword123" + } + } + } diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..c611080 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,300 @@ +# app/services/email_service.py +""" +Email service with placeholder implementation. + +This service provides email sending functionality with a simple console/log-based +placeholder that can be easily replaced with a real email provider (SendGrid, SES, etc.) +""" +import logging +from typing import List, Optional +from abc import ABC, abstractmethod + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailBackend(ABC): + """Abstract base class for email backends.""" + + @abstractmethod + async def send_email( + self, + to: List[str], + subject: str, + html_content: str, + text_content: Optional[str] = None + ) -> bool: + """Send an email.""" + pass + + +class ConsoleEmailBackend(EmailBackend): + """ + Console/log-based email backend for development and testing. + + This backend logs email content instead of actually sending emails. + Replace this with a real backend (SMTP, SendGrid, SES) for production. + """ + + async def send_email( + self, + to: List[str], + subject: str, + html_content: str, + text_content: Optional[str] = None + ) -> bool: + """ + Log email content to console/logs. + + Args: + to: List of recipient email addresses + subject: Email subject + html_content: HTML version of the email + text_content: Plain text version of the email + + Returns: + True if "sent" successfully + """ + logger.info("=" * 80) + logger.info("EMAIL SENT (Console Backend)") + logger.info("=" * 80) + logger.info(f"To: {', '.join(to)}") + logger.info(f"Subject: {subject}") + logger.info("-" * 80) + if text_content: + logger.info("Plain Text Content:") + logger.info(text_content) + logger.info("-" * 80) + logger.info("HTML Content:") + logger.info(html_content) + logger.info("=" * 80) + return True + + +class SMTPEmailBackend(EmailBackend): + """ + SMTP email backend for production use. + + TODO: Implement SMTP sending with proper error handling. + This is a placeholder for future implementation. + """ + + def __init__(self, host: str, port: int, username: str, password: str): + self.host = host + self.port = port + self.username = username + self.password = password + + async def send_email( + self, + to: List[str], + subject: str, + html_content: str, + text_content: Optional[str] = None + ) -> bool: + """Send email via SMTP.""" + # TODO: Implement SMTP sending + logger.warning("SMTP backend not yet implemented, falling back to console") + console_backend = ConsoleEmailBackend() + return await console_backend.send_email(to, subject, html_content, text_content) + + +class EmailService: + """ + High-level email service that uses different backends. + + This service provides a clean interface for sending various types of emails + and can be configured to use different backends (console, SMTP, SendGrid, etc.) + """ + + def __init__(self, backend: Optional[EmailBackend] = None): + """ + Initialize email service with a backend. + + Args: + backend: Email backend to use. Defaults to ConsoleEmailBackend. + """ + self.backend = backend or ConsoleEmailBackend() + + async def send_password_reset_email( + self, + to_email: str, + reset_token: str, + user_name: Optional[str] = None + ) -> bool: + """ + Send password reset email. + + Args: + to_email: Recipient email address + reset_token: Password reset token + user_name: User's name for personalization + + Returns: + True if email sent successfully + """ + # Generate reset URL + reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}" + + # Prepare email content + subject = "Password Reset Request" + + # Plain text version + text_content = f""" +Hello{' ' + user_name if user_name else ''}, + +You requested a password reset for your account. Click the link below to reset your password: + +{reset_url} + +This link will expire in 1 hour. + +If you didn't request this, please ignore this email. + +Best regards, +The {settings.PROJECT_NAME} Team +""" + + # HTML version + html_content = f""" + + + + + + +
+
+

Password Reset

+
+
+

Hello{' ' + user_name if user_name else ''},

+

You requested a password reset for your account. Click the button below to reset your password:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+

{reset_url}

+

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+
+ +
+ + +""" + + try: + return await self.backend.send_email( + to=[to_email], + subject=subject, + html_content=html_content, + text_content=text_content + ) + except Exception as e: + logger.error(f"Failed to send password reset email to {to_email}: {str(e)}") + return False + + async def send_email_verification( + self, + to_email: str, + verification_token: str, + user_name: Optional[str] = None + ) -> bool: + """ + Send email verification email. + + Args: + to_email: Recipient email address + verification_token: Email verification token + user_name: User's name for personalization + + Returns: + True if email sent successfully + """ + # Generate verification URL + verification_url = f"{settings.FRONTEND_URL}/verify-email?token={verification_token}" + + # Prepare email content + subject = "Verify Your Email Address" + + # Plain text version + text_content = f""" +Hello{' ' + user_name if user_name else ''}, + +Thank you for signing up! Please verify your email address by clicking the link below: + +{verification_url} + +This link will expire in 24 hours. + +If you didn't create an account, please ignore this email. + +Best regards, +The {settings.PROJECT_NAME} Team +""" + + # HTML version + html_content = f""" + + + + + + +
+
+

Verify Your Email

+
+
+

Hello{' ' + user_name if user_name else ''},

+

Thank you for signing up! Please verify your email address by clicking the button below:

+

+ Verify Email +

+

Or copy and paste this link into your browser:

+

{verification_url}

+

This link will expire in 24 hours.

+

If you didn't create an account, please ignore this email.

+
+ +
+ + +""" + + try: + return await self.backend.send_email( + to=[to_email], + subject=subject, + html_content=html_content, + text_content=text_content + ) + except Exception as e: + logger.error(f"Failed to send verification email to {to_email}: {str(e)}") + return False + + +# Global email service instance +email_service = EmailService() diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py index cc2b4f8..2abe3f1 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -11,6 +11,7 @@ import json import secrets import time from typing import Dict, Any, Optional +from datetime import datetime, timedelta from app.core.config import settings @@ -108,3 +109,189 @@ def verify_upload_token(token: str) -> Optional[Dict[str, Any]]: except (ValueError, KeyError, json.JSONDecodeError): return None + + +def create_password_reset_token(email: str, expires_in: int = 3600) -> str: + """ + Create a signed token for password reset. + + Args: + email: User's email address + expires_in: Expiration time in seconds (default: 3600 = 1 hour) + + Returns: + A base64 encoded token string + + Example: + >>> token = create_password_reset_token("user@example.com") + >>> # Send token to user via email + """ + # Create the payload + payload = { + "email": email, + "exp": int(time.time()) + expires_in, + "nonce": secrets.token_hex(16), # Extra randomness + "purpose": "password_reset" + } + + # Convert to JSON and encode + payload_bytes = json.dumps(payload).encode('utf-8') + + # Create a signature using the secret key + signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + # Combine payload and signature + token_data = { + "payload": payload, + "signature": signature + } + + # Encode the final token + token_json = json.dumps(token_data) + token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8') + + return token + + +def verify_password_reset_token(token: str) -> Optional[str]: + """ + Verify a password reset token and return the email if valid. + + Args: + token: The token string to verify + + Returns: + The email address if valid, None if invalid or expired + + Example: + >>> email = verify_password_reset_token(token_from_user) + >>> if email: + ... # Proceed with password reset + ... else: + ... # Token invalid or expired + """ + try: + # Decode the token + token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8') + token_data = json.loads(token_json) + + # Extract payload and signature + payload = token_data["payload"] + signature = token_data["signature"] + + # Verify it's a password reset token + if payload.get("purpose") != "password_reset": + return None + + # Verify signature + payload_bytes = json.dumps(payload).encode('utf-8') + expected_signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + if signature != expected_signature: + return None + + # Check expiration + if payload["exp"] < int(time.time()): + return None + + return payload["email"] + + except (ValueError, KeyError, json.JSONDecodeError): + return None + + +def create_email_verification_token(email: str, expires_in: int = 86400) -> str: + """ + Create a signed token for email verification. + + Args: + email: User's email address + expires_in: Expiration time in seconds (default: 86400 = 24 hours) + + Returns: + A base64 encoded token string + + Example: + >>> token = create_email_verification_token("user@example.com") + >>> # Send token to user via email + """ + # Create the payload + payload = { + "email": email, + "exp": int(time.time()) + expires_in, + "nonce": secrets.token_hex(16), + "purpose": "email_verification" + } + + # Convert to JSON and encode + payload_bytes = json.dumps(payload).encode('utf-8') + + # Create a signature using the secret key + signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + # Combine payload and signature + token_data = { + "payload": payload, + "signature": signature + } + + # Encode the final token + token_json = json.dumps(token_data) + token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8') + + return token + + +def verify_email_verification_token(token: str) -> Optional[str]: + """ + Verify an email verification token and return the email if valid. + + Args: + token: The token string to verify + + Returns: + The email address if valid, None if invalid or expired + + Example: + >>> email = verify_email_verification_token(token_from_user) + >>> if email: + ... # Mark email as verified + ... else: + ... # Token invalid or expired + """ + try: + # Decode the token + token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8') + token_data = json.loads(token_json) + + # Extract payload and signature + payload = token_data["payload"] + signature = token_data["signature"] + + # Verify it's an email verification token + if payload.get("purpose") != "email_verification": + return None + + # Verify signature + payload_bytes = json.dumps(payload).encode('utf-8') + expected_signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + if signature != expected_signature: + return None + + # Check expiration + if payload["exp"] < int(time.time()): + return None + + return payload["email"] + + except (ValueError, KeyError, json.JSONDecodeError): + return None