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."
|
||||
|
||||
@@ -409,6 +409,32 @@ async def shop_forgot_password_page(request: Request, db: Session = Depends(get_
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/reset-password", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def shop_reset_password_page(
|
||||
request: Request, token: str = None, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render reset password page.
|
||||
User lands here after clicking the reset link in their email.
|
||||
Token is passed as query parameter.
|
||||
"""
|
||||
logger.debug(
|
||||
"[SHOP_HANDLER] shop_reset_password_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
"has_token": bool(token),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"shop/account/reset-password.html", get_shop_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
|
||||
# ============================================================================
|
||||
|
||||
321
app/templates/shop/account/reset-password.html
Normal file
321
app/templates/shop/account/reset-password.html
Normal file
@@ -0,0 +1,321 @@
|
||||
{# app/templates/shop/account/reset-password.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="resetPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reset Password - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🔑</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Create new password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Reset Password Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<!-- Invalid Token State -->
|
||||
<template x-if="tokenInvalid">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900">
|
||||
<svg class="w-8 h-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Invalid or Expired Link
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
Please request a new password reset link.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/forgot-password"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Request New Link
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Form State -->
|
||||
<template x-if="!tokenInvalid && !resetComplete">
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your new password below. Password must be at least 8 characters.
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Reset Password Form -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">New Password</span>
|
||||
<input x-model="password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="Enter new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Confirm Password</span>
|
||||
<input x-model="confirmPassword"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.confirmPassword }"
|
||||
placeholder="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Reset Password</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Resetting...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template x-if="resetComplete">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Password Reset Complete
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your password has been successfully reset.
|
||||
You can now sign in with your new password.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/login"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Reset Password Logic -->
|
||||
<script>
|
||||
function resetPassword() {
|
||||
return {
|
||||
// Data
|
||||
token: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
tokenInvalid: false,
|
||||
resetComplete: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
|
||||
// Get token from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.token = urlParams.get('token');
|
||||
|
||||
if (!this.token) {
|
||||
this.tokenInvalid = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle form submission
|
||||
async handleSubmit() {
|
||||
this.clearErrors();
|
||||
|
||||
// Validation
|
||||
if (!this.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Please confirm your password';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reset_token: this.token,
|
||||
new_password: this.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for token-related errors
|
||||
if (response.status === 400 && data.detail) {
|
||||
if (data.detail.includes('invalid') || data.detail.includes('expired')) {
|
||||
this.tokenInvalid = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(data.detail || 'Failed to reset password');
|
||||
}
|
||||
|
||||
// Success
|
||||
this.resetComplete = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
this.showAlert(error.message || 'Failed to reset password. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user