feat: implement customer authentication with JWT tokens
Implement secure customer authentication system with dedicated JWT tokens, separate from admin/vendor authentication. Backend Changes: - Add customer JWT token support in deps.py - New get_current_customer_from_cookie_or_header dependency - Validates customer-specific tokens with type checking - Returns Customer object instead of User for shop routes - Extend AuthService with customer token support - Add verify_password() method - Add create_access_token_with_data() for custom token payloads - Update CustomerService authentication - Generate customer-specific JWT tokens with type="customer" - Use vendor-scoped customer lookup - Enhance exception handler - Sanitize validation errors to prevent password leaks in logs - Fix shop login redirect to support multi-access routing - Improve vendor context detection from Referer header - Consistent "path" detection method for cookie path logic Schema Changes: - Rename UserLogin.username to email_or_username for flexibility - Update field validators accordingly API Changes: - Update admin/vendor auth endpoints to use email_or_username - Customer auth already uses email field correctly Route Changes: - Update shop account routes to use Customer dependency - Add /account redirect (without trailing slash) - Change parameter names from current_user to current_customer Frontend Changes: - Update login forms to use email_or_username in API calls - Change button text from "Log in" to "Sign in" for consistency - Improve loading spinner layout with flexbox Security Improvements: - Customer tokens scoped to vendor_id - Token type validation prevents cross-context token usage - Password inputs redacted from validation error logs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -315,9 +315,9 @@ def get_current_customer_from_cookie_or_header(
|
|||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
customer_token: Optional[str] = Cookie(None),
|
customer_token: Optional[str] = Cookie(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> User:
|
):
|
||||||
"""
|
"""
|
||||||
Get current customer user from customer_token cookie or Authorization header.
|
Get current customer from customer_token cookie or Authorization header.
|
||||||
|
|
||||||
Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
|
Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
|
||||||
Note: Public shop pages (/shop/products, etc.) don't use this dependency.
|
Note: Public shop pages (/shop/products, etc.) don't use this dependency.
|
||||||
@@ -333,12 +333,15 @@ def get_current_customer_from_cookie_or_header(
|
|||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User: Authenticated customer user
|
Customer: Authenticated customer object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidTokenException: If no token or invalid token
|
InvalidTokenException: If no token or invalid token
|
||||||
InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
|
|
||||||
"""
|
"""
|
||||||
|
from models.database.customer import Customer
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
token, source = _get_token_from_request(
|
token, source = _get_token_from_request(
|
||||||
credentials,
|
credentials,
|
||||||
customer_token,
|
customer_token,
|
||||||
@@ -350,35 +353,50 @@ def get_current_customer_from_cookie_or_header(
|
|||||||
logger.warning(f"Customer auth failed: No token for {request.url.path}")
|
logger.warning(f"Customer auth failed: No token for {request.url.path}")
|
||||||
raise InvalidTokenException("Customer authentication required")
|
raise InvalidTokenException("Customer authentication required")
|
||||||
|
|
||||||
# Validate token and get user
|
# Decode and validate customer JWT token
|
||||||
user = _validate_user_token(token, db)
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
# CRITICAL: Block admins from customer routes
|
token,
|
||||||
if user.role == "admin":
|
auth_manager.secret_key,
|
||||||
logger.warning(
|
algorithms=[auth_manager.algorithm]
|
||||||
f"Admin user {user.username} attempted shop account: {request.url.path}"
|
|
||||||
)
|
|
||||||
raise InsufficientPermissionsException(
|
|
||||||
"Customer access only - admins cannot use shop"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# CRITICAL: Block vendors from customer routes
|
# Verify this is a customer token
|
||||||
if user.role == "vendor":
|
token_type = payload.get("type")
|
||||||
logger.warning(
|
if token_type != "customer":
|
||||||
f"Vendor user {user.username} attempted shop account: {request.url.path}"
|
logger.warning(f"Invalid token type for customer route: {token_type}")
|
||||||
)
|
raise InvalidTokenException("Customer authentication required")
|
||||||
raise InsufficientPermissionsException(
|
|
||||||
"Customer access only - vendors cannot use shop"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify user is customer
|
# Get customer ID from token
|
||||||
if user.role != "customer":
|
customer_id: str = payload.get("sub")
|
||||||
logger.warning(
|
if customer_id is None:
|
||||||
f"Non-customer user {user.username} attempted shop account: {request.url.path}"
|
logger.warning("Token missing 'sub' (customer_id)")
|
||||||
)
|
raise InvalidTokenException("Invalid token")
|
||||||
raise InsufficientPermissionsException("Customer privileges required")
|
|
||||||
|
|
||||||
return user
|
# Verify token hasn't expired
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp and datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
|
||||||
|
logger.warning(f"Expired customer token for customer_id={customer_id}")
|
||||||
|
raise InvalidTokenException("Token has expired")
|
||||||
|
|
||||||
|
except JWTError as e:
|
||||||
|
logger.warning(f"JWT decode error: {str(e)}")
|
||||||
|
raise InvalidTokenException("Could not validate credentials")
|
||||||
|
|
||||||
|
# Load customer from database
|
||||||
|
customer = db.query(Customer).filter(Customer.id == int(customer_id)).first()
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
logger.warning(f"Customer not found: {customer_id}")
|
||||||
|
raise InvalidTokenException("Customer not found")
|
||||||
|
|
||||||
|
if not customer.is_active:
|
||||||
|
logger.warning(f"Inactive customer attempted access: {customer.email}")
|
||||||
|
raise InvalidTokenException("Customer account is inactive")
|
||||||
|
|
||||||
|
logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})")
|
||||||
|
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
def get_current_customer_api(
|
def get_current_customer_api(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def admin_login(
|
|||||||
|
|
||||||
# Verify user is admin
|
# Verify user is admin
|
||||||
if login_result["user"].role != "admin":
|
if login_result["user"].role != "admin":
|
||||||
logger.warning(f"Non-admin user attempted admin login: {user_credentials.username}")
|
logger.warning(f"Non-admin user attempted admin login: {user_credentials.email_or_username}")
|
||||||
raise InvalidCredentialsException("Admin access required")
|
raise InvalidCredentialsException("Admin access required")
|
||||||
|
|
||||||
logger.info(f"Admin login successful: {login_result['user'].username}")
|
logger.info(f"Admin login successful: {login_result['user'].username}")
|
||||||
|
|||||||
@@ -20,14 +20,24 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.customer_service import customer_service
|
from app.services.customer_service import customer_service
|
||||||
from models.schema.auth import LoginResponse, UserLogin
|
from models.schema.auth import UserLogin
|
||||||
from models.schema.customer import CustomerRegister, CustomerResponse
|
from models.schema.customer import CustomerRegister, CustomerResponse
|
||||||
from app.core.environment import should_use_secure_cookies
|
from app.core.environment import should_use_secure_cookies
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.post("/auth/register", response_model=CustomerResponse)
|
||||||
def register_customer(
|
def register_customer(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -85,7 +95,7 @@ def register_customer(
|
|||||||
return CustomerResponse.model_validate(customer)
|
return CustomerResponse.model_validate(customer)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auth/login", response_model=LoginResponse)
|
@router.post("/auth/login", response_model=CustomerLoginResponse)
|
||||||
def customer_login(
|
def customer_login(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_credentials: UserLogin,
|
user_credentials: UserLogin,
|
||||||
@@ -144,8 +154,18 @@ def customer_login(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
# Set HTTP-only cookie for browser navigation
|
||||||
# CRITICAL: path=/shop restricts cookie to shop routes only
|
# Cookie path matches the vendor's shop routes
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="customer_token",
|
key="customer_token",
|
||||||
value=login_result["token_data"]["access_token"],
|
value=login_result["token_data"]["access_token"],
|
||||||
@@ -153,24 +173,25 @@ def customer_login(
|
|||||||
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||||
samesite="lax", # CSRF protection
|
samesite="lax", # CSRF protection
|
||||||
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||||
path="/shop", # RESTRICTED TO SHOP ROUTES ONLY
|
path=cookie_path, # Matches vendor's shop routes
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||||
f"(path=/shop, httponly=True, secure={should_use_secure_cookies()})",
|
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
|
||||||
extra={
|
extra={
|
||||||
"expires_in": login_result['token_data']['expires_in'],
|
"expires_in": login_result['token_data']['expires_in'],
|
||||||
"secure": should_use_secure_cookies(),
|
"secure": should_use_secure_cookies(),
|
||||||
|
"cookie_path": cookie_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return full login response
|
# Return full login response
|
||||||
return LoginResponse(
|
return CustomerLoginResponse(
|
||||||
access_token=login_result["token_data"]["access_token"],
|
access_token=login_result["token_data"]["access_token"],
|
||||||
token_type=login_result["token_data"]["token_type"],
|
token_type=login_result["token_data"]["token_type"],
|
||||||
expires_in=login_result["token_data"]["expires_in"],
|
expires_in=login_result["token_data"]["expires_in"],
|
||||||
user=login_result["customer"], # Return customer as user
|
user=CustomerResponse.model_validate(login_result["customer"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,13 +218,23 @@ def customer_logout(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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)
|
# Clear the cookie (must match path used when setting)
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
key="customer_token",
|
key="customer_token",
|
||||||
path="/shop",
|
path=cookie_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Deleted customer_token cookie")
|
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
|
||||||
|
|
||||||
return {"message": "Logged out successfully"}
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|||||||
@@ -135,10 +135,19 @@ def setup_exception_handlers(app):
|
|||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
"""Handle Pydantic validation errors with consistent format."""
|
"""Handle Pydantic validation errors with consistent format."""
|
||||||
|
|
||||||
|
# Sanitize errors to remove sensitive data from logs
|
||||||
|
sanitized_errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
sanitized_error = error.copy()
|
||||||
|
# Remove 'input' field which may contain passwords
|
||||||
|
if 'input' in sanitized_error:
|
||||||
|
sanitized_error['input'] = '<redacted>'
|
||||||
|
sanitized_errors.append(sanitized_error)
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Validation error in {request.method} {request.url}: {exc.errors()}",
|
f"Validation error in {request.method} {request.url}: {len(sanitized_errors)} validation error(s)",
|
||||||
extra={
|
extra={
|
||||||
"validation_errors": exc.errors(),
|
"validation_errors": sanitized_errors,
|
||||||
"url": str(request.url),
|
"url": str(request.url),
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"exception_type": "RequestValidationError",
|
"exception_type": "RequestValidationError",
|
||||||
@@ -357,6 +366,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
Redirect to appropriate login page based on request context.
|
Redirect to appropriate login page based on request context.
|
||||||
|
|
||||||
Uses context detection to determine admin vs vendor vs shop login.
|
Uses context detection to determine admin vs vendor vs shop login.
|
||||||
|
Properly handles multi-access routing (domain, subdomain, path-based).
|
||||||
"""
|
"""
|
||||||
context_type = get_request_context(request)
|
context_type = get_request_context(request)
|
||||||
|
|
||||||
@@ -368,8 +378,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
return RedirectResponse(url="/vendor/login", status_code=302)
|
return RedirectResponse(url="/vendor/login", status_code=302)
|
||||||
elif context_type == RequestContext.SHOP:
|
elif context_type == RequestContext.SHOP:
|
||||||
# For shop context, redirect to shop login (customer login)
|
# For shop context, redirect to shop login (customer login)
|
||||||
logger.debug("Redirecting to /shop/login")
|
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
|
||||||
return RedirectResponse(url="/shop/login", status_code=302)
|
vendor = getattr(request.state, 'vendor', None)
|
||||||
|
vendor_context = getattr(request.state, 'vendor_context', None)
|
||||||
|
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
|
||||||
|
|
||||||
|
base_url = "/"
|
||||||
|
if access_method == "path" and vendor:
|
||||||
|
full_prefix = vendor_context.get('full_prefix', '/vendor/') if vendor_context else '/vendor/'
|
||||||
|
base_url = f"{full_prefix}{vendor.subdomain}/"
|
||||||
|
|
||||||
|
login_url = f"{base_url}shop/account/login"
|
||||||
|
logger.debug(f"Redirecting to {login_url}")
|
||||||
|
return RedirectResponse(url=login_url, status_code=302)
|
||||||
else:
|
else:
|
||||||
# Fallback to root for unknown contexts
|
# Fallback to root for unknown contexts
|
||||||
logger.debug("Unknown context, redirecting to /")
|
logger.debug("Unknown context, redirecting to /")
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
||||||
from app.services.content_page_service import content_page_service
|
from app.services.content_page_service import content_page_service
|
||||||
from models.database.user import User
|
from models.database.customer import Customer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -370,10 +370,11 @@ async def shop_forgot_password_page(request: Request):
|
|||||||
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
|
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/account", response_class=RedirectResponse, include_in_schema=False)
|
||||||
@router.get("/account/", response_class=RedirectResponse, include_in_schema=False)
|
@router.get("/account/", response_class=RedirectResponse, include_in_schema=False)
|
||||||
async def shop_account_root(request: Request):
|
async def shop_account_root(request: Request):
|
||||||
"""
|
"""
|
||||||
Redirect /shop/account/ to dashboard.
|
Redirect /shop/account or /shop/account/ to dashboard.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[SHOP_HANDLER] shop_products_page REACHED",
|
f"[SHOP_HANDLER] shop_products_page REACHED",
|
||||||
@@ -400,7 +401,7 @@ async def shop_account_root(request: Request):
|
|||||||
@router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_account_dashboard_page(
|
async def shop_account_dashboard_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -419,14 +420,14 @@ async def shop_account_dashboard_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/dashboard.html",
|
"shop/account/dashboard.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_orders_page(
|
async def shop_orders_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -445,7 +446,7 @@ async def shop_orders_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/orders.html",
|
"shop/account/orders.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -453,7 +454,7 @@ async def shop_orders_page(
|
|||||||
async def shop_order_detail_page(
|
async def shop_order_detail_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -472,14 +473,14 @@ async def shop_order_detail_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/order-detail.html",
|
"shop/account/order-detail.html",
|
||||||
get_shop_context(request, user=current_user, order_id=order_id)
|
get_shop_context(request, user=current_customer, order_id=order_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_profile_page(
|
async def shop_profile_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -498,14 +499,14 @@ async def shop_profile_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/profile.html",
|
"shop/account/profile.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_addresses_page(
|
async def shop_addresses_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -524,14 +525,14 @@ async def shop_addresses_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/addresses.html",
|
"shop/account/addresses.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_wishlist_page(
|
async def shop_wishlist_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -550,14 +551,14 @@ async def shop_wishlist_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/wishlist.html",
|
"shop/account/wishlist.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_settings_page(
|
async def shop_settings_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_customer_from_cookie_or_header),
|
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -576,7 +577,7 @@ async def shop_settings_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/account/settings.html",
|
"shop/account/settings.html",
|
||||||
get_shop_context(request, user=current_user)
|
get_shop_context(request, user=current_customer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class AuthService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = self.auth_manager.authenticate_user(
|
user = self.auth_manager.authenticate_user(
|
||||||
db, user_credentials.username, user_credentials.password
|
db, user_credentials.email_or_username, user_credentials.password
|
||||||
)
|
)
|
||||||
if not user:
|
if not user:
|
||||||
raise InvalidCredentialsException("Incorrect username or password")
|
raise InvalidCredentialsException("Incorrect username or password")
|
||||||
@@ -161,6 +161,52 @@ class AuthService:
|
|||||||
logger.error(f"Error hashing password: {str(e)}")
|
logger.error(f"Error hashing password: {str(e)}")
|
||||||
raise ValidationException("Failed to hash password")
|
raise ValidationException("Failed to hash password")
|
||||||
|
|
||||||
|
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against hash."""
|
||||||
|
try:
|
||||||
|
return self.auth_manager.verify_password(plain_password, hashed_password)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying password: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_access_token_with_data(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create JWT token with custom data payload.
|
||||||
|
|
||||||
|
Useful for non-User entities like customers that need tokens.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing token payload data (must include 'sub')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with access_token, token_type, and expires_in
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from jose import jwt
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
|
||||||
|
# Build payload with provided data
|
||||||
|
payload = {
|
||||||
|
**data,
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating access token with data: {str(e)}")
|
||||||
|
raise ValidationException("Failed to create access token")
|
||||||
|
|
||||||
# Private helper methods
|
# Private helper methods
|
||||||
def _email_exists(self, db: Session, email: str) -> bool:
|
def _email_exists(self, db: Session, email: str) -> bool:
|
||||||
"""Check if email already exists."""
|
"""Check if email already exists."""
|
||||||
|
|||||||
@@ -149,15 +149,15 @@ class CustomerService:
|
|||||||
customer = db.query(Customer).filter(
|
customer = db.query(Customer).filter(
|
||||||
and_(
|
and_(
|
||||||
Customer.vendor_id == vendor_id,
|
Customer.vendor_id == vendor_id,
|
||||||
Customer.email == credentials.username.lower()
|
Customer.email == credentials.email_or_username.lower()
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not customer:
|
if not customer:
|
||||||
raise InvalidCustomerCredentialsException()
|
raise InvalidCustomerCredentialsException()
|
||||||
|
|
||||||
# Verify password
|
# Verify password using auth_manager directly
|
||||||
if not self.auth_service.verify_password(
|
if not self.auth_service.auth_manager.verify_password(
|
||||||
credentials.password,
|
credentials.password,
|
||||||
customer.hashed_password
|
customer.hashed_password
|
||||||
):
|
):
|
||||||
@@ -168,14 +168,30 @@ class CustomerService:
|
|||||||
raise CustomerNotActiveException(customer.email)
|
raise CustomerNotActiveException(customer.email)
|
||||||
|
|
||||||
# Generate JWT token with customer context
|
# Generate JWT token with customer context
|
||||||
token_data = self.auth_service.create_access_token(
|
# Use auth_manager directly since Customer is not a User model
|
||||||
data={
|
from datetime import datetime, timedelta, timezone
|
||||||
"sub": str(customer.id),
|
from jose import jwt
|
||||||
"email": customer.email,
|
|
||||||
"vendor_id": vendor_id,
|
auth_manager = self.auth_service.auth_manager
|
||||||
"type": "customer"
|
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
|
||||||
}
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
)
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(customer.id),
|
||||||
|
"email": customer.email,
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"type": "customer",
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(payload, auth_manager.secret_key, algorithm=auth_manager.algorithm)
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": auth_manager.token_expire_minutes * 60,
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Customer login successful: {customer.email} "
|
f"Customer login successful: {customer.email} "
|
||||||
|
|||||||
@@ -69,8 +69,8 @@
|
|||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<button type="submit" :disabled="loading"
|
||||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
<span x-show="!loading">Log in</span>
|
<span x-show="!loading">Sign in</span>
|
||||||
<span x-show="loading">
|
<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">
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
4
app/templates/vendor/login.html
vendored
4
app/templates/vendor/login.html
vendored
@@ -84,8 +84,8 @@
|
|||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<button type="submit" :disabled="loading"
|
||||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
<span x-show="!loading">Log in</span>
|
<span x-show="!loading">Sign in</span>
|
||||||
<span x-show="loading">
|
<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">
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -256,13 +256,18 @@ class VendorContextManager:
|
|||||||
path_parts = referer_path[len(prefix):].split("/")
|
path_parts = referer_path[len(prefix):].split("/")
|
||||||
if len(path_parts) >= 1 and path_parts[0]:
|
if len(path_parts) >= 1 and path_parts[0]:
|
||||||
vendor_code = path_parts[0]
|
vendor_code = path_parts[0]
|
||||||
|
prefix_len = len(prefix)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
|
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
|
||||||
extra={"vendor_code": vendor_code, "method": "referer_path"}
|
extra={"vendor_code": vendor_code, "method": "referer_path"}
|
||||||
)
|
)
|
||||||
|
# Use "path" as detection_method to be consistent with direct path detection
|
||||||
|
# This allows cookie path logic to work the same way
|
||||||
return {
|
return {
|
||||||
"subdomain": vendor_code,
|
"subdomain": vendor_code,
|
||||||
"detection_method": "referer_path",
|
"detection_method": "path", # Consistent with direct path detection
|
||||||
|
"path_prefix": referer_path[:prefix_len + len(vendor_code)], # /vendor/vendor1
|
||||||
|
"full_prefix": prefix, # /vendor/ or /vendors/
|
||||||
"host": referer_host,
|
"host": referer_host,
|
||||||
"referer": referer,
|
"referer": referer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ class UserRegister(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
username: str = Field(..., description="Username")
|
email_or_username: str = Field(..., description="Username or email address")
|
||||||
password: str = Field(..., description="Password")
|
password: str = Field(..., description="Password")
|
||||||
vendor_code: Optional[str] = Field(None, description="Optional vendor code for context")
|
vendor_code: Optional[str] = Field(None, description="Optional vendor code for context")
|
||||||
|
|
||||||
@field_validator("username")
|
@field_validator("email_or_username")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_username(cls, v):
|
def validate_email_or_username(cls, v):
|
||||||
return v.strip()
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function adminLogin() {
|
|||||||
|
|
||||||
const url = '/admin/auth/login';
|
const url = '/admin/auth/login';
|
||||||
const payload = {
|
const payload = {
|
||||||
username: this.credentials.username.trim(),
|
email_or_username: this.credentials.username.trim(),
|
||||||
password: this.credentials.password
|
password: this.credentials.password
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
static/vendor/js/login.js
vendored
2
static/vendor/js/login.js
vendored
@@ -90,7 +90,7 @@ function vendorLogin() {
|
|||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const response = await apiClient.post('/vendor/auth/login', {
|
const response = await apiClient.post('/vendor/auth/login', {
|
||||||
username: this.credentials.username,
|
email_or_username: this.credentials.username,
|
||||||
password: this.credentials.password,
|
password: this.credentials.password,
|
||||||
vendor_code: this.vendorCode
|
vendor_code: this.vendorCode
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user