Add admin pages for managing subscription tiers, vendor subscriptions, and billing history: - Subscription Tiers page: Create, edit, activate/deactivate tiers - Vendor Subscriptions page: View/edit subscriptions, custom limits - Billing History page: View invoices with filters and PDF links - Stats dashboard with MRR/ARR calculations Also includes: - Pydantic schemas for billing operations (models/schema/billing.py) - Admin subscription service layer for database operations - Security validation fixes (SEC-001, SEC-021, SEC-022) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
330 lines
10 KiB
Python
330 lines
10 KiB
Python
# app/api/v1/shop/auth.py
|
|
"""
|
|
Shop Authentication API (Public)
|
|
|
|
Public endpoints for customer authentication in shop frontend.
|
|
Uses vendor from request.state (injected by VendorContextMiddleware).
|
|
|
|
Implements dual token storage with path restriction:
|
|
- Sets HTTP-only cookie with path=/shop (restricted to shop routes only)
|
|
- Returns token in response for localStorage (API calls)
|
|
|
|
This prevents:
|
|
- Customer cookies from being sent to admin or vendor routes
|
|
- Cross-context authentication confusion
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.environment import should_use_secure_cookies
|
|
from app.exceptions import VendorNotFoundException
|
|
from app.services.customer_service import customer_service
|
|
from models.schema.auth import (
|
|
LogoutResponse,
|
|
PasswordResetRequestResponse,
|
|
PasswordResetResponse,
|
|
UserLogin,
|
|
)
|
|
from models.schema.customer import CustomerRegister, CustomerResponse
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Response model for customer login
|
|
class CustomerLoginResponse(BaseModel):
|
|
"""Customer login response with token and customer data."""
|
|
|
|
access_token: str
|
|
token_type: str
|
|
expires_in: int
|
|
user: CustomerResponse # Use CustomerResponse instead of UserResponse
|
|
|
|
|
|
@router.post("/auth/register", response_model=CustomerResponse)
|
|
def register_customer(
|
|
request: Request, customer_data: CustomerRegister, db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Register a new customer for current vendor.
|
|
|
|
Vendor is automatically determined from request context.
|
|
Customer accounts are vendor-scoped - each vendor has independent customers.
|
|
Same email can be used for different vendors.
|
|
|
|
Request Body:
|
|
- email: Customer email address
|
|
- password: Customer password
|
|
- first_name: Customer first name
|
|
- last_name: Customer last name
|
|
- phone: Customer phone number (optional)
|
|
"""
|
|
# Get vendor from middleware
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
if not vendor:
|
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
|
|
extra={
|
|
"vendor_id": vendor.id,
|
|
"vendor_code": vendor.subdomain,
|
|
"email": customer_data.email,
|
|
},
|
|
)
|
|
|
|
# Create customer account
|
|
customer = customer_service.register_customer(
|
|
db=db, vendor_id=vendor.id, customer_data=customer_data
|
|
)
|
|
db.commit()
|
|
|
|
logger.info(
|
|
f"New customer registered: {customer.email} for vendor {vendor.subdomain}",
|
|
extra={
|
|
"customer_id": customer.id,
|
|
"vendor_id": vendor.id,
|
|
"email": customer.email,
|
|
},
|
|
)
|
|
|
|
return CustomerResponse.model_validate(customer)
|
|
|
|
|
|
@router.post("/auth/login", response_model=CustomerLoginResponse)
|
|
def customer_login(
|
|
request: Request,
|
|
user_credentials: UserLogin,
|
|
response: Response,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Customer login for current vendor.
|
|
|
|
Vendor is automatically determined from request context.
|
|
Authenticates customer and returns JWT token.
|
|
Customer must belong to the specified vendor.
|
|
|
|
Sets token in two places:
|
|
1. HTTP-only cookie with path=/shop (for browser page navigation)
|
|
2. Response body (for localStorage and API calls)
|
|
|
|
The cookie is restricted to /shop/* routes only to prevent
|
|
it from being sent to admin or vendor routes.
|
|
|
|
Request Body:
|
|
- email_or_username: Customer email or username
|
|
- password: Customer password
|
|
"""
|
|
# Get vendor from middleware
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
if not vendor:
|
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
|
|
extra={
|
|
"vendor_id": vendor.id,
|
|
"vendor_code": vendor.subdomain,
|
|
"email_or_username": user_credentials.email_or_username,
|
|
},
|
|
)
|
|
|
|
# Authenticate customer
|
|
login_result = customer_service.login_customer(
|
|
db=db, vendor_id=vendor.id, credentials=user_credentials
|
|
)
|
|
|
|
logger.info(
|
|
f"Customer login successful: {login_result['customer'].email} for vendor {vendor.subdomain}",
|
|
extra={
|
|
"customer_id": login_result["customer"].id,
|
|
"vendor_id": vendor.id,
|
|
"email": login_result["customer"].email,
|
|
},
|
|
)
|
|
|
|
# Calculate cookie path based on vendor access method
|
|
vendor_context = getattr(request.state, "vendor_context", None)
|
|
access_method = (
|
|
vendor_context.get("detection_method", "unknown")
|
|
if vendor_context
|
|
else "unknown"
|
|
)
|
|
|
|
cookie_path = "/shop" # Default for domain/subdomain access
|
|
if access_method == "path":
|
|
# For path-based access like /vendors/wizamart/shop
|
|
full_prefix = (
|
|
vendor_context.get("full_prefix", "/vendor/")
|
|
if vendor_context
|
|
else "/vendor/"
|
|
)
|
|
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
|
|
|
|
# Set HTTP-only cookie for browser navigation
|
|
# Cookie path matches the vendor's shop routes
|
|
response.set_cookie(
|
|
key="customer_token",
|
|
value=login_result["token_data"]["access_token"],
|
|
httponly=True, # JavaScript cannot access (XSS protection)
|
|
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
|
samesite="lax", # CSRF protection
|
|
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
|
path=cookie_path, # Matches vendor's shop routes
|
|
)
|
|
|
|
logger.debug(
|
|
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
|
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
|
|
extra={
|
|
"expires_in": login_result["token_data"]["expires_in"],
|
|
"secure": should_use_secure_cookies(),
|
|
"cookie_path": cookie_path,
|
|
},
|
|
)
|
|
|
|
# Return full login response
|
|
return CustomerLoginResponse(
|
|
access_token=login_result["token_data"]["access_token"],
|
|
token_type=login_result["token_data"]["token_type"],
|
|
expires_in=login_result["token_data"]["expires_in"],
|
|
user=CustomerResponse.model_validate(login_result["customer"]),
|
|
)
|
|
|
|
|
|
@router.post("/auth/logout", response_model=LogoutResponse)
|
|
def customer_logout(request: Request, response: Response):
|
|
"""
|
|
Customer logout for current vendor.
|
|
|
|
Vendor is automatically determined from request context.
|
|
Clears the customer_token cookie.
|
|
Client should also remove token from localStorage.
|
|
"""
|
|
# Get vendor from middleware (for logging)
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
logger.info(
|
|
f"Customer logout for vendor {vendor.subdomain if vendor else 'unknown'}",
|
|
extra={
|
|
"vendor_id": vendor.id if vendor else None,
|
|
"vendor_code": vendor.subdomain if vendor else None,
|
|
},
|
|
)
|
|
|
|
# Calculate cookie path based on vendor access method (must match login)
|
|
vendor_context = getattr(request.state, "vendor_context", None)
|
|
access_method = (
|
|
vendor_context.get("detection_method", "unknown")
|
|
if vendor_context
|
|
else "unknown"
|
|
)
|
|
|
|
cookie_path = "/shop" # Default for domain/subdomain access
|
|
if access_method == "path" and vendor:
|
|
# For path-based access like /vendors/wizamart/shop
|
|
full_prefix = (
|
|
vendor_context.get("full_prefix", "/vendor/")
|
|
if vendor_context
|
|
else "/vendor/"
|
|
)
|
|
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
|
|
|
|
# Clear the cookie (must match path used when setting)
|
|
response.delete_cookie(
|
|
key="customer_token",
|
|
path=cookie_path,
|
|
)
|
|
|
|
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
|
|
|
|
return LogoutResponse(message="Logged out successfully")
|
|
|
|
|
|
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
|
|
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Request password reset for customer.
|
|
|
|
Vendor is automatically determined from request context.
|
|
Sends password reset email to customer if account exists.
|
|
|
|
Request Body:
|
|
- email: Customer email address
|
|
"""
|
|
# Get vendor from middleware
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
if not vendor:
|
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
|
|
extra={
|
|
"vendor_id": vendor.id,
|
|
"vendor_code": vendor.subdomain,
|
|
"email": email,
|
|
},
|
|
)
|
|
|
|
# TODO: Implement password reset functionality
|
|
# - Generate reset token
|
|
# - Store token in database with expiry
|
|
# - Send reset email to customer
|
|
# - Return success message (don't reveal if email exists)
|
|
|
|
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})") # noqa: sec-021
|
|
|
|
return PasswordResetRequestResponse(
|
|
message="If an account exists with this email, a password reset link has been sent."
|
|
)
|
|
|
|
|
|
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
|
|
def reset_password(
|
|
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Reset customer password using reset token.
|
|
|
|
Vendor is automatically determined from request context.
|
|
|
|
Request Body:
|
|
- reset_token: Password reset token from email
|
|
- new_password: New password
|
|
"""
|
|
# Get vendor from middleware
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
if not vendor:
|
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
|
|
extra={
|
|
"vendor_id": vendor.id,
|
|
"vendor_code": vendor.subdomain,
|
|
},
|
|
)
|
|
|
|
# TODO: Implement password reset
|
|
# - Validate reset token
|
|
# - Check token expiry
|
|
# - Update customer password
|
|
# - Invalidate reset token
|
|
# - Return success
|
|
|
|
logger.info(f"Password reset completed (vendor: {vendor.subdomain})") # noqa: sec-021
|
|
|
|
return PasswordResetResponse(
|
|
message="Password reset successfully. You can now log in with your new password."
|
|
)
|