- Fix cost tracker dashboard URL (cost.oliver.agency → optical-dev.oliver.solutions/cost-tracker/analytics) in UserList, QCDetail, FinalDetail; centralise into src/lib/costTracker.ts - Wire audit logging across backend routes (was 1 call site, now covers all key events): · routes_auth: LOGIN_SUCCESS/FAILURE for local + MS SSO, LOGOUT · routes_files: FILE_UPLOAD on signed URL generation · routes_jobs: JOB_CREATE, JOB_APPROVE, JOB_REJECT, JOB_STATUS_CHANGE, JOB_DELETE, VTT_EDIT · routes_admin: USER_CREATE, USER_UPDATE, USER_ROLE_CHANGE, USER_DEACTIVATE - Add Audit Log admin UI page (/admin/audit-log): · Three tabs: All Events (paginated, server-side filters), Security Events, User Activity · Filters: action group, severity, success/failure, free-text search · Click-to-expand row shows IP, request ID, resource, details JSON · Wired into App.tsx (RoleGate: production + admin) and sidebar nav Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
342 lines
12 KiB
Python
342 lines
12 KiB
Python
import re
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
from fastapi.security import HTTPBearer
|
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
|
|
|
from ...core.config import settings
|
|
from ...core.database import get_database
|
|
from ...core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
verify_password,
|
|
)
|
|
from ...models.user import User, AuthProvider, UserRole
|
|
from ...schemas.auth import (
|
|
LoginRequest,
|
|
LoginResponse,
|
|
LogoutResponse,
|
|
RefreshResponse,
|
|
MicrosoftLoginRequest,
|
|
MicrosoftLoginResponse,
|
|
)
|
|
from ...services.microsoft_auth import (
|
|
get_microsoft_auth_service,
|
|
MicrosoftTokenValidationError,
|
|
MicrosoftAuthError,
|
|
)
|
|
from ...services.audit_logger import log_auth_success, log_auth_failure, audit_logger
|
|
from ...models.audit_log import AuditAction, AuditLogSeverity
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
security = HTTPBearer()
|
|
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
async def login(
|
|
login_data: LoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
):
|
|
print(f"LOGIN: Starting login for {login_data.email}")
|
|
# Create database connection directly (bypass dependency injection issues)
|
|
client = AsyncIOMotorClient(settings.mongodb_uri)
|
|
db = client[settings.mongodb_db]
|
|
|
|
try:
|
|
print("LOGIN: Database connection created")
|
|
# Find user by email
|
|
print("LOGIN: Looking up user in database")
|
|
user_doc = await db.users.find_one({"email": login_data.email})
|
|
print(f"LOGIN: User lookup complete, found: {user_doc is not None}")
|
|
if not user_doc:
|
|
await log_auth_failure(login_data.email, request, "User not found")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect email or password",
|
|
)
|
|
|
|
user = User(**user_doc)
|
|
|
|
# Check if user uses Microsoft authentication
|
|
if user.auth_provider == AuthProvider.MICROSOFT:
|
|
await log_auth_failure(login_data.email, request, "Account uses Microsoft SSO")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="This account uses Microsoft authentication. Please sign in with Microsoft.",
|
|
)
|
|
|
|
# Verify password
|
|
if not user.hashed_password or not verify_password(login_data.password, user.hashed_password):
|
|
await log_auth_failure(login_data.email, request, "Invalid password")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect email or password",
|
|
)
|
|
|
|
if not user.is_active:
|
|
await log_auth_failure(login_data.email, request, "Account disabled")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User account is disabled",
|
|
)
|
|
|
|
# Create tokens
|
|
access_token = create_access_token(subject=str(user.id))
|
|
refresh_token = create_refresh_token(subject=str(user.id))
|
|
|
|
# Set refresh token as HttpOnly cookie
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=refresh_token,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
domain=settings.cookie_domain if settings.app_env == "prod" else None,
|
|
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
|
)
|
|
|
|
await log_auth_success(user, request)
|
|
return LoginResponse(
|
|
access_token=access_token,
|
|
user_id=str(user.id),
|
|
role=user.role,
|
|
)
|
|
|
|
finally:
|
|
# Close database connection
|
|
client.close()
|
|
|
|
|
|
@router.post("/microsoft", response_model=MicrosoftLoginResponse)
|
|
async def microsoft_login(
|
|
login_data: MicrosoftLoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
):
|
|
"""Authenticate user with Microsoft ID token.
|
|
|
|
This endpoint validates the Microsoft ID token, finds or creates the user,
|
|
and returns JWT tokens for API access.
|
|
"""
|
|
print(f"MICROSOFT LOGIN: Starting Microsoft authentication")
|
|
|
|
# Create database connection
|
|
client = AsyncIOMotorClient(settings.mongodb_uri)
|
|
db = client[settings.mongodb_db]
|
|
|
|
try:
|
|
# Validate Microsoft token
|
|
microsoft_auth = get_microsoft_auth_service()
|
|
try:
|
|
user_info = microsoft_auth.validate_token(login_data.id_token)
|
|
print(f"MICROSOFT LOGIN: Token validated for {user_info.email}")
|
|
except MicrosoftTokenValidationError as e:
|
|
print(f"MICROSOFT LOGIN ERROR: Token validation failed: {e}")
|
|
await log_auth_failure(login_data.id_token[:20] + "…", request, f"MS token invalid: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=f"Microsoft authentication failed: {str(e)}",
|
|
)
|
|
except MicrosoftAuthError as e:
|
|
print(f"MICROSOFT LOGIN ERROR: Authentication error: {e}")
|
|
await log_auth_failure("microsoft-sso", request, f"MS auth service error: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Microsoft authentication service error",
|
|
)
|
|
|
|
# Find or create user
|
|
# Look up by Microsoft-derived ID first — handles email casing changes across logins
|
|
# (Microsoft can return vadymsamoilenko@... vs VadymSamoilenko@... for the same user)
|
|
ms_user_id = f"ms-{user_info.sub[:20]}"
|
|
user_doc = await db.users.find_one({"_id": ms_user_id})
|
|
if not user_doc:
|
|
# Fall back to case-insensitive email lookup (handles local-to-Microsoft migration)
|
|
user_doc = await db.users.find_one(
|
|
{"email": {"$regex": f"^{re.escape(user_info.email)}$", "$options": "i"}}
|
|
)
|
|
|
|
if user_doc:
|
|
# User exists
|
|
user = User(**user_doc)
|
|
print(f"MICROSOFT LOGIN: Existing user found: {user.id}")
|
|
|
|
# Update auth_provider if user is switching from local to Microsoft
|
|
if user.auth_provider == AuthProvider.LOCAL:
|
|
print(f"MICROSOFT LOGIN: Updating user to Microsoft auth provider")
|
|
await db.users.update_one(
|
|
{"_id": user_doc["_id"]},
|
|
{
|
|
"$set": {
|
|
"auth_provider": AuthProvider.MICROSOFT.value,
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
}
|
|
)
|
|
user.auth_provider = AuthProvider.MICROSOFT
|
|
|
|
else:
|
|
# Create new user with zero org memberships (SaaS model).
|
|
# They will see a "no access" landing until an admin invites them.
|
|
print(f"MICROSOFT LOGIN: Creating new user for {user_info.email}")
|
|
new_user = {
|
|
"_id": ms_user_id,
|
|
"email": user_info.email,
|
|
"full_name": user_info.name,
|
|
"hashed_password": None,
|
|
"role": UserRole.CLIENT.value,
|
|
"auth_provider": AuthProvider.MICROSOFT.value,
|
|
"is_active": True,
|
|
"pm_client_ids": [],
|
|
"created_at": datetime.utcnow(),
|
|
"updated_at": datetime.utcnow(),
|
|
}
|
|
|
|
await db.users.insert_one(new_user)
|
|
user = User(**new_user)
|
|
print(f"MICROSOFT LOGIN: New user created (zero memberships): {user.id}")
|
|
|
|
# Check if user is active
|
|
if not user.is_active:
|
|
await log_auth_failure(user.email, request, "Account disabled")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User account is disabled",
|
|
)
|
|
|
|
# Create JWT tokens
|
|
access_token = create_access_token(subject=str(user.id))
|
|
refresh_token = create_refresh_token(subject=str(user.id))
|
|
|
|
# Set refresh token as HttpOnly cookie
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=refresh_token,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
domain=settings.cookie_domain if settings.app_env == "prod" else None,
|
|
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
|
)
|
|
|
|
print(f"MICROSOFT LOGIN: Authentication successful for {user.email}")
|
|
await log_auth_success(user, request)
|
|
return MicrosoftLoginResponse(
|
|
access_token=access_token,
|
|
user_id=str(user.id),
|
|
role=user.role if isinstance(user.role, str) else user.role.value,
|
|
email=user.email,
|
|
full_name=user.full_name,
|
|
auth_provider=user.auth_provider,
|
|
)
|
|
|
|
finally:
|
|
# Close database connection
|
|
client.close()
|
|
|
|
|
|
@router.post("/refresh", response_model=RefreshResponse)
|
|
async def refresh_token(
|
|
request: Request,
|
|
response: Response,
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
refresh_token = request.cookies.get("refresh_token")
|
|
print(f"🔍 REFRESH DEBUG: Cookie exists: {bool(refresh_token)}")
|
|
|
|
if not refresh_token:
|
|
print("🚨 REFRESH ERROR: No refresh token in cookies")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Refresh token not found",
|
|
)
|
|
|
|
try:
|
|
print(f"🔍 REFRESH DEBUG: Attempting to decode token...")
|
|
payload = decode_token(refresh_token)
|
|
print(f"🔍 REFRESH DEBUG: Token decoded successfully, type={payload.get('type')}")
|
|
|
|
if payload.get("type") != "refresh":
|
|
print(f"🚨 REFRESH ERROR: Wrong token type: {payload.get('type')}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token type",
|
|
)
|
|
|
|
user_id = payload.get("sub")
|
|
print(f"🔍 REFRESH DEBUG: User ID from token: {user_id}")
|
|
if not user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token",
|
|
)
|
|
|
|
# Verify user still exists and is active
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if not user_doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found",
|
|
)
|
|
|
|
user = User(**user_doc)
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User account is disabled",
|
|
)
|
|
|
|
# Create new tokens
|
|
new_access_token = create_access_token(subject=user_id)
|
|
new_refresh_token = create_refresh_token(subject=user_id)
|
|
|
|
# Update refresh token cookie
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=new_refresh_token,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
domain=settings.cookie_domain if settings.app_env == "prod" else None,
|
|
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
|
)
|
|
|
|
print(f"🔍 REFRESH DEBUG: Refresh successful for user {user_id}")
|
|
return RefreshResponse(
|
|
access_token=new_access_token,
|
|
user_id=user_id,
|
|
role=user.role if isinstance(user.role, str) else user.role.value,
|
|
email=user.email,
|
|
full_name=user.full_name
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"🚨 REFRESH ERROR: Exception during refresh: {type(e).__name__}: {e}")
|
|
import traceback
|
|
print(f"Traceback:\n{traceback.format_exc()}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=f"Invalid refresh token: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.post("/logout", response_model=LogoutResponse)
|
|
async def logout(request: Request, response: Response):
|
|
# Clear refresh token cookie
|
|
response.delete_cookie(
|
|
key="refresh_token",
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
domain=settings.cookie_domain if settings.app_env == "prod" else None,
|
|
)
|
|
|
|
await audit_logger.log_action(
|
|
action=AuditAction.LOGOUT,
|
|
description="User logged out",
|
|
request=request,
|
|
severity=AuditLogSeverity.INFO,
|
|
)
|
|
return LogoutResponse()
|