Technical Documentation: - docs/development/architecture-fixes-2026-01.md: Complete guide to all architecture validation fixes (62 -> 0 errors) User Guides: - docs/guides/email-templates.md: How-to guide for vendors and admins to use the email template customization system Implementation Docs: - docs/implementation/password-reset-implementation.md: Technical documentation for the password reset feature - Updated email-templates-architecture.md with EmailTemplateService documentation and related links Bugfix: - Fixed TemplateListItem Pydantic model to match service output (languages vs available_languages field name) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
11 KiB
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
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/shop/auth.py
POST /api/v1/shop/auth/forgot-password
Request a password reset link.
Request:
{
"email": "customer@example.com"
}
Response: (Always returns success to prevent email enumeration)
{
"message": "If an account exists with this email, a password reset link has been sent."
}
Implementation:
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Look up customer (vendor-scoped)
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
if customer:
# Generate token and send email
plaintext_token = PasswordResetToken.create_for_customer(db, customer.id)
reset_link = f"{scheme}://{host}/shop/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),
},
vendor_id=vendor.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/shop/auth/reset-password
Reset password using token from email.
Request:
{
"reset_token": "abc123...",
"new_password": "newSecurePassword123"
}
Response:
{
"message": "Password reset successfully. You can now log in with your new password."
}
Implementation:
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Service handles all validation and password update
customer = customer_service.validate_and_reset_password(
db=db,
vendor_id=vendor.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
def get_customer_for_password_reset(
self, db: Session, vendor_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.vendor_id == vendor_id,
Customer.email == email.lower(),
Customer.is_active == True,
)
.first()
)
validate_and_reset_password
def validate_and_reset_password(
self,
db: Session,
vendor_id: int,
reset_token: str,
new_password: str,
) -> Customer:
"""Validate reset token and update customer password.
Args:
db: Database session
vendor_id: Vendor 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 vendor ownership
customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
if not customer or customer.vendor_id != vendor_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
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/shop/account/forgot-password.html
Route: /shop/account/forgot-password
Features:
- Email input form
- Form validation
- Success message display
- Link back to login
Reset Password Page
Template: app/templates/shop/account/reset-password.html
Route: /shop/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
- Hashed Storage: Tokens stored as SHA256 hashes
- Short Expiry: 1 hour expiration
- Single Use: Tokens invalidated after use
- 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
Vendor Isolation
- Tokens are verified against the requesting vendor
- Prevents cross-vendor 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 vendor
File Structure
├── alembic/versions/
│ └── t8b9c0d1e2f3_add_password_reset_tokens.py
├── app/
│ ├── api/v1/shop/
│ │ └── auth.py
│ ├── exceptions/
│ │ └── customer.py
│ ├── routes/
│ │ └── shop_pages.py
│ ├── services/
│ │ └── customer_service.py
│ └── templates/shop/account/
│ ├── forgot-password.html
│ └── reset-password.html
├── models/
│ ├── database/
│ │ └── password_reset_token.py
│ └── schema/
│ └── auth.py
└── scripts/
└── seed_email_templates.py