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:
2026-01-03 17:16:27 +01:00
parent 2c7ac5b6b2
commit 2e1a2fc9fc
8 changed files with 1282 additions and 16 deletions

View File

@@ -0,0 +1,53 @@
"""add password_reset_tokens table
Revision ID: t8b9c0d1e2f3
Revises: s7a8b9c0d1e2
Create Date: 2026-01-03
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "t8b9c0d1e2f3"
down_revision = "s7a8b9c0d1e2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("customer_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_password_reset_tokens_customer_id",
"password_reset_tokens",
["customer_id"],
)
op.create_index(
"ix_password_reset_tokens_token_hash",
"password_reset_tokens",
["token_hash"],
)
def downgrade() -> None:
op.drop_index("ix_password_reset_tokens_token_hash", table_name="password_reset_tokens")
op.drop_index("ix_password_reset_tokens_customer_id", table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")

View File

@@ -16,7 +16,7 @@ This prevents:
import logging import logging
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session 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.core.environment import should_use_secure_cookies
from app.exceptions import VendorNotFoundException from app.exceptions import VendorNotFoundException
from app.services.customer_service import customer_service 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 ( from models.schema.auth import (
LogoutResponse, LogoutResponse,
PasswordResetRequestResponse, PasswordResetRequestResponse,
@@ -275,14 +278,61 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
}, },
) )
# TODO: Implement password reset functionality # Look up customer by email (vendor-scoped)
# - Generate reset token customer = (
# - Store token in database with expiry db.query(Customer)
# - Send reset email to customer .filter(
# - Return success message (don't reveal if email exists) 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( return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent." message="If an account exists with this email, a password reset link has been sent."
) )
@@ -299,7 +349,7 @@ def reset_password(
Request Body: Request Body:
- reset_token: Password reset token from email - reset_token: Password reset token from email
- new_password: New password - new_password: New password (minimum 8 characters)
""" """
# Get vendor from middleware # Get vendor from middleware
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
@@ -315,14 +365,49 @@ def reset_password(
}, },
) )
# TODO: Implement password reset # Validate password length
# - Validate reset token if len(new_password) < 8:
# - Check token expiry raise HTTPException(
# - Update customer password status_code=400,
# - Invalidate reset token detail="Password must be at least 8 characters long",
# - Return success )
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( return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password." message="Password reset successfully. You can now log in with your new password."

View File

@@ -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 # CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
# ============================================================================ # ============================================================================

View 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>

View File

@@ -18,6 +18,7 @@ from .base import Base
from .company import Company from .company import Company
from .content_page import ContentPage from .content_page import ContentPage
from .customer import Customer, CustomerAddress from .customer import Customer, CustomerAddress
from .password_reset_token import PasswordResetToken
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
from .inventory import Inventory from .inventory import Inventory
@@ -102,9 +103,10 @@ __all__ = [
"VendorTheme", "VendorTheme",
# Content # Content
"ContentPage", "ContentPage",
# Customer # Customer & Auth
"Customer", "Customer",
"CustomerAddress", "CustomerAddress",
"PasswordResetToken",
# Email # Email
"EmailCategory", "EmailCategory",
"EmailLog", "EmailLog",

View File

@@ -0,0 +1,85 @@
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
class PasswordResetToken(Base):
"""Password reset token for customer accounts.
Security:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 1 hour
- Only one active token per customer (old tokens invalidated on new request)
"""
__tablename__ = "password_reset_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 1
id = Column(Integer, primary_key=True, index=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
customer = relationship("Customer")
def __repr__(self):
return f"<PasswordResetToken(id={self.id}, customer_id={self.customer_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_customer(cls, db: Session, customer_id: int) -> str:
"""Create a new password reset token for a customer.
Invalidates any existing tokens for the customer.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this customer
db.query(cls).filter(
cls.customer_id == customer_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
customer_id=customer_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "PasswordResetToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()

View File

@@ -653,6 +653,249 @@ Liwweradress:
Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt. Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt.
Merci fir Ären Akaf! Merci fir Ären Akaf!
""",
},
# -------------------------------------------------------------------------
# PASSWORD RESET
# -------------------------------------------------------------------------
{
"code": "password_reset",
"language": "en",
"name": "Password Reset",
"description": "Sent to customers when they request a password reset",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Reset Your Password",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reset Your Password</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reset Password
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Best regards,<br><strong>The Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</body>
</html>""",
"body_text": """Reset Your Password
Hi {{ customer_name }},
We received a request to reset your password. Click the link below to create a new password:
{{ reset_link }}
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
Best regards,
The Team
""",
},
{
"code": "password_reset",
"language": "fr",
"name": "Reinitialisation du mot de passe",
"description": "Envoye aux clients lorsqu'ils demandent une reinitialisation de mot de passe",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Reinitialiser votre mot de passe",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reinitialiser votre mot de passe</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour creer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reinitialiser le mot de passe
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Si le bouton ne fonctionne pas, copiez et collez ce lien dans votre navigateur :<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Cordialement,<br><strong>L'equipe</strong></p>
</div>
</body>
</html>""",
"body_text": """Reinitialiser votre mot de passe
Bonjour {{ customer_name }},
Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le lien ci-dessous pour creer un nouveau mot de passe :
{{ reset_link }}
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
Cordialement,
L'equipe
""",
},
{
"code": "password_reset",
"language": "de",
"name": "Passwort zurucksetzen",
"description": "An Kunden gesendet, wenn sie eine Passwortzurucksetzung anfordern",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Passwort zurucksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwort zurucksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf die Schaltflache unten, um ein neues Passwort zu erstellen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwort zurucksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wenn die Schaltflache nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mit freundlichen Grussen,<br><strong>Das Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwort zurucksetzen
Hallo {{ customer_name }},
Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf den Link unten, um ein neues Passwort zu erstellen:
{{ reset_link }}
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Das Team
""",
},
{
"code": "password_reset",
"language": "lb",
"name": "Passwuert zrecksetzen",
"description": "Un Clienten gescheckt wann si eng Passwuertzrecksetzung ufroen",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Passwuert zrecksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwuert zrecksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Knäppchen hei drënner fir en neit Passwuert ze kreéieren:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwuert zrecksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dëse Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir dës Passwuertzrecksetzung net ugefrot hutt, kënnt Dir dës E-Mail ignoréieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wann de Knäppchen net fonctionnéiert, kopéiert dëse Link an Äre Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mat beschte Gréiss,<br><strong>D'Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwuert zrecksetzen
Moien {{ customer_name }},
Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Link hei drënner fir en neit Passwuert ze kreéieren:
{{ reset_link }}
Dëse Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir dës Passwuertzrecksetzung net ugefrot hutt, kënnt Dir dës E-Mail ignoréieren.
Mat beschte Gréiss,
D'Team
""", """,
}, },
] ]

View File

@@ -0,0 +1,451 @@
# tests/integration/api/v1/shop/test_password_reset.py
"""Integration tests for shop password reset API endpoints.
Tests the /api/v1/shop/auth/forgot-password and /api/v1/shop/auth/reset-password endpoints.
"""
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from models.database.customer import Customer
from models.database.password_reset_token import PasswordResetToken
@pytest.fixture
def shop_customer(db, test_vendor):
"""Create a test customer for shop API tests."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="customer@example.com",
hashed_password=auth_manager.hash_password("oldpassword123"),
first_name="Test",
last_name="Customer",
customer_number="CUST001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def inactive_customer(db, test_vendor):
"""Create an inactive customer for testing."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="inactive@example.com",
hashed_password=auth_manager.hash_password("password123"),
first_name="Inactive",
last_name="Customer",
customer_number="CUST002",
is_active=False,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def valid_reset_token(db, shop_customer):
"""Create a valid password reset token."""
token = PasswordResetToken.create_for_customer(db, shop_customer.id)
db.commit()
return token
@pytest.fixture
def expired_reset_token(db, shop_customer):
"""Create an expired password reset token."""
import secrets
token = secrets.token_urlsafe(32)
token_hash = PasswordResetToken.hash_token(token)
reset_token = PasswordResetToken(
customer_id=shop_customer.id,
token_hash=token_hash,
expires_at=datetime.utcnow() - timedelta(hours=2), # Already expired
)
db.add(reset_token)
db.commit()
return token
@pytest.fixture
def used_reset_token(db, shop_customer):
"""Create a used password reset token."""
import secrets
token = secrets.token_urlsafe(32)
token_hash = PasswordResetToken.hash_token(token)
reset_token = PasswordResetToken(
customer_id=shop_customer.id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=1),
used_at=datetime.utcnow(), # Already used
)
db.add(reset_token)
db.commit()
return token
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestForgotPasswordAPI:
"""Test forgot password endpoint at /api/v1/shop/auth/forgot-password."""
def test_forgot_password_existing_customer(
self, client, db, test_vendor, shop_customer
):
"""Test password reset request for existing customer."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
# Mock email service to avoid actual email sending
with patch("app.api.v1.shop.auth.EmailService") as mock_email_service:
mock_instance = MagicMock()
mock_email_service.return_value = mock_instance
response = client.post(
"/api/v1/shop/auth/forgot-password",
params={"email": shop_customer.email},
)
assert response.status_code == 200
data = response.json()
assert "password reset link has been sent" in data["message"].lower()
# Verify email was sent
mock_instance.send_template.assert_called_once()
call_kwargs = mock_instance.send_template.call_args.kwargs
assert call_kwargs["template_code"] == "password_reset"
assert call_kwargs["to_email"] == shop_customer.email
def test_forgot_password_nonexistent_email(self, client, db, test_vendor):
"""Test password reset request for non-existent email (same response)."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/forgot-password",
params={"email": "nonexistent@example.com"},
)
# Should return same success message to prevent email enumeration
assert response.status_code == 200
data = response.json()
assert "password reset link has been sent" in data["message"].lower()
def test_forgot_password_inactive_customer(
self, client, db, test_vendor, inactive_customer
):
"""Test password reset request for inactive customer."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/forgot-password",
params={"email": inactive_customer.email},
)
# Should return same success message (inactive customers can't reset)
assert response.status_code == 200
data = response.json()
assert "password reset link has been sent" in data["message"].lower()
def test_forgot_password_creates_token(
self, client, db, test_vendor, shop_customer
):
"""Test that forgot password creates a token in the database."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.v1.shop.auth.EmailService"):
response = client.post(
"/api/v1/shop/auth/forgot-password",
params={"email": shop_customer.email},
)
assert response.status_code == 200
# Verify token was created
token = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.customer_id == shop_customer.id)
.first()
)
assert token is not None
assert token.used_at is None
assert token.expires_at > datetime.utcnow()
def test_forgot_password_invalidates_old_tokens(
self, client, db, test_vendor, shop_customer, valid_reset_token
):
"""Test that requesting new token invalidates old ones."""
# Get the old token record
old_token_count = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.customer_id == shop_customer.id,
PasswordResetToken.used_at.is_(None),
)
.count()
)
assert old_token_count == 1
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.v1.shop.auth.EmailService"):
response = client.post(
"/api/v1/shop/auth/forgot-password",
params={"email": shop_customer.email},
)
assert response.status_code == 200
# Old token should be deleted, new one created
new_token_count = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.customer_id == shop_customer.id,
PasswordResetToken.used_at.is_(None),
)
.count()
)
assert new_token_count == 1
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestResetPasswordAPI:
"""Test reset password endpoint at /api/v1/shop/auth/reset-password."""
def test_reset_password_success(
self, client, db, test_vendor, shop_customer, valid_reset_token
):
"""Test successful password reset."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
new_password = "newpassword123"
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": valid_reset_token,
"new_password": new_password,
},
)
assert response.status_code == 200
data = response.json()
assert "password reset successfully" in data["message"].lower()
# Verify password was changed
db.refresh(shop_customer)
from middleware.auth import AuthManager
auth_manager = AuthManager()
assert auth_manager.verify_password(
new_password, shop_customer.hashed_password
)
def test_reset_password_token_marked_used(
self, client, db, test_vendor, shop_customer, valid_reset_token
):
"""Test that token is marked as used after successful reset."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": valid_reset_token,
"new_password": "newpassword123",
},
)
assert response.status_code == 200
# Verify token is marked as used
token_record = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.customer_id == shop_customer.id)
.first()
)
assert token_record.used_at is not None
def test_reset_password_invalid_token(self, client, db, test_vendor):
"""Test reset with invalid token."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": "invalid_token_12345",
"new_password": "newpassword123",
},
)
assert response.status_code == 400
def test_reset_password_expired_token(
self, client, db, test_vendor, shop_customer, expired_reset_token
):
"""Test reset with expired token."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": expired_reset_token,
"new_password": "newpassword123",
},
)
assert response.status_code == 400
def test_reset_password_used_token(
self, client, db, test_vendor, shop_customer, used_reset_token
):
"""Test reset with already used token."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": used_reset_token,
"new_password": "newpassword123",
},
)
assert response.status_code == 400
def test_reset_password_short_password(
self, client, db, test_vendor, shop_customer, valid_reset_token
):
"""Test reset with password that's too short."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": valid_reset_token,
"new_password": "short", # Less than 8 chars
},
)
assert response.status_code == 400
def test_reset_password_cannot_reuse_token(
self, client, db, test_vendor, shop_customer, valid_reset_token
):
"""Test that token cannot be reused after successful reset."""
with patch("app.api.v1.shop.auth.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
# First reset should succeed
response1 = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": valid_reset_token,
"new_password": "newpassword123",
},
)
assert response1.status_code == 200
# Second reset with same token should fail
response2 = client.post(
"/api/v1/shop/auth/reset-password",
params={
"reset_token": valid_reset_token,
"new_password": "anotherpassword123",
},
)
assert response2.status_code == 400
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestPasswordResetTokenModel:
"""Test PasswordResetToken model functionality."""
def test_token_hash_is_deterministic(self):
"""Test that hashing the same token produces the same hash."""
token = "test_token_12345"
hash1 = PasswordResetToken.hash_token(token)
hash2 = PasswordResetToken.hash_token(token)
assert hash1 == hash2
def test_different_tokens_produce_different_hashes(self):
"""Test that different tokens produce different hashes."""
hash1 = PasswordResetToken.hash_token("token1")
hash2 = PasswordResetToken.hash_token("token2")
assert hash1 != hash2
def test_create_for_customer_returns_plaintext(self, db, shop_customer):
"""Test that create_for_customer returns plaintext token."""
token = PasswordResetToken.create_for_customer(db, shop_customer.id)
db.commit()
# Token should be URL-safe base64
assert token is not None
assert len(token) > 20 # secrets.token_urlsafe(32) produces ~43 chars
def test_find_valid_token_works_with_plaintext(self, db, shop_customer):
"""Test that find_valid_token works with plaintext token."""
plaintext_token = PasswordResetToken.create_for_customer(db, shop_customer.id)
db.commit()
found = PasswordResetToken.find_valid_token(db, plaintext_token)
assert found is not None
assert found.customer_id == shop_customer.id
def test_find_valid_token_returns_none_for_invalid(self, db):
"""Test that find_valid_token returns None for invalid token."""
found = PasswordResetToken.find_valid_token(db, "invalid_token")
assert found is None
def test_mark_used_sets_timestamp(self, db, shop_customer):
"""Test that mark_used sets the used_at timestamp."""
plaintext_token = PasswordResetToken.create_for_customer(db, shop_customer.id)
db.commit()
token_record = PasswordResetToken.find_valid_token(db, plaintext_token)
assert token_record.used_at is None
token_record.mark_used(db)
db.commit()
assert token_record.used_at is not None
def test_used_token_not_found_by_find_valid(self, db, shop_customer):
"""Test that used tokens are not returned by find_valid_token."""
plaintext_token = PasswordResetToken.create_for_customer(db, shop_customer.id)
db.commit()
token_record = PasswordResetToken.find_valid_token(db, plaintext_token)
token_record.mark_used(db)
db.commit()
# Should not find the used token
found = PasswordResetToken.find_valid_token(db, plaintext_token)
assert found is None