# Password Reset Implementation ## Overview This document describes the password reset feature for shop customers, allowing them to securely reset their password via email. --- ## Architecture ### Flow Diagram ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Customer │───▶│ Forgot Page │───▶│ API: POST │───▶│ Email Sent │ │ Clicks │ │ Enter Email │ │ /forgot-pwd │ │ with Link │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Success │◀───│ API: POST │◀───│ Reset Page │◀───│ Customer │ │ Redirect │ │ /reset-pwd │ │ New Password │ │ Clicks Link │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ ``` --- ## Components ### Database Model **File:** `models/database/password_reset_token.py` ```python class PasswordResetToken(Base): __tablename__ = "password_reset_tokens" id: int # Primary key customer_id: int # FK to customers.id token_hash: str # SHA256 hash of token expires_at: datetime # Expiration timestamp used_at: datetime | None # When token was used created_at: datetime # Creation timestamp TOKEN_EXPIRY_HOURS = 1 # Token valid for 1 hour ``` **Key Methods:** | Method | Description | |--------|-------------| | `create_for_customer(db, customer_id)` | Creates new token, returns plaintext | | `find_valid_token(db, plaintext_token)` | Finds unexpired, unused token | | `mark_used(db)` | Marks token as used | **Security:** - Tokens are hashed with SHA256 before storage - Only one active token per customer (old tokens invalidated) - Tokens expire after 1 hour - Tokens can only be used once --- ### API Endpoints **File:** `app/api/v1/storefront/auth.py` #### POST /api/v1/storefront/auth/forgot-password Request a password reset link. **Request:** ```json { "email": "customer@example.com" } ``` **Response:** (Always returns success to prevent email enumeration) ```json { "message": "If an account exists with this email, a password reset link has been sent." } ``` **Implementation:** ```python @router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse) def forgot_password(request: Request, email: str, db: Session = Depends(get_db)): store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") # Look up customer (store-scoped) customer = customer_service.get_customer_for_password_reset(db, store.id, email) if customer: # Generate token and send email plaintext_token = PasswordResetToken.create_for_customer(db, customer.id) reset_link = f"{scheme}://{host}/storefront/account/reset-password?token={plaintext_token}" email_service.send_template( template_code="password_reset", to_email=customer.email, variables={ "customer_name": customer.first_name, "reset_link": reset_link, "expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS), }, store_id=store.id, ) db.commit() # Always return success (security: don't reveal if email exists) return PasswordResetRequestResponse( message="If an account exists with this email, a password reset link has been sent." ) ``` --- #### POST /api/v1/storefront/auth/reset-password Reset password using token from email. **Request:** ```json { "reset_token": "abc123...", "new_password": "newSecurePassword123" } ``` **Response:** ```json { "message": "Password reset successfully. You can now log in with your new password." } ``` **Implementation:** ```python @router.post("/auth/reset-password", response_model=PasswordResetResponse) def reset_password( request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db) ): store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") # Service handles all validation and password update customer = customer_service.validate_and_reset_password( db=db, store_id=store.id, reset_token=reset_token, new_password=new_password, ) db.commit() return PasswordResetResponse( message="Password reset successfully. You can now log in with your new password." ) ``` --- ### Service Layer **File:** `app/services/customer_service.py` #### get_customer_for_password_reset ```python def get_customer_for_password_reset( self, db: Session, store_id: int, email: str ) -> Customer | None: """Get active customer by email for password reset. Returns None if customer doesn't exist or is inactive. This method is designed to not reveal whether an email exists. """ return ( db.query(Customer) .filter( Customer.store_id == store_id, Customer.email == email.lower(), Customer.is_active == True, ) .first() ) ``` #### validate_and_reset_password ```python def validate_and_reset_password( self, db: Session, store_id: int, reset_token: str, new_password: str, ) -> Customer: """Validate reset token and update customer password. Args: db: Database session store_id: Store ID (for security verification) reset_token: Password reset token from email new_password: New password Returns: Customer: Updated customer Raises: PasswordTooShortException: If password < 8 characters InvalidPasswordResetTokenException: If token invalid/expired CustomerNotActiveException: If customer account is inactive """ # Validate password length if len(new_password) < 8: raise PasswordTooShortException(min_length=8) # Find valid token token_record = PasswordResetToken.find_valid_token(db, reset_token) if not token_record: raise InvalidPasswordResetTokenException() # Get customer and verify store ownership customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first() if not customer or customer.store_id != store_id: raise InvalidPasswordResetTokenException() if not customer.is_active: raise CustomerNotActiveException(customer.email) # Update password hashed_password = self.auth_service.hash_password(new_password) customer.hashed_password = hashed_password # Mark token as used token_record.mark_used(db) return customer ``` --- ### Exceptions **File:** `app/exceptions/customer.py` ```python class InvalidPasswordResetTokenException(ValidationException): """Raised when password reset token is invalid or expired.""" def __init__(self): super().__init__( message="Invalid or expired password reset link. Please request a new one.", field="reset_token", ) self.error_code = "INVALID_RESET_TOKEN" class PasswordTooShortException(ValidationException): """Raised when password doesn't meet minimum length requirement.""" def __init__(self, min_length: int = 8): super().__init__( message=f"Password must be at least {min_length} characters long", field="password", details={"min_length": min_length}, ) self.error_code = "PASSWORD_TOO_SHORT" ``` --- ### Frontend Pages #### Forgot Password Page **Template:** `app/templates/storefront/account/forgot-password.html` **Route:** `/storefront/account/forgot-password` Features: - Email input form - Form validation - Success message display - Link back to login #### Reset Password Page **Template:** `app/templates/storefront/account/reset-password.html` **Route:** `/storefront/account/reset-password?token=...` Features: - New password input - Confirm password input - Password strength indicator - Password match validation - Success redirect to login --- ### Email Template **Code:** `password_reset` **Category:** AUTH **Languages:** en, fr, de, lb **Variables:** | Variable | Description | |----------|-------------| | `customer_name` | Customer's first name | | `reset_link` | Full URL to reset password page | | `expiry_hours` | Hours until link expires | **Example Email:** ``` Subject: Reset Your Password Hi {{ customer_name }}, We received a request to reset your password. Click here to reset your password: {{ reset_link }} This link will expire in {{ expiry_hours }} hour(s). If you didn't request this, you can safely ignore this email. ``` --- ## Security Considerations ### Token Security 1. **Hashed Storage**: Tokens stored as SHA256 hashes 2. **Short Expiry**: 1 hour expiration 3. **Single Use**: Tokens invalidated after use 4. **One Active Token**: Creating new token invalidates previous ### Email Enumeration Prevention - Same response whether email exists or not - Same response timing regardless of email existence ### Store Isolation - Tokens are verified against the requesting store - Prevents cross-store token reuse ### Password Requirements - Minimum 8 characters - Enforced by service layer exception --- ## Testing Checklist - [ ] Request reset for existing email (email received) - [ ] Request reset for non-existent email (same response) - [ ] Click reset link within expiry (success) - [ ] Click reset link after expiry (error) - [ ] Click reset link twice (second fails) - [ ] Password too short (validation error) - [ ] Password mismatch on form (client validation) - [ ] Multi-language email templates - [ ] Token works only for correct store --- ## File Structure ``` ├── alembic/versions/ │ └── t8b9c0d1e2f3_add_password_reset_tokens.py ├── app/ │ ├── api/v1/storefront/ │ │ └── auth.py │ ├── exceptions/ │ │ └── customer.py │ ├── routes/ │ │ └── storefront_pages.py │ ├── services/ │ │ └── customer_service.py │ └── templates/storefront/account/ │ ├── forgot-password.html │ └── reset-password.html ├── models/ │ ├── database/ │ │ └── password_reset_token.py │ └── schema/ │ └── auth.py └── scripts/ └── seed_email_templates.py ```