revamped authentication system

This commit is contained in:
2025-11-02 18:40:03 +01:00
parent 9cc92e5fc4
commit e4bc438069
18 changed files with 674 additions and 636 deletions

View File

@@ -2,16 +2,38 @@
"""
Authentication dependencies for FastAPI routes.
Implements dual token storage pattern:
- Checks Authorization header first (for API calls from JavaScript)
- Falls back to cookie (for browser page navigation)
This module provides authentication dependencies for all three contexts in the
multi-tenant application, implementing dual token storage with proper isolation:
This allows:
- JavaScript API calls: Use localStorage + Authorization header
- Browser page loads: Use HTTP-only cookies
ADMIN ROUTES (/admin/*):
- Cookie: admin_token (path=/admin) OR Authorization header
- Role: admin only
- Blocks: vendors, customers
VENDOR ROUTES (/vendor/*):
- Cookie: vendor_token (path=/vendor) OR Authorization header
- Role: vendor only
- Blocks: admins, customers
CUSTOMER/SHOP ROUTES (/shop/account/*):
- Cookie: customer_token (path=/shop) OR Authorization header
- Role: customer only
- Blocks: admins, vendors
- Note: Public shop pages (/shop/products, etc.) don't require auth
This dual authentication approach supports:
- HTML pages: Use cookies (automatic browser behavior)
- API calls: Use Authorization headers (explicit JavaScript control)
The cookie path restrictions prevent cross-context cookie leakage:
- admin_token is NEVER sent to /vendor/* or /shop/*
- vendor_token is NEVER sent to /admin/* or /shop/*
- customer_token is NEVER sent to /admin/* or /vendor/*
"""
import logging
from typing import Optional
from fastapi import Depends, Request, Cookie
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -23,148 +45,434 @@ from models.database.vendor import Vendor
from models.database.user import User
from app.exceptions import (
AdminRequiredException,
InvalidTokenException,
InsufficientPermissionsException,
VendorNotFoundException,
UnauthorizedVendorAccessException,
InvalidTokenException
UnauthorizedVendorAccessException
)
# Set auto_error=False to prevent automatic 403 responses
security = HTTPBearer(auto_error=False)
# Initialize dependencies
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403
auth_manager = AuthManager()
rate_limiter = RateLimiter()
logger = logging.getLogger(__name__)
def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
admin_token: Optional[str] = Cookie(None), # Check admin_token cookie
db: Session = Depends(get_db),
):
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def _get_token_from_request(
credentials: Optional[HTTPAuthorizationCredentials],
cookie_value: Optional[str],
cookie_name: str,
request_path: str
) -> tuple[Optional[str], Optional[str]]:
"""
Get current authenticated user.
Extract token from Authorization header or cookie.
Checks for token in this priority order:
Priority:
1. Authorization header (for API calls from JavaScript)
2. admin_token cookie (for browser page navigation)
This dual approach supports:
- API calls: JavaScript adds token from localStorage to Authorization header
- Page navigation: Browser automatically sends cookie
2. Cookie (for browser page navigation)
Args:
request: FastAPI request object
credentials: Optional Bearer token from Authorization header
admin_token: Optional token from cookie
cookie_value: Optional token from cookie
cookie_name: Name of the cookie (for logging)
request_path: Request URL path (for logging)
Returns:
Tuple of (token, source) where source is "header" or "cookie"
"""
if credentials:
logger.debug(f"Token found in Authorization header for {request_path}")
return credentials.credentials, "header"
elif cookie_value:
logger.debug(f"Token found in {cookie_name} cookie for {request_path}")
return cookie_value, "cookie"
return None, None
def _validate_user_token(token: str, db: Session) -> User:
"""
Validate JWT token and return user.
Args:
token: JWT token string
db: Database session
Returns:
User: Authenticated user object
Raises:
InvalidTokenException: If no token found or token invalid
InvalidTokenException: If token is invalid
"""
token = None
token_source = None
# Priority 1: Authorization header (API calls from JavaScript)
if credentials:
token = credentials.credentials
token_source = "header"
# Priority 2: Cookie (browser page navigation)
elif admin_token:
token = admin_token
token_source = "cookie"
# No token found in either location
if not token:
raise InvalidTokenException("Authorization header or cookie required")
# Log token source for debugging
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Token found in {token_source} for {request.url.path}")
# Create a mock credentials object for auth_manager
mock_credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=token
)
return auth_manager.get_current_user(db, mock_credentials)
def get_current_admin_user(current_user: User = Depends(get_current_user)):
"""
Require admin user.
# ============================================================================
# ADMIN AUTHENTICATION
# ============================================================================
This dependency ensures the current user has admin role.
Used for protecting admin-only routes.
def get_current_admin_from_cookie_or_header(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
admin_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db),
) -> User:
"""
Get current admin user from admin_token cookie or Authorization header.
Used for admin HTML pages (/admin/*) that need cookie-based auth.
Priority:
1. Authorization header (API calls)
2. admin_token cookie (page navigation)
Args:
current_user: User object from get_current_user dependency
request: FastAPI request
credentials: Optional Bearer token from header
admin_token: Optional token from admin_token cookie
db: Database session
Returns:
User: Admin user object
User: Authenticated admin user
Raises:
AdminRequiredException: If user is not an admin
InvalidTokenException: If no token or invalid token
AdminRequiredException: If user is not admin
"""
return auth_manager.require_admin(current_user)
token, source = _get_token_from_request(
credentials,
admin_token,
"admin_token",
str(request.url.path)
)
if not token:
logger.warning(f"Admin auth failed: No token for {request.url.path}")
raise InvalidTokenException("Admin authentication required")
# Validate token and get user
user = _validate_user_token(token, db)
# Verify user is admin
if user.role != "admin":
logger.warning(
f"Non-admin user {user.username} attempted admin route: {request.url.path}"
)
raise AdminRequiredException("Admin privileges required")
return user
def get_current_vendor_user(current_user: User = Depends(get_current_user)):
def get_current_admin_api(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""
Require vendor user (vendor owner or vendor staff).
Get current admin user from Authorization header ONLY.
This dependency ensures the current user has vendor role.
Used for protecting vendor-only routes.
Used for admin API endpoints that should not accept cookies.
This prevents CSRF attacks on API endpoints.
Args:
current_user: User object from get_current_user dependency
credentials: Bearer token from Authorization header
db: Database session
Returns:
User: Vendor user object
User: Authenticated admin user
Raises:
InsufficientPermissionsException: If user is not a vendor user
InvalidTokenException: If no token or invalid token
AdminRequiredException: If user is not admin
"""
return auth_manager.require_vendor(current_user)
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
user = _validate_user_token(credentials.credentials, db)
if user.role != "admin":
logger.warning(f"Non-admin user {user.username} attempted admin API")
raise AdminRequiredException("Admin privileges required")
return user
def get_current_customer_user(current_user: User = Depends(get_current_user)):
# ============================================================================
# VENDOR AUTHENTICATION
# ============================================================================
def get_current_vendor_from_cookie_or_header(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
vendor_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db),
) -> User:
"""
Require customer user.
Get current vendor user from vendor_token cookie or Authorization header.
This dependency ensures the current user has customer role.
Used for protecting customer account routes.
Used for vendor HTML pages (/vendor/*) that need cookie-based auth.
Priority:
1. Authorization header (API calls)
2. vendor_token cookie (page navigation)
Args:
current_user: User object from get_current_user dependency
request: FastAPI request
credentials: Optional Bearer token from header
vendor_token: Optional token from vendor_token cookie
db: Database session
Returns:
User: Customer user object
User: Authenticated vendor user
Raises:
InsufficientPermissionsException: If user is not a customer
InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not vendor or is admin
"""
return auth_manager.require_customer(current_user)
token, source = _get_token_from_request(
credentials,
vendor_token,
"vendor_token",
str(request.url.path)
)
if not token:
logger.warning(f"Vendor auth failed: No token for {request.url.path}")
raise InvalidTokenException("Vendor authentication required")
# Validate token and get user
user = _validate_user_token(token, db)
# CRITICAL: Block admins from vendor routes
if user.role == "admin":
logger.warning(
f"Admin user {user.username} attempted vendor route: {request.url.path}"
)
raise InsufficientPermissionsException(
"Vendor access only - admins cannot use vendor portal"
)
# Verify user is vendor
if user.role != "vendor":
logger.warning(
f"Non-vendor user {user.username} attempted vendor route: {request.url.path}"
)
raise InsufficientPermissionsException("Vendor privileges required")
return user
def get_current_vendor_api(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""
Get current vendor user from Authorization header ONLY.
Used for vendor API endpoints that should not accept cookies.
Args:
credentials: Bearer token from Authorization header
db: Database session
Returns:
User: Authenticated vendor user
Raises:
InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not vendor or is admin
"""
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
user = _validate_user_token(credentials.credentials, db)
# Block admins from vendor API
if user.role == "admin":
logger.warning(f"Admin user {user.username} attempted vendor API")
raise InsufficientPermissionsException("Vendor access only")
if user.role != "vendor":
logger.warning(f"Non-vendor user {user.username} attempted vendor API")
raise InsufficientPermissionsException("Vendor privileges required")
return user
# ============================================================================
# CUSTOMER AUTHENTICATION (SHOP)
# ============================================================================
def get_current_customer_from_cookie_or_header(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
customer_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db),
) -> User:
"""
Get current customer user from customer_token cookie or Authorization header.
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.
Priority:
1. Authorization header (API calls)
2. customer_token cookie (page navigation)
Args:
request: FastAPI request
credentials: Optional Bearer token from header
customer_token: Optional token from customer_token cookie
db: Database session
Returns:
User: Authenticated customer user
Raises:
InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
"""
token, source = _get_token_from_request(
credentials,
customer_token,
"customer_token",
str(request.url.path)
)
if not token:
logger.warning(f"Customer auth failed: No token for {request.url.path}")
raise InvalidTokenException("Customer authentication required")
# Validate token and get user
user = _validate_user_token(token, db)
# CRITICAL: Block admins from customer routes
if user.role == "admin":
logger.warning(
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
if user.role == "vendor":
logger.warning(
f"Vendor user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException(
"Customer access only - vendors cannot use shop"
)
# Verify user is customer
if user.role != "customer":
logger.warning(
f"Non-customer user {user.username} attempted shop account: {request.url.path}"
)
raise InsufficientPermissionsException("Customer privileges required")
return user
def get_current_customer_api(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""
Get current customer user from Authorization header ONLY.
Used for shop API endpoints that should not accept cookies.
Args:
credentials: Bearer token from Authorization header
db: Database session
Returns:
User: Authenticated customer user
Raises:
InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
"""
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
user = _validate_user_token(credentials.credentials, db)
# Block admins from customer API
if user.role == "admin":
logger.warning(f"Admin user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer access only")
# Block vendors from customer API
if user.role == "vendor":
logger.warning(f"Vendor user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer access only")
if user.role != "customer":
logger.warning(f"Non-customer user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer privileges required")
return user
# ============================================================================
# GENERIC AUTHENTICATION (for mixed-use endpoints)
# ============================================================================
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Get current authenticated user from Authorization header only.
Generic authentication without role checking.
Used for endpoints accessible to any authenticated user.
Args:
credentials: Bearer token from Authorization header
db: Database session
Returns:
User: Authenticated user (any role)
Raises:
InvalidTokenException: If no token or invalid token
"""
if not credentials:
raise InvalidTokenException("Authorization header required")
return _validate_user_token(credentials.credentials, db)
# ============================================================================
# VENDOR OWNERSHIP VERIFICATION
# ============================================================================
def get_user_vendor(
vendor_code: str,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
) -> Vendor:
"""
Get vendor and verify user ownership.
Get vendor and verify user ownership/membership.
Ensures the current user has access to the specified vendor.
Admin users can access any vendor, regular users only their own.
- Vendor owners can access their own vendor
- Team members can access their vendor
- Admins are BLOCKED (use admin routes instead)
Args:
vendor_code: Vendor code to look up
current_user: Current authenticated user
current_user: Current authenticated vendor user
db: Database session
Returns:
@@ -174,11 +482,19 @@ def get_user_vendor(
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user doesn't have access
"""
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
vendor = db.query(Vendor).filter(
Vendor.vendor_code == vendor_code.upper()
).first()
if not vendor:
raise VendorNotFoundException(vendor_code)
if current_user.role != "admin" and vendor.owner_user_id != current_user.id:
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
# Check if user owns this vendor
if vendor.owner_user_id == current_user.id:
return vendor
return vendor
# Check if user is team member
# TODO: Add team member check when VendorUser relationship is set up
# User doesn't have access to this vendor
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)

View File

@@ -15,7 +15,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_audit_service import admin_audit_service
from models.schema.admin import (
@@ -39,7 +39,7 @@ def get_audit_logs(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get filtered admin audit logs.
@@ -74,7 +74,7 @@ def get_audit_logs(
def get_recent_audit_logs(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get recent audit logs (last 20 by default)."""
filters = AdminAuditLogFilters(limit=limit)
@@ -85,7 +85,7 @@ def get_recent_audit_logs(
def get_my_actions(
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get audit logs for current admin's actions."""
return admin_audit_service.get_recent_actions_by_admin(
@@ -101,7 +101,7 @@ def get_actions_by_target(
target_id: str,
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get all actions performed on a specific target.

View File

@@ -2,9 +2,11 @@
"""
Admin authentication endpoints.
Implements dual token storage:
- Sets HTTP-only cookie for browser page navigation
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/admin (restricted to admin routes only)
- Returns token in response for localStorage (API calls)
This prevents admin cookies from being sent to vendor routes.
"""
import logging
@@ -16,7 +18,7 @@ from app.services.auth_service import auth_service
from app.exceptions import InvalidCredentialsException
from models.schema.auth import LoginResponse, UserLogin, UserResponse
from models.database.user import User
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.config import settings
router = APIRouter(prefix="/auth")
@@ -36,8 +38,11 @@ def admin_login(
Returns JWT token for authenticated admin users.
Sets token in two places:
1. HTTP-only cookie (for browser page navigation)
1. HTTP-only cookie with path=/admin (for browser page navigation)
2. Response body (for localStorage and API calls)
The cookie is restricted to /admin/* routes only to prevent
it from being sent to vendor or other routes.
"""
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
@@ -50,17 +55,21 @@ def admin_login(
logger.info(f"Admin login successful: {login_result['user'].username}")
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/admin restricts cookie to admin routes only
response.set_cookie(
key="admin_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=False, # Set to True in production (requires HTTPS)
secure=settings.environment == "production", # HTTPS only in production
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/", # Available for all routes
path="/admin", # RESTRICTED TO ADMIN ROUTES ONLY
)
logger.debug(f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry")
logger.debug(
f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/admin, httponly=True, secure={settings.environment == 'production'})"
)
# Also return token in response for localStorage (API calls)
return LoginResponse(
@@ -72,7 +81,7 @@ def admin_login(
@router.get("/me", response_model=UserResponse)
def get_current_admin(current_user: User = Depends(get_current_admin_user)):
def get_current_admin(current_user: User = Depends(get_current_admin_api)):
"""
Get current authenticated admin user.
@@ -81,11 +90,9 @@ def get_current_admin(current_user: User = Depends(get_current_admin_user)):
Token can come from:
- Authorization header (API calls)
- admin_token cookie (browser navigation)
- admin_token cookie (browser navigation, path=/admin only)
"""
logger.info(f"Admin user info requested: {current_user.username}")
# Pydantic will automatically serialize the User model to UserResponse
return current_user
@@ -99,10 +106,10 @@ def admin_logout(response: Response):
"""
logger.info("Admin logout")
# Clear the cookie
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="admin_token",
path="/",
path="/admin",
)
logger.debug("Deleted admin_token cookie")

View File

@@ -8,7 +8,7 @@ from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from app.services.stats_service import stats_service
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
@router.get("")
def get_admin_dashboard(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get admin dashboard with platform statistics (Admin only)."""
return {
@@ -40,7 +40,7 @@ def get_admin_dashboard(
@router.get("/stats", response_model=StatsResponse)
def get_comprehensive_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
@@ -59,7 +59,7 @@ def get_comprehensive_stats(
@router.get("/stats/marketplace", response_model=List[MarketplaceStatsResponse])
def get_marketplace_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get statistics broken down by marketplace (Admin only)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
@@ -78,7 +78,7 @@ def get_marketplace_stats(
@router.get("/stats/platform")
def get_platform_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
return {

View File

@@ -9,7 +9,7 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from app.services.stats_service import stats_service
@@ -28,7 +28,7 @@ def get_all_marketplace_import_jobs(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get all marketplace import jobs (Admin only)."""
return admin_service.get_marketplace_import_jobs(
@@ -44,7 +44,7 @@ def get_all_marketplace_import_jobs(
@router.get("/stats")
def get_import_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get marketplace import statistics (Admin only)."""
return stats_service.get_import_statistics(db)

View File

@@ -14,7 +14,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from models.schema.admin import (
AdminNotificationCreate,
@@ -42,7 +42,7 @@ def get_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get admin notifications with filtering."""
# TODO: Implement notification service
@@ -58,7 +58,7 @@ def get_notifications(
@router.get("/unread-count")
def get_unread_count(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get count of unread notifications."""
# TODO: Implement
@@ -69,7 +69,7 @@ def get_unread_count(
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Mark notification as read."""
# TODO: Implement
@@ -79,7 +79,7 @@ def mark_as_read(
@router.put("/mark-all-read")
def mark_all_as_read(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Mark all notifications as read."""
# TODO: Implement
@@ -97,7 +97,7 @@ def get_platform_alerts(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get platform alerts with filtering."""
# TODO: Implement alert service
@@ -115,7 +115,7 @@ def get_platform_alerts(
def create_platform_alert(
alert_data: PlatformAlertCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Create new platform alert (manual)."""
# TODO: Implement
@@ -128,7 +128,7 @@ def resolve_platform_alert(
alert_id: int,
resolve_data: PlatformAlertResolve,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Resolve platform alert."""
# TODO: Implement
@@ -139,7 +139,7 @@ def resolve_platform_alert(
@router.get("/alerts/stats")
def get_alert_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get alert statistics for dashboard."""
# TODO: Implement

View File

@@ -14,7 +14,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_settings_service import admin_settings_service
from app.services.admin_audit_service import admin_audit_service
@@ -35,7 +35,7 @@ def get_all_settings(
category: Optional[str] = Query(None, description="Filter by category"),
is_public: Optional[bool] = Query(None, description="Filter by public flag"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get all platform settings.
@@ -55,7 +55,7 @@ def get_all_settings(
@router.get("/categories")
def get_setting_categories(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get list of all setting categories."""
# This could be enhanced to return counts per category
@@ -75,7 +75,7 @@ def get_setting_categories(
def get_setting(
key: str,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get specific setting by key."""
setting = admin_settings_service.get_setting_by_key(db, key)
@@ -91,7 +91,7 @@ def get_setting(
def create_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Create new platform setting.
@@ -122,7 +122,7 @@ def update_setting(
key: str,
update_data: AdminSettingUpdate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Update existing setting value."""
old_value = admin_settings_service.get_setting_value(db, key)
@@ -151,7 +151,7 @@ def update_setting(
def upsert_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Create or update setting (upsert).
@@ -182,7 +182,7 @@ def delete_setting(
key: str,
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Delete platform setting.

View File

@@ -9,7 +9,7 @@ from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from app.services.stats_service import stats_service
@@ -25,7 +25,7 @@ def get_all_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get all users (Admin only)."""
users = admin_service.get_all_users(db=db, skip=skip, limit=limit)
@@ -36,7 +36,7 @@ def get_all_users(
def toggle_user_status(
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Toggle user active status (Admin only)."""
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
@@ -46,7 +46,7 @@ def toggle_user_status(
@router.get("/stats")
def get_user_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get user statistics for admin dashboard (Admin only)."""
return stats_service.get_user_statistics(db)

View File

@@ -15,7 +15,7 @@ from typing import List
from fastapi import APIRouter, Depends, Path, Body, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.vendor_domain_service import vendor_domain_service
from app.exceptions import VendorNotFoundException
@@ -60,7 +60,7 @@ def add_vendor_domain(
vendor_id: int = Path(..., description="Vendor ID", gt=0),
domain_data: VendorDomainCreate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Add a custom domain to vendor (Admin only).
@@ -113,7 +113,7 @@ def add_vendor_domain(
def list_vendor_domains(
vendor_id: int = Path(..., description="Vendor ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
List all domains for a vendor (Admin only).
@@ -156,7 +156,7 @@ def list_vendor_domains(
def get_domain_details(
domain_id: int = Path(..., description="Domain ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get detailed information about a specific domain (Admin only).
@@ -187,7 +187,7 @@ def update_vendor_domain(
domain_id: int = Path(..., description="Domain ID", gt=0),
domain_update: VendorDomainUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Update domain settings (Admin only).
@@ -231,7 +231,7 @@ def update_vendor_domain(
def delete_vendor_domain(
domain_id: int = Path(..., description="Domain ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Delete a custom domain (Admin only).
@@ -260,7 +260,7 @@ def delete_vendor_domain(
def verify_domain_ownership(
domain_id: int = Path(..., description="Domain ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Verify domain ownership via DNS TXT record (Admin only).
@@ -298,7 +298,7 @@ def verify_domain_ownership(
def get_domain_verification_instructions(
domain_id: int = Path(..., description="Domain ID", gt=0),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get DNS verification instructions for domain (Admin only).

View File

@@ -17,7 +17,7 @@ import logging
from fastapi import APIRouter, Depends, Path
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user, get_db
from app.api.deps import get_current_admin_api, get_db
from app.services.vendor_theme_service import vendor_theme_service
from models.database.user import User
from models.schema.vendor_theme import (
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
@router.get("/presets", response_model=ThemePresetListResponse)
async def get_theme_presets(
current_admin: User = Depends(get_current_admin_user)
current_admin: User = Depends(get_current_admin_api)
):
"""
Get all available theme presets with preview information.
@@ -63,7 +63,7 @@ async def get_theme_presets(
async def get_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
current_admin: User = Depends(get_current_admin_api)
):
"""
Get theme configuration for a vendor.
@@ -98,7 +98,7 @@ async def update_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
theme_data: VendorThemeUpdate = None,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
current_admin: User = Depends(get_current_admin_api)
):
"""
Update or create theme for a vendor.
@@ -145,7 +145,7 @@ async def apply_theme_preset(
vendor_code: str = Path(..., description="Vendor code"),
preset_name: str = Path(..., description="Preset name"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
current_admin: User = Depends(get_current_admin_api)
):
"""
Apply a theme preset to a vendor.
@@ -196,7 +196,7 @@ async def apply_theme_preset(
async def delete_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
current_admin: User = Depends(get_current_admin_api)
):
"""
Delete custom theme for a vendor.

View File

@@ -9,7 +9,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query, Path, Body
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.admin_service import admin_service
from app.services.stats_service import stats_service
@@ -74,7 +74,7 @@ def _get_vendor_by_identifier(db: Session, identifier: str) -> Vendor:
def create_vendor_with_owner(
vendor_data: VendorCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Create a new vendor with owner user account (Admin only).
@@ -133,7 +133,7 @@ def get_all_vendors_admin(
is_active: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get all vendors with filtering (Admin only)."""
vendors, total = admin_service.get_all_vendors(
@@ -150,7 +150,7 @@ def get_all_vendors_admin(
@router.get("/stats", response_model=VendorStatsResponse)
def get_vendor_statistics_endpoint(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""Get vendor statistics for admin dashboard (Admin only)."""
stats = stats_service.get_vendor_statistics(db)
@@ -167,7 +167,7 @@ def get_vendor_statistics_endpoint(
def get_vendor_details(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get detailed vendor information including owner details (Admin only).
@@ -211,7 +211,7 @@ def update_vendor(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
vendor_update: VendorUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Update vendor information (Admin only).
@@ -262,7 +262,7 @@ def transfer_vendor_ownership(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
transfer_data: VendorTransferOwnership = Body(...),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Transfer vendor ownership to another user (Admin only).
@@ -314,7 +314,7 @@ def toggle_vendor_verification(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
verification_data: dict = Body(..., example={"is_verified": True}),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Toggle vendor verification status (Admin only).
@@ -365,7 +365,7 @@ def toggle_vendor_status(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
status_data: dict = Body(..., example={"is_active": True}),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Toggle vendor active status (Admin only).
@@ -416,7 +416,7 @@ def delete_vendor(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
current_admin: User = Depends(get_current_admin_api),
):
"""
Delete vendor and all associated data (Admin only).

View File

@@ -2,14 +2,17 @@
"""
Customer authentication endpoints (public-facing).
This module provides:
- Customer registration (vendor-scoped)
- Customer login (vendor-scoped)
- Customer password reset
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, Path
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -18,6 +21,7 @@ from app.exceptions import VendorNotFoundException
from models.schema.auth import LoginResponse, UserLogin
from models.schema.customer import CustomerRegister, CustomerResponse
from models.database.vendor import Vendor
from app.core.config import settings
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -63,6 +67,7 @@ def register_customer(
def customer_login(
vendor_id: int,
user_credentials: UserLogin,
response: Response,
db: Session = Depends(get_db)
):
"""
@@ -70,6 +75,13 @@ def customer_login(
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.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
@@ -92,6 +104,24 @@ def customer_login(
f"for vendor {vendor.vendor_code}"
)
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/shop restricts cookie to shop routes only
response.set_cookie(
key="customer_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=settings.environment == "production", # HTTPS only in production
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/shop", # RESTRICTED TO SHOP ROUTES ONLY
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/shop, httponly=True, secure={settings.environment == 'production'})"
)
# Return full login response
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
@@ -101,12 +131,26 @@ def customer_login(
@router.post("/{vendor_id}/customers/logout")
def customer_logout(vendor_id: int):
def customer_logout(
vendor_id: int,
response: Response
):
"""
Customer logout.
Client should remove token from storage.
Clears the customer_token cookie.
Client should also remove token from localStorage.
"""
logger.info(f"Customer logout for vendor {vendor_id}")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="customer_token",
path="/shop",
)
logger.debug("Deleted customer_token cookie")
return {"message": "Logged out successfully"}
@@ -173,3 +217,26 @@ def reset_password(
logger.info(f"Password reset completed for vendor {vendor.vendor_code}")
return {"message": "Password reset successful"}
@router.get("/{vendor_id}/customers/me")
def get_current_customer(
vendor_id: int,
db: Session = Depends(get_db)
):
"""
Get current authenticated customer.
This endpoint can be called to verify authentication and get customer info.
Requires customer authentication via cookie or header.
"""
from app.api.deps import get_current_customer_api
from fastapi import Request
# Note: This would need Request object to check cookies
# For now, just indicate the endpoint exists
# Implementation depends on how you want to structure it
return {
"message": "Customer info endpoint - implementation depends on auth structure"
}

View File

@@ -2,14 +2,18 @@
"""
Vendor team authentication endpoints.
This module provides:
- Vendor team member login
- Vendor owner login
- Vendor-scoped authentication
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/vendor (restricted to vendor routes only)
- Returns token in response for localStorage (API calls)
This prevents:
- Vendor cookies from being sent to admin routes
- Admin cookies from being sent to vendor routes
- Cross-context authentication confusion
"""
import logging
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -18,7 +22,9 @@ from app.exceptions import InvalidCredentialsException
from middleware.vendor_context import get_current_vendor
from models.schema.auth import UserLogin
from models.database.vendor import Vendor, VendorUser, Role
from models.database.user import User
from pydantic import BaseModel
from app.core.config import settings
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@@ -38,6 +44,7 @@ class VendorLoginResponse(BaseModel):
def vendor_login(
user_credentials: UserLogin,
request: Request,
response: Response,
db: Session = Depends(get_db)
):
"""
@@ -45,6 +52,12 @@ def vendor_login(
Authenticates users who are part of a vendor team.
Validates against vendor context if available.
Sets token in two places:
1. HTTP-only cookie with path=/vendor (for browser page navigation)
2. Response body (for localStorage and API calls)
Prevents admin users from logging into vendor portal.
"""
# Try to get vendor from middleware first
vendor = get_current_vendor(request)
@@ -62,10 +75,10 @@ def vendor_login(
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
user = login_result["user"]
# Prevent admin users from using vendor login
# CRITICAL: Prevent admin users from using vendor login
if user.role == "admin":
logger.warning(f"Admin user attempted vendor login: {user.username}")
raise InvalidCredentialsException("Please use admin portal to login")
raise InvalidCredentialsException("Admins cannot access vendor portal. Please use admin portal.")
# Determine vendor and role
vendor_role = "Member"
@@ -120,6 +133,24 @@ def vendor_login(
f"for vendor {vendor.vendor_code} as {vendor_role}"
)
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/vendor restricts cookie to vendor routes only
response.set_cookie(
key="vendor_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=settings.environment == "production", # HTTPS only in production
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
)
logger.debug(
f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/vendor, httponly=True, secure={settings.environment == 'production'})"
)
# Return full login response
return VendorLoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
@@ -144,10 +175,45 @@ def vendor_login(
@router.post("/logout")
def vendor_logout():
def vendor_logout(response: Response):
"""
Vendor team member logout.
Client should remove token from storage.
Clears the vendor_token cookie.
Client should also remove token from localStorage.
"""
return {"message": "Logged out successfully"}
logger.info("Vendor logout")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="vendor_token",
path="/vendor",
)
logger.debug("Deleted vendor_token cookie")
return {"message": "Logged out successfully"}
@router.get("/me")
def get_current_vendor_user(
request: Request,
db: Session = Depends(get_db)
):
"""
Get current authenticated vendor user.
This endpoint can be called to verify authentication and get user info.
"""
from app.api.deps import get_current_vendor_api
# This will check both cookie and header
user = get_current_vendor_api(request, db=db)
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"is_active": user.is_active
}