diff --git a/.env.example b/.env.example index e47b296..7f84578 100755 --- a/.env.example +++ b/.env.example @@ -58,3 +58,16 @@ GRAFANA_USER=grafanaUser GRAFANA_PASSWORD=grafanaPassword GRAFANA_HOST=${HOSTNAME:-localhost} GRAFANA_PORT=8109 + +# SMTP setting +SMTP_ENABLE=false +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=SMTPUserName +SMTP_PASSWORD=SMTPPassword +SMTP_FROM_EMAIL=SMTPFromEmail +SMTP_FROM_NAME=Docker Fullstack Template +SMTP_ENCRYPTION=tls + +# Email verification setting +EMAIL_VERIFICATION_ENABLE=false \ No newline at end of file diff --git a/backend/api/account/controller.py b/backend/api/account/controller.py index e8329df..a8cfdb2 100644 --- a/backend/api/account/controller.py +++ b/backend/api/account/controller.py @@ -2,11 +2,12 @@ from core.dependencies import get_db from core.security import verify_token from sqlalchemy.ext.asyncio import AsyncSession -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from .schema import UserProfile, UserUpdate, PasswordChange from utils.response import APIResponse, parse_responses, common_responses from .services import get_user_by_id, update_user_profile, change_password from utils.custom_exception import AuthenticationException, NotFoundException +from extensions.smtp import get_mailer, SMTPMailer router = APIRouter(tags=["Account"]) @@ -54,24 +55,32 @@ async def get_user_profile_api( response_model_exclude_unset=True, summary="Update current user profile", responses=parse_responses({ - 200: ("User profile updated successfully", UserProfile) + 200: ("User profile updated successfully", UserProfile), + 202: ("Email verification required", UserProfile) }, common_responses) ) async def update_user_profile_api( user_update: UserUpdate, + response: Response, token: dict = Depends(verify_token), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + redis_client = Depends(get_redis), + mailer: SMTPMailer = Depends(get_mailer) ): """ Update the current authenticated user's profile information (excluding password). """ try: - user_id = token.get("sub") - user = await update_user_profile(db, user_id, user_update) + user_id = token.get("sub") + result = await update_user_profile( + db, user_id, user_update, mailer, redis_client + ) - if not user: + if not result: raise NotFoundException("User not found") + user, email_change_requested = result + user_data = UserProfile( id=user.id, first_name=user.first_name, @@ -82,6 +91,10 @@ async def update_user_profile_api( created_at=user.created_at ) + if email_change_requested: + response.status_code = 202 + return APIResponse(code=202, message="Email verification required", data=user_data) + return APIResponse(code=200, message="User profile updated successfully", data=user_data) except NotFoundException: raise HTTPException(status_code=404, detail="User not found") diff --git a/backend/api/account/services.py b/backend/api/account/services.py index f75094d..6fa2bc1 100644 --- a/backend/api/account/services.py +++ b/backend/api/account/services.py @@ -1,10 +1,19 @@ -from typing import Optional -from sqlalchemy import select +import redis +import logging +from typing import Optional, Tuple +from urllib.parse import quote +from sqlalchemy import select, or_ from models.users import Users +from core.config import settings +from extensions.smtp import SMTPMailer from .schema import UserUpdate, PasswordChange from sqlalchemy.ext.asyncio import AsyncSession -from utils.custom_exception import AuthenticationException, ServerException +from utils.custom_exception import AuthenticationException, ServerException, SMTPNotConfiguredException from core.security import hash_password, verify_password, clear_user_all_sessions +from utils.email_templates import EMAIL_VERIFICATION_TEMPLATE +from api.auth.services import _request_email_change_verification_email + +logger = logging.getLogger(__name__) async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[Users]: """Get user info by id""" @@ -13,26 +22,92 @@ async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[Users]: ) return result.scalar_one_or_none() -async def update_user_profile(db: AsyncSession, user_id: str, user_update: UserUpdate) -> Optional[Users]: +async def update_user_profile( + db: AsyncSession, + user_id: str, + user_update: UserUpdate, + mailer: Optional[SMTPMailer] = None, + redis_client: Optional[redis.Redis] = None +) -> Optional[Tuple[Users, bool]]: """Update user info (excluding password)""" user = await get_user_by_id(db, user_id) if not user: return None - if user_update.email and user_update.email != user.email: + email_change_requested = False + new_email = user_update.email + if new_email and new_email != user.email: result = await db.execute( - select(Users).where(Users.email == user_update.email, Users.id != user_id) + select(Users).where( + or_( + Users.email == new_email, + Users.pending_email == new_email + ), + Users.id != user_id + ) ) if result.scalar_one_or_none(): raise ValueError("Email already exists") + + # Defer email change until verification completes. + user.pending_email = new_email + email_change_requested = True update_data = user_update.model_dump(exclude_unset=True) + update_data.pop("email", None) for field, value in update_data.items(): setattr(user, field, value) + if email_change_requested and settings.SMTP_ENABLE and mailer and getattr(mailer, "enabled", False): + should_send = True + if redis_client: + cooldown_key = f"email_verification_cooldown:{new_email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + try: + remaining_seconds = int(remaining_seconds) + except (TypeError, ValueError): + remaining_seconds = 0 + if remaining_seconds > 0: + should_send = False + + if should_send: + try: + token_meta = await _request_email_change_verification_email(db, user, new_email) + verification_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/auth/verify-email?token={quote(token_meta['verification_token'], safe='')}" + ) + + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = EMAIL_VERIFICATION_TEMPLATE.render( + verification_url=verification_url, + user_name=user_name, + app_name=app_name, + expire_minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES, + ) + + mailer.send_text( + to_emails=[new_email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + + if redis_client: + await redis_client.setex( + cooldown_key, + settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS, + "1" + ) + except SMTPNotConfiguredException as exc: + logger.warning("Skip email change verification send: %s", exc) + await db.commit() await db.refresh(user) - return user + return user, email_change_requested async def change_password(db: AsyncSession, user_id: str, password_change: PasswordChange, redis_client=None) -> bool: """Change user password""" diff --git a/backend/api/auth/controller.py b/backend/api/auth/controller.py index de4d71d..e1f8ca3 100644 --- a/backend/api/auth/controller.py +++ b/backend/api/auth/controller.py @@ -6,18 +6,24 @@ from datetime import datetime, timedelta from utils.get_real_ip import get_real_ip from sqlalchemy.ext.asyncio import AsyncSession -from core.security import verify_password_reset_token, verify_token -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from core.security import verify_password_reset_token, verify_token, verify_email_verification_token +from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query from utils.response import APIResponse, parse_responses, common_responses +from extensions.smtp import get_mailer, SMTPMailer +from utils.custom_exception import SMTPNotConfiguredException from .schema import ( UserRegister, UserLogin, UserLoginResponse, TokenResponse, - PasswordResetRequiredResponse, ResetPasswordRequest, TokenValidationResponse, - LogoutRequest + LogoutRequest, + ForgotPasswordRequest, + PasswordResetCooldownResponse, + ResendVerificationRequest, + ActionRequiredResponse, + action_required_response_examples ) from .services import ( register, @@ -26,13 +32,19 @@ token, logout_all_devices, reset_password, - validate_password_reset_token + validate_password_reset_token, + forgot_password, + get_password_reset_cooldown, + verify_email, + resend_verification_email, ) from utils.custom_exception import ( ConflictException, AuthenticationException, PasswordResetRequiredException, - NotFoundException + NotFoundException, + ValidationException, + EmailVerificationRequiredException, ) logger = logging.getLogger(__name__) @@ -45,6 +57,7 @@ summary="Register account", responses=parse_responses({ 200: ("User registered successfully", UserLoginResponse), + 202: ("Email verification required", None), 409: ("Email already exists", None) }, common_responses) ) @@ -53,13 +66,14 @@ async def register_api( request: Request, response: Response, db: AsyncSession = Depends(get_db), - redis_client = Depends(get_redis) + redis_client = Depends(get_redis), + mailer: SMTPMailer = Depends(get_mailer) ): try: client_ip = get_real_ip(request) user_agent = request.headers.get("user-agent", "Registration") - result = await register(db, redis_client, user_data, client_ip, user_agent) + result = await register(db, redis_client, user_data, client_ip, user_agent, mailer) user = result["user"] session_id = result["session_id"] @@ -88,6 +102,9 @@ async def register_api( user=user_response ) return APIResponse(code=200, message="User registered successfully", data=response_data) + except EmailVerificationRequiredException as e: + resp = APIResponse(code=202, message="Email verification required") + raise HTTPException(status_code=202, detail=resp.dict(exclude_none=True)) except ConflictException: raise HTTPException(status_code=409, detail="Email already exists") except Exception: @@ -100,7 +117,7 @@ async def register_api( summary="Login account", responses=parse_responses({ 200: ("User logged in successfully", UserLoginResponse), - 202: ("Password reset required", PasswordResetRequiredResponse), + 202: ("Password reset required / Email verification required", ActionRequiredResponse, action_required_response_examples), 401: ("Invalid email or password", None) }, common_responses) ) @@ -109,13 +126,14 @@ async def login_api( request: Request, response: Response, db: AsyncSession = Depends(get_db), - redis_client = Depends(get_redis) + redis_client = Depends(get_redis), + mailer: SMTPMailer = Depends(get_mailer) ): try: client_ip = get_real_ip(request) user_agent = request.headers.get("user-agent", "") - result = await login(db, redis_client, user_data, client_ip, user_agent) + result = await login(db, redis_client, user_data, client_ip, user_agent, mailer) user = result["user"] session_id = result["session_id"] @@ -147,6 +165,9 @@ async def login_api( except PasswordResetRequiredException as e: resp = APIResponse(code=202, message="Password reset required", data=e.details) raise HTTPException(status_code=202, detail=resp.dict(exclude_none=True)) + except EmailVerificationRequiredException as e: + resp = APIResponse(code=202, message="Email verification required", data=e.details) + raise HTTPException(status_code=202, detail=resp.dict(exclude_none=True)) except AuthenticationException as e: raise HTTPException(status_code=401, detail="Invalid email or password") except Exception: @@ -310,5 +331,199 @@ async def validate_reset_token_api( return APIResponse(code=200, message="Token is valid", data=result) except AuthenticationException: raise HTTPException(status_code=401, detail="Invalid or expired token") + except Exception: + raise HTTPException(status_code=500) + + +@router.post( + "/forgot-password", + response_model=APIResponse[None], + response_model_exclude_none=True, + summary="Send reset password email", + responses=parse_responses({ + 200: ("Reset password email sent", None), + 400: ("Please wait before requesting another password reset email", None), + 403: ("Account is disabled", None), + 404: ("User not registered", None), + 503: ("SMTP is disabled", None), + }, common_responses), +) +async def forgot_password_api( + request: Request, + request_data: ForgotPasswordRequest, + db: AsyncSession = Depends(get_db), + mailer: SMTPMailer = Depends(get_mailer), + redis_client = Depends(get_redis), +): + """ + Send password reset email based on input email. + """ + try: + await forgot_password(db, request_data.email, mailer, redis_client) + return APIResponse(code=200, message="Reset password email sent") + except ValidationException: + raise HTTPException(status_code=400, detail="Please wait before requesting another password reset email") + except AuthenticationException: + raise HTTPException(status_code=403, detail="Account is disabled") + except NotFoundException: + raise HTTPException(status_code=404, detail="User not registered") + except SMTPNotConfiguredException: + raise HTTPException(status_code=503, detail="SMTP is disabled") + except Exception: + raise HTTPException(status_code=500) + +@router.get( + "/forgot-password/cooldown", + response_model=APIResponse[PasswordResetCooldownResponse], + response_model_exclude_none=True, + summary="Get password reset email cooldown status", + responses=parse_responses({ + 200: ("Cooldown status retrieved", PasswordResetCooldownResponse), + }, common_responses), +) +async def get_password_reset_cooldown_api( + email: str = Query(..., description="Email address to check cooldown for"), + redis_client = Depends(get_redis), +): + """ + Get remaining cooldown time for password reset email. + Returns 0 if no cooldown is active. + """ + try: + result = await get_password_reset_cooldown(email, redis_client) + response_data = PasswordResetCooldownResponse( + cooldown_seconds=result["cooldown_seconds"] + ) + return APIResponse(code=200, message="Cooldown status retrieved", data=response_data) + except Exception: + raise HTTPException(status_code=500) + +@router.get( + "/verify-email", + response_model=APIResponse[UserLoginResponse], + response_model_exclude_none=True, + summary="Verify email address", + responses=parse_responses({ + 200: ("Email verified successfully", UserLoginResponse), + 401: ("Invalid or expired token", None), + 404: ("User not found", None), + 409: ("Email already exists", None) + }, common_responses) +) +async def verify_email_api( + request: Request, + response: Response, + token: dict = Depends(verify_email_verification_token), + db: AsyncSession = Depends(get_db), + redis_client = Depends(get_redis) +): + """Verify email address using token and create session""" + try: + client_ip = get_real_ip(request) + user_agent = request.headers.get("user-agent", "") + + result = await verify_email(db, redis_client, token, client_ip, user_agent) + + user = result["user"] + session_id = result["session_id"] + access_token = result["access_token"] + + user_response = UserResponse( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + phone=user.phone + ) + + response_data = UserLoginResponse( + access_token=access_token, + expires_at=datetime.now().astimezone() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + user=user_response + ) + + response.set_cookie( + key="session_id", + value=session_id, + httponly=settings.COOKIE_HTTPONLY, + secure=settings.COOKIE_SECURE, + samesite=settings.COOKIE_SAMESITE, + max_age=settings.SESSION_EXPIRE_MINUTES * 60 + ) + + return APIResponse(code=200, message="Email verified successfully", data=response_data) + except AuthenticationException: + raise HTTPException(status_code=401, detail="Invalid or expired token") + except NotFoundException: + raise HTTPException(status_code=404, detail="User not found") + except ConflictException: + raise HTTPException(status_code=409, detail="Email already exists") + except Exception: + raise HTTPException(status_code=500) + +@router.post( + "/resend-verification", + response_model=APIResponse[None], + response_model_exclude_none=True, + summary="Resend email verification", + responses=parse_responses({ + 200: ("Verification email sent", None), + 400: ("Please wait before requesting another verification email", None), + 403: ("Account is disabled", None), + 404: ("User not registered", None), + 503: ("SMTP is disabled", None), + }, common_responses) +) +async def resend_verification_api( + request: Request, + request_data: ResendVerificationRequest, + db: AsyncSession = Depends(get_db), + mailer: SMTPMailer = Depends(get_mailer), + redis_client = Depends(get_redis), +): + """Resend email verification email""" + try: + await resend_verification_email(db, request_data.email, mailer, redis_client) + return APIResponse(code=200, message="Verification email sent") + except ValidationException: + raise HTTPException(status_code=400, detail="Please wait before requesting another verification email") + except AuthenticationException: + raise HTTPException(status_code=403, detail="Account is disabled") + except NotFoundException: + raise HTTPException(status_code=404, detail="User not registered") + except SMTPNotConfiguredException: + raise HTTPException(status_code=503, detail="SMTP is disabled") + except Exception: + raise HTTPException(status_code=500) + +@router.get( + "/resend-verification/cooldown", + response_model=APIResponse[PasswordResetCooldownResponse], + response_model_exclude_none=True, + summary="Get email verification cooldown status", + responses=parse_responses({ + 200: ("Cooldown status retrieved", PasswordResetCooldownResponse), + }, common_responses), +) +async def get_email_verification_cooldown_api( + email: str = Query(..., description="Email address to check cooldown for"), + redis_client = Depends(get_redis), +): + """ + Get remaining cooldown time for email verification email. + Returns 0 if no cooldown is active. + """ + try: + cooldown_key = f"email_verification_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + # TTL returns -1 if key exists but has no expiry, -2 if key doesn't exist + if remaining_seconds < 0: + remaining_seconds = 0 + + response_data = PasswordResetCooldownResponse( + cooldown_seconds=remaining_seconds + ) + return APIResponse(code=200, message="Cooldown status retrieved", data=response_data) except Exception: raise HTTPException(status_code=500) \ No newline at end of file diff --git a/backend/api/auth/schema.py b/backend/api/auth/schema.py index ea4b650..4b83b5a 100644 --- a/backend/api/auth/schema.py +++ b/backend/api/auth/schema.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from typing import TypedDict, Optional from datetime import datetime from core.config import settings from pydantic import BaseModel, EmailStr, Field @@ -39,9 +39,10 @@ class TokenResponse(BaseModel): access_token: str = Field(..., description="JWT access token") expires_at: datetime = Field(..., description="Token expiration time") -class PasswordResetRequiredResponse(BaseModel): - reset_token: str = Field(..., description="Password reset token") - expires_at: str = Field(..., description="Token expiration time") +class ActionRequiredResponse(BaseModel): + action_type: str = Field(..., description="Action type for frontend routing: 'password_reset' or 'email_verification'") + token: Optional[str] = Field(default=None, description="Token for the password reset") + expires_at: Optional[str] = Field(default=None, description="Token expiration time (ISO format)") class LogoutRequest(BaseModel): logout_all: bool = Field(False, description="Whether to logout from all devices") @@ -50,4 +51,50 @@ class ResetPasswordRequest(BaseModel): new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="New password") class TokenValidationResponse(BaseModel): - is_valid: bool = Field(..., description="Whether the token is valid") \ No newline at end of file + is_valid: bool = Field(..., description="Whether the token is valid") + +class ForgotPasswordRequest(BaseModel): + email: EmailStr = Field(..., description="User email address") + +class PasswordResetCooldownResponse(BaseModel): + cooldown_seconds: int = Field(..., description="Remaining cooldown time in seconds") + +class EmailVerificationResponse(BaseModel): + message: str = Field(..., description="Verification result message") + +class EmailVerificationRequiredResponse(BaseModel): + expires_at: Optional[str] = Field(default=None, description="Token expiration time (ISO format)") + +class PasswordResetRequiredResponse(BaseModel): + reset_token: str = Field(..., description="Password reset token") + expires_at: str = Field(..., description="Token expiration time (ISO format)") + +class ResendVerificationRequest(BaseModel): + email: EmailStr = Field(..., description="Email address to resend verification") + +action_required_response_examples = { + "passwordReset": { + "summary": "Password reset required", + "value": { + "code": 202, + "message": "Password reset required", + "data": { + "action_type": "password_reset", + "token": "password_reset_token", + "expires_at": "2024-01-01T12:00:00+00:00" + } + } + }, + "emailVerification": { + "summary": "Email verification required", + "value": { + "code": 202, + "message": "Email verification required", + "data": { + "action_type": "email_verification", + "token": None, + "expires_at": "2024-01-01T12:00:00+00:00" + } + } + } +} \ No newline at end of file diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py index 9e53080..39b1c7a 100644 --- a/backend/api/auth/services.py +++ b/backend/api/auth/services.py @@ -1,21 +1,29 @@ import ast import redis from typing import Optional -from sqlalchemy import select +from sqlalchemy import select, update, or_ +from urllib.parse import quote from models.users import Users from core.config import settings +from extensions.smtp import SMTPMailer from models.login_logs import LoginLogs from datetime import datetime, timedelta from models.user_sessions import UserSessions from sqlalchemy.ext.asyncio import AsyncSession +from utils.email_templates import ( + PASSWORD_RESET_TEMPLATE, + EMAIL_VERIFICATION_TEMPLATE, +) from models.password_reset_tokens import PasswordResetTokens +from models.email_verification_tokens import EmailVerificationTokens from .schema import ( UserRegister, UserLogin, LoginResult, SessionResult, TokenValidationResponse, - PasswordResetRequiredResponse + ActionRequiredResponse, + PasswordResetRequiredResponse, ) from core.security import ( verify_password, @@ -23,25 +31,74 @@ hash_password, extend_session_ttl, clear_user_all_sessions, - create_password_reset_token + create_password_reset_token, + create_email_verification_token ) from utils.custom_exception import ( ConflictException, AuthenticationException, PasswordResetRequiredException, + NotFoundException, + SMTPNotConfiguredException, ServerException, - NotFoundException + ValidationException, + EmailVerificationRequiredException, ) + async def register( db: AsyncSession, redis_client: redis.Redis, user_data: UserRegister, ip_address: str, - user_agent: str + user_agent: str, + mailer: Optional[SMTPMailer] = None ) -> LoginResult: """User register""" user = await _create_user(db, user_data) + + # Check if email verification is required + if settings.EMAIL_VERIFICATION_ENABLE and settings.SMTP_ENABLE and mailer: + # Check cooldown + cooldown_key = f"email_verification_cooldown:{user.email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + if remaining_seconds > 0: + # In cooldown, return 202 without data + await _log_login_attempt( + db, email=user.email, + ip_address=ip_address, + user_agent=user_agent, + is_success=True, + user_id=user.id + ) + raise EmailVerificationRequiredException( + message="Email verification required", + details=None + ) + else: + # Not in cooldown, send verification email + await _send_registration_verification_email(db, mailer, user) + + # Set cooldown + await redis_client.setex( + cooldown_key, + settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS, + "1" + ) + + await _log_login_attempt( + db, email=user.email, + ip_address=ip_address, + user_agent=user_agent, + is_success=True, + user_id=user.id + ) + raise EmailVerificationRequiredException( + message="Email verification required", + details=None + ) + session_result = await _create_user_session( db, redis_client, user, ip_address, user_agent ) @@ -63,7 +120,8 @@ async def login( redis_client: redis.Redis, login_data: UserLogin, ip_address: str, - user_agent: str + user_agent: str, + mailer: Optional[SMTPMailer] = None ) -> LoginResult: """User login""" result = await db.execute( @@ -127,10 +185,65 @@ async def login( message="Password reset required", details=PasswordResetRequiredResponse( reset_token=reset_token, - expires_at=reset_token_record.expires_at.isoformat() + expires_at=reset_token_record.expires_at.isoformat() if reset_token_record.expires_at else None ) ) + # Check if email verification is required + if settings.EMAIL_VERIFICATION_ENABLE and settings.SMTP_ENABLE and mailer: + if not user.email_verified: + # Check cooldown + cooldown_key = f"email_verification_cooldown:{user.email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + if remaining_seconds > 0: + # In cooldown, return 202 with cooldown time + await _log_login_attempt( + db, email=user.email, + ip_address=ip_address, + user_agent=user_agent, + is_success=True, + user_id=user.id + ) + # Calculate expires_at from cooldown + expires_at = (datetime.now().astimezone() + timedelta(seconds=remaining_seconds)).isoformat() + raise EmailVerificationRequiredException( + message="Email verification required", + details=ActionRequiredResponse( + action_type="email_verification", + token=None, + expires_at=expires_at + ) + ) + else: + # Not in cooldown, send verification email + await _send_registration_verification_email(db, mailer, user) + + # Set cooldown + await redis_client.setex( + cooldown_key, + settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS, + "1" + ) + + await _log_login_attempt( + db, email=user.email, + ip_address=ip_address, + user_agent=user_agent, + is_success=True, + user_id=user.id + ) + # Calculate expires_at from cooldown + expires_at = (datetime.now().astimezone() + timedelta(seconds=settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS)).isoformat() + raise EmailVerificationRequiredException( + message="Email verification required", + details=ActionRequiredResponse( + action_type="email_verification", + token=None, + expires_at=expires_at + ) + ) + session_result = await _create_user_session( db, redis_client, user, ip_address, user_agent ) @@ -256,9 +369,6 @@ async def reset_password( if not user: raise NotFoundException("User not found") - if not user.password_reset_required: - raise AuthenticationException("Password reset not required for this user") - user.hash_password = await hash_password(new_password) user.password_reset_required = False @@ -312,8 +422,8 @@ async def validate_password_reset_token( ) user = result.scalar_one_or_none() - if not user or not user.password_reset_required: - raise AuthenticationException("User not found or password reset not required") + if not user or not user.status: + raise AuthenticationException("User not found or account disabled") return TokenValidationResponse( is_valid=True @@ -324,6 +434,89 @@ async def validate_password_reset_token( except Exception as e: raise ServerException(f"Token validation failed: {str(e)}") +async def forgot_password( + db: AsyncSession, + email: str, + mailer: SMTPMailer, + redis_client: redis.Redis, +) -> dict: + """ + Forgot password and send reset password email + """ + try: + user = await _get_user_by_email_for_password_reset(db, email) + + if not getattr(mailer, "enabled", False): + raise SMTPNotConfiguredException("SMTP is disabled") + + cooldown_key = f"password_reset_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + if remaining_seconds > 0: + raise ValidationException( + f"Please wait {remaining_seconds} seconds before requesting another password reset email", + details={"cooldown_seconds": remaining_seconds} + ) + + token_meta = await _request_password_reset_email(db, user) + reset_token = token_meta["reset_token"] + reset_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/auth/reset-password?token={quote(reset_token, safe='')}" + ) + + # Render email template with user name and app name + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = PASSWORD_RESET_TEMPLATE.render( + reset_url=reset_url, + user_name=user_name, + app_name=app_name, + ) + + mailer.send_text( + to_emails=[email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + + # Set cooldown period in Redis + await redis_client.setex( + cooldown_key, + settings.PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS, + "1" + ) + + return {**token_meta, "reset_url": reset_url} + + except (NotFoundException, AuthenticationException, SMTPNotConfiguredException, ValidationException): + raise + except Exception as e: + raise ServerException(f"Failed to send password reset email: {str(e)}") + + +async def get_password_reset_cooldown( + email: str, + redis_client: redis.Redis, +) -> dict: + """ + Get remaining cooldown time for password reset email. + + Returns: + Dict with 'cooldown_seconds' (0 if no cooldown active) + """ + cooldown_key = f"password_reset_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + # TTL returns -1 if key exists but has no expiry, -2 if key doesn't exist + if remaining_seconds < 0: + remaining_seconds = 0 + + return {"cooldown_seconds": remaining_seconds} + async def _update_session_expiry(db: AsyncSession, session_id: str) -> None: """Update session expiry time in database""" try: @@ -340,7 +533,12 @@ async def _update_session_expiry(db: AsyncSession, session_id: str) -> None: async def _create_user(db: AsyncSession, user_data: UserRegister) -> Users: try: result = await db.execute( - select(Users).where(Users.email == user_data.email) + select(Users).where( + or_( + Users.email == user_data.email, + Users.pending_email == user_data.email + ) + ) ) existing_user = result.scalar_one_or_none() if existing_user: @@ -439,4 +637,329 @@ async def _log_login_attempt( failure_reason=failure_reason ) db.add(log) - await db.commit() \ No newline at end of file + await db.commit() + +async def _get_user_by_email_for_password_reset(db: AsyncSession, email: str) -> Users: + result = await db.execute(select(Users).where(Users.email == email)) + user = result.scalar_one_or_none() + if not user: + raise NotFoundException("User not registered") + if not user.status: + raise AuthenticationException("Account is disabled") + return user + +async def _request_password_reset_email( + db: AsyncSession, + user: Users, +) -> dict: + """ + Create a password reset token record for the user and return token metadata. + Invalidates all previous unused tokens for this user before creating a new one. + """ + now = datetime.now().astimezone() + expires_at = now + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES) + + # Invalidate all previous unused tokens for this user + await db.execute( + update(PasswordResetTokens) + .where( + PasswordResetTokens.user_id == user.id, + PasswordResetTokens.is_used == False + ) + .values(is_used=True) + ) + + reset_token = await create_password_reset_token(user.id, user.email) + reset_token_record = PasswordResetTokens( + user_id=user.id, + token=reset_token, + expires_at=expires_at, + ) + db.add(reset_token_record) + await db.commit() + + return { + "reset_token": reset_token, + "expires_at": expires_at, + "user_id": user.id, + } + +async def verify_email( + db: AsyncSession, + redis_client: redis.Redis, + token: dict, + ip_address: str, + user_agent: str +) -> LoginResult: + """Verify email using token and create session""" + try: + user_id = token.get("sub") + email = token.get("email") + verification_type = token.get("verification_type") + token_string = token.get("token") + + # Verify token record exists and is valid + result = await db.execute( + select(EmailVerificationTokens).where( + EmailVerificationTokens.token == token_string, + EmailVerificationTokens.user_id == user_id, + EmailVerificationTokens.email == email, + EmailVerificationTokens.token_type == verification_type, + EmailVerificationTokens.is_used == False, + EmailVerificationTokens.expires_at > datetime.now().astimezone() + ) + ) + token_record = result.scalar_one_or_none() + + if not token_record: + raise AuthenticationException("Invalid or expired token") + + # Get user + result = await db.execute( + select(Users).where(Users.id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise NotFoundException("User not found") + + if not user.status: + raise AuthenticationException("Account is disabled") + + # Mark token as used + token_record.is_used = True + + if verification_type == "registration": + # Mark email as verified + user.email_verified = True + elif verification_type == "email_change": + # Update email from pending_email to email + if user.pending_email != email: + raise AuthenticationException("Email mismatch") + + # Check if new email already exists + result = await db.execute( + select(Users).where(Users.email == email, Users.id != user_id) + ) + if result.scalar_one_or_none(): + raise ConflictException("Email already exists") + + user.email = email + user.pending_email = None + user.email_verified = True + + # Create session for verified user + session_result = await _create_user_session( + db, redis_client, user, ip_address, user_agent + ) + + await db.commit() + + return { + "user": user, + "session_id": session_result["session_id"], + "access_token": session_result["access_token"] + } + + except (AuthenticationException, NotFoundException, ConflictException): + raise + except Exception as e: + raise ServerException(f"Failed to verify email: {str(e)}") + +async def resend_verification_email( + db: AsyncSession, + email: str, + mailer: SMTPMailer, + redis_client: redis.Redis, +) -> dict: + """Resend email verification""" + try: + user = await _get_user_by_email_for_password_reset(db, email) + + if not settings.SMTP_ENABLE or not getattr(mailer, "enabled", False): + raise SMTPNotConfiguredException("SMTP is disabled") + + # Check cooldown + cooldown_key = f"email_verification_cooldown:{email}" + remaining_seconds = await redis_client.ttl(cooldown_key) + + if remaining_seconds > 0: + raise ValidationException( + f"Please wait {remaining_seconds} seconds before requesting another verification email", + details={"cooldown_seconds": remaining_seconds} + ) + + # Determine verification type + if user.email_verified: + # If email is already verified but there's a pending email, resend email change verification + if user.pending_email: + token_meta = await _request_email_change_verification_email(db, user, user.pending_email) + verification_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/auth/verify-email?token={quote(token_meta['verification_token'], safe='')}" + ) + + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = EMAIL_VERIFICATION_TEMPLATE.render( + verification_url=verification_url, + user_name=user_name, + app_name=app_name, + expire_minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES, + ) + + mailer.send_text( + to_emails=[user.pending_email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + else: + raise ValidationException("Email is already verified") + else: + # Resend registration verification + token_meta = await _request_registration_verification_email(db, user) + verification_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/auth/verify-email?token={quote(token_meta['verification_token'], safe='')}" + ) + + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = EMAIL_VERIFICATION_TEMPLATE.render( + verification_url=verification_url, + user_name=user_name, + app_name=app_name, + expire_minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES, + ) + + mailer.send_text( + to_emails=[email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + + # Set cooldown + await redis_client.setex( + cooldown_key, + settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS, + "1" + ) + + return {"message": "Verification email sent"} + + except (NotFoundException, AuthenticationException, SMTPNotConfiguredException, ValidationException): + raise + except Exception as e: + raise ServerException(f"Failed to send verification email: {str(e)}") + +async def _send_registration_verification_email( + db: AsyncSession, + mailer: SMTPMailer, + user: Users +) -> None: + """Send registration verification email""" + if not settings.SMTP_ENABLE or not getattr(mailer, "enabled", False): + return + + token_meta = await _request_registration_verification_email(db, user) + verification_url = ( + f"http{'s' if settings.SSL_ENABLE else ''}://" + f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}" + f"/auth/verify-email?token={quote(token_meta['verification_token'], safe='')}" + ) + + user_name = f"{user.first_name} {user.last_name}".strip() + app_name = settings.PROJECT_NAME + + email_content = EMAIL_VERIFICATION_TEMPLATE.render( + verification_url=verification_url, + user_name=user_name, + app_name=app_name, + expire_minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES, + ) + + mailer.send_text( + to_emails=[user.email], + subject=email_content["subject"], + body=email_content["body"], + html_body=email_content.get("html_body"), + ) + +async def _request_registration_verification_email( + db: AsyncSession, + user: Users, +) -> dict: + """Create a registration verification token record""" + now = datetime.now().astimezone() + expires_at = now + timedelta(minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES) + + # Invalidate all previous unused registration tokens for this user + await db.execute( + update(EmailVerificationTokens) + .where( + EmailVerificationTokens.user_id == user.id, + EmailVerificationTokens.token_type == "registration", + EmailVerificationTokens.is_used == False + ) + .values(is_used=True) + ) + + verification_token = await create_email_verification_token(user.id, user.email, "registration") + token_record = EmailVerificationTokens( + user_id=user.id, + email=user.email, + token=verification_token, + token_type="registration", + expires_at=expires_at, + ) + db.add(token_record) + await db.commit() + + return { + "verification_token": verification_token, + "expires_at": expires_at, + "user_id": user.id, + } + +async def _request_email_change_verification_email( + db: AsyncSession, + user: Users, + new_email: str, +) -> dict: + """Create an email change verification token record""" + now = datetime.now().astimezone() + expires_at = now + timedelta(minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES) + + # Invalidate all previous unused email_change tokens for this user + await db.execute( + update(EmailVerificationTokens) + .where( + EmailVerificationTokens.user_id == user.id, + EmailVerificationTokens.token_type == "email_change", + EmailVerificationTokens.is_used == False + ) + .values(is_used=True) + ) + + verification_token = await create_email_verification_token(user.id, new_email, "email_change") + token_record = EmailVerificationTokens( + user_id=user.id, + email=new_email, + token=verification_token, + token_type="email_change", + expires_at=expires_at, + ) + db.add(token_record) + await db.commit() + + return { + "verification_token": verification_token, + "expires_at": expires_at, + "user_id": user.id, + } \ No newline at end of file diff --git a/backend/api/users/services.py b/backend/api/users/services.py index c246a0d..6a56c43 100644 --- a/backend/api/users/services.py +++ b/backend/api/users/services.py @@ -158,7 +158,12 @@ async def create_user(db: AsyncSession, user_data: UserCreate) -> UserResponse: try: # Check if the email already exists result = await db.execute( - select(Users).where(Users.email == user_data.email) + select(Users).where( + or_( + Users.email == user_data.email, + Users.pending_email == user_data.email + ) + ) ) existing_user = result.scalar_one_or_none() if existing_user: @@ -211,7 +216,13 @@ async def update_user(db: AsyncSession, user_id: str, user_data: UserUpdate) -> # Check if the email is already used by another user if user_data.email and user_data.email != user.email: result = await db.execute( - select(Users).where(Users.email == user_data.email, Users.id != user_id) + select(Users).where( + or_( + Users.email == user_data.email, + Users.pending_email == user_data.email + ), + Users.id != user_id + ) ) if result.scalar_one_or_none(): raise ConflictException("Email already exists") diff --git a/backend/core/config.py b/backend/core/config.py index 7279e80..01039d0 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -41,6 +41,12 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 1 day PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30 # 30 minutes + PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS: int = 60 # 1 minute + + # Email verification settings + EMAIL_VERIFICATION_ENABLE: bool = False + EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES: int = 30 # 30 minutes + EMAIL_VERIFICATION_COOLDOWN_SECONDS: int = 60 # 1 minute # Session settings SESSION_EXPIRE_MINUTES: int = 10080 # 7 days @@ -54,7 +60,17 @@ class Settings(BaseSettings): PASSWORD_MIN_LENGTH: int = 6 RATE_LIMIT: int = 200 RATE_LIMIT_WINDOW_SECONDS: int = 300 # 5 minutes - BLOCK_TIME_SECONDS: int = 600 # 10 minutes + BLOCK_TIME_SECONDS: int = 600 # 10 minutes + + # SMTP setting + SMTP_ENABLE: bool = False + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_EMAIL: str = "" + SMTP_FROM_NAME: str = "Docker Fullstack Template" + SMTP_ENCRYPTION: str = "tls" # Default admin user settings DEFAULT_ADMIN_EMAIL: str = "admin@example.com" diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py index a6636cd..44c51b9 100644 --- a/backend/core/dependencies.py +++ b/backend/core/dependencies.py @@ -1,4 +1,5 @@ import logging +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from core.database import AsyncSessionLocal, SessionLocal @@ -9,6 +10,9 @@ async def get_db() -> AsyncSession: async with AsyncSessionLocal() as db: try: yield db + except (HTTPException): + await db.rollback() + raise except Exception as e: logger.error(f"Database error: {e}") await db.rollback() diff --git a/backend/core/security.py b/backend/core/security.py index 71a64ef..fb7d573 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -55,6 +55,28 @@ async def create_password_reset_token(user_id: str, email: str) -> str: except Exception as e: raise ServerException(f"Failed to create password reset token: {str(e)}") +async def create_email_verification_token(user_id: str, email: str, token_type: str) -> str: + """Create email verification token""" + try: + now = datetime.now().astimezone() + expire = now + timedelta(minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES) + + payload = { + "sub": user_id, + "email": email, + "token_type": "email_verification", + "verification_type": token_type, # 'registration' or 'email_change' + "iat": now, + "exp": expire + } + + token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + return token + + except Exception as e: + raise ServerException(f"Failed to create email verification token: {str(e)}") + async def get_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False))) -> str: if not credentials: raise HTTPException( @@ -172,6 +194,51 @@ async def verify_password_reset_token(token: str = Depends(get_token)) -> Dict[s headers={"WWW-Authenticate": "Bearer"} ) +async def verify_email_verification_token(token: str = Depends(get_token)) -> Dict[str, Any]: + """Verify email verification token""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + if payload.get("token_type") != "email_verification": + raise ValueError("Invalid token type") + + verification_type = payload.get("verification_type") + if verification_type not in ["registration", "email_change"]: + raise ValueError("Invalid verification type") + + if payload.get("exp") < datetime.now().astimezone().timestamp(): + raise ValueError("Token expired") + + user_id = payload.get("sub") + email = payload.get("email") + + if not user_id or not email: + raise ValueError("Invalid token payload") + + return { + "token": token, + "sub": user_id, + "email": email, + "verification_type": verification_type, + "exp": payload.get("exp"), + "iat": payload.get("iat") + } + + except JWTError as e: + logger.warning(f"JWT validation failed: {type(e).__name__}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"} + ) + except ValueError as e: + logger.error(f"Failed to verify email verification token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"} + ) + async def extend_session_ttl(redis_client, session_id: str, session_data: Dict[str, Any]) -> None: """Extend session TTL and update last activity time""" try: diff --git a/backend/extensions/__init__.py b/backend/extensions/__init__.py index 6f9d386..0b83571 100644 --- a/backend/extensions/__init__.py +++ b/backend/extensions/__init__.py @@ -1,5 +1,7 @@ from .exception_handler import add_exception_handlers +from .smtp import add_smtp def register_extensions(app): # Add new extensions imports below. - add_exception_handlers(app) \ No newline at end of file + add_exception_handlers(app) + add_smtp(app) \ No newline at end of file diff --git a/backend/extensions/smtp.py b/backend/extensions/smtp.py new file mode 100644 index 0000000..2ceb6ef --- /dev/null +++ b/backend/extensions/smtp.py @@ -0,0 +1,173 @@ +import ssl +import smtplib +import logging +from fastapi import FastAPI +from core.config import settings +from dataclasses import dataclass +from email.mime.text import MIMEText +from typing import Iterable, Optional +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from utils.custom_exception import SMTPNotConfiguredException + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SMTPSettings: + enabled: bool + host: str + port: int + username: Optional[str] + password: Optional[str] + from_email: Optional[str] + from_name: str + encryption: str + + +class SMTPMailer: + def __init__(self, cfg: SMTPSettings): + self._cfg = cfg + + @property + def enabled(self) -> bool: + return self._cfg.enabled + + def _validate(self) -> None: + if not self._cfg.enabled: + raise SMTPNotConfiguredException("SMTP is disabled") + missing = [] + if not self._cfg.host: + missing.append("SMTP_HOST") + if not self._cfg.port: + missing.append("SMTP_PORT") + if not self._cfg.username: + missing.append("SMTP_USERNAME/SMTP_USER") + if not self._cfg.password: + missing.append("SMTP_PASSWORD") + if not self._cfg.from_email: + missing.append("SMTP_FROM_EMAIL/SMTP_FROM") + if missing: + raise SMTPNotConfiguredException(f"Missing SMTP settings: {', '.join(missing)}") + + enc = (self._cfg.encryption or "").strip().lower() + if enc not in {"tls", "ssl", "none"}: + raise SMTPNotConfiguredException("SMTP_ENCRYPTION must be tls, ssl, or none") + + def _open(self, timeout: int = 30) -> smtplib.SMTP: + """ + Create a new SMTP connection per send. + This avoids keeping long-lived connections in the web process. + """ + self._validate() + + enc = self._cfg.encryption.strip().lower() + context = ssl.create_default_context() + + if enc == "ssl": + client: smtplib.SMTP = smtplib.SMTP_SSL(self._cfg.host, self._cfg.port, timeout=timeout, context=context) + else: + client = smtplib.SMTP(self._cfg.host, self._cfg.port, timeout=timeout) + + try: + client.ehlo() + if enc == "tls": + client.starttls(context=context) + client.ehlo() + if self._cfg.username and self._cfg.password: + client.login(self._cfg.username, self._cfg.password) + return client + except Exception: + try: + client.quit() + except Exception: + pass + raise + + def send_text( + self, + *, + to_emails: Iterable[str], + subject: str, + body: str, + html_body: Optional[str] = None, + from_email: Optional[str] = None, + from_name: Optional[str] = None, + timeout: int = 30, + ) -> None: + self._validate() + + sender_email = from_email or self._cfg.from_email + sender_name = from_name or self._cfg.from_name + + # If HTML body is provided, create multipart message + if html_body: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{sender_name} <{sender_email}>" + msg["To"] = ", ".join(list(to_emails)) + + # Add plain text and HTML parts + part1 = MIMEText(body, "plain", "utf-8") + part2 = MIMEText(html_body, "html", "utf-8") + msg.attach(part1) + msg.attach(part2) + else: + # Plain text only + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = f"{sender_name} <{sender_email}>" + msg["To"] = ", ".join(list(to_emails)) + msg.set_content(body) + + with self._open(timeout=timeout) as client: + client.send_message(msg) + + +def build_smtp_settings() -> SMTPSettings: + return SMTPSettings( + enabled=bool(settings.SMTP_ENABLE), + host=(settings.SMTP_HOST or "").strip(), + port=int(settings.SMTP_PORT), + username=(settings.SMTP_USERNAME or None), + password=(settings.SMTP_PASSWORD or None), + from_email=(settings.SMTP_FROM_EMAIL or None), + from_name=(settings.SMTP_FROM_NAME or "Docker Fullstack Template"), + encryption=(settings.SMTP_ENCRYPTION or "tls"), + ) + + +# Global singleton instance +_SMTP_MAILER: Optional[SMTPMailer] = None + + +def get_mailer() -> SMTPMailer: + """ + Get SMTP mailer singleton instance. + Lazy initialization on first access. + """ + global _SMTP_MAILER + if _SMTP_MAILER is None: + cfg = build_smtp_settings() + _SMTP_MAILER = SMTPMailer(cfg) + + if cfg.enabled: + logger.info( + "SMTP enabled: host=%s port=%s encryption=%s from=%s", + cfg.host, + cfg.port, + (cfg.encryption or "").lower(), + cfg.from_email, + ) + else: + logger.info("SMTP disabled") + + return _SMTP_MAILER + + +def add_smtp(app: FastAPI) -> None: + """ + Initialize SMTP mailer and register to app.state. + """ + mailer = get_mailer() + app.state.smtp = mailer \ No newline at end of file diff --git a/backend/migrations/versions/2023d62e40eb_add_email_verification_support.py b/backend/migrations/versions/2023d62e40eb_add_email_verification_support.py new file mode 100644 index 0000000..ee1e583 --- /dev/null +++ b/backend/migrations/versions/2023d62e40eb_add_email_verification_support.py @@ -0,0 +1,64 @@ +"""add_email_verification_support + +Revision ID: 2023d62e40eb +Revises: 966d244d4548 +Create Date: 2026-01-16 15:39:28.684258 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2023d62e40eb' +down_revision: Union[str, None] = '966d244d4548' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_verification_tokens', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('email', sa.String(length=50), nullable=False), + sa.Column('token', sa.Text(), nullable=False), + sa.Column('token_type', sa.String(length=20), nullable=False), + sa.Column('is_used', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False), + sa.Column('expires_at', sa.TIMESTAMP(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_email_verification_tokens_email'), 'email_verification_tokens', ['email'], unique=False) + op.create_index(op.f('ix_email_verification_tokens_id'), 'email_verification_tokens', ['id'], unique=True) + op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True) + op.create_index(op.f('ix_email_verification_tokens_token_type'), 'email_verification_tokens', ['token_type'], unique=False) + op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False) + op.drop_index(op.f('uq_role_attributes_name'), table_name='role_attributes') + op.drop_index(op.f('uq_roles_name'), table_name='roles') + op.add_column('users', sa.Column('email_verified', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('users', sa.Column('pending_email', sa.String(length=50), nullable=True)) + op.create_index(op.f('ix_users_pending_email'), 'users', ['pending_email'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_pending_email'), table_name='users') + op.drop_column('users', 'pending_email') + op.drop_column('users', 'email_verified') + op.create_index(op.f('uq_roles_name'), 'roles', ['name'], unique=True) + op.create_index(op.f('uq_role_attributes_name'), 'role_attributes', ['name'], unique=True) + op.drop_index(op.f('ix_email_verification_tokens_user_id'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_token_type'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_token'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_id'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_email'), table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + # ### end Alembic commands ### diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 568d1bd..1dae284 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -3,6 +3,7 @@ from .login_logs import LoginLogs from .user_sessions import UserSessions from .password_reset_tokens import PasswordResetTokens +from .email_verification_tokens import EmailVerificationTokens from .roles import Roles from .role_mapper import RoleMapper from .role_attributes import RoleAttributes diff --git a/backend/models/email_verification_tokens.py b/backend/models/email_verification_tokens.py new file mode 100644 index 0000000..6b61963 --- /dev/null +++ b/backend/models/email_verification_tokens.py @@ -0,0 +1,20 @@ +from uuid_utils import uuid7 +from core.database import Base +from sqlalchemy.orm import relationship +from sqlalchemy import Column, String, Boolean, TIMESTAMP, ForeignKey, Text, text + +class EmailVerificationTokens(Base): + __tablename__ = "email_verification_tokens" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid7()), unique=True, index=True) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + email = Column(String(50), nullable=False, index=True) + token = Column(Text, nullable=False, unique=True, index=True) + token_type = Column(String(20), nullable=False, index=True) + is_used = Column(Boolean, nullable=False, default=False) + created_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) + updated_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')) + expires_at = Column(TIMESTAMP, nullable=False) + + # Relationships + user = relationship("Users", back_populates="email_verification_tokens") \ No newline at end of file diff --git a/backend/models/users.py b/backend/models/users.py index ea5c2e6..850c376 100644 --- a/backend/models/users.py +++ b/backend/models/users.py @@ -14,6 +14,8 @@ class Users(Base): hash_password = Column(String(255), nullable=True) status = Column(Boolean, nullable=False, default=True) password_reset_required = Column(Boolean, nullable=False, default=False, server_default='0') + email_verified = Column(Boolean, nullable=False, default=False, server_default='0') + pending_email = Column(String(50), nullable=True, index=True) created_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) updated_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')) @@ -21,4 +23,5 @@ class Users(Base): login_logs = relationship("LoginLogs", back_populates="user") user_sessions = relationship("UserSessions", back_populates="user") role_mappings = relationship("RoleMapper", back_populates="user") - password_reset_tokens = relationship("PasswordResetTokens", back_populates="user") \ No newline at end of file + password_reset_tokens = relationship("PasswordResetTokens", back_populates="user") + email_verification_tokens = relationship("EmailVerificationTokens", back_populates="user") \ No newline at end of file diff --git a/backend/schedule/__init__.py b/backend/schedule/__init__.py index 0ab875a..f9b2e15 100644 --- a/backend/schedule/__init__.py +++ b/backend/schedule/__init__.py @@ -23,4 +23,13 @@ def register_schedules(): id="cleanup_expired_sessions", name="Cleanup Expired Sessions", replace_existing=True + ) + scheduler.add_job( + cleanup_tasks.cleanup_expired_email_verifications, + "cron", + hour="*/1", + minute=0, + id="cleanup_expired_email_verifications", + name="Cleanup Expired Email Verifications", + replace_existing=True ) \ No newline at end of file diff --git a/backend/schedule/cleanup_tasks.py b/backend/schedule/cleanup_tasks.py index 487b133..6241cb3 100644 --- a/backend/schedule/cleanup_tasks.py +++ b/backend/schedule/cleanup_tasks.py @@ -1,8 +1,10 @@ import logging from datetime import datetime -from sqlalchemy import delete, or_ +from sqlalchemy import delete, or_, select, update, func from core.database import AsyncSessionLocal from models.user_sessions import UserSessions +from models.email_verification_tokens import EmailVerificationTokens +from models.users import Users class CleanupTasks: def __init__(self): @@ -24,4 +26,34 @@ async def cleanup_expired_sessions(self): self.logger.info(f"Cleaned up {expired_sessions.rowcount} expired or inactive sessions") except Exception as e: self.logger.error(f"Failed to cleanup expired sessions: {e}") + await db.rollback() + + async def cleanup_expired_email_verifications(self): + """Cleanup expired email verification tokens and pending emails""" + async with AsyncSessionLocal() as db: + try: + now = datetime.now().astimezone() + expired_emails_subquery = ( + select(EmailVerificationTokens.email) + .group_by(EmailVerificationTokens.email) + .having(func.max(EmailVerificationTokens.expires_at) < now) + ) + + await db.execute( + update(Users) + .where(Users.pending_email.in_(expired_emails_subquery)) + .values(pending_email=None) + ) + + expired_tokens = await db.execute( + delete(EmailVerificationTokens) + .where(EmailVerificationTokens.email.in_(expired_emails_subquery)) + ) + + await db.commit() + self.logger.info( + f"Cleaned up {expired_tokens.rowcount} expired email verification tokens" + ) + except Exception as e: + self.logger.error(f"Failed to cleanup expired email verifications: {e}") await db.rollback() \ No newline at end of file diff --git a/backend/tests/api/account/test_controller.py b/backend/tests/api/account/test_controller.py index 58b127b..9d2e2b7 100644 --- a/backend/tests/api/account/test_controller.py +++ b/backend/tests/api/account/test_controller.py @@ -114,13 +114,13 @@ async def test_update_user_profile_success( headers={"Authorization": account_auth_headers["Authorization"]}, ) - assert response.status_code == 200 + assert response.status_code == 202 data = response.json() - assert data["code"] == 200 - assert data["message"] == "User profile updated successfully" + assert data["code"] == 202 + assert data["message"] == "Email verification required" assert data["data"]["first_name"] == "Updated" assert data["data"]["last_name"] == "Name" - assert data["data"]["email"] == "updated@example.com" + assert data["data"]["email"] == account_test_user.email assert data["data"]["phone"] == "+9876543210" @pytest.mark.asyncio @@ -143,6 +143,26 @@ async def test_update_user_profile_partial( assert data["data"]["last_name"] == account_test_user.last_name assert data["data"]["email"] == account_test_user.email + @pytest.mark.asyncio + async def test_update_user_profile_no_email_change_response( + self, client: AsyncClient, account_test_user: Users, account_auth_headers: dict + ): + """Test profile update returns 200 when email not changed""" + update_data = {"first_name": "NoEmailChange"} + + with patch("api.account.controller.update_user_profile") as mock_update: + mock_update.return_value = (account_test_user, False) + response = await client.put( + "/api/account/profile", + json=update_data, + headers={"Authorization": account_auth_headers["Authorization"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "User profile updated successfully" + @pytest.mark.asyncio async def test_update_user_profile_unauthorized(self, client: AsyncClient): """Test profile update without authentication""" @@ -309,6 +329,27 @@ async def test_change_password_success( assert data["message"] == "Password changed successfully" # data field is excluded when None due to response_model_exclude_unset=True + @pytest.mark.asyncio + async def test_change_password_success_response(self, client: AsyncClient, account_auth_headers: dict): + """Test change password returns success response when service succeeds""" + password_data = { + "current_password": "AccountTestPassword123!", + "new_password": "NewPassword123!", + "logout_all_devices": False, + } + + with patch("api.account.controller.change_password", return_value=True): + response = await client.put( + "/api/account/password", + json=password_data, + headers={"Authorization": account_auth_headers["Authorization"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Password changed successfully" + @pytest.mark.asyncio async def test_change_password_wrong_current_password( self, client: AsyncClient, account_test_user: Users, account_auth_headers: dict @@ -502,10 +543,10 @@ async def test_full_user_workflow( json=update_data, headers={"Authorization": account_auth_headers["Authorization"]}, ) - assert response.status_code == 200 + assert response.status_code == 202 updated_data = response.json()["data"] assert updated_data["first_name"] == "Updated" - assert updated_data["email"] == "updated@example.com" + assert updated_data["email"] == initial_data["email"] # 3. Change password password_data = { @@ -528,7 +569,7 @@ async def test_full_user_workflow( assert response.status_code == 200 final_data = response.json()["data"] assert final_data["first_name"] == "Updated" - assert final_data["email"] == "updated@example.com" + assert final_data["email"] == initial_data["email"] @pytest.mark.asyncio async def test_error_recovery( diff --git a/backend/tests/api/account/test_service.py b/backend/tests/api/account/test_service.py index c75de16..a9fe65e 100644 --- a/backend/tests/api/account/test_service.py +++ b/backend/tests/api/account/test_service.py @@ -1,12 +1,14 @@ import pytest from models.users import Users from core.security import verify_password -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, patch, MagicMock from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from api.account.schema import UserUpdate, PasswordChange -from utils.custom_exception import AuthenticationException, ServerException +from utils.custom_exception import AuthenticationException, ServerException, SMTPNotConfiguredException from api.account.services import get_user_by_id, update_user_profile, change_password +from extensions.smtp import SMTPMailer +from core.config import settings class TestGetUserById: @@ -58,7 +60,7 @@ async def test_update_user_profile_success_all_fields( phone="+9876543210", ) - updated_user = await update_user_profile( + updated_user, email_change_requested = await update_user_profile( test_db_session, test_user.id, update_data ) @@ -66,8 +68,10 @@ async def test_update_user_profile_success_all_fields( assert updated_user.id == test_user.id assert updated_user.first_name == "Updated" assert updated_user.last_name == "Name" - assert updated_user.email == "updated@example.com" + assert updated_user.email == test_user.email + assert updated_user.pending_email == "updated@example.com" assert updated_user.phone == "+9876543210" + assert email_change_requested is True @pytest.mark.asyncio async def test_update_user_profile_success_partial_fields( @@ -76,7 +80,7 @@ async def test_update_user_profile_success_partial_fields( """Test successful profile update with only some fields""" update_data = UserUpdate(first_name="PartialUpdate") - updated_user = await update_user_profile( + updated_user, email_change_requested = await update_user_profile( test_db_session, test_user.id, update_data ) @@ -85,6 +89,7 @@ async def test_update_user_profile_success_partial_fields( assert updated_user.last_name == test_user.last_name # Unchanged assert updated_user.email == test_user.email # Unchanged assert updated_user.phone == test_user.phone # Unchanged + assert email_change_requested is False @pytest.mark.asyncio async def test_update_user_profile_user_not_found( @@ -132,12 +137,13 @@ async def test_update_user_profile_same_email( """Test profile update with same email (should succeed)""" update_data = UserUpdate(email=test_user.email) # Same email - updated_user = await update_user_profile( + updated_user, email_change_requested = await update_user_profile( test_db_session, test_user.id, update_data ) assert updated_user is not None assert updated_user.email == test_user.email + assert email_change_requested is False @pytest.mark.asyncio async def test_update_user_profile_database_commit_error( @@ -153,6 +159,85 @@ async def test_update_user_profile_database_commit_error( with pytest.raises(SQLAlchemyError): await update_user_profile(test_db_session, test_user.id, update_data) + @pytest.mark.asyncio + async def test_update_user_profile_email_change_sends_verification( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test profile update sends email verification when needed""" + update_data = UserUpdate(email="verifychange@example.com") + mock_redis = AsyncMock() + mock_redis.ttl.return_value = "invalid" + mock_redis.setex.return_value = True + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + with patch.object(settings, "SMTP_ENABLE", True): + updated_user, email_change_requested = await update_user_profile( + test_db_session, + test_user.id, + update_data, + mock_mailer, + mock_redis, + ) + + assert email_change_requested is True + assert updated_user.pending_email == "verifychange@example.com" + mock_mailer.send_text.assert_called_once() + mock_redis.setex.assert_called_once() + + @pytest.mark.asyncio + async def test_update_user_profile_email_change_cooldown( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test profile update skips email send during cooldown""" + update_data = UserUpdate(email="cooldown@example.com") + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 60 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + with patch.object(settings, "SMTP_ENABLE", True): + updated_user, email_change_requested = await update_user_profile( + test_db_session, + test_user.id, + update_data, + mock_mailer, + mock_redis, + ) + + assert email_change_requested is True + assert updated_user.pending_email == "cooldown@example.com" + mock_mailer.send_text.assert_not_called() + + @pytest.mark.asyncio + async def test_update_user_profile_email_change_smtp_disabled( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test profile update handles SMTP not configured exception""" + update_data = UserUpdate(email="smtpdown@example.com") + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 0 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text.side_effect = SMTPNotConfiguredException("SMTP is disabled") + + with patch.object(settings, "SMTP_ENABLE", True): + updated_user, email_change_requested = await update_user_profile( + test_db_session, + test_user.id, + update_data, + mock_mailer, + mock_redis, + ) + + assert email_change_requested is True + assert updated_user.pending_email == "smtpdown@example.com" + class TestChangePassword: """Test change_password service function""" @@ -297,12 +382,14 @@ async def test_update_profile_then_change_password( """Test updating profile then changing password in sequence""" # First update profile profile_update = UserUpdate(first_name="Updated", email="updated@example.com") - updated_user = await update_user_profile( + updated_user, email_change_requested = await update_user_profile( test_db_session, test_user.id, profile_update ) assert updated_user.first_name == "Updated" - assert updated_user.email == "updated@example.com" + assert updated_user.email == test_user.email + assert updated_user.pending_email == "updated@example.com" + assert email_change_requested is True # Then change password password_data = PasswordChange( @@ -321,7 +408,8 @@ async def test_update_profile_then_change_password( # Verify both changes were applied await test_db_session.refresh(updated_user) assert updated_user.first_name == "Updated" - assert updated_user.email == "updated@example.com" + assert updated_user.email == test_user.email + assert updated_user.pending_email == "updated@example.com" assert await verify_password("NewPassword123!", updated_user.hash_password) @pytest.mark.asyncio @@ -350,8 +438,8 @@ async def test_concurrent_profile_updates( update2 = UserUpdate(last_name="Update2") # Apply updates sequentially (in real scenario, these might be concurrent) - user1 = await update_user_profile(test_db_session, test_user.id, update1) - user2 = await update_user_profile(test_db_session, test_user.id, update2) + user1, _ = await update_user_profile(test_db_session, test_user.id, update1) + user2, _ = await update_user_profile(test_db_session, test_user.id, update2) # Both should succeed assert user1 is not None @@ -376,7 +464,7 @@ async def test_update_profile_with_none_values( # Don't set other fields to None explicitly, just omit them ) - updated_user = await update_user_profile( + updated_user, email_change_requested = await update_user_profile( test_db_session, test_user.id, update_data ) @@ -384,6 +472,7 @@ async def test_update_profile_with_none_values( assert updated_user.last_name == test_user.last_name # Unchanged assert updated_user.email == test_user.email # Unchanged assert updated_user.phone == test_user.phone # Unchanged + assert email_change_requested is False @pytest.mark.asyncio async def test_change_password_with_none_redis_client( diff --git a/backend/tests/api/auth/test_controller.py b/backend/tests/api/auth/test_controller.py index 5e99f7c..1df58b0 100644 --- a/backend/tests/api/auth/test_controller.py +++ b/backend/tests/api/auth/test_controller.py @@ -1,12 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch from httpx import AsyncClient -from fastapi import HTTPException from api.auth.schema import ( - UserRegister, - UserLogin, - ResetPasswordRequest, - LogoutRequest, PasswordResetRequiredResponse, ) from utils.custom_exception import ( @@ -14,13 +9,16 @@ AuthenticationException, PasswordResetRequiredException, NotFoundException, + SMTPNotConfiguredException, + ValidationException, + EmailVerificationRequiredException ) from core.security import ( - create_password_reset_token, verify_password_reset_token, - get_token, verify_token, + verify_email_verification_token, ) +from core.redis import get_redis from main import app @@ -84,6 +82,29 @@ async def test_register_email_already_exists(self, client: AsyncClient): assert data["code"] == 409 assert data["message"] == "Email already exists" + @pytest.mark.asyncio + async def test_register_email_verification_required(self, client: AsyncClient): + """Test registration requires email verification""" + register_data = { + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "phone": "+1234567890", + "password": "TestPassword123!", + } + + with patch("api.auth.controller.register") as mock_register: + mock_register.side_effect = EmailVerificationRequiredException( + "Email verification required" + ) + + response = await client.post("/api/auth/register", json=register_data) + + assert response.status_code == 202 + data = response.json() + assert data["code"] == 202 + assert data["message"] == "Email verification required" + @pytest.mark.asyncio async def test_register_server_error(self, client: AsyncClient): """Test registration with server error""" @@ -335,6 +356,27 @@ async def test_login_password_reset_required(self, client: AsyncClient): assert "reset_token" in data["data"] assert data["data"]["reset_token"] == "test-reset-token" + @pytest.mark.asyncio + async def test_login_email_verification_required(self, client: AsyncClient): + """Test login with email verification required""" + login_data = {"email": "verify@example.com", "password": "TestPassword123!"} + + with patch("api.auth.controller.login") as mock_login: + mock_exception = EmailVerificationRequiredException("Email verification required") + mock_exception.details = { + "action_type": "email_verification", + "token": None, + "expires_at": "2024-01-01T00:00:00Z", + } + mock_login.side_effect = mock_exception + + response = await client.post("/api/auth/login", json=login_data) + assert response.status_code == 202 + data = response.json() + assert data["code"] == 202 + assert data["message"] == "Email verification required" + assert data["data"]["action_type"] == "email_verification" + @pytest.mark.asyncio async def test_reset_password_success(self, client: AsyncClient): """Test successful password reset""" @@ -393,6 +435,68 @@ async def test_reset_password_invalid_token(self, client: AsyncClient): assert data["code"] == 401 assert data["message"] == "Invalid or expired token" + @pytest.mark.asyncio + async def test_reset_password_authentication_exception(self, client: AsyncClient): + """Test password reset with authentication exception""" + reset_data = {"new_password": "NewPassword123!"} + + async def mock_verify_password_reset_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "test@example.com", + } + + with patch("api.auth.controller.reset_password") as mock_reset: + mock_reset.side_effect = AuthenticationException("Invalid or expired token") + app.dependency_overrides[verify_password_reset_token] = ( + mock_verify_password_reset_token + ) + + try: + response = await client.post( + "/api/auth/reset-password", + json=reset_data, + headers={"Authorization": "Bearer valid-reset-token"}, + ) + assert response.status_code == 401 + data = response.json() + assert data["code"] == 401 + assert data["message"] == "Invalid or expired token" + finally: + app.dependency_overrides.pop(verify_password_reset_token, None) + + @pytest.mark.asyncio + async def test_reset_password_server_error(self, client: AsyncClient): + """Test password reset with server error""" + reset_data = {"new_password": "NewPassword123!"} + + async def mock_verify_password_reset_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "test@example.com", + } + + with patch("api.auth.controller.reset_password") as mock_reset: + mock_reset.side_effect = Exception("Database error") + app.dependency_overrides[verify_password_reset_token] = ( + mock_verify_password_reset_token + ) + + try: + response = await client.post( + "/api/auth/reset-password", + json=reset_data, + headers={"Authorization": "Bearer valid-reset-token"}, + ) + assert response.status_code == 500 + data = response.json() + assert data["code"] == 500 + assert data["message"] == "Internal Server Error" + finally: + app.dependency_overrides.pop(verify_password_reset_token, None) + @pytest.mark.asyncio async def test_reset_password_user_not_found(self, client: AsyncClient): """Test password reset with user not found""" @@ -559,4 +663,352 @@ async def mock_verify_password_reset_token(): assert response.status_code == 200 assert "session_id" in response.cookies finally: - app.dependency_overrides.pop(verify_password_reset_token, None) \ No newline at end of file + app.dependency_overrides.pop(verify_password_reset_token, None) + + @pytest.mark.asyncio + async def test_forgot_password_send_email_success(self, client: AsyncClient): + """Test forgot password sends reset email""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.return_value = {"reset_url": "http://localhost:3000/reset-password?token=test"} + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Reset password email sent" + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_forgot_password_user_not_found(self, client: AsyncClient): + """Test forgot password returns error if user not found""" + req_data = {"email": "not-exist@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = NotFoundException("User not found") + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 404 + data = response.json() + assert data["code"] == 404 + assert data["message"] == "User not registered" + + @pytest.mark.asyncio + async def test_forgot_password_cooldown_active(self, client: AsyncClient): + """Test forgot password returns 400 when cooldown is active""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = ValidationException( + "Please wait 60 seconds before requesting another password reset email", + details={"cooldown_seconds": 60} + ) + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 400 + data = response.json() + assert data["code"] == 400 + assert data["message"] == "Please wait before requesting another password reset email" + + @pytest.mark.asyncio + async def test_forgot_password_account_disabled(self, client: AsyncClient): + """Test forgot password returns 403 when account is disabled""" + req_data = {"email": "disabled@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = AuthenticationException("Account is disabled") + + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 403 + data = response.json() + assert data["code"] == 403 + assert data["message"] == "Account is disabled" + + @pytest.mark.asyncio + async def test_forgot_password_smtp_disabled(self, client: AsyncClient): + """Test forgot password returns 503 if SMTP disabled""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.forgot_password", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = SMTPNotConfiguredException("SMTP is disabled") + response = await client.post("/api/auth/forgot-password", json=req_data) + assert response.status_code == 503 + data = response.json() + assert data["code"] == 503 + assert data["message"] == "SMTP is disabled" + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_success(self, client: AsyncClient): + """Test get password reset cooldown returns remaining time""" + with patch("api.auth.controller.get_password_reset_cooldown", new_callable=AsyncMock) as mock_cooldown: + mock_cooldown.return_value = {"cooldown_seconds": 120} + + response = await client.get("/api/auth/forgot-password/cooldown?email=test@example.com") + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Cooldown status retrieved" + assert data["data"]["cooldown_seconds"] == 120 + mock_cooldown.assert_called_once() + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_no_cooldown(self, client: AsyncClient): + """Test get password reset cooldown returns 0 when no cooldown""" + with patch("api.auth.controller.get_password_reset_cooldown", new_callable=AsyncMock) as mock_cooldown: + mock_cooldown.return_value = {"cooldown_seconds": 0} + + response = await client.get("/api/auth/forgot-password/cooldown?email=test@example.com") + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["cooldown_seconds"] == 0 + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_server_error(self, client: AsyncClient): + """Test get password reset cooldown with server error""" + with patch("api.auth.controller.get_password_reset_cooldown", new_callable=AsyncMock) as mock_cooldown: + mock_cooldown.side_effect = Exception("Redis error") + response = await client.get("/api/auth/forgot-password/cooldown?email=test@example.com") + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_verify_email_success(self, client: AsyncClient): + """Test verify email success""" + async def mock_verify_email_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "john.doe@example.com", + "verification_type": "registration", + } + + with patch("api.auth.controller.verify_email") as mock_verify: + mock_verify.return_value = { + "user": type( + "User", + (), + { + "id": "test-user-id", + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "phone": "+1234567890", + }, + )(), + "session_id": "test-session-id", + "access_token": "test-access-token", + } + app.dependency_overrides[verify_email_verification_token] = ( + mock_verify_email_token + ) + + try: + response = await client.get( + "/api/auth/verify-email", + headers={"Authorization": "Bearer valid-verify-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Email verified successfully" + assert "session_id" in response.cookies + finally: + app.dependency_overrides.pop(verify_email_verification_token, None) + + @pytest.mark.asyncio + async def test_verify_email_invalid_token(self, client: AsyncClient): + """Test verify email with invalid token""" + response = await client.get("/api/auth/verify-email") + assert response.status_code == 401 + data = response.json() + assert data["code"] == 401 + assert data["message"] == "Invalid or expired token" + + @pytest.mark.asyncio + async def test_verify_email_user_not_found(self, client: AsyncClient): + """Test verify email when user not found""" + async def mock_verify_email_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "john.doe@example.com", + "verification_type": "registration", + } + + with patch("api.auth.controller.verify_email") as mock_verify: + mock_verify.side_effect = NotFoundException("User not found") + app.dependency_overrides[verify_email_verification_token] = ( + mock_verify_email_token + ) + try: + response = await client.get( + "/api/auth/verify-email", + headers={"Authorization": "Bearer valid-verify-token"}, + ) + assert response.status_code == 404 + data = response.json() + assert data["code"] == 404 + assert data["message"] == "User not found" + finally: + app.dependency_overrides.pop(verify_email_verification_token, None) + + @pytest.mark.asyncio + async def test_verify_email_conflict(self, client: AsyncClient): + """Test verify email when email already exists""" + async def mock_verify_email_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "john.doe@example.com", + "verification_type": "email_change", + } + + with patch("api.auth.controller.verify_email") as mock_verify: + mock_verify.side_effect = ConflictException("Email already exists") + app.dependency_overrides[verify_email_verification_token] = ( + mock_verify_email_token + ) + try: + response = await client.get( + "/api/auth/verify-email", + headers={"Authorization": "Bearer valid-verify-token"}, + ) + assert response.status_code == 409 + data = response.json() + assert data["code"] == 409 + assert data["message"] == "Email already exists" + finally: + app.dependency_overrides.pop(verify_email_verification_token, None) + + @pytest.mark.asyncio + async def test_verify_email_server_error(self, client: AsyncClient): + """Test verify email with server error""" + async def mock_verify_email_token(): + return { + "sub": "test-user-id", + "token": "test-token", + "email": "john.doe@example.com", + "verification_type": "registration", + } + + with patch("api.auth.controller.verify_email") as mock_verify: + mock_verify.side_effect = Exception("Database error") + app.dependency_overrides[verify_email_verification_token] = ( + mock_verify_email_token + ) + try: + response = await client.get( + "/api/auth/verify-email", + headers={"Authorization": "Bearer valid-verify-token"}, + ) + assert response.status_code == 500 + data = response.json() + assert data["code"] == 500 + assert data["message"] == "Internal Server Error" + finally: + app.dependency_overrides.pop(verify_email_verification_token, None) + + @pytest.mark.asyncio + async def test_resend_verification_email_success(self, client: AsyncClient): + """Test resend verification email success""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.return_value = {"message": "Verification email sent"} + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Verification email sent" + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_resend_verification_email_cooldown(self, client: AsyncClient): + """Test resend verification email with cooldown""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = ValidationException("Please wait") + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 400 + data = response.json() + assert data["code"] == 400 + assert data["message"] == "Please wait before requesting another verification email" + + @pytest.mark.asyncio + async def test_resend_verification_email_disabled_account(self, client: AsyncClient): + """Test resend verification email with disabled account""" + req_data = {"email": "disabled@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = AuthenticationException("Account is disabled") + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 403 + data = response.json() + assert data["code"] == 403 + assert data["message"] == "Account is disabled" + + @pytest.mark.asyncio + async def test_resend_verification_email_not_found(self, client: AsyncClient): + """Test resend verification email when user not found""" + req_data = {"email": "missing@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = NotFoundException("User not registered") + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 404 + data = response.json() + assert data["code"] == 404 + assert data["message"] == "User not registered" + + @pytest.mark.asyncio + async def test_resend_verification_email_smtp_disabled(self, client: AsyncClient): + """Test resend verification email when SMTP disabled""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = SMTPNotConfiguredException("SMTP is disabled") + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 503 + data = response.json() + assert data["code"] == 503 + assert data["message"] == "SMTP is disabled" + + @pytest.mark.asyncio + async def test_resend_verification_email_server_error(self, client: AsyncClient): + """Test resend verification email with server error""" + req_data = {"email": "john.doe@example.com"} + with patch("api.auth.controller.resend_verification_email", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = Exception("Database error") + response = await client.post("/api/auth/resend-verification", json=req_data) + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_get_email_verification_cooldown(self, client: AsyncClient): + """Test get email verification cooldown returns remaining time""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 120 + async def override_get_redis(): + return mock_redis + app.dependency_overrides[get_redis] = override_get_redis + + try: + response = await client.get( + "/api/auth/resend-verification/cooldown?email=test@example.com" + ) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "Cooldown status retrieved" + assert data["data"]["cooldown_seconds"] == 120 + finally: + app.dependency_overrides.pop(get_redis, None) + + @pytest.mark.asyncio + async def test_get_email_verification_cooldown_server_error(self, client: AsyncClient): + """Test get email verification cooldown with server error""" + mock_redis = AsyncMock() + mock_redis.ttl.side_effect = Exception("Redis error") + async def override_get_redis(): + return mock_redis + app.dependency_overrides[get_redis] = override_get_redis + + try: + response = await client.get( + "/api/auth/resend-verification/cooldown?email=test@example.com" + ) + assert response.status_code == 500 + finally: + app.dependency_overrides.pop(get_redis, None) \ No newline at end of file diff --git a/backend/tests/api/auth/test_schema.py b/backend/tests/api/auth/test_schema.py index 584d775..1f656c8 100644 --- a/backend/tests/api/auth/test_schema.py +++ b/backend/tests/api/auth/test_schema.py @@ -11,6 +11,8 @@ LogoutRequest, ResetPasswordRequest, TokenValidationResponse, + ForgotPasswordRequest, + PasswordResetCooldownResponse, LoginResult, SessionResult, ) @@ -302,6 +304,38 @@ def test_token_validation_response_invalid_type(self): errors = exc_info.value.errors() assert len(errors) == 1 + def test_forgot_password_request_valid(self): + """Test forgot password request schema""" + data = {"email": "john.doe@example.com"} + req = ForgotPasswordRequest(**data) + assert req.email == "john.doe@example.com" + + def test_forgot_password_request_invalid_email(self): + """Test forgot password request invalid email""" + data = {"email": "invalid-email"} + with pytest.raises(ValidationError): + ForgotPasswordRequest(**data) + + def test_password_reset_cooldown_response_valid(self): + """Test password reset cooldown response with valid data""" + data = {"cooldown_seconds": 120} + cooldown_response = PasswordResetCooldownResponse(**data) + assert cooldown_response.cooldown_seconds == 120 + + def test_password_reset_cooldown_response_zero(self): + """Test password reset cooldown response with zero cooldown""" + data = {"cooldown_seconds": 0} + cooldown_response = PasswordResetCooldownResponse(**data) + assert cooldown_response.cooldown_seconds == 0 + + def test_password_reset_cooldown_response_missing_field(self): + """Test password reset cooldown response with missing field""" + data = {} + with pytest.raises(ValidationError) as exc_info: + PasswordResetCooldownResponse(**data) + errors = exc_info.value.errors() + assert len(errors) == 1 + def test_login_result_typing(self): """Test LoginResult TypedDict""" user_data = { diff --git a/backend/tests/api/auth/test_service.py b/backend/tests/api/auth/test_service.py index 679ad7c..9b5b478 100644 --- a/backend/tests/api/auth/test_service.py +++ b/backend/tests/api/auth/test_service.py @@ -1,11 +1,14 @@ import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime, timedelta +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.users import Users from models.user_sessions import UserSessions from models.password_reset_tokens import PasswordResetTokens +from models.email_verification_tokens import EmailVerificationTokens from core.security import hash_password, create_access_token +from extensions.smtp import SMTPMailer from api.auth.services import ( register, login, @@ -14,6 +17,16 @@ token, reset_password, validate_password_reset_token, + forgot_password, + get_password_reset_cooldown, + verify_email, + resend_verification_email, + _send_registration_verification_email, + _request_registration_verification_email, + _request_email_change_verification_email, + _create_user, + _create_user_session, + _update_session_expiry, ) from api.auth.schema import UserRegister, UserLogin from utils.custom_exception import ( @@ -22,7 +35,11 @@ PasswordResetRequiredException, ServerException, NotFoundException, + SMTPNotConfiguredException, + ValidationException, + EmailVerificationRequiredException, ) +from core.config import settings class TestAuthService: @@ -419,48 +436,6 @@ async def test_reset_password_user_not_found( assert "User not found" in str(exc_info.value) - @pytest.mark.asyncio - async def test_reset_password_not_required(self, test_db_session: AsyncSession): - """Test password reset when not required""" - mock_redis = AsyncMock() - user_id = "test-no-reset-user" - hashed_pwd = await hash_password("TestPassword123!") - - no_reset_user = Users( - id=user_id, - email="noreset@example.com", - first_name="No", - last_name="Reset", - phone="+1234567890", - hash_password=hashed_pwd, - status=True, - password_reset_required=False, - ) - test_db_session.add(no_reset_user) - await test_db_session.commit() - reset_token_record = PasswordResetTokens( - user_id=user_id, - token="test_token_123", - is_used=False, - expires_at=datetime.now() + timedelta(minutes=30), - ) - test_db_session.add(reset_token_record) - await test_db_session.commit() - - token_data = {"sub": user_id, "token": "test_token_123"} - - with pytest.raises(AuthenticationException) as exc_info: - await reset_password( - test_db_session, - mock_redis, - token_data, - "NewPassword123!", - "127.0.0.1", - "TestAgent/1.0", - ) - - assert "Password reset not required for this user" in str(exc_info.value) - @pytest.mark.asyncio async def test_validate_password_reset_token_success( self, test_db_session: AsyncSession @@ -520,16 +495,16 @@ async def test_validate_password_reset_token_user_not_found( with pytest.raises(AuthenticationException) as exc_info: await validate_password_reset_token(test_db_session, token_data) - assert "User not found or password reset not required" in str(exc_info.value) + assert "User not found or account disabled" in str(exc_info.value) @pytest.mark.asyncio async def test_validate_password_reset_token_not_required( self, test_db_session: AsyncSession ): - """Test password reset token validation when not required""" + """Test password reset token validation when password reset not required""" user_id = "test-no-reset-validate-user" hashed_pwd = await hash_password("TestPassword123!") - + no_reset_user = Users( id=user_id, email="noresetvalidate@example.com", @@ -553,7 +528,779 @@ async def test_validate_password_reset_token_not_required( token_data = {"sub": user_id, "token": "test_token_123"} + # validate_password_reset_token does not check password_reset_required, + # it only validates token existence and user status + result = await validate_password_reset_token(test_db_session, token_data) + assert result.is_valid is True + + @pytest.mark.asyncio + async def test_forgot_password_success( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test successful forgot password flow""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 # No cooldown + mock_redis.setex.return_value = True + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + result = await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + assert "reset_token" in result + assert "reset_url" in result + assert "expires_at" in result + mock_mailer.send_text.assert_called_once() + mock_redis.setex.assert_called_once() + + @pytest.mark.asyncio + async def test_forgot_password_user_not_found(self, test_db_session: AsyncSession): + """Test forgot password with non-existent user""" + mock_redis = AsyncMock() + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + with pytest.raises(NotFoundException): + await forgot_password( + test_db_session, + "notfound@example.com", + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_forgot_password_account_disabled( + self, test_db_session: AsyncSession + ): + """Test forgot password with disabled account""" + hashed_pwd = await hash_password("TestPassword123!") + disabled_user = Users( + id="test-disabled-user", + email="disabled@example.com", + first_name="Disabled", + last_name="User", + phone="+1234567890", + hash_password=hashed_pwd, + status=False, + password_reset_required=False, + ) + test_db_session.add(disabled_user) + await test_db_session.commit() + + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + with pytest.raises(AuthenticationException) as exc_info: - await validate_password_reset_token(test_db_session, token_data) + await forgot_password( + test_db_session, + disabled_user.email, + mock_mailer, + mock_redis, + ) + assert "Account is disabled" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_forgot_password_cooldown_active( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test forgot password when cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 60 # 60 seconds remaining + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + with pytest.raises(ValidationException) as exc_info: + await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + assert "Please wait" in str(exc_info.value) + assert exc_info.value.details.get("cooldown_seconds") == 60 + + @pytest.mark.asyncio + async def test_forgot_password_smtp_disabled( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test forgot password when SMTP is disabled""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = False + + with pytest.raises(SMTPNotConfiguredException): + await forgot_password( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_with_cooldown(self): + """Test get password reset cooldown when cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 180 # 180 seconds remaining + + result = await get_password_reset_cooldown("test@example.com", mock_redis) + + assert result["cooldown_seconds"] == 180 + mock_redis.ttl.assert_called_once_with("password_reset_cooldown:test@example.com") + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_no_cooldown(self): + """Test get password reset cooldown when no cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -2 # Key doesn't exist + + result = await get_password_reset_cooldown("test@example.com", mock_redis) + + assert result["cooldown_seconds"] == 0 + mock_redis.ttl.assert_called_once_with("password_reset_cooldown:test@example.com") + + @pytest.mark.asyncio + async def test_get_password_reset_cooldown_expired(self): + """Test get password reset cooldown when key exists but expired""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 # Key exists but no expiry + + result = await get_password_reset_cooldown("test@example.com", mock_redis) + + assert result["cooldown_seconds"] == 0 + + @pytest.mark.asyncio + async def test_register_email_verification_cooldown(self, test_db_session: AsyncSession): + """Test register email verification cooldown flow""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 60 + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + user_data = UserRegister( + first_name="Verify", + last_name="User", + email="verify@example.com", + phone="+1234567890", + password="TestPassword123!", + ) + + with patch.object(settings, "EMAIL_VERIFICATION_ENABLE", True), patch.object( + settings, "SMTP_ENABLE", True + ): + with pytest.raises(EmailVerificationRequiredException): + await register( + test_db_session, + mock_redis, + user_data, + "127.0.0.1", + "TestAgent/1.0", + mock_mailer, + ) + + @pytest.mark.asyncio + async def test_register_email_verification_send(self, test_db_session: AsyncSession): + """Test register sends verification email when not in cooldown""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 0 + mock_redis.setex.return_value = True + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + user_data = UserRegister( + first_name="Verify", + last_name="Send", + email="sendverify@example.com", + phone="+1234567890", + password="TestPassword123!", + ) + + with patch.object(settings, "EMAIL_VERIFICATION_ENABLE", True), patch.object( + settings, "SMTP_ENABLE", True + ): + with pytest.raises(EmailVerificationRequiredException): + await register( + test_db_session, + mock_redis, + user_data, + "127.0.0.1", + "TestAgent/1.0", + mock_mailer, + ) + + mock_mailer.send_text.assert_called_once() + + @pytest.mark.asyncio + async def test_login_email_verification_cooldown(self, test_db_session: AsyncSession): + """Test login email verification required in cooldown""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 30 + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id="verify-login-user", + email="loginverify@example.com", + first_name="Login", + last_name="Verify", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + email_verified=False, + ) + test_db_session.add(user) + await test_db_session.commit() + + login_data = UserLogin(email=user.email, password="TestPassword123!") + + with patch.object(settings, "EMAIL_VERIFICATION_ENABLE", True), patch.object( + settings, "SMTP_ENABLE", True + ): + with pytest.raises(EmailVerificationRequiredException) as exc_info: + await login( + test_db_session, + mock_redis, + login_data, + "127.0.0.1", + "TestAgent/1.0", + mock_mailer, + ) + assert exc_info.value.details.action_type == "email_verification" + + @pytest.mark.asyncio + async def test_login_email_verification_send(self, test_db_session: AsyncSession): + """Test login sends verification email when not in cooldown""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 0 + mock_redis.setex.return_value = True + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id="verify-login-send-user", + email="loginverify2@example.com", + first_name="Login", + last_name="Send", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + email_verified=False, + ) + test_db_session.add(user) + await test_db_session.commit() + + login_data = UserLogin(email=user.email, password="TestPassword123!") + + with patch.object(settings, "EMAIL_VERIFICATION_ENABLE", True), patch.object( + settings, "SMTP_ENABLE", True + ): + with pytest.raises(EmailVerificationRequiredException): + await login( + test_db_session, + mock_redis, + login_data, + "127.0.0.1", + "TestAgent/1.0", + mock_mailer, + ) + + mock_mailer.send_text.assert_called() + + @pytest.mark.asyncio + async def test_logout_server_error(self, test_db_session: AsyncSession, test_user: Users, test_user_session: UserSessions): + """Test logout server error""" + mock_redis = AsyncMock() + mock_redis.delete.side_effect = Exception("Redis error") + + with pytest.raises(ServerException): + await logout(test_db_session, mock_redis, test_user.id, test_user_session.id) + + @pytest.mark.asyncio + async def test_token_invalid_session_data(self, test_db_session: AsyncSession): + """Test token with invalid session data format""" + mock_redis = AsyncMock() + mock_redis.get.return_value = "invalid{session" + + with pytest.raises(AuthenticationException): + await token(test_db_session, mock_redis, "session-id") + + @pytest.mark.asyncio + async def test_reset_password_server_exception( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test reset password server exception""" + mock_redis = AsyncMock() + token_record = PasswordResetTokens( + user_id=test_user.id, + token="test_reset_token", + is_used=False, + expires_at=datetime.now() + timedelta(minutes=30), + ) + test_db_session.add(token_record) + await test_db_session.commit() + + token_data = {"sub": test_user.id, "token": "test_reset_token"} + + with patch("api.auth.services.clear_user_all_sessions", side_effect=Exception("Redis error")): + with pytest.raises(ServerException): + await reset_password( + test_db_session, + mock_redis, + token_data, + "NewPassword123!", + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_validate_password_reset_token_server_exception(self, test_db_session: AsyncSession): + """Test validate password reset token server exception""" + with patch.object(test_db_session, "execute", side_effect=Exception("DB error")): + with pytest.raises(ServerException): + await validate_password_reset_token(test_db_session, {"sub": "x", "token": "y"}) + + @pytest.mark.asyncio + async def test_forgot_password_server_exception(self, test_db_session: AsyncSession, test_user: Users): + """Test forgot password server exception""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text.side_effect = Exception("SMTP error") + + with pytest.raises(ServerException): + await forgot_password(test_db_session, test_user.email, mock_mailer, mock_redis) + + +class TestAuthEmailVerificationHelpers: + """Test email verification helper functions""" - assert "User not found or password reset not required" in str(exc_info.value) \ No newline at end of file + @pytest.mark.asyncio + async def test_send_registration_verification_email(self, test_db_session: AsyncSession, test_user: Users): + """Test sending registration verification email""" + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + with patch.object(settings, "SMTP_ENABLE", True): + await _send_registration_verification_email(test_db_session, mock_mailer, test_user) + + mock_mailer.send_text.assert_called_once() + + @pytest.mark.asyncio + async def test_request_registration_verification_email(self, test_db_session: AsyncSession, test_user: Users): + """Test creating registration verification token record""" + token_meta = await _request_registration_verification_email(test_db_session, test_user) + + assert token_meta["verification_token"] + result = await test_db_session.execute( + select(EmailVerificationTokens).where(EmailVerificationTokens.user_id == test_user.id) + ) + assert result.scalar_one_or_none() is not None + + @pytest.mark.asyncio + async def test_request_email_change_verification_email(self, test_db_session: AsyncSession, test_user: Users): + """Test creating email change verification token record""" + new_email = "change@example.com" + token_meta = await _request_email_change_verification_email(test_db_session, test_user, new_email) + + assert token_meta["verification_token"] + result = await test_db_session.execute( + select(EmailVerificationTokens).where(EmailVerificationTokens.email == new_email) + ) + assert result.scalar_one_or_none() is not None + + @pytest.mark.asyncio + async def test_verify_email_email_change_mismatch(self, test_db_session: AsyncSession): + """Test verify email with pending email mismatch""" + mock_redis = AsyncMock() + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id="email-change-user", + email="old@example.com", + pending_email="new@example.com", + first_name="Email", + last_name="Change", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + email_verified=True, + ) + test_db_session.add(user) + await test_db_session.commit() + + token_record = EmailVerificationTokens( + user_id=user.id, + email="mismatch@example.com", + token="mismatch-token", + token_type="email_change", + expires_at=datetime.now().astimezone() + timedelta(minutes=30), + ) + test_db_session.add(token_record) + await test_db_session.commit() + + token_data = { + "sub": user.id, + "email": "mismatch@example.com", + "verification_type": "email_change", + "token": "mismatch-token", + } + + with pytest.raises(AuthenticationException): + await verify_email( + test_db_session, + mock_redis, + token_data, + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_verify_email_email_change_conflict(self, test_db_session: AsyncSession): + """Test verify email with email conflict""" + mock_redis = AsyncMock() + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id="email-change-user-2", + email="old2@example.com", + pending_email="new2@example.com", + first_name="Email", + last_name="Change", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + email_verified=True, + ) + other_user = Users( + id="existing-email-user", + email="new2@example.com", + first_name="Existing", + last_name="User", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + ) + test_db_session.add(user) + test_db_session.add(other_user) + await test_db_session.commit() + + token_record = EmailVerificationTokens( + user_id=user.id, + email="new2@example.com", + token="conflict-token", + token_type="email_change", + expires_at=datetime.now().astimezone() + timedelta(minutes=30), + ) + test_db_session.add(token_record) + await test_db_session.commit() + + token_data = { + "sub": user.id, + "email": "new2@example.com", + "verification_type": "email_change", + "token": "conflict-token", + } + + with pytest.raises(ConflictException): + await verify_email( + test_db_session, + mock_redis, + token_data, + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_verify_email_user_disabled(self, test_db_session: AsyncSession): + """Test verify email when user is disabled""" + mock_redis = AsyncMock() + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id="disabled-verify-user", + email="disabled@example.com", + first_name="Disabled", + last_name="User", + phone="+1234567890", + hash_password=hashed_pwd, + status=False, + password_reset_required=False, + email_verified=False, + ) + test_db_session.add(user) + await test_db_session.commit() + + token_record = EmailVerificationTokens( + user_id=user.id, + email=user.email, + token="disabled-token", + token_type="registration", + expires_at=datetime.now().astimezone() + timedelta(minutes=30), + ) + test_db_session.add(token_record) + await test_db_session.commit() + + token_data = { + "sub": user.id, + "email": user.email, + "verification_type": "registration", + "token": "disabled-token", + } + + with pytest.raises(AuthenticationException): + await verify_email( + test_db_session, + mock_redis, + token_data, + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_resend_verification_email_verified_no_pending( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test resend verification when already verified without pending email""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + test_user.email_verified = True + test_user.pending_email = None + await test_db_session.commit() + + with patch.object(settings, "SMTP_ENABLE", True): + with pytest.raises(ValidationException): + await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_resend_verification_email_verified_pending( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test resend verification for pending email change""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + mock_redis.setex.return_value = True + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + test_user.email_verified = True + test_user.pending_email = "pending@example.com" + await test_db_session.commit() + + with patch.object(settings, "SMTP_ENABLE", True): + result = await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + assert result["message"] == "Verification email sent" + mock_mailer.send_text.assert_called_once() + + @pytest.mark.asyncio + async def test_resend_verification_email_not_verified( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test resend verification when user not verified""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + mock_redis.setex.return_value = True + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + mock_mailer.send_text = MagicMock() + + test_user.email_verified = False + test_user.pending_email = None + await test_db_session.commit() + + with patch.object(settings, "SMTP_ENABLE", True): + result = await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + assert result["message"] == "Verification email sent" + mock_mailer.send_text.assert_called_once() + + +class TestAuthServiceInternals: + """Test internal helper errors""" + + @pytest.mark.asyncio + async def test_update_session_expiry_server_exception(self, test_db_session: AsyncSession): + """Test update session expiry server exception""" + with patch.object(test_db_session, "execute", side_effect=Exception("DB error")): + with pytest.raises(ServerException): + await _update_session_expiry(test_db_session, "session-id") + + @pytest.mark.asyncio + async def test_create_user_server_exception(self, test_db_session: AsyncSession): + """Test _create_user server exception""" + user_data = UserRegister( + first_name="Fail", + last_name="User", + email="fail@example.com", + phone="+1234567890", + password="TestPassword123!", + ) + with patch.object(test_db_session, "execute", side_effect=Exception("DB error")): + with pytest.raises(ServerException): + await _create_user(test_db_session, user_data) + + @pytest.mark.asyncio + async def test_create_user_session_server_exception(self, test_db_session: AsyncSession, test_user: Users): + """Test _create_user_session server exception""" + mock_redis = AsyncMock() + mock_redis.setex.side_effect = Exception("Redis error") + with pytest.raises(ServerException): + await _create_user_session( + test_db_session, + mock_redis, + test_user, + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_verify_email_registration_success(self, test_db_session: AsyncSession): + """Test verify email for registration token""" + mock_redis = AsyncMock() + mock_redis.setex.return_value = True + + user_id = "test-verify-email-user" + hashed_pwd = await hash_password("TestPassword123!") + user = Users( + id=user_id, + email="verify@example.com", + first_name="Verify", + last_name="User", + phone="+1234567890", + hash_password=hashed_pwd, + status=True, + password_reset_required=False, + email_verified=False, + ) + test_db_session.add(user) + await test_db_session.commit() + + token_value = "verify_token_123" + token_record = EmailVerificationTokens( + user_id=user_id, + email=user.email, + token=token_value, + token_type="registration", + expires_at=datetime.now().astimezone() + timedelta(minutes=30), + ) + test_db_session.add(token_record) + await test_db_session.commit() + + token_data = { + "sub": user_id, + "email": user.email, + "verification_type": "registration", + "token": token_value, + } + + result = await verify_email( + test_db_session, + mock_redis, + token_data, + "127.0.0.1", + "TestAgent/1.0", + ) + + assert "user" in result + assert "session_id" in result + assert "access_token" in result + await test_db_session.refresh(user) + assert user.email_verified is True + + @pytest.mark.asyncio + async def test_verify_email_invalid_token(self, test_db_session: AsyncSession): + """Test verify email with invalid token""" + mock_redis = AsyncMock() + token_data = { + "sub": "missing-user", + "email": "missing@example.com", + "verification_type": "registration", + "token": "invalid-token", + } + + with pytest.raises(AuthenticationException): + await verify_email( + test_db_session, + mock_redis, + token_data, + "127.0.0.1", + "TestAgent/1.0", + ) + + @pytest.mark.asyncio + async def test_resend_verification_email_smtp_disabled( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test resend verification email when SMTP is disabled""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = -1 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = False + + with pytest.raises(SMTPNotConfiguredException): + await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + + @pytest.mark.asyncio + async def test_resend_verification_email_cooldown_active( + self, test_db_session: AsyncSession, test_user: Users + ): + """Test resend verification email when cooldown is active""" + mock_redis = AsyncMock() + mock_redis.ttl.return_value = 60 + + mock_mailer = MagicMock(spec=SMTPMailer) + mock_mailer.enabled = True + + with patch.object(settings, "SMTP_ENABLE", True): + with pytest.raises(ValidationException) as exc_info: + await resend_verification_email( + test_db_session, + test_user.email, + mock_mailer, + mock_redis, + ) + assert "Please wait" in str(exc_info.value) \ No newline at end of file diff --git a/backend/tests/api/users/test_controller.py b/backend/tests/api/users/test_controller.py index ef555d8..b4a2fe8 100644 --- a/backend/tests/api/users/test_controller.py +++ b/backend/tests/api/users/test_controller.py @@ -2,7 +2,7 @@ from datetime import datetime from httpx import AsyncClient from unittest.mock import patch -from utils.custom_exception import NotFoundException +from utils.custom_exception import NotFoundException, ConflictException from api.users.schema import UserResponse, UserPagination from api.users.schema import UserDeleteBatchResponse, UserDeleteResult @@ -74,6 +74,17 @@ async def test_get_users_with_filters( assert response.status_code == 200 mock_get_users.assert_called_once() + @pytest.mark.asyncio + async def test_get_users_server_error(self, client: AsyncClient, users_auth_headers: dict): + """Test users retrieval with server error""" + with patch("api.users.controller.get_all_users") as mock_get_users: + mock_get_users.side_effect = Exception("Database error") + response = await client.get( + "/api/users", + headers={"Authorization": users_auth_headers["Authorization"]}, + ) + assert response.status_code == 500 + @pytest.mark.asyncio async def test_create_user_success( self, client: AsyncClient, users_auth_headers: dict @@ -226,6 +237,26 @@ async def test_update_user_not_found( assert data["code"] == 404 assert data["message"] == "User not found" + @pytest.mark.asyncio + async def test_update_user_email_conflict( + self, client: AsyncClient, users_auth_headers: dict + ): + """Test user update with email conflict""" + user_id = "test-user-id" + update_data = {"email": "existing@example.com"} + + with patch("api.users.controller.update_user") as mock_update_user: + mock_update_user.side_effect = ConflictException("Email already exists") + response = await client.put( + f"/api/users/{user_id}", + json=update_data, + headers={"Authorization": users_auth_headers["Authorization"]}, + ) + assert response.status_code == 409 + data = response.json() + assert data["code"] == 409 + assert data["message"] == "Email already exists" + @pytest.mark.asyncio async def test_delete_users_success( self, client: AsyncClient, users_auth_headers: dict @@ -329,6 +360,20 @@ async def test_delete_users_all_failed( assert data["data"]["success_count"] == 0 assert data["data"]["failed_count"] == 2 + @pytest.mark.asyncio + async def test_delete_users_server_error(self, client: AsyncClient, users_auth_headers: dict): + """Test users deletion with server error""" + delete_data = {"user_ids": ["user1"]} + with patch("api.users.controller.delete_users") as mock_delete_users: + mock_delete_users.side_effect = Exception("Database error") + response = await client.request( + "DELETE", + "/api/users", + json=delete_data, + headers={"Authorization": users_auth_headers["Authorization"]}, + ) + assert response.status_code == 500 + @pytest.mark.asyncio async def test_reset_password_success( self, client: AsyncClient, users_auth_headers: dict diff --git a/backend/tests/api/users/test_service.py b/backend/tests/api/users/test_service.py index 4ed41f6..f51d2d0 100644 --- a/backend/tests/api/users/test_service.py +++ b/backend/tests/api/users/test_service.py @@ -6,7 +6,7 @@ from models.users import Users from models.roles import Roles from models.role_mapper import RoleMapper -from utils.custom_exception import ConflictException, NotFoundException +from utils.custom_exception import ConflictException, NotFoundException, ServerException from api.users.services import ( get_all_users, create_user, @@ -15,6 +15,7 @@ reset_user_password, _assign_user_role, _update_user_role, + _delete_user_related_records, ) from api.users.schema import ( UserCreate, @@ -278,6 +279,49 @@ async def test_get_all_users_sorting(self, test_db_session: AsyncSession): assert result.users[0].email == "a@example.com" assert result.users[1].email == "b@example.com" + @pytest.mark.asyncio + async def test_get_all_users_sort_by_role_desc(self, test_db_session: AsyncSession): + """Test users retrieval with role sorting desc""" + user1 = Users( + id="user1", + email="a@example.com", + first_name="A", + last_name="User", + phone="+1234567890", + hash_password="hashed_password", + status=True, + created_at=datetime.now() + ) + user2 = Users( + id="user2", + email="b@example.com", + first_name="B", + last_name="User", + phone="+1234567891", + hash_password="hashed_password", + status=True, + created_at=datetime.now() + ) + role_admin = Roles(id="role1", name="admin", description="Admin role") + role_user = Roles(id="role2", name="user", description="User role") + test_db_session.add_all([user1, user2, role_admin, role_user]) + await test_db_session.commit() + + test_db_session.add(RoleMapper(user_id="user1", role_id="role1")) + test_db_session.add(RoleMapper(user_id="user2", role_id="role2")) + await test_db_session.commit() + + result = await get_all_users( + db=test_db_session, + sort_by="role", + desc=True, + page=1, + per_page=10 + ) + + assert result.users[0].role == "user" + assert result.users[1].role == "admin" + class TestCreateUser: """Test create_user service function""" @@ -600,6 +644,30 @@ async def test_delete_users_with_foreign_key_constraints(self, test_db_session: # Verify that related records deletion was called mock_delete_related.assert_called_once_with(test_db_session, "user1") + @pytest.mark.asyncio + async def test_delete_users_skip_own_account(self, test_db_session: AsyncSession): + """Test delete users skips current user""" + user = Users( + id="current-user", + email="current@example.com", + first_name="Current", + last_name="User", + phone="+1234567890", + hash_password="hashed_password", + status=True, + created_at=datetime.now() + ) + test_db_session.add(user) + await test_db_session.commit() + + mock_redis = AsyncMock() + token = {"sub": "current-user"} + + result = await delete_users(test_db_session, mock_redis, ["current-user"], token) + + assert result.failed_count == 1 + assert result.results[0].message == "Cannot delete your own account" + class TestResetUserPassword: """Test reset_user_password service function""" @@ -649,6 +717,29 @@ async def test_reset_password_user_not_found(self, test_db_session: AsyncSession assert "User not found" in str(exc_info.value) + @pytest.mark.asyncio + async def test_reset_password_server_error(self, test_db_session: AsyncSession): + """Test password reset server error""" + user = Users( + id="user1", + email="user@example.com", + first_name="User", + last_name="Test", + phone="+1234567890", + hash_password="old_password", + status=True, + created_at=datetime.now() + ) + test_db_session.add(user) + await test_db_session.commit() + + mock_redis = AsyncMock() + with patch("api.users.services.clear_user_all_sessions", side_effect=Exception("Redis error")): + with pytest.raises(ServerException): + await reset_user_password( + test_db_session, mock_redis, "user1", "NewPassword123!" + ) + class TestRoleManagement: """Test role management helper functions""" @@ -693,6 +784,38 @@ async def test_assign_user_role_role_not_found(self, test_db_session: AsyncSessi assert "Role 'nonexistent_role' not found" in str(exc_info.value) + @pytest.mark.asyncio + async def test_assign_user_role_existing_mapping(self, test_db_session: AsyncSession): + """Test role assignment skips existing mapping""" + user = Users( + id="user1", + email="user@example.com", + first_name="User", + last_name="Test", + phone="+1234567890", + hash_password="hashed_password", + status=True, + created_at=datetime.now() + ) + role = Roles( + id="role1", + name="admin", + description="Administrator role" + ) + test_db_session.add(user) + test_db_session.add(role) + await test_db_session.commit() + + test_db_session.add(RoleMapper(user_id="user1", role_id="role1")) + await test_db_session.commit() + + await _assign_user_role(test_db_session, "user1", "admin") + + result = await test_db_session.execute( + text("SELECT COUNT(*) FROM role_mapper WHERE user_id = 'user1' AND role_id = 'role1'") + ) + assert result.scalar() == 1 + @pytest.mark.asyncio async def test_update_user_role_success(self, test_db_session: AsyncSession): """Test successful role update""" @@ -772,4 +895,18 @@ async def test_update_user_role_remove_only(self, test_db_session: AsyncSession) text("SELECT * FROM role_mapper WHERE user_id = 'user1'") ) mapping = result.fetchone() - assert mapping is None \ No newline at end of file + assert mapping is None + + @pytest.mark.asyncio + async def test_update_user_role_server_error(self, test_db_session: AsyncSession): + """Test role update server error""" + with patch.object(test_db_session, "execute", side_effect=Exception("DB error")): + with pytest.raises(ServerException): + await _update_user_role(test_db_session, "user1", "admin") + + @pytest.mark.asyncio + async def test_delete_user_related_records_server_error(self, test_db_session: AsyncSession): + """Test delete user related records server error""" + with patch.object(test_db_session, "execute", side_effect=Exception("DB error")): + with pytest.raises(ServerException): + await _delete_user_related_records(test_db_session, "user1") \ No newline at end of file diff --git a/backend/tests/extensions/__init__.py b/backend/tests/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/extensions/test_smtp.py b/backend/tests/extensions/test_smtp.py new file mode 100644 index 0000000..dd66386 --- /dev/null +++ b/backend/tests/extensions/test_smtp.py @@ -0,0 +1,630 @@ +import pytest +from unittest.mock import MagicMock, patch +from fastapi import FastAPI +import extensions.smtp +from utils.custom_exception import SMTPNotConfiguredException +from extensions.smtp import ( + SMTPSettings, + SMTPMailer, + build_smtp_settings, + get_mailer, + add_smtp, +) + + +class TestSMTPSettings: + """Test SMTPSettings dataclass""" + + def test_smtp_settings_creation(self): + """Test creating SMTPSettings with all fields""" + settings = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + assert settings.enabled is True + assert settings.host == "smtp.example.com" + assert settings.port == 587 + assert settings.username == "user@example.com" + assert settings.password == "password123" + assert settings.from_email == "from@example.com" + assert settings.from_name == "Test App" + assert settings.encryption == "tls" + + def test_smtp_settings_optional_fields(self): + """Test SMTPSettings with optional None fields""" + settings = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + assert settings.enabled is False + assert settings.username is None + assert settings.password is None + assert settings.from_email is None + + +class TestSMTPMailer: + """Test SMTPMailer class""" + + def test_enabled_property(self): + """Test enabled property""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + assert mailer.enabled is True + + cfg_disabled = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mailer_disabled = SMTPMailer(cfg_disabled) + assert mailer_disabled.enabled is False + + def test_validate_disabled(self): + """Test validation when SMTP is disabled""" + cfg = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP is disabled" in str(exc_info.value) + + def test_validate_missing_host(self): + """Test validation when host is missing""" + cfg = SMTPSettings( + enabled=True, + host="", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_HOST" in str(exc_info.value) + + def test_validate_missing_port(self): + """Test validation when port is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=0, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_PORT" in str(exc_info.value) + + def test_validate_missing_username(self): + """Test validation when username is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username=None, + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_USERNAME" in str(exc_info.value) + + def test_validate_missing_password(self): + """Test validation when password is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password=None, + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_PASSWORD" in str(exc_info.value) + + def test_validate_missing_from_email(self): + """Test validation when from_email is missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email=None, + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_FROM_EMAIL" in str(exc_info.value) + + def test_validate_invalid_encryption(self): + """Test validation with invalid encryption""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="invalid", + ) + mailer = SMTPMailer(cfg) + + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._validate() + assert "SMTP_ENCRYPTION must be tls, ssl, or none" in str(exc_info.value) + + def test_validate_success(self): + """Test successful validation""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + # Should not raise exception + mailer._validate() + + @patch("extensions.smtp.smtplib.SMTP") + @patch("extensions.smtp.ssl.create_default_context") + def test_open_tls(self, mock_ssl_context, mock_smtp): + """Test opening SMTP connection with TLS""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp.return_value = mock_client + mock_ssl_context.return_value = MagicMock() + + client = mailer._open() + + mock_smtp.assert_called_once_with("smtp.example.com", 587, timeout=30) + mock_client.ehlo.assert_called() + assert mock_client.ehlo.call_count == 2 # Called before and after starttls + mock_client.starttls.assert_called_once() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + @patch("extensions.smtp.smtplib.SMTP_SSL") + @patch("extensions.smtp.ssl.create_default_context") + def test_open_ssl(self, mock_ssl_context, mock_smtp_ssl): + """Test opening SMTP connection with SSL""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=465, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="ssl", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp_ssl.return_value = mock_client + mock_ssl_context.return_value = MagicMock() + + client = mailer._open() + + mock_smtp_ssl.assert_called_once_with( + "smtp.example.com", 465, timeout=30, context=mock_ssl_context.return_value + ) + mock_client.ehlo.assert_called_once() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + @patch("extensions.smtp.smtplib.SMTP") + def test_open_none_encryption(self, mock_smtp): + """Test opening SMTP connection with no encryption""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=25, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_smtp.return_value = mock_client + + client = mailer._open() + + mock_smtp.assert_called_once_with("smtp.example.com", 25, timeout=30) + mock_client.ehlo.assert_called_once() + mock_client.starttls.assert_not_called() + mock_client.login.assert_called_once_with("user", "pass") + assert client == mock_client + + def test_open_no_auth_validation_error(self): + """Test that _open raises error when username/password are missing""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=25, + username=None, + password=None, + from_email="from@example.com", + from_name="App", + encryption="none", + ) + mailer = SMTPMailer(cfg) + + # _validate() is called in _open(), and it requires username and password + with pytest.raises(SMTPNotConfiguredException) as exc_info: + mailer._open() + assert "SMTP_USERNAME" in str(exc_info.value) or "SMTP_PASSWORD" in str(exc_info.value) + + @patch("extensions.smtp.smtplib.SMTP") + def test_open_connection_error(self, mock_smtp): + """Test handling connection errors""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_client.ehlo.side_effect = Exception("Connection failed") + mock_smtp.return_value = mock_client + + with pytest.raises(Exception) as exc_info: + mailer._open() + assert "Connection failed" in str(exc_info.value) + mock_client.quit.assert_called_once() + + @patch.object(SMTPMailer, "_open") + def test_send_text_plain(self, mock_open): + """Test sending plain text email""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + ) + + mock_open.assert_called_once() + mock_client.send_message.assert_called_once() + msg = mock_client.send_message.call_args[0][0] + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "Test App " + assert msg["To"] == "to@example.com" + + @patch.object(SMTPMailer, "_open") + def test_send_text_html(self, mock_open): + """Test sending HTML email""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="Test App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to1@example.com", "to2@example.com"], + subject="Test Subject", + body="Plain text", + html_body="HTML content", + ) + + mock_open.assert_called_once() + mock_client.send_message.assert_called_once() + msg = mock_client.send_message.call_args[0][0] + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "Test App " + assert msg["To"] == "to1@example.com, to2@example.com" + assert msg.is_multipart() is True + + @patch.object(SMTPMailer, "_open") + def test_send_text_custom_from(self, mock_open): + """Test sending email with custom from address""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="default@example.com", + from_name="Default Name", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + from_email="custom@example.com", + from_name="Custom Name", + ) + + msg = mock_client.send_message.call_args[0][0] + assert msg["From"] == "Custom Name " + + @patch.object(SMTPMailer, "_open") + def test_send_text_custom_timeout(self, mock_open): + """Test sending email with custom timeout""" + cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mailer = SMTPMailer(cfg) + + mock_client = MagicMock() + mock_open.return_value.__enter__.return_value = mock_client + + mailer.send_text( + to_emails=["to@example.com"], + subject="Test Subject", + body="Test body", + timeout=60, + ) + + mock_open.assert_called_once_with(timeout=60) + + +class TestBuildSMTPSettings: + """Test build_smtp_settings function""" + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_enabled(self, mock_settings): + """Test building SMTP settings when enabled""" + mock_settings.SMTP_ENABLE = True + mock_settings.SMTP_HOST = "smtp.example.com" + mock_settings.SMTP_PORT = 587 + mock_settings.SMTP_USERNAME = "user" + mock_settings.SMTP_PASSWORD = "pass" + mock_settings.SMTP_FROM_EMAIL = "from@example.com" + mock_settings.SMTP_FROM_NAME = "Test App" + mock_settings.SMTP_ENCRYPTION = "tls" + + settings = build_smtp_settings() + + assert settings.enabled is True + assert settings.host == "smtp.example.com" + assert settings.port == 587 + assert settings.username == "user" + assert settings.password == "pass" + assert settings.from_email == "from@example.com" + assert settings.from_name == "Test App" + assert settings.encryption == "tls" + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_disabled(self, mock_settings): + """Test building SMTP settings when disabled""" + mock_settings.SMTP_ENABLE = False + mock_settings.SMTP_HOST = "" + mock_settings.SMTP_PORT = 25 + mock_settings.SMTP_USERNAME = None + mock_settings.SMTP_PASSWORD = None + mock_settings.SMTP_FROM_EMAIL = None + mock_settings.SMTP_FROM_NAME = "App" + mock_settings.SMTP_ENCRYPTION = "none" + + settings = build_smtp_settings() + + assert settings.enabled is False + + @patch("extensions.smtp.settings") + def test_build_smtp_settings_defaults(self, mock_settings): + """Test building SMTP settings with default values""" + mock_settings.SMTP_ENABLE = True + mock_settings.SMTP_HOST = " smtp.example.com " + mock_settings.SMTP_PORT = 587 + mock_settings.SMTP_USERNAME = " user " + mock_settings.SMTP_PASSWORD = "pass" + mock_settings.SMTP_FROM_EMAIL = None + mock_settings.SMTP_FROM_NAME = None + mock_settings.SMTP_ENCRYPTION = None + + settings = build_smtp_settings() + + assert settings.host == "smtp.example.com" # Stripped + assert settings.username == " user " # Not stripped, kept as is + assert settings.from_email is None + assert settings.from_name == "Docker Fullstack Template" # Default + assert settings.encryption == "tls" # Default + + +class TestGetMailer: + """Test get_mailer function""" + + def setup_method(self): + """Reset singleton before each test""" + extensions.smtp._SMTP_MAILER = None + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_first_call(self, mock_logger, mock_build): + """Test get_mailer on first call creates instance""" + mock_cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mock_build.return_value = mock_cfg + + mailer1 = get_mailer() + + assert mailer1 is not None + mock_build.assert_called_once() + mock_logger.info.assert_called_once() + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_singleton(self, mock_logger, mock_build): + """Test get_mailer returns same instance (singleton)""" + mock_cfg = SMTPSettings( + enabled=True, + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_email="from@example.com", + from_name="App", + encryption="tls", + ) + mock_build.return_value = mock_cfg + + mailer1 = get_mailer() + mailer2 = get_mailer() + + assert mailer1 is mailer2 + assert mock_build.call_count == 1 # Only called once + + @patch("extensions.smtp.build_smtp_settings") + @patch("extensions.smtp.logger") + def test_get_mailer_disabled_logging(self, mock_logger, mock_build): + """Test get_mailer logs when SMTP is disabled""" + mock_cfg = SMTPSettings( + enabled=False, + host="", + port=25, + username=None, + password=None, + from_email=None, + from_name="App", + encryption="none", + ) + mock_build.return_value = mock_cfg + + get_mailer() + + mock_logger.info.assert_called_once_with("SMTP disabled") + + +class TestAddSMTP: + """Test add_smtp function""" + + def setup_method(self): + """Reset singleton before each test""" + extensions.smtp._SMTP_MAILER = None + + @patch("extensions.smtp.get_mailer") + def test_add_smtp_registers_to_app(self, mock_get_mailer): + """Test add_smtp registers mailer to app.state""" + app = FastAPI() + mock_mailer = MagicMock() + mock_get_mailer.return_value = mock_mailer + + add_smtp(app) + + mock_get_mailer.assert_called_once() + assert app.state.smtp == mock_mailer diff --git a/backend/utils/custom_exception.py b/backend/utils/custom_exception.py index 1dd3dc2..007b89d 100644 --- a/backend/utils/custom_exception.py +++ b/backend/utils/custom_exception.py @@ -45,6 +45,11 @@ class PasswordResetRequiredException(BaseServiceException): def __init__(self, message: str = "Password reset required", details: Dict[str, Any] = None): super().__init__(message=message, error_code="PASSWORD_RESET_REQUIRED", details=details, status_code=202, log_level="warning") +class EmailVerificationRequiredException(BaseServiceException): + """Email verification required exception""" + def __init__(self, message: str = "Email verification required", details: Dict[str, Any] = None): + super().__init__(message=message, error_code="EMAIL_VERIFICATION_REQUIRED", details=details, status_code=202, log_level="warning") + class AuthorizationException(BaseServiceException): """Authorization related exceptions""" def __init__(self, message: str = "Permission denied", details: Dict[str, Any] = None): @@ -68,4 +73,9 @@ def __init__(self, message: str = "Resource conflict", details: Dict[str, Any] = class TokenException(BaseServiceException): """Token related exceptions""" def __init__(self, message: str = "Token error", details: Dict[str, Any] = None): - super().__init__(message=message, error_code="TOKEN_ERROR", details=details, status_code=401, log_level="warning") \ No newline at end of file + super().__init__(message=message, error_code="TOKEN_ERROR", details=details, status_code=401, log_level="warning") + +class SMTPNotConfiguredException(BaseServiceException): + """SMTP configuration related exceptions""" + def __init__(self, message: str = "SMTP is not configured", details: Dict[str, Any] = None): + super().__init__(message=message, error_code="SMTP_NOT_CONFIGURED", details=details, status_code=503, log_level="warning") \ No newline at end of file diff --git a/backend/utils/email_templates.py b/backend/utils/email_templates.py new file mode 100644 index 0000000..bc0857f --- /dev/null +++ b/backend/utils/email_templates.py @@ -0,0 +1,117 @@ +from typing import Dict, Any, Optional + +class EmailTemplate: + """Email template with subject, plain text body, and optional HTML body""" + + def __init__( + self, + subject: str, + body: str, + html_body: Optional[str] = None + ): + self.subject = subject + self.body = body + self.html_body = html_body + + def render(self, **kwargs: Any) -> Dict[str, str]: + """ + Render template with variables. + + Args: + **kwargs: Variables to substitute in template + + Returns: + Dict with 'subject', 'body', and optionally 'html_body' keys + """ + result = { + "subject": self.subject.format(**kwargs), + "body": self.body.format(**kwargs), + } + + if self.html_body: + result["html_body"] = self.html_body.format(**kwargs) + + return result + + +# Password Reset Email Template +PASSWORD_RESET_TEMPLATE = EmailTemplate( + subject="Reset your password - {app_name}", + body=( + "Hi {user_name},\n\n" + "You requested a password reset for your {app_name} account.\n\n" + "Please click the link below to set a new password:\n{reset_url}\n\n" + "This link will expire in 30 minutes.\n\n" + "If you did not request this, you can safely ignore this email." + ), + html_body=( + "" + "" + "" + "" + "" + "" + "" + "
" + "

Hi {user_name},

" + "

" + "You requested a password reset for your {app_name} account.
Click the button below to set a new password. This link will expire in 30 minutes." + "

" + "

" + "" + "Reset Password" + "" + "

" + "

" + "If you did not request this password reset, you can safely ignore this email. Your account remains secure." + "

" + "
" + "" + "" + ), +) + +# Email Verification Template +EMAIL_VERIFICATION_TEMPLATE = EmailTemplate( + subject="Verify your email - {app_name}", + body=( + "Hi {user_name},\n\n" + "Please verify your email address for your {app_name} account.\n\n" + "Please click the link below to verify your email address:\n{verification_url}\n\n" + "This link will expire in {expire_minutes} minutes.\n\n" + "If you did not request this email, you can safely ignore it." + ), + html_body=( + "" + "" + "" + "" + "" + "" + "" + "
" + "

Hi {user_name},

" + "

" + "Please verify your email address for your {app_name} account.
Please click the button below to verify your email address. This link will expire in {expire_minutes} minutes." + "

" + "

" + "" + "Verify Email" + "" + "

" + "

" + "If you did not request this email, you can safely ignore it." + "

" + "
" + "" + "" + ), +) \ No newline at end of file diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 01ff5b3..79e0dc5 100755 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -67,6 +67,8 @@ services: # API settings - VITE_API_HOST=${API_HOST} - VITE_API_PORT=${API_PORT} + # SMTP settings + - VITE_SMTP_ENABLE=${SMTP_ENABLE} command: ["sh", "./init-prod.sh"] depends_on: backend: @@ -110,6 +112,17 @@ services: - DB_WRITE_TIMEOUT=${DB_WRITE_TIMEOUT} # Redis settings - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + # SMTP settings + - SMTP_ENABLE=${SMTP_ENABLE} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USERNAME=${SMTP_USERNAME} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL} + - SMTP_FROM_NAME=${SMTP_FROM_NAME} + - SMTP_ENCRYPTION=${SMTP_ENCRYPTION} + # Email verification settings + - EMAIL_VERIFICATION_ENABLE=${EMAIL_VERIFICATION_ENABLE} command: ["sh", "./init-prod.sh"] depends_on: mariadb: diff --git a/docker-compose.yaml b/docker-compose.yaml index 52d0dd1..8de1f62 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -66,6 +66,8 @@ services: # API settings - VITE_API_HOST=${API_HOST:-localhost} - VITE_API_PORT=${API_PORT:-5000} + # SMTP settings + - VITE_SMTP_ENABLE=${SMTP_ENABLE:-false} command: ["sh", "./init-dev.sh"] depends_on: backend: @@ -109,6 +111,17 @@ services: - DB_WRITE_TIMEOUT=${DB_WRITE_TIMEOUT:-30} # Redis settings - REDIS_URL=redis://:${REDIS_PASSWORD:-redis}@redis:6379/0 + # SMTP settings + - SMTP_ENABLE=${SMTP_ENABLE:-false} + - SMTP_HOST=${SMTP_HOST:-smtp.gmail.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USERNAME=${SMTP_USERNAME:-your-email@gmail.com} + - SMTP_PASSWORD=${SMTP_PASSWORD:-your-password} + - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-your-email@gmail.com} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-Docker Fullstack Template} + - SMTP_ENCRYPTION=${SMTP_ENCRYPTION:-tls} + # Email verification settings + - EMAIL_VERIFICATION_ENABLE=${EMAIL_VERIFICATION_ENABLE:-false} command: ["sh", "./init-dev.sh"] depends_on: mariadb: diff --git a/frontend/src/components/auth/forgot-password-form.jsx b/frontend/src/components/auth/forgot-password-form.jsx new file mode 100644 index 0000000..25437b5 --- /dev/null +++ b/frontend/src/components/auth/forgot-password-form.jsx @@ -0,0 +1,373 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Link, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { cn, debugWarn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { MailCheck } from 'lucide-react' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { authService } from '@/services/auth.service' +import { debugError } from '@/lib/utils' +import { Spinner } from '@/components/ui/spinner' +import { useIsMobile } from '@/hooks/useMobile' + +const SubmitButton = React.memo(({ onSubmit, t, className, isSubmitting }) => { + return ( + + ) +}) + +SubmitButton.displayName = 'SubmitButton' + +export const ForgotPasswordForm = ({ className, onStateChange, ...props }) => { + const location = useLocation() + const { t } = useTranslation() + const isMobile = useIsMobile() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isResending, setIsResending] = useState(false) + const [cooldownSeconds, setCooldownSeconds] = useState(0) + const [isLoadingCooldown, setIsLoadingCooldown] = useState(false) + const [showConfirmation, setShowConfirmation] = useState(false) + const [email, setEmail] = useState('') + const intervalRef = useRef(null) + + const fetchCooldown = useCallback(async (emailToCheck) => { + if (!emailToCheck) return + + setIsLoadingCooldown(true) + try { + const result = await authService.getPasswordResetCooldown(emailToCheck) + if (result.status === 'success' && result.data?.data) { + setCooldownSeconds(result.data.data.cooldown_seconds || 0) + } else if (result.status === 'success' && result.data?.cooldown_seconds !== undefined) { + setCooldownSeconds(result.data.cooldown_seconds || 0) + } + } catch (error) { + debugError('Failed to fetch cooldown:', error) + } finally { + setIsLoadingCooldown(false) + } + }, []) + + // Initialize email from location state (only when coming from other pages) + useEffect(() => { + const stateEmail = location.state?.email + + if (stateEmail) { + setEmail(stateEmail) + setShowConfirmation(true) + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + // Fetch cooldown status + fetchCooldown(stateEmail) + } + }, [fetchCooldown, location.state?.email, onStateChange]) + + // Countdown timer - only countdown, stop at 0 + useEffect(() => { + if (cooldownSeconds > 0) { + intervalRef.current = setInterval(() => { + setCooldownSeconds((prev) => { + if (prev <= 1) { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + return 0 + } + return prev - 1 + }) + }, 1000) + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [cooldownSeconds]) + + const formSchema = useMemo(() => { + return z.object({ + email: z + .string() + .min(1, t('pages.auth.forgotPassword.fields.email.validation.required', { defaultValue: 'Please enter your email' })) + .refine((val) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(val) + }, { + message: t('pages.auth.forgotPassword.fields.email.validation.invalid', { defaultValue: 'Please enter a valid email format' }), + }), + }) + }, [t]) + + const stableDefaultValues = useMemo(() => ({ + email: email || '', + }), [email]) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: stableDefaultValues, + }) + + // Update form when email changes + useEffect(() => { + if (email && form.getValues('email') !== email) { + form.setValue('email', email, { shouldValidate: false }) + } + }, [email, form]) + + useEffect(() => { + try { + form.clearErrors() + const newResolver = zodResolver(formSchema) + + if (form._options && typeof form._options === 'object' && 'resolver' in form._options) { + form._options.resolver = newResolver + } + + if ('_resolver' in form && form._resolver !== undefined) { + form._resolver = newResolver + } + + if (form.formState.isSubmitted) { + setTimeout(() => { + form.trigger() + }, 0) + } + } catch (error) { + debugWarn('Failed to update form resolver:', error) + form.clearErrors() + if (form.formState.isSubmitted) { + setTimeout(() => { + form.trigger() + }, 0) + } + } + }, [formSchema, form]) + + const formMethodsRef = useRef(form) + useEffect(() => { + formMethodsRef.current = form + }, [form]) + + const handleSubmit = useCallback(async (formValues) => { + setIsSubmitting(true) + + try { + await authService.forgotPassword(formValues.email, { + showErrorToast: true, + showSuccessToast: true, + }) + + // Success - switch to confirmation mode + const submittedEmail = formValues.email + setEmail(submittedEmail) + setShowConfirmation(true) + + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + + // Fetch cooldown status + await fetchCooldown(submittedEmail) + + setIsSubmitting(false) + } catch (error) { + debugError('Forgot password error:', error) + // If error is due to cooldown, switch to confirmation mode and fetch cooldown + if (error.response?.status === 400 && formValues.email) { + const submittedEmail = formValues.email + setEmail(submittedEmail) + setShowConfirmation(true) + + // Notify parent component about confirmation state + if (onStateChange) { + onStateChange(true) + } + + await fetchCooldown(submittedEmail) + } + setIsSubmitting(false) + } + }, [fetchCooldown, onStateChange]) + + const handleResend = useCallback(async () => { + if (cooldownSeconds > 0 || isResending || !email) { + return + } + + setIsResending(true) + + try { + await authService.forgotPassword(email, { + showErrorToast: true, + showSuccessToast: true, + }) + + // Fetch new cooldown after successful send + await fetchCooldown(email) + } catch (error) { + debugError('Resend email error:', error) + // If error is due to cooldown, fetch the current cooldown status + if (error.response?.status === 400) { + await fetchCooldown(email) + } + } finally { + setIsResending(false) + } + }, [email, cooldownSeconds, isResending, fetchCooldown]) + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const onSubmitHandler = useCallback((e) => { + e?.preventDefault?.() + formMethodsRef.current.handleSubmit(handleSubmit)() + }, [handleSubmit]) + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !isSubmitting && !showConfirmation) { + e.preventDefault() + onSubmitHandler(e) + } + }, [onSubmitHandler, isSubmitting, showConfirmation]) + + const emailInputRef = useRef(null) + useEffect(() => { + if (!isMobile && emailInputRef.current && !showConfirmation) { + emailInputRef.current.focus() + } + }, [isMobile, showConfirmation]) + + // Confirmation view + if (showConfirmation && email) { + return ( +
+
+
+ +
+ +
+

+ {t('pages.auth.forgotPasswordConfirmation.description', { + defaultValue: 'We\'ve sent a password reset link to {{email}}', + email + })} +

+
+
+ +
+ ) + } + + // Input form view + return ( +
+ + + ( + + {t('pages.auth.forgotPassword.fields.email.label', { defaultValue: 'Email' })} + + { + emailInputRef.current = e + field.ref(e) + }} + disabled={isSubmitting} + /> + + + + )} + /> + + + +
+ + {t('pages.auth.forgotPassword.actions.backToLogin', { defaultValue: 'Back to Login' })} + +
+ + + ) +} + +export default ForgotPasswordForm diff --git a/frontend/src/components/auth/login-form.jsx b/frontend/src/components/auth/login-form.jsx index e116d06..4cb7c7a 100644 --- a/frontend/src/components/auth/login-form.jsx +++ b/frontend/src/components/auth/login-form.jsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useMemo, useEffect, useLayoutEffect, useRef } from 'react' +import ENV from '@/config/env.config' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -223,8 +224,30 @@ export const LoginForm = ({ className, redirectTo = '/', ...props }) => { setEmailValueRef.current('') formMethodsRef.current.reset({ email: '', password: '' }, { keepValues: false }) setTimeout(() => { - navigateRef.current(`/reset-password?token=${encodeURIComponent(result.resetToken)}`, { replace: true }) + navigateRef.current(`/auth/reset-password?token=${encodeURIComponent(result.resetToken)}`, { replace: true }) }, 0) + } else if (result.requiresEmailVerification) { + // Email verification required - redirect to verification page with email + const emailToKeep = formValues.email || emailRef.current || emailPersistRef.current + + emailRef.current = emailToKeep + emailPersistRef.current = emailToKeep + + formMethodsRef.current.reset({ email: emailToKeep, password: '' }, { keepValues: false }) + + setEmailValueRef.current(emailToKeep) + + setIsSubmitting(false) + + // Redirect to verification page with email in state + requestAnimationFrame(() => { + requestAnimationFrame(() => { + navigateRef.current('/auth/verify-email', { + replace: true, + state: { email: emailToKeep } + }); + }); + }); } else { const emailToKeep = formValues.email || emailRef.current || emailPersistRef.current @@ -314,7 +337,18 @@ export const LoginForm = ({ className, redirectTo = '/', ...props }) => { name="password" render={({ field }) => ( - {t('pages.auth.login.fields.password.label', { defaultValue: 'Password' })} +
+ {t('pages.auth.login.fields.password.label', { defaultValue: 'Password' })} + {ENV.SMTP_ENABLE && ( + + {t('pages.auth.login.links.forgotPassword', { defaultValue: 'Forgot password?' })} + + )} +
{
{t('pages.auth.login.links.newUser', { defaultValue: 'New user? ' })} diff --git a/frontend/src/components/auth/register-form.jsx b/frontend/src/components/auth/register-form.jsx index 7a5700d..de75a82 100644 --- a/frontend/src/components/auth/register-form.jsx +++ b/frontend/src/components/auth/register-form.jsx @@ -333,7 +333,7 @@ export const RegisterForm = ({ className, redirectTo = '/', ...props }) => {
{t('pages.auth.register.links.existingUser', { defaultValue: 'Already have an account? ' })} diff --git a/frontend/src/components/auth/reset-password-form.jsx b/frontend/src/components/auth/reset-password-form.jsx index 986a353..a3168de 100644 --- a/frontend/src/components/auth/reset-password-form.jsx +++ b/frontend/src/components/auth/reset-password-form.jsx @@ -252,7 +252,7 @@ export const ResetPasswordForm = ({ className, token, ...props }) => {
+
+
+ ) + } + + if (verificationStatus === 'pending' && email) { + return ( +
+
+

+ {t('pages.auth.verifyEmail.messages.pending', { defaultValue: 'A verification email has been sent to your email address. Please check your inbox and click the verification link.' })} +

+
+ +
+ +
+ +
+ +
+
+ ) + } + + if (verificationStatus === 'error') { + return ( +
+
+

+ {errorMessage || t('pages.auth.verifyEmail.messages.invalidToken', { defaultValue: 'The verification link has expired or is invalid. Please request a new one.' })} +

+
+ + {email && ( +
+ +
+ )} + +
+ +
+
+ ) + } + + return null +} + +export default VerifyEmailForm \ No newline at end of file diff --git a/frontend/src/components/core/layout.jsx b/frontend/src/components/core/layout.jsx index d630515..a556f1e 100644 --- a/frontend/src/components/core/layout.jsx +++ b/frontend/src/components/core/layout.jsx @@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next'; import { useIsMobile } from '@/hooks/useMobile'; import { useNavigate, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { motion } from 'framer-motion'; import { AlertDialog, AlertDialogAction, @@ -34,7 +33,8 @@ export const Layout = ({ const [shouldDelayLoginButton, setShouldDelayLoginButton] = useState(false); const prevIsAuthenticatedRef = useRef(isAuthenticated); - const isAuthPage = location.pathname === '/login' || location.pathname === '/register' || location.pathname === '/reset-password'; + // Check if current path is any auth-related page + const isAuthPage = location.pathname.startsWith('/auth'); const prevIsAuthPageRef = useRef(isAuthPage); const dockPositionClasses = { @@ -55,9 +55,8 @@ export const Layout = ({ }; const handleLoginClick = () => { - navigate('/login'); + navigate('/auth/login'); }; - const userInitials = user?.first_name?.[0]?.toUpperCase() + user?.last_name?.[0]?.toUpperCase() || 'U'; const userName = user ? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || 'User' : 'User'; const userEmail = user?.email || ''; diff --git a/frontend/src/components/core/protected-route.jsx b/frontend/src/components/core/protected-route.jsx index 9705582..ce9ccc1 100644 --- a/frontend/src/components/core/protected-route.jsx +++ b/frontend/src/components/core/protected-route.jsx @@ -22,8 +22,10 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul } }, [isLoading]); - // Handle reset password page - logout authenticated users - const isResetPasswordPage = location.pathname === '/reset-password'; + const isResetPasswordPage = location.pathname === '/auth/reset-password'; + const isVerificationPage = location.pathname === '/auth/verify-email'; + const isForgotPasswordPage = location.pathname === '/auth/forgot-password'; + const isAuthFlowPage = isResetPasswordPage || isVerificationPage || isForgotPasswordPage; // Auto logout authenticated users visiting reset password page (except during password reset) useEffect(() => { @@ -34,11 +36,11 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul // Redirect to login when user becomes unauthenticated useEffect(() => { - if (wasAuthenticatedRef.current && !isAuthenticated && location.pathname !== '/login' && !isResetPasswordPage) { - navigate('/login', { state: { from: location }, replace: true }); + if (wasAuthenticatedRef.current && !isAuthenticated && location.pathname !== '/auth/login' && !isAuthFlowPage) { + navigate('/auth/login', { state: { from: location }, replace: true }); } wasAuthenticatedRef.current = isAuthenticated; - }, [isAuthenticated, location, navigate, isResetPasswordPage]); + }, [isAuthenticated, location, navigate, isAuthFlowPage]); // Check if user has required permissions const hasPermission = React.useMemo(() => { @@ -78,11 +80,12 @@ export const ProtectedRoute = ({ children, requireAuth = true, permissions = nul // Redirect to login if authentication required if (requireAuth && !isAuthenticated) { - return ; + return ; } - // Redirect authenticated users away from login/register pages - if (!requireAuth && isAuthenticated && (location.pathname === "/login" || location.pathname === "/register")) { + // Redirect authenticated users away from login/register pages (but not verify-email, reset-password, or forgot-password) + const isAuthPage = location.pathname === "/auth/login" || location.pathname === "/auth/register"; + if (!requireAuth && isAuthenticated && isAuthPage && !isAuthFlowPage) { const from = location.state?.from?.pathname; return ; } diff --git a/frontend/src/components/profile/update-profile-form.jsx b/frontend/src/components/profile/update-profile-form.jsx index d890d47..7f3c618 100644 --- a/frontend/src/components/profile/update-profile-form.jsx +++ b/frontend/src/components/profile/update-profile-form.jsx @@ -3,7 +3,6 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { cn, debugError } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { @@ -17,12 +16,16 @@ import { import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '@/hooks/useAuth'; -import { useIsMobile } from '@/hooks/useMobile'; -export function UpdateProfileForm({ user, onSuccess, onClose, onSubmittingChange }) { +export function UpdateProfileForm({ + user, + onSuccess, + onClose, + onSubmittingChange, + onRequiresEmailVerification, +}) { const { t } = useTranslation(); const { updateUserProfile, isLoading } = useAuth(); - const isMobile = useIsMobile(); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -80,29 +83,33 @@ export function UpdateProfileForm({ user, onSuccess, onClose, onSubmittingChange const handleSubmit = useCallback(async (formValues) => { setIsSubmitting(true); - + try { const result = await updateUserProfile({ first_name: formValues.first_name, last_name: formValues.last_name, email: formValues.email, phone: formValues.phone, - }); - - if (result.success) { + }, {returnStatus: true}); + + if (result?.status === 'success') { if (onSuccess) { await onSuccess(result.data); } - } else { - toast.error(result.error || t('common.status.error')); + } else if (result?.requiresEmailVerification) { + if (onRequiresEmailVerification) { + await onRequiresEmailVerification({ + email: result.email || formValues.email, + profile: result.data, + }); + } } } catch (error) { debugError('Update profile error:', error); - toast.error(error.message || t('common.status.error')); } finally { setIsSubmitting(false); } - }, [updateUserProfile, onSuccess]); + }, [updateUserProfile, onSuccess, onRequiresEmailVerification]); return (
diff --git a/frontend/src/config/env.config.js b/frontend/src/config/env.config.js index ab4ed2c..ecf1ca1 100755 --- a/frontend/src/config/env.config.js +++ b/frontend/src/config/env.config.js @@ -11,6 +11,8 @@ export const ENV = { // API settings API_HOST: import.meta.env.VITE_API_HOST || 'localhost', API_PORT: import.meta.env.VITE_API_PORT || 5000, + // SMTP settings + SMTP_ENABLE: import.meta.env.VITE_SMTP_ENABLE === 'true' || false, }; export default ENV; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.jsx b/frontend/src/hooks/useAuth.jsx index 4a2c716..6cff73c 100644 --- a/frontend/src/hooks/useAuth.jsx +++ b/frontend/src/hooks/useAuth.jsx @@ -3,7 +3,6 @@ import { useAuth as useAuthContext } from '@/contexts/authContext'; import authService from '@/services/auth.service'; import accountService from '@/services/account.service'; import rolesService from '@/services/roles.service'; -import i18n from '@/i18n'; import { debugError } from '@/lib/utils'; export const useAuth = () => { @@ -99,15 +98,51 @@ export const useAuth = () => { const result = await authService.login(credentials); - // Check if response indicates password reset is required (202 status) - if (result?._statusCode === 202 || result?.reset_token) { - const resetToken = result?.reset_token || result?.data?.reset_token; + // Check if response indicates action is required (202 status) + if (result?._statusCode === 202) { + const actionData = result; + const actionType = actionData?.action_type; + const resetToken = actionData?.token; setLoading(false); + + if (!actionType) { + if (resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: result + }; + } else { + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + } + + if (actionType === 'password_reset' || resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: result + }; + } else if (actionType === 'email_verification') { + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + + // Fallback for unknown action types return { success: false, - requiresPasswordReset: true, - resetToken: resetToken, + requiresAction: true, + actionType: actionType, data: result }; } @@ -125,14 +160,34 @@ export const useAuth = () => { throw new Error('Invalid login response format'); } } catch (error) { - // Check if error response indicates password reset is required (202 status) + // Check if error response indicates action is required (202 status) if (error.response?.status === 202) { - const resetToken = error.response?.data?.data?.reset_token || error.response?.data?.reset_token; + // For 202 status, data is in error.response.data.data + const actionData = error.response?.data?.data || error.response?.data; + const actionType = actionData?.action_type; + const resetToken = actionData?.token; + setLoading(false); + + if (actionType === 'password_reset' || resetToken) { + return { + success: false, + requiresPasswordReset: true, + resetToken: resetToken, + data: error.response?.data + }; + } else if (actionType === 'email_verification') { + return { + success: false, + requiresEmailVerification: true, + data: error.response?.data + }; + } + return { success: false, - requiresPasswordReset: true, - resetToken: resetToken, + requiresAction: true, + actionType: actionType, data: error.response?.data }; } @@ -154,6 +209,17 @@ export const useAuth = () => { const result = await authService.register(userData); + // Check if response indicates action is required (202 status) + if (result?._statusCode === 202) { + // For 202 status, email verification is required + setLoading(false); + return { + success: false, + requiresEmailVerification: true, + data: result + }; + } + if (result?.user && result?.access_token) { const { user, access_token: token } = result; @@ -165,6 +231,17 @@ export const useAuth = () => { setLoading(false); return { success: true, data: result }; } catch (error) { + // Check if error response indicates action is required (202 status) + if (error.response?.status === 202) { + // For 202 status, email verification is required + setLoading(false); + return { + success: false, + requiresEmailVerification: true, + data: error.response?.data + }; + } + const errorMessage = error.response?.data?.message || error.message || 'Registration failed'; setError(errorMessage); setLoading(false); @@ -260,8 +337,12 @@ export const useAuth = () => { // Fetch profile with new token await fetchAndUpdateProfile(); - // Allow permissions to load + // Reset permissions load ref to allow fresh load + permissionsLoadRef.current = false; + + // Allow permissions to load and actively load them isResettingPasswordRef.current = false; + await loadPermissions(); setLoading(false); return { success: true, data: result }; @@ -277,7 +358,7 @@ export const useAuth = () => { setLoading(false); return { success: false, error: errorMessage, status }; } - }, [setLoading, clearError, loginSuccess, fetchAndUpdateProfile, setError]); + }, [setLoading, clearError, loginSuccess, fetchAndUpdateProfile, setError, loadPermissions]); // Validate password reset token const validateResetToken = useCallback(async (resetToken) => { @@ -309,14 +390,49 @@ export const useAuth = () => { try { clearError(); - const result = await accountService.updateProfile(userData, { ...config }); - setUser(result); - - return { success: true, data: result }; + const { returnStatus, ...restConfig } = config || {}; + const result = await accountService.updateProfile(userData, { ...restConfig, returnStatus }); + const responseData = returnStatus ? result?.data : result; + + if (returnStatus && result?.status === 'error') { + const errorMessage = result?.error?.message || 'Failed to update user profile'; + setError(errorMessage); + return { + ...result, + success: false, + error: result?.error || { message: errorMessage }, + }; + } + + if (responseData?._statusCode === 202) { + const { _statusCode: _ignored, ...profileData } = responseData; + if (profileData) { + setUser(profileData); + } + const baseResult = { + success: false, + requiresEmailVerification: true, + data: profileData, + email: userData?.email, + }; + return returnStatus + ? { ...result, ...baseResult, data: profileData } + : baseResult; + } + + if (responseData) { + setUser(responseData); + } + + return returnStatus + ? { ...result, success: true, data: responseData } + : { success: true, data: responseData }; } catch (error) { const errorMessage = error.response?.data?.message || error.message || 'Failed to update user profile'; setError(errorMessage); - return { success: false, error: errorMessage }; + return config?.returnStatus + ? { data: null, status: 'error', error: { message: errorMessage }, success: false } + : { success: false, error: errorMessage }; } }, [clearError, setUser, setError]); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 339cb05..e752be1 100755 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -199,7 +199,8 @@ }, "links": { "newUser": "New user?", - "register": "Sign up" + "register": "Sign up", + "forgotPassword": "Forgot password?" }, "messages": { "success": "Sign in successful", @@ -307,6 +308,53 @@ "messages": { "success": "Reset token is valid" } + }, + "forgotPassword": { + "title": "Forgot Password", + "description": "Enter your email address and we'll send you a link to reset your password.", + "fields": { + "email": { + "label": "Email", + "validation": { + "required": "Please enter your email", + "invalid": "Please enter a valid email format" + } + } + }, + "actions": { + "backToLogin": "Back to Login" + }, + "messages": { + "success": "Password reset email sent", + "cooldown": "Please wait before requesting another password reset email", + "accountDisabled": "Account is disabled", + "emailNotRegistered": "This email is not registered", + "smtpDisabled": "Email service is disabled" + } + }, + "forgotPasswordConfirmation": { + "description": "We've sent a password reset link to {{email}}", + "actions": { + "resend": "Resend email", + "resendCooldown": "Resend email ({{time}})" + } + }, + "verifyEmail": { + "messages": { + "noToken": "No verification token provided", + "success": "Email verified successfully", + "pending": "A verification email has been sent to your email address. Please check your inbox and click the verification link.", + "invalidToken": "The verification link has expired or is invalid. Please request a new one.", + "verificationFailed": "Email verification failed", + "userNotFound": "User not found", + "emailExists": "Email already exists" + }, + "actions": { + "backToHome": "Go to Home", + "resend": "Resend Verification Email", + "resendCooldown": "Resend Email ({{cooldownSeconds}}s)", + "backToLogin": "Back to Login" + } } }, "profile": { @@ -367,7 +415,9 @@ "submitting": "Updating..." }, "messages": { - "success": "Profile updated successfully" + "success": "Profile updated successfully", + "emailVerificationRequired": "Email verification required", + "emailAlreadyExists": "Email already exists" } }, "security": { diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 6909399..265dacf 100755 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -199,7 +199,8 @@ }, "links": { "newUser": "新的使用者?", - "register": "註冊" + "register": "註冊", + "forgotPassword": "忘記密碼?" }, "messages": { "success": "登入成功", @@ -306,6 +307,53 @@ "messages": { "success": "重設令牌有效" } + }, + "forgotPassword": { + "title": "忘記密碼", + "description": "請輸入您的電子信箱,我們將寄送重設密碼連結給您。", + "fields": { + "email": { + "label": "電子信箱", + "validation": { + "required": "請輸入您的電子信箱", + "invalid": "請輸入有效的電子信箱格式" + } + } + }, + "actions": { + "backToLogin": "返回登入" + }, + "messages": { + "success": "已寄送重設密碼郵件", + "cooldown": "請稍候再申請重設密碼郵件", + "accountDisabled": "帳號已停用", + "emailNotRegistered": "此信箱尚未被使用者註冊", + "smtpDisabled": "未啟用寄信服務" + } + }, + "forgotPasswordConfirmation": { + "description": "我們已將重設密碼連結寄送至 {{email}}", + "actions": { + "resend": "重新寄送", + "resendCooldown": "重新寄送 ({{time}})" + } + }, + "verifyEmail": { + "messages": { + "noToken": "未提供驗證令牌", + "success": "電子信箱驗證成功", + "pending": "已寄送驗證郵件至您的電子信箱,請查看信箱並點擊驗證連結。", + "invalidToken": "驗證連結已失效或無效,請重新申請。", + "verificationFailed": "電子信箱驗證失敗", + "userNotFound": "使用者不存在", + "emailExists": "此電子信箱已被使用" + }, + "actions": { + "backToHome": "回到首頁", + "resend": "重新寄送驗證郵件", + "resendCooldown": "重新寄送 ({{cooldownSeconds}}秒)", + "backToLogin": "返回登入" + } } }, "profile": { @@ -362,7 +410,9 @@ } }, "messages": { - "success": "個人資料更新成功" + "success": "個人資料更新成功", + "emailVerificationRequired": "需要進行電子信箱驗證", + "emailAlreadyExists": "此電子信箱已被使用" } }, "security": { diff --git a/frontend/src/pages/Auth/index.jsx b/frontend/src/pages/Auth/index.jsx index 1a4d908..622ebfa 100644 --- a/frontend/src/pages/Auth/index.jsx +++ b/frontend/src/pages/Auth/index.jsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { AnimatePresence, motion } from 'motion/react' import { cn } from '@/lib/utils' @@ -14,6 +14,8 @@ import { import { LoginForm } from '@/components/auth/login-form' import { RegisterForm } from '@/components/auth/register-form' import { ResetPasswordForm } from '@/components/auth/reset-password-form' +import { ForgotPasswordForm } from '@/components/auth/forgot-password-form' +import { VerifyEmailForm } from '@/components/auth/verify-email-form' const arePropsEqual = (prevProps, nextProps) => { const prevPath = prevProps.location?.pathname @@ -22,18 +24,22 @@ const arePropsEqual = (prevProps, nextProps) => { const nextSearch = nextProps.location?.search const prevState = prevProps.location?.state?.from?.pathname const nextState = nextProps.location?.state?.from?.pathname + const prevStateEmail = prevProps.location?.state?.email + const nextStateEmail = nextProps.location?.state?.email const prevLanguage = prevProps.language const nextLanguage = nextProps.language const areEqual = prevPath === nextPath && prevSearch === nextSearch && prevState === nextState && + prevStateEmail === nextStateEmail && prevLanguage === nextLanguage return areEqual } -const AuthComponent = React.memo(({ t, location, language, navigate }) => { +// language prop is used in arePropsEqual to trigger re-render on language change +const AuthComponent = React.memo(({ t, location, language }) => { const redirect = React.useMemo(() => { const result = location.state?.from?.pathname || new URLSearchParams(location.search).get('redirect') || @@ -45,16 +51,51 @@ const AuthComponent = React.memo(({ t, location, language, navigate }) => { return new URLSearchParams(location.search).get('token') }, [location.search]) + const verifyToken = React.useMemo(() => { + return new URLSearchParams(location.search).get('token') + }, [location.search]) + const activeTab = React.useMemo(() => { - if (location.pathname === '/reset-password') { + if (location.pathname === '/auth/verify-email') { + return 'verify-email' + } + if (location.pathname === '/auth/reset-password') { return 'reset-password' } - if (location.pathname === '/register') { + if (location.pathname === '/auth/forgot-password') { + return 'forgot-password' + } + if (location.pathname === '/auth/register') { return 'register' } return 'login' }, [location.pathname]) + // State to track if forgot password is in confirmation state + const [isForgotPasswordConfirmation, setIsForgotPasswordConfirmation] = React.useState(() => { + if (activeTab !== 'forgot-password') return false + + const stateEmail = location.state?.email + return !!stateEmail + }) + + // Reset confirmation state when switching away from forgot-password tab + React.useEffect(() => { + if (activeTab !== 'forgot-password') { + setIsForgotPasswordConfirmation(false) + } else { + const stateEmail = location.state?.email + setIsForgotPasswordConfirmation(!!stateEmail) + } + }, [activeTab, location.state?.email]) + + // Callback to update confirmation state from child component + const handleForgotPasswordStateChange = React.useCallback((isConfirmation) => { + if (activeTab === 'forgot-password') { + setIsForgotPasswordConfirmation(isConfirmation) + } + }, [activeTab]) + return (
@@ -81,8 +122,12 @@ const AuthComponent = React.memo(({ t, location, language, navigate }) => { - {activeTab === 'reset-password' + {activeTab === 'verify-email' + ? t("pages.auth.verifyEmail.title", { defaultValue: "Verify Email" }) + : activeTab === 'reset-password' ? t("pages.auth.resetPassword.title", { defaultValue: "Reset Password" }) + : activeTab === 'forgot-password' + ? t("pages.auth.forgotPassword.title", { defaultValue: "Forgot Password" }) : activeTab === 'register' ? t("pages.auth.register.title", { defaultValue: "Sign up" }) : t("pages.auth.login.title", { defaultValue: "Sign in" }) @@ -90,11 +135,22 @@ const AuthComponent = React.memo(({ t, location, language, navigate }) => { + + {activeTab === 'forgot-password' && !isForgotPasswordConfirmation && ( +

+ {t("pages.auth.forgotPassword.description", { defaultValue: "Enter your email address and we'll send you a link to reset your password." })} +

+ )} +
- {activeTab === 'reset-password' ? ( + {activeTab === 'verify-email' ? ( + + ) : activeTab === 'reset-password' ? ( + ) : activeTab === 'forgot-password' ? ( + ) : activeTab === 'register' ? ( ) : ( @@ -119,23 +175,21 @@ const getLocationKey = (location) => { export function Auth() { const { t, i18n } = useTranslation() - const location = useLocation() - const navigate = useNavigate() + const location = useLocation() const locationKey = getLocationKey(location) const language = i18n.language - const propsRef = useRef({ t, location, locationKey, language, navigate }) + const propsRef = useRef({ t, location, locationKey, language }) if (propsRef.current.locationKey !== locationKey || propsRef.current.language !== language) { - propsRef.current = { t, location, locationKey, language, navigate } + propsRef.current = { t, location, locationKey, language } } else { propsRef.current.t = t propsRef.current.location = location propsRef.current.language = language - propsRef.current.navigate = navigate } - return + return } export default Auth \ No newline at end of file diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index 93277c1..00e4dd6 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Edit, Lock, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -14,6 +15,7 @@ import { ChangePasswordForm } from '@/components/profile/change-password-form'; export function Profile() { const { t } = useTranslation(); + const navigate = useNavigate(); const isMobile = useIsMobile(); const { user, isLoading, loadProfile } = useAuth(); @@ -27,6 +29,14 @@ export function Profile() { await loadProfile(); }, [t, loadProfile]); + const handleEmailVerificationRequired = useCallback(({ email }) => { + setIsEditingProfile(false); + navigate('/auth/verify-email', { + state: { email }, + replace: false, + }); + }, [navigate]); + const handlePasswordChange = useCallback(async () => { setIsEditingSecurity(false); await loadProfile(); @@ -106,6 +116,7 @@ export function Profile() { onSuccess={handleProfileUpdate} onClose={cancelProfileEdit} onSubmittingChange={setIsSubmittingProfile} + onRequiresEmailVerification={handleEmailVerificationRequired} /> )} @@ -195,6 +206,7 @@ export function Profile() { onSuccess={handleProfileUpdate} onClose={cancelProfileEdit} onSubmittingChange={setIsSubmittingProfile} + onRequiresEmailVerification={handleEmailVerificationRequired} /> )} diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index c9713a9..e5f018c 100755 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -1,3 +1,5 @@ +import ENV from '@/config/env.config'; + /** * Route Configuration * @typedef {Object} RouteConfig @@ -54,7 +56,7 @@ export const routes = [ }, }, { - path: "/login", + path: "/auth/login", element: "Auth", requireAuth: false, permissions: [], @@ -63,7 +65,7 @@ export const routes = [ }, }, { - path: "/register", + path: "/auth/register", element: "Auth", requireAuth: false, permissions: [], @@ -72,7 +74,7 @@ export const routes = [ }, }, { - path: "/reset-password", + path: "/auth/reset-password", element: "Auth", requireAuth: false, permissions: [], @@ -80,6 +82,26 @@ export const routes = [ showInSidebar: false, }, }, + // Only include forgot-password route when SMTP is enabled + ...(ENV.SMTP_ENABLE ? [{ + path: "/auth/forgot-password", + element: "Auth", + requireAuth: false, + permissions: [], + sidebar: { + showInSidebar: false, + }, + }] : []), + // Only include verify-email route when SMTP is enabled + ...(ENV.SMTP_ENABLE ? [{ + path: "/auth/verify-email", + element: "Auth", + requireAuth: false, + permissions: [], + sidebar: { + showInSidebar: false, + }, + }] : []), { path: "/profile", element: "Profile", diff --git a/frontend/src/services/account.service.js b/frontend/src/services/account.service.js index 8f62a3a..1466b3b 100644 --- a/frontend/src/services/account.service.js +++ b/frontend/src/services/account.service.js @@ -24,6 +24,8 @@ export const accountService = { showSuccessToast: true, messageMap: { success: i18n.t('pages.profile.profile.messages.success'), + 202: i18n.t('pages.profile.profile.messages.emailVerificationRequired'), + 409: i18n.t('pages.profile.profile.messages.emailAlreadyExists'), ...config.messageMap, }, ...config, diff --git a/frontend/src/services/api.service.js b/frontend/src/services/api.service.js index e624e18..c78ec54 100755 --- a/frontend/src/services/api.service.js +++ b/frontend/src/services/api.service.js @@ -20,7 +20,8 @@ const apiClient = axios.create({ let getTokenFunction = null; let logoutFunction = null; let getTokenFunctionFromContext = null; -let isGettingToken = false; +let tokenRefreshPromise = null; +let failedQueue = []; export const setTokenGetter = (getToken, logout, getTokenFromContext = null) => { getTokenFunction = getToken; @@ -56,7 +57,7 @@ apiClient.interceptors.request.use( } else { delete config.headers.Authorization; } - } catch (error) { + } catch { delete config.headers.Authorization; } @@ -74,11 +75,19 @@ apiClient.interceptors.response.use( const messageMap = config?.messageMap; const successMessage = config?.successMessage || (messageMap && messageMap.success); - // 202 indicates password reset is required + // 202 indicates password reset or email verification is required if (response.status === 202) { - // Don't show success toast for 202, as it requires special handling if (response.data) { - const responseData = response.data.data !== undefined ? response.data.data : response.data; + let responseData; + if (response.data.data !== undefined && response.data.data !== null) { + responseData = response.data.data; + } else if (response.data.action_type !== undefined) { + responseData = response.data; + } else { + const { code: _code, message: _message, ...rest } = response.data; + responseData = rest; + } + return { ...responseData, _statusCode: 202 }; } return { _statusCode: 202 }; @@ -97,7 +106,7 @@ apiClient.interceptors.response.use( const originalRequest = error.config; const retryOn401 = originalRequest.retryOn401 !== false; // Default to true for backward compatibility - if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.noToken && !isGettingToken && retryOn401) { + if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.noToken && retryOn401) { const errorMessage = error.response?.data?.message || ''; const isPasswordError = errorMessage.includes('Current password is incorrect') || errorMessage.includes('password is incorrect') || @@ -109,34 +118,80 @@ apiClient.interceptors.response.use( } else { // Token expired: try to refresh originalRequest._retry = true; - isGettingToken = true; - try { - if (getTokenFunctionFromContext) { - const tokenResult = await getTokenFunctionFromContext(false, true); - + // If token refresh is already in progress, add to queue and wait for it + if (tokenRefreshPromise) { + failedQueue.push(originalRequest); + + try { + const tokenResult = await tokenRefreshPromise; if (tokenResult?.success && tokenResult?.token) { originalRequest.headers.Authorization = `Bearer ${tokenResult.token}`; - const result = await apiClient(originalRequest); - isGettingToken = false; - return result; + return apiClient(originalRequest); } - } - - if (logoutFunction) { - // Clear local state only (skip API call to avoid loop) - logoutFunction(true); + } catch { + // Token refresh failed, error already handled in tokenRefreshPromise + // Reject the original request to trigger error handling error.config = error.config || {}; error.config.showErrorToast = false; + handleApiError(error, logoutFunction); + return Promise.reject(error); } - } catch (tokenError) { - if (logoutFunction) { - logoutFunction(true); + } else { + failedQueue.push(originalRequest); + + tokenRefreshPromise = (async () => { + try { + if (getTokenFunctionFromContext) { + const tokenResult = await getTokenFunctionFromContext(false, true); + + if (tokenResult?.success && tokenResult?.token) { + // Update all queued requests with new token + failedQueue.forEach(request => { + request.headers.Authorization = `Bearer ${tokenResult.token}`; + }); + + return tokenResult; + } + } + + // Token refresh failed + if (logoutFunction) { + logoutFunction(true); + error.config = error.config || {}; + error.config.showErrorToast = false; + } + + throw new Error('Token refresh failed'); + } catch (tokenError) { + if (logoutFunction) { + logoutFunction(true); + error.config = error.config || {}; + error.config.showErrorToast = false; + } + + throw tokenError; + } finally { + tokenRefreshPromise = null; + failedQueue = []; + } + })(); + + try { + const tokenResult = await tokenRefreshPromise; + if (tokenResult?.success && tokenResult?.token) { + // Retry this request with new token + originalRequest.headers.Authorization = `Bearer ${tokenResult.token}`; + return apiClient(originalRequest); + } + } catch { + // Token refresh failed, error already handled in tokenRefreshPromise + // Reject the original request to trigger error handling error.config = error.config || {}; error.config.showErrorToast = false; + handleApiError(error, logoutFunction); + return Promise.reject(error); } - } finally { - isGettingToken = false; } } } diff --git a/frontend/src/services/auth.service.js b/frontend/src/services/auth.service.js index e15e645..2b0d552 100644 --- a/frontend/src/services/auth.service.js +++ b/frontend/src/services/auth.service.js @@ -64,6 +64,7 @@ export const authService = { resetPassword: (newPassword, resetToken, config = {}) => apiService.post(`${BASE_AUTH}/reset-password`, { new_password: newPassword }, { headers: { Authorization: `Bearer ${resetToken}` }, + noToken: true, retryOn401: false, showErrorToast: true, showSuccessToast: true, @@ -89,6 +90,82 @@ export const authService = { }, ...config, }), + + // Forgot password - send reset email + forgotPassword: (email, config = {}) => + apiService.post(`${BASE_AUTH}/forgot-password`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: true, + showSuccessToast: true, + messageMap: { + success: i18n.t('pages.auth.forgotPassword.messages.success', 'Password reset email sent'), + 400: i18n.t('pages.auth.forgotPassword.messages.cooldown', 'Please wait before requesting another password reset email'), + 403: i18n.t('pages.auth.forgotPassword.messages.accountDisabled', 'Account is disabled'), + 404: i18n.t('pages.auth.forgotPassword.messages.emailNotRegistered', 'This email is not registered'), + 503: i18n.t('pages.auth.forgotPassword.messages.smtpDisabled', 'SMTP is disabled'), + ...config.messageMap, + }, + ...config, + }), + + // Get password reset cooldown status + getPasswordResetCooldown: (email, config = {}) => + apiService.get(`${BASE_AUTH}/forgot-password/cooldown`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: false, + showSuccessToast: false, + returnStatus: true, + ...config, + }), + + // Verify email address + verifyEmail: (verificationToken, config = {}) => + apiService.get(`${BASE_AUTH}/verify-email`, {}, { + headers: { Authorization: `Bearer ${verificationToken}` }, + noToken: true, + retryOn401: false, + showErrorToast: true, + showSuccessToast: true, + messageMap: { + success: i18n.t('pages.auth.verifyEmail.messages.success', 'Email verified successfully'), + 401: i18n.t('pages.auth.verifyEmail.messages.invalidToken', 'The verification link has expired or is invalid. Please request a new one.'), + 404: i18n.t('pages.auth.verifyEmail.messages.userNotFound', 'User not found'), + 409: i18n.t('pages.auth.verifyEmail.messages.emailExists', 'Email already exists'), + ...config.messageMap, + }, + ...config, + }), + + // Resend verification email + resendVerification: (email, config = {}) => + apiService.post(`${BASE_AUTH}/resend-verification`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: true, + showSuccessToast: true, + messageMap: { + success: i18n.t('pages.auth.verifyEmail.messages.emailSent', 'Verification email sent'), + 400: i18n.t('pages.auth.verifyEmail.messages.cooldown', 'Please wait before requesting another verification email'), + 403: i18n.t('pages.auth.verifyEmail.messages.accountDisabled', 'Account is disabled'), + 404: i18n.t('pages.auth.verifyEmail.messages.emailNotRegistered', 'This email is not registered'), + 503: i18n.t('pages.auth.verifyEmail.messages.smtpDisabled', 'SMTP is disabled'), + ...config.messageMap, + }, + ...config, + }), + + // Get email verification cooldown status + getEmailVerificationCooldown: (email, config = {}) => + apiService.get(`${BASE_AUTH}/resend-verification/cooldown`, { email }, { + noToken: true, + retryOn401: false, + showErrorToast: false, + showSuccessToast: false, + returnStatus: true, + ...config, + }), }; export default authService; \ No newline at end of file