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"""
+
+
+
+
+
+
+
+
+
+
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"""
+
+
+
+
+
+
+
+
+
+
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