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:
53
alembic/versions/t8b9c0d1e2f3_add_password_reset_tokens.py
Normal file
53
alembic/versions/t8b9c0d1e2f3_add_password_reset_tokens.py
Normal 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")
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
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>
|
||||||
@@ -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",
|
||||||
|
|||||||
85
models/database/password_reset_token.py
Normal file
85
models/database/password_reset_token.py
Normal 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()
|
||||||
@@ -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
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
451
tests/integration/api/v1/shop/test_password_reset.py
Normal file
451
tests/integration/api/v1/shop/test_password_reset.py
Normal 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
|
||||||
Reference in New Issue
Block a user