Implementing MFA
This commit is contained in:
parent
35701d9719
commit
32f81f6314
@ -350,6 +350,154 @@ class RedisDatabase:
|
|||||||
await self.redis.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# MFA and Email Verification operations
|
# MFA and Email Verification operations
|
||||||
|
async def find_verification_token_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find pending verification token by email address"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
email_lower = email.lower()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
if (verification_info.get("email", "").lower() == email_lower and
|
||||||
|
not verification_info.get("verified", False)):
|
||||||
|
# Extract token from key
|
||||||
|
token = key.replace("email_verification:", "")
|
||||||
|
verification_info["token"] = token
|
||||||
|
return verification_info
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error finding verification token by email {email}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_pending_verifications_count(self) -> int:
|
||||||
|
"""Get count of pending email verifications (admin function)"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
if not verification_info.get("verified", False):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error counting pending verifications: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def cleanup_expired_verification_tokens(self) -> int:
|
||||||
|
"""Clean up expired verification tokens and return count of cleaned tokens"""
|
||||||
|
try:
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
cleaned_count = 0
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
expires_at = datetime.fromisoformat(verification_info.get("expires_at", ""))
|
||||||
|
|
||||||
|
if current_time > expires_at:
|
||||||
|
await self.redis.delete(key)
|
||||||
|
cleaned_count += 1
|
||||||
|
logger.debug(f"🧹 Cleaned expired verification token for {verification_info.get('email')}")
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cleaned_count > 0:
|
||||||
|
logger.info(f"🧹 Cleaned up {cleaned_count} expired verification tokens")
|
||||||
|
|
||||||
|
return cleaned_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error cleaning up expired verification tokens: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_verification_attempts_count(self, email: str) -> int:
|
||||||
|
"""Get the number of verification emails sent for an email in the last 24 hours"""
|
||||||
|
try:
|
||||||
|
key = f"verification_attempts:{email.lower()}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
attempts_data = json.loads(data)
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
window_start = current_time - timedelta(hours=24)
|
||||||
|
|
||||||
|
# Filter out old attempts
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
return len(recent_attempts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting verification attempts count for {email}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def record_verification_attempt(self, email: str) -> bool:
|
||||||
|
"""Record a verification email attempt"""
|
||||||
|
try:
|
||||||
|
key = f"verification_attempts:{email.lower()}"
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Get existing attempts
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
attempts_data = json.loads(data) if data else []
|
||||||
|
|
||||||
|
# Add current attempt
|
||||||
|
attempts_data.append(current_time.isoformat())
|
||||||
|
|
||||||
|
# Keep only last 24 hours of attempts
|
||||||
|
window_start = current_time - timedelta(hours=24)
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
# Store with 24 hour expiration
|
||||||
|
await self.redis.setex(
|
||||||
|
key,
|
||||||
|
24 * 60 * 60, # 24 hours
|
||||||
|
json.dumps(recent_attempts)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error recording verification attempt for {email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
|
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
|
||||||
"""Store email verification token with user data"""
|
"""Store email verification token with user data"""
|
||||||
try:
|
try:
|
||||||
|
@ -163,13 +163,16 @@ email_service = EmailService()
|
|||||||
|
|
||||||
class EnhancedEmailService:
|
class EnhancedEmailService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
|
# Configure these in your .env file
|
||||||
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
self.smtp_server = os.getenv("SMTP_SERVER")
|
||||||
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
|
self.smtp_port = int(os.getenv("SMTP_PORT", "0"))
|
||||||
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
|
self.email_user = os.getenv("EMAIL_USER",)
|
||||||
|
self.email_password = os.getenv("EMAIL_PASSWORD")
|
||||||
self.from_name = os.getenv("FROM_NAME", "Backstory")
|
self.from_name = os.getenv("FROM_NAME", "Backstory")
|
||||||
self.app_name = os.getenv("APP_NAME", "Backstory")
|
self.app_name = os.getenv("APP_NAME", "Backstory")
|
||||||
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
|
||||||
|
if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None:
|
||||||
|
raise ValueError("SMTP configuration is not set in the environment variables")
|
||||||
|
|
||||||
def _get_template(self, template_name: str) -> dict:
|
def _get_template(self, template_name: str) -> dict:
|
||||||
"""Get email template by name"""
|
"""Get email template by name"""
|
||||||
@ -279,6 +282,8 @@ class EnhancedEmailService:
|
|||||||
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
async def _send_email(self, to_email: str, subject: str, html_content: str):
|
||||||
"""Send email using SMTP with improved error handling"""
|
"""Send email using SMTP with improved error handling"""
|
||||||
try:
|
try:
|
||||||
|
if not self.email_user:
|
||||||
|
raise ValueError("Email user is not configured")
|
||||||
# Create message
|
# Create message
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
msg['From'] = f"{self.from_name} <{self.email_user}>"
|
||||||
@ -292,6 +297,9 @@ class EnhancedEmailService:
|
|||||||
|
|
||||||
# Send email with connection pooling and retry logic
|
# Send email with connection pooling and retry logic
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
if not self.smtp_server or self.smtp_port == 0 or not self.email_user or not self.email_password:
|
||||||
|
raise ValueError("SMTP configuration is not set in the environment variables")
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||||
@ -365,3 +373,59 @@ class EmailRateLimiter:
|
|||||||
ttl_minutes * 60,
|
ttl_minutes * 60,
|
||||||
json.dumps([timestamp.isoformat()])
|
json.dumps([timestamp.isoformat()])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class VerificationEmailRateLimiter:
|
||||||
|
def __init__(self, database: RedisDatabase):
|
||||||
|
self.database = database
|
||||||
|
self.max_attempts_per_hour = 3 # Maximum 3 emails per hour
|
||||||
|
self.max_attempts_per_day = 10 # Maximum 10 emails per day
|
||||||
|
self.cooldown_minutes = 5 # 5 minute cooldown between emails
|
||||||
|
|
||||||
|
async def can_send_verification_email(self, email: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if verification email can be sent based on rate limiting
|
||||||
|
Returns (can_send, reason_if_not)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
email_lower = email.lower()
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Check daily limit
|
||||||
|
daily_count = await self.database.get_verification_attempts_count(email)
|
||||||
|
if daily_count >= self.max_attempts_per_day:
|
||||||
|
return False, f"Daily limit reached. You can request up to {self.max_attempts_per_day} verification emails per day."
|
||||||
|
|
||||||
|
# Check hourly limit
|
||||||
|
hour_ago = current_time - timedelta(hours=1)
|
||||||
|
hourly_key = f"verification_attempts:{email_lower}"
|
||||||
|
data = await self.database.redis.get(hourly_key)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
attempts_data = json.loads(data)
|
||||||
|
recent_attempts = [
|
||||||
|
attempt for attempt in attempts_data
|
||||||
|
if datetime.fromisoformat(attempt) > hour_ago
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(recent_attempts) >= self.max_attempts_per_hour:
|
||||||
|
return False, f"Hourly limit reached. You can request up to {self.max_attempts_per_hour} verification emails per hour."
|
||||||
|
|
||||||
|
# Check cooldown period
|
||||||
|
if recent_attempts:
|
||||||
|
last_attempt = max(datetime.fromisoformat(attempt) for attempt in recent_attempts)
|
||||||
|
time_since_last = current_time - last_attempt
|
||||||
|
|
||||||
|
if time_since_last.total_seconds() < self.cooldown_minutes * 60:
|
||||||
|
remaining_minutes = self.cooldown_minutes - int(time_since_last.total_seconds() / 60)
|
||||||
|
return False, f"Please wait {remaining_minutes} more minute(s) before requesting another email."
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error checking verification email rate limit: {e}")
|
||||||
|
# On error, be conservative and deny
|
||||||
|
return False, "Rate limit check failed. Please try again later."
|
||||||
|
|
||||||
|
async def record_email_sent(self, email: str):
|
||||||
|
"""Record that a verification email was sent"""
|
||||||
|
await self.database.record_verification_attempt(email)
|
||||||
|
@ -45,7 +45,7 @@ from database import RedisDatabase, redis_manager, DatabaseManager
|
|||||||
from metrics import Metrics
|
from metrics import Metrics
|
||||||
from llm_manager import llm_manager
|
from llm_manager import llm_manager
|
||||||
import entities
|
import entities
|
||||||
from email_service import email_service
|
from email_service import VerificationEmailRateLimiter, email_service
|
||||||
from device_manager import DeviceManager
|
from device_manager import DeviceManager
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
@ -376,12 +376,12 @@ api_router = APIRouter(prefix="/api/1.0")
|
|||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
@api_router.post("/auth/login")
|
@api_router.post("/auth/login")
|
||||||
async def enhanced_login(
|
async def login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
http_request: Request,
|
http_request: Request,
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Enhanced login with device detection and MFA"""
|
"""Login with device detection and MFA"""
|
||||||
try:
|
try:
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
auth_manager = AuthenticationManager(database)
|
auth_manager = AuthenticationManager(database)
|
||||||
@ -458,7 +458,7 @@ async def enhanced_login(
|
|||||||
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Enhanced login error: {e}")
|
logger.error(f"❌ Login error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
||||||
@ -941,30 +941,140 @@ async def resend_verification_email(
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Resend verification email"""
|
"""Resend verification email with comprehensive rate limiting and validation"""
|
||||||
try:
|
try:
|
||||||
# Check if user exists and is pending
|
email_lower = request.email.lower().strip()
|
||||||
user_data = await database.get_user(request.email)
|
|
||||||
|
|
||||||
if user_data:
|
# Initialize rate limiter
|
||||||
|
rate_limiter = VerificationEmailRateLimiter(database)
|
||||||
|
|
||||||
|
# Check rate limiting
|
||||||
|
can_send, reason = await rate_limiter.can_send_verification_email(email_lower)
|
||||||
|
if not can_send:
|
||||||
|
logger.warning(f"⚠️ Verification email rate limit exceeded for {email_lower}: {reason}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=429,
|
||||||
content=create_error_response("ALREADY_VERIFIED", "Account is already verified")
|
content=create_error_response("RATE_LIMITED", reason)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Look for pending verification
|
# Clean up expired tokens first
|
||||||
# This would require scanning verification tokens (implement if needed)
|
await database.cleanup_expired_verification_tokens()
|
||||||
# For now, return a generic success message
|
|
||||||
|
|
||||||
|
# Check if user already exists and is verified
|
||||||
|
user_data = await database.get_user(email_lower)
|
||||||
|
if user_data:
|
||||||
|
# User exists and is verified - don't reveal this for security
|
||||||
|
logger.info(f"🔍 Resend verification requested for already verified user: {email_lower}")
|
||||||
|
await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse
|
||||||
return create_success_response({
|
return create_success_response({
|
||||||
"message": "If your email is in our system and pending verification, a new verification email has been sent."
|
"message": "If your email is in our system and pending verification, a new verification email has been sent."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Look for pending verification token
|
||||||
|
verification_data = await database.find_verification_token_by_email(email_lower)
|
||||||
|
|
||||||
|
if not verification_data:
|
||||||
|
# No pending verification found - don't reveal this for security
|
||||||
|
logger.info(f"🔍 Resend verification requested for non-existent pending verification: {email_lower}")
|
||||||
|
await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse
|
||||||
|
return create_success_response({
|
||||||
|
"message": "If your email is in our system and pending verification, a new verification email has been sent."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check if verification token has expired
|
||||||
|
expires_at = datetime.fromisoformat(verification_data["expires_at"])
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if current_time > expires_at:
|
||||||
|
# Token expired - clean it up and inform user
|
||||||
|
await database.redis.delete(f"email_verification:{verification_data['token']}")
|
||||||
|
logger.info(f"🧹 Cleaned up expired verification token for {email_lower}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response(
|
||||||
|
"TOKEN_EXPIRED",
|
||||||
|
"Your verification link has expired. Please register again to create a new account."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new verification token (invalidate old one)
|
||||||
|
old_token = verification_data["token"]
|
||||||
|
new_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Update verification data with new token and reset attempts
|
||||||
|
verification_data.update({
|
||||||
|
"token": new_token,
|
||||||
|
"expires_at": (current_time + timedelta(hours=24)).isoformat(),
|
||||||
|
"resent_at": current_time.isoformat(),
|
||||||
|
"resend_count": verification_data.get("resend_count", 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
# Store new token and remove old one
|
||||||
|
await database.redis.delete(f"email_verification:{old_token}")
|
||||||
|
await database.store_email_verification_token(
|
||||||
|
email_lower,
|
||||||
|
new_token,
|
||||||
|
verification_data["user_type"],
|
||||||
|
verification_data["user_data"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user name for email
|
||||||
|
user_data_container = verification_data["user_data"]
|
||||||
|
user_type = verification_data["user_type"]
|
||||||
|
|
||||||
|
if user_type == "candidate":
|
||||||
|
candidate_data = user_data_container["candidate_data"]
|
||||||
|
user_name = candidate_data.get("fullName", "User")
|
||||||
|
elif user_type == "employer":
|
||||||
|
employer_data = user_data_container["employer_data"]
|
||||||
|
user_name = employer_data.get("companyName", "User")
|
||||||
|
else:
|
||||||
|
user_name = "User"
|
||||||
|
|
||||||
|
# Record email attempt
|
||||||
|
await rate_limiter.record_email_sent(email_lower)
|
||||||
|
|
||||||
|
# Send new verification email in background
|
||||||
|
background_tasks.add_task(
|
||||||
|
email_service.send_verification_email,
|
||||||
|
email_lower,
|
||||||
|
new_token,
|
||||||
|
user_name,
|
||||||
|
user_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log security event
|
||||||
|
await database.log_security_event(
|
||||||
|
verification_data["user_data"].get("candidate_data", {}).get("id") or
|
||||||
|
verification_data["user_data"].get("employer_data", {}).get("id") or "unknown",
|
||||||
|
"verification_resend",
|
||||||
|
{
|
||||||
|
"email": email_lower,
|
||||||
|
"user_type": user_type,
|
||||||
|
"resend_count": verification_data.get("resend_count", 1),
|
||||||
|
"old_token_invalidated": old_token[:8] + "...", # Log partial token for debugging
|
||||||
|
"ip_address": "unknown" # You can extract this from request if needed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Verification email resent to {email_lower} (attempt #{verification_data.get('resend_count', 1)})")
|
||||||
|
|
||||||
|
return create_success_response({
|
||||||
|
"message": "A new verification email has been sent to your email address. Please check your inbox and spam folder.",
|
||||||
|
"resendCount": verification_data.get("resend_count", 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.warning(f"⚠️ Invalid resend verification request: {ve}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("VALIDATION_ERROR", str(ve))
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Resend verification error: {e}")
|
logger.error(f"❌ Resend verification email error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("RESEND_FAILED", "Failed to resend verification email")
|
content=create_error_response("RESEND_FAILED", "An error occurred while processing your request. Please try again later.")
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_router.post("/auth/mfa/request")
|
@api_router.post("/auth/mfa/request")
|
||||||
@ -1745,8 +1855,7 @@ async def search_jobs(
|
|||||||
# ============================
|
# ============================
|
||||||
# Chat Endpoints
|
# Chat Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
# Enhanced Chat Session Endpoints with Username Association
|
# Chat Session Endpoints with Username Association
|
||||||
# Add these modifications to your main.py file
|
|
||||||
@api_router.get("/chat/statistics")
|
@api_router.get("/chat/statistics")
|
||||||
async def get_chat_statistics(
|
async def get_chat_statistics(
|
||||||
current_user = Depends(get_current_user),
|
current_user = Depends(get_current_user),
|
||||||
@ -1832,7 +1941,7 @@ async def archive_chat_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Chat Endpoints (Enhanced)
|
# Chat Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
@api_router.post("/chat/sessions")
|
@api_router.post("/chat/sessions")
|
||||||
@ -2334,6 +2443,121 @@ async def get_candidate_chat_sessions(
|
|||||||
content=create_error_response("FETCH_ERROR", str(e))
|
content=create_error_response("FETCH_ERROR", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Admin Endpoints
|
||||||
|
# ============================
|
||||||
|
# @api_router.get("/admin/verification-stats")
|
||||||
|
async def get_verification_statistics(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get verification statistics (admin only)"""
|
||||||
|
try:
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"pending_verifications": await database.get_pending_verifications_count(),
|
||||||
|
"expired_tokens_cleaned": await database.cleanup_expired_verification_tokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_success_response(stats)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting verification stats: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("STATS_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.post("/admin/cleanup-verifications")
|
||||||
|
async def cleanup_verification_tokens(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Manually trigger cleanup of expired verification tokens (admin only)"""
|
||||||
|
try:
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
cleaned_count = await database.cleanup_expired_verification_tokens()
|
||||||
|
|
||||||
|
logger.info(f"🧹 Manual cleanup completed by admin {current_user.id}: {cleaned_count} tokens cleaned")
|
||||||
|
|
||||||
|
return create_success_response({
|
||||||
|
"message": f"Cleanup completed. Removed {cleaned_count} expired verification tokens.",
|
||||||
|
"cleaned_count": cleaned_count
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error in manual cleanup: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("CLEANUP_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.get("/admin/pending-verifications")
|
||||||
|
async def get_pending_verifications(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get list of pending email verifications (admin only)"""
|
||||||
|
try:
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
pattern = "email_verification:*"
|
||||||
|
cursor = 0
|
||||||
|
pending_verifications = []
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await database.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await database.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
verification_info = json.loads(token_data)
|
||||||
|
if not verification_info.get("verified", False):
|
||||||
|
expires_at = datetime.fromisoformat(verification_info.get("expires_at", ""))
|
||||||
|
|
||||||
|
pending_verifications.append({
|
||||||
|
"email": verification_info.get("email"),
|
||||||
|
"user_type": verification_info.get("user_type"),
|
||||||
|
"created_at": verification_info.get("created_at"),
|
||||||
|
"expires_at": verification_info.get("expires_at"),
|
||||||
|
"is_expired": current_time > expires_at,
|
||||||
|
"resend_count": verification_info.get("resend_count", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Sort by creation date (newest first)
|
||||||
|
pending_verifications.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
total = len(pending_verifications)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
end = start + limit
|
||||||
|
paginated_verifications = pending_verifications[start:end]
|
||||||
|
|
||||||
|
paginated_response = create_paginated_response(
|
||||||
|
paginated_verifications,
|
||||||
|
page, limit, total
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_success_response(paginated_response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting pending verifications: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("FETCH_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Health Check and Info Endpoints
|
# Health Check and Info Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
@ -2342,8 +2566,8 @@ async def get_redis() -> redis.Redis:
|
|||||||
return redis_manager.get_client()
|
return redis_manager.get_client()
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def enhanced_health_check():
|
async def health_check():
|
||||||
"""Enhanced health check endpoint"""
|
"""Health check endpoint"""
|
||||||
try:
|
try:
|
||||||
database = db_manager.get_database()
|
database = db_manager.get_database()
|
||||||
if not redis_manager.redis:
|
if not redis_manager.redis:
|
||||||
|
@ -401,6 +401,7 @@ class BaseUser(BaseModel):
|
|||||||
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
||||||
profile_image: Optional[str] = Field(None, alias="profileImage")
|
profile_image: Optional[str] = Field(None, alias="profileImage")
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
is_admin: bool = Field(default=False, alias="isAdmin")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"populate_by_name": True, # Allow both field names and aliases
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
Loading…
x
Reference in New Issue
Block a user