feat: implement password reset for shop customers
Add complete password reset functionality: Database: - Add password_reset_tokens migration with token hash, expiry, used_at - Create PasswordResetToken model with secure token hashing (SHA256) - One active token per customer (old tokens invalidated on new request) - 1-hour token expiry for security API: - Implement forgot_password endpoint with email lookup - Implement reset_password endpoint with token validation - No email enumeration (same response for all requests) - Password minimum 8 characters validation Frontend: - Add reset-password.html template with Alpine.js - Support for invalid/expired token states - Success state with login redirect - Dark mode support Email: - Add password_reset email templates (en, fr, de, lb) - Uses existing EmailService with template rendering Testing: - Add comprehensive pytest tests (19 tests) - Test token creation, validation, expiry, reuse prevention - Test endpoint success and error cases Removes critical launch blocker for password reset functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ This prevents:
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -24,6 +24,9 @@ from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import VendorNotFoundException
|
||||
from app.services.customer_service import customer_service
|
||||
from app.services.email_service import EmailService
|
||||
from models.database.customer import Customer
|
||||
from models.database.password_reset_token import PasswordResetToken
|
||||
from models.schema.auth import (
|
||||
LogoutResponse,
|
||||
PasswordResetRequestResponse,
|
||||
@@ -275,14 +278,61 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
||||
},
|
||||
)
|
||||
|
||||
# TODO: Implement password reset functionality
|
||||
# - Generate reset token
|
||||
# - Store token in database with expiry
|
||||
# - Send reset email to customer
|
||||
# - Return success message (don't reveal if email exists)
|
||||
# Look up customer by email (vendor-scoped)
|
||||
customer = (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
Customer.vendor_id == vendor.id,
|
||||
Customer.email == email.lower(),
|
||||
Customer.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})") # noqa: sec-021
|
||||
# If customer exists, generate token and send email
|
||||
if customer:
|
||||
try:
|
||||
# Generate reset token (returns plaintext token)
|
||||
plaintext_token = PasswordResetToken.create_for_customer(db, customer.id)
|
||||
|
||||
# Build reset link
|
||||
# Use request host to construct the URL
|
||||
scheme = "https" if should_use_secure_cookies() else "http"
|
||||
host = request.headers.get("host", "localhost")
|
||||
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
|
||||
|
||||
# Send password reset email
|
||||
email_service = EmailService(db)
|
||||
email_service.send_template(
|
||||
template_code="password_reset",
|
||||
to_email=customer.email,
|
||||
to_name=customer.full_name,
|
||||
language=customer.preferred_language or "en",
|
||||
variables={
|
||||
"customer_name": customer.first_name or customer.full_name,
|
||||
"reset_link": reset_link,
|
||||
"expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS),
|
||||
},
|
||||
vendor_id=vendor.id,
|
||||
related_type="customer",
|
||||
related_id=customer.id,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Password reset email sent to {email} (vendor: {vendor.subdomain})"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to send password reset email: {e}")
|
||||
# Don't reveal the error to the user for security
|
||||
else:
|
||||
# Log but don't reveal that email doesn't exist
|
||||
logger.info(
|
||||
f"Password reset requested for non-existent email {email} (vendor: {vendor.subdomain})"
|
||||
)
|
||||
|
||||
# Always return the same message (don't reveal if email exists)
|
||||
return PasswordResetRequestResponse(
|
||||
message="If an account exists with this email, a password reset link has been sent."
|
||||
)
|
||||
@@ -299,7 +349,7 @@ def reset_password(
|
||||
|
||||
Request Body:
|
||||
- reset_token: Password reset token from email
|
||||
- new_password: New password
|
||||
- new_password: New password (minimum 8 characters)
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
@@ -315,14 +365,49 @@ def reset_password(
|
||||
},
|
||||
)
|
||||
|
||||
# TODO: Implement password reset
|
||||
# - Validate reset token
|
||||
# - Check token expiry
|
||||
# - Update customer password
|
||||
# - Invalidate reset token
|
||||
# - Return success
|
||||
# Validate password length
|
||||
if len(new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Password must be at least 8 characters long",
|
||||
)
|
||||
|
||||
logger.info(f"Password reset completed (vendor: {vendor.subdomain})") # noqa: sec-021
|
||||
# Find valid token
|
||||
token_record = PasswordResetToken.find_valid_token(db, reset_token)
|
||||
|
||||
if not token_record:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired password reset link. Please request a new one.",
|
||||
)
|
||||
|
||||
# Get the customer and verify they belong to this vendor
|
||||
customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
|
||||
|
||||
if not customer or customer.vendor_id != vendor.id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired password reset link. Please request a new one.",
|
||||
)
|
||||
|
||||
if not customer.is_active:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This account is not active. Please contact support.",
|
||||
)
|
||||
|
||||
# Hash the new password and update customer
|
||||
hashed_password = customer_service.auth_service.hash_password(new_password)
|
||||
customer.hashed_password = hashed_password
|
||||
|
||||
# Mark token as used
|
||||
token_record.mark_used(db)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Password reset completed for customer {customer.id} (vendor: {vendor.subdomain})"
|
||||
)
|
||||
|
||||
return PasswordResetResponse(
|
||||
message="Password reset successfully. You can now log in with your new password."
|
||||
|
||||
Reference in New Issue
Block a user