1065 lines
42 KiB
Python
1065 lines
42 KiB
Python
"""
|
|
Authentication routes
|
|
"""
|
|
import json
|
|
import jwt
|
|
import secrets
|
|
import uuid
|
|
import os
|
|
from datetime import datetime, timedelta, timezone, UTC
|
|
from typing import Any, Dict
|
|
|
|
from fastapi import APIRouter, Depends, Body, Request, BackgroundTasks
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, EmailStr, ValidationError, field_validator
|
|
|
|
import backstory_traceback as backstory_traceback
|
|
from utils.rate_limiter import RateLimiter
|
|
from database.manager import RedisDatabase, redis_manager
|
|
from device_manager import DeviceManager
|
|
from email_service import VerificationEmailRateLimiter, email_service
|
|
from logger import logger
|
|
from models import (
|
|
LoginRequest,
|
|
CreateCandidateRequest,
|
|
Candidate,
|
|
Employer,
|
|
Guest,
|
|
AuthResponse,
|
|
MFARequest,
|
|
MFAData,
|
|
MFAVerifyRequest,
|
|
ResendVerificationRequest,
|
|
MFARequestResponse,
|
|
MFARequestResponse,
|
|
)
|
|
from utils.dependencies import get_current_admin, get_database, get_current_user, create_access_token
|
|
from utils.responses import create_success_response, create_error_response
|
|
from utils.rate_limiter import get_rate_limiter
|
|
from utils.auth_utils import (
|
|
AuthenticationManager,
|
|
SecurityConfig,
|
|
validate_password_strength,
|
|
)
|
|
|
|
# Create router for authentication endpoints
|
|
router = APIRouter(prefix="/auth", tags=["authentication"])
|
|
|
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
|
|
if JWT_SECRET_KEY == "":
|
|
raise ValueError("JWT_SECRET_KEY environment variable is not set")
|
|
ALGORITHM = "HS256"
|
|
|
|
|
|
# ============================
|
|
# Password Reset Endpoints
|
|
# ============================
|
|
class PasswordResetRequest(BaseModel):
|
|
email: EmailStr
|
|
|
|
|
|
class PasswordResetConfirm(BaseModel):
|
|
token: str
|
|
new_password: str
|
|
|
|
@field_validator("new_password")
|
|
def validate_password_strength(cls, v):
|
|
is_valid, issues = validate_password_strength(v)
|
|
if not is_valid:
|
|
raise ValueError("; ".join(issues))
|
|
return v
|
|
|
|
|
|
@router.post("/guest")
|
|
async def create_guest_session_enhanced(
|
|
request: Request,
|
|
database: RedisDatabase = Depends(get_database),
|
|
rate_limiter: RateLimiter = Depends(get_rate_limiter),
|
|
):
|
|
"""Create a guest session with enhanced validation and persistence"""
|
|
try:
|
|
# Apply rate limiting for guest creation
|
|
ip_address = request.client.host if request.client else "unknown"
|
|
|
|
# Check rate limits for guest session creation
|
|
rate_result = await rate_limiter.check_rate_limit(
|
|
user_id=ip_address, user_type="guest_creation", is_admin=False, endpoint="/guest"
|
|
)
|
|
|
|
if not rate_result.allowed:
|
|
logger.warning(f"🚫 Guest creation rate limit exceeded for IP {ip_address}")
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content=create_error_response("RATE_LIMITED", rate_result.reason or "Too many guest sessions created"),
|
|
headers={"Retry-After": str(rate_result.retry_after_seconds or 300)},
|
|
)
|
|
|
|
# Generate unique guest identifier with timestamp for uniqueness
|
|
current_time = datetime.now(UTC)
|
|
guest_id = str(uuid.uuid4())
|
|
session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(8)}"
|
|
guest_username = f"guest-{session_id[-12:]}"
|
|
|
|
# Verify username is unique (unlikely but possible collision)
|
|
while True:
|
|
existing_user = await database.get_user(guest_username)
|
|
if existing_user:
|
|
# Regenerate if collision
|
|
session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(12)}"
|
|
guest_username = f"guest-{session_id[-16:]}"
|
|
else:
|
|
break
|
|
|
|
# Create guest user data with comprehensive info
|
|
guest_data = {
|
|
"id": guest_id,
|
|
"session_id": session_id,
|
|
"username": guest_username,
|
|
"email": f"{guest_username}@guest.backstory.ketrenos.com",
|
|
"first_name": "Guest",
|
|
"last_name": "User",
|
|
"full_name": "Guest User",
|
|
"user_type": "guest",
|
|
"created_at": current_time.isoformat(),
|
|
"updated_at": current_time.isoformat(),
|
|
"last_activity": current_time.isoformat(),
|
|
"last_login": current_time.isoformat(),
|
|
"status": "active",
|
|
"is_admin": False,
|
|
"ip_address": ip_address,
|
|
"user_agent": request.headers.get("user-agent", "Unknown"),
|
|
"converted_to_user_id": None,
|
|
"browser_session": True, # Mark as browser session
|
|
"persistent": True, # Mark as persistent
|
|
}
|
|
|
|
# Store guest with enhanced persistence
|
|
await database.set_guest(guest_id, guest_data)
|
|
|
|
# Create user lookup records
|
|
user_auth_data = {
|
|
"id": guest_id,
|
|
"type": "guest",
|
|
"email": guest_data["email"],
|
|
"username": guest_username,
|
|
"session_id": session_id,
|
|
"created_at": current_time.isoformat(),
|
|
}
|
|
|
|
await database.set_user(guest_data["email"], user_auth_data)
|
|
await database.set_user(guest_username, user_auth_data)
|
|
await database.set_user_by_id(guest_id, user_auth_data)
|
|
|
|
# Create authentication tokens with longer expiry for guests
|
|
access_token = create_access_token(
|
|
data={"sub": guest_id, "type": "guest"},
|
|
expires_delta=timedelta(hours=48), # Longer expiry for guests
|
|
)
|
|
refresh_token = create_access_token(
|
|
data={"sub": guest_id, "type": "refresh_guest"},
|
|
expires_delta=timedelta(days=14), # 2 weeks refresh for guests
|
|
)
|
|
|
|
# Verify guest was stored correctly
|
|
verification = await database.get_guest(guest_id)
|
|
if not verification:
|
|
logger.error(f"❌ Failed to verify guest storage: {guest_id}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("STORAGE_ERROR", "Failed to create guest session")
|
|
)
|
|
|
|
# Create guest object for response
|
|
guest = Guest.model_validate(guest_data)
|
|
|
|
# Log successful creation
|
|
logger.info(f"👤 Guest session created and verified: {guest_username} (ID: {guest_id}) from IP: {ip_address}")
|
|
|
|
# Create auth response
|
|
auth_response = {
|
|
"accessToken": access_token,
|
|
"refreshToken": refresh_token,
|
|
"user": guest.model_dump(by_alias=True),
|
|
"expiresAt": int((current_time + timedelta(hours=48)).timestamp()),
|
|
"userType": "guest",
|
|
"isGuest": True,
|
|
}
|
|
|
|
return create_success_response(auth_response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Guest session creation error: {e}")
|
|
import traceback
|
|
|
|
logger.error(traceback.format_exc())
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("GUEST_CREATION_FAILED", "Failed to create guest session")
|
|
)
|
|
|
|
|
|
@router.post("/guest/convert")
|
|
async def convert_guest_to_user(
|
|
registration_data: Dict[str, Any] = Body(...),
|
|
current_user=Depends(get_current_user),
|
|
database: RedisDatabase = Depends(get_database),
|
|
):
|
|
"""Convert a guest session to a permanent user account"""
|
|
try:
|
|
# Verify current user is a guest
|
|
if current_user.user_type != "guest":
|
|
return JSONResponse(
|
|
status_code=400, content=create_error_response("NOT_GUEST", "Only guest users can be converted")
|
|
)
|
|
|
|
guest: Guest = current_user
|
|
account_type = registration_data.get("accountType", "candidate")
|
|
|
|
if account_type == "candidate":
|
|
# Validate candidate registration data
|
|
try:
|
|
candidate_request = CreateCandidateRequest.model_validate(registration_data)
|
|
except ValidationError as e:
|
|
return JSONResponse(status_code=400, content=create_error_response("VALIDATION_ERROR", str(e)))
|
|
|
|
# Check if email/username already exists
|
|
auth_manager = AuthenticationManager(database)
|
|
user_exists, conflict_field = await auth_manager.check_user_exists(
|
|
candidate_request.email, candidate_request.username
|
|
)
|
|
|
|
if user_exists:
|
|
return JSONResponse(
|
|
status_code=409,
|
|
content=create_error_response("USER_EXISTS", f"A user with this {conflict_field} already exists"),
|
|
)
|
|
|
|
# Create candidate
|
|
candidate_id = str(uuid.uuid4())
|
|
current_time = datetime.now(timezone.utc)
|
|
|
|
candidate_data = {
|
|
"id": candidate_id,
|
|
"user_type": "candidate",
|
|
"email": candidate_request.email,
|
|
"username": candidate_request.username,
|
|
"first_name": candidate_request.first_name,
|
|
"last_name": candidate_request.last_name,
|
|
"full_name": f"{candidate_request.first_name} {candidate_request.last_name}",
|
|
"phone": candidate_request.phone,
|
|
"created_at": current_time.isoformat(),
|
|
"updated_at": current_time.isoformat(),
|
|
"status": "active",
|
|
"is_admin": False,
|
|
"converted_from_guest": guest.id,
|
|
}
|
|
|
|
candidate = Candidate.model_validate(candidate_data)
|
|
|
|
# Create authentication
|
|
await auth_manager.create_user_authentication(candidate_id, candidate_request.password)
|
|
|
|
# Store candidate
|
|
await database.set_candidate(candidate_id, candidate.model_dump())
|
|
|
|
# Update user lookup records
|
|
user_auth_data = {
|
|
"id": candidate_id,
|
|
"type": "candidate",
|
|
"email": candidate.email,
|
|
"username": candidate.username,
|
|
}
|
|
|
|
await database.set_user(candidate.email, user_auth_data)
|
|
await database.set_user(candidate.username, user_auth_data)
|
|
await database.set_user_by_id(candidate_id, user_auth_data)
|
|
|
|
# Mark guest as converted
|
|
guest_data = guest.model_dump()
|
|
guest_data["converted_to_user_id"] = candidate_id
|
|
guest_data["updated_at"] = current_time.isoformat()
|
|
await database.set_guest(guest.id, guest_data)
|
|
|
|
# Create new tokens for the candidate
|
|
access_token = create_access_token(data={"sub": candidate_id})
|
|
refresh_token = create_access_token(
|
|
data={"sub": candidate_id, "type": "refresh"},
|
|
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS),
|
|
)
|
|
|
|
auth_response = AuthResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=candidate,
|
|
expires_at=int((current_time + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()),
|
|
)
|
|
|
|
logger.info(f"✅ Guest {guest.session_id} converted to candidate {candidate.username}")
|
|
|
|
return create_success_response(
|
|
{
|
|
"message": "Guest account successfully converted to candidate",
|
|
"auth": auth_response.model_dump(by_alias=True),
|
|
"conversionType": "candidate",
|
|
}
|
|
)
|
|
|
|
else:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content=create_error_response("INVALID_TYPE", "Only candidate conversion is currently supported"),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Guest conversion error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("CONVERSION_FAILED", "Failed to convert guest account")
|
|
)
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(
|
|
access_token: str = Body(..., alias="accessToken"),
|
|
refresh_token: str = Body(..., alias="refreshToken"),
|
|
current_user=Depends(get_current_user),
|
|
database: RedisDatabase = Depends(get_database),
|
|
):
|
|
"""Logout endpoint - revokes both access and refresh tokens"""
|
|
logger.info(f"🔑 User {current_user.id} is logging out")
|
|
try:
|
|
# Verify refresh token
|
|
try:
|
|
refresh_payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
|
|
user_id = refresh_payload.get("sub")
|
|
token_type = refresh_payload.get("type")
|
|
refresh_exp = refresh_payload.get("exp")
|
|
|
|
if not user_id or token_type != "refresh":
|
|
return JSONResponse(
|
|
status_code=401, content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
|
|
)
|
|
except jwt.PyJWTError as e:
|
|
logger.warning(f"⚠️ Invalid refresh token during logout: {e}")
|
|
return JSONResponse(
|
|
status_code=401, content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
|
|
)
|
|
|
|
# Verify that the refresh token belongs to the current user
|
|
if user_id != current_user.id:
|
|
return JSONResponse(
|
|
status_code=403, content=create_error_response("FORBIDDEN", "Token does not belong to current user")
|
|
)
|
|
|
|
# Get Redis client
|
|
redis = redis_manager.get_client()
|
|
|
|
# Revoke refresh token (blacklist it until its natural expiration)
|
|
refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp()))
|
|
if refresh_ttl > 0:
|
|
await redis.setex(
|
|
f"blacklisted_token:{refresh_token}",
|
|
refresh_ttl,
|
|
json.dumps(
|
|
{
|
|
"user_id": user_id,
|
|
"token_type": "refresh",
|
|
"revoked_at": datetime.now(UTC).isoformat(),
|
|
"reason": "user_logout",
|
|
}
|
|
),
|
|
)
|
|
logger.info(f"🔒 Blacklisted refresh token for user {user_id}")
|
|
|
|
# If access token is provided, revoke it too
|
|
if access_token:
|
|
try:
|
|
access_payload = jwt.decode(access_token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
|
|
access_user_id = access_payload.get("sub")
|
|
access_exp = access_payload.get("exp")
|
|
|
|
# Verify access token belongs to same user
|
|
if access_user_id == user_id:
|
|
access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp()))
|
|
if access_ttl > 0:
|
|
await redis.setex(
|
|
f"blacklisted_token:{access_token}",
|
|
access_ttl,
|
|
json.dumps(
|
|
{
|
|
"user_id": user_id,
|
|
"token_type": "access",
|
|
"revoked_at": datetime.now(UTC).isoformat(),
|
|
"reason": "user_logout",
|
|
}
|
|
),
|
|
)
|
|
logger.info(f"🔒 Blacklisted access token for user {user_id}")
|
|
else:
|
|
logger.warning(f"⚠️ Access token user mismatch during logout: {access_user_id} != {user_id}")
|
|
except jwt.PyJWTError as e:
|
|
logger.warning(f"⚠️ Invalid access token during logout (non-critical): {e}")
|
|
# Don't fail logout if access token is invalid
|
|
|
|
# Optional: Revoke all tokens for this user (for "logout from all devices")
|
|
# Uncomment the following lines if you want to implement this feature:
|
|
#
|
|
# await redis.setex(
|
|
# f"user_tokens_revoked:{user_id}",
|
|
# timedelta(days=30).total_seconds(), # Max refresh token lifetime
|
|
# datetime.now(UTC).isoformat()
|
|
# )
|
|
|
|
logger.info(f"🔑 User {user_id} logged out successfully")
|
|
return create_success_response(
|
|
{
|
|
"message": "Logged out successfully",
|
|
"tokensRevoked": {"refreshToken": True, "accessToken": bool(access_token)},
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Logout error: {e}")
|
|
return JSONResponse(status_code=500, content=create_error_response("LOGOUT_ERROR", str(e)))
|
|
|
|
|
|
@router.post("/logout-all")
|
|
async def logout_all_devices(current_user=Depends(get_current_admin), database: RedisDatabase = Depends(get_database)):
|
|
"""Logout from all devices by revoking all tokens for the user"""
|
|
try:
|
|
redis = redis_manager.get_client()
|
|
|
|
# Set a timestamp that invalidates all tokens issued before this moment
|
|
await redis.setex(
|
|
f"user_tokens_revoked:{current_user.id}",
|
|
int(timedelta(days=30).total_seconds()), # Max refresh token lifetime
|
|
datetime.now(UTC).isoformat(),
|
|
)
|
|
|
|
logger.info(f"🔒 All tokens revoked for user {current_user.id}")
|
|
return create_success_response({"message": "Logged out from all devices successfully"})
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Logout all devices error: {e}")
|
|
return JSONResponse(status_code=500, content=create_error_response("LOGOUT_ALL_ERROR", str(e)))
|
|
|
|
|
|
@router.post("/refresh")
|
|
async def refresh_token_endpoint(
|
|
refresh_token: str = Body(..., alias="refreshToken"), database: RedisDatabase = Depends(get_database)
|
|
):
|
|
"""Refresh token endpoint"""
|
|
try:
|
|
# Verify refresh token
|
|
payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
|
|
user_id = payload.get("sub")
|
|
token_type = payload.get("type")
|
|
|
|
if not user_id or token_type != "refresh":
|
|
return JSONResponse(
|
|
status_code=401, content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
|
|
)
|
|
|
|
# Create new access token
|
|
access_token = create_access_token(data={"sub": user_id})
|
|
|
|
# Get user
|
|
user = None
|
|
candidate_data = await database.get_candidate(user_id)
|
|
if candidate_data:
|
|
user = Candidate.model_validate(candidate_data)
|
|
else:
|
|
employer_data = await database.get_employer(user_id)
|
|
if employer_data:
|
|
user = Employer.model_validate(employer_data)
|
|
|
|
if not user:
|
|
return JSONResponse(status_code=404, content=create_error_response("USER_NOT_FOUND", "User not found"))
|
|
|
|
auth_response = AuthResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token, # Keep same refresh token
|
|
user=user,
|
|
expires_at=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()),
|
|
)
|
|
|
|
return create_success_response(auth_response.model_dump(by_alias=True))
|
|
|
|
except jwt.PyJWTError:
|
|
return JSONResponse(status_code=401, content=create_error_response("INVALID_TOKEN", "Invalid refresh token"))
|
|
except Exception as e:
|
|
logger.error(f"❌ Token refresh error: {e}")
|
|
return JSONResponse(status_code=500, content=create_error_response("REFRESH_ERROR", str(e)))
|
|
|
|
|
|
@router.post("/resend-verification")
|
|
async def resend_verification_email(
|
|
request: ResendVerificationRequest,
|
|
background_tasks: BackgroundTasks,
|
|
database: RedisDatabase = Depends(get_database),
|
|
):
|
|
"""Resend verification email with comprehensive rate limiting and validation"""
|
|
try:
|
|
email_lower = request.email.lower().strip()
|
|
|
|
# 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(status_code=429, content=create_error_response("RATE_LIMITED", reason))
|
|
|
|
# Clean up expired tokens first
|
|
await database.cleanup_expired_verification_tokens()
|
|
|
|
# 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(
|
|
{
|
|
"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:
|
|
logger.error(f"❌ Resend verification email error: {e}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content=create_error_response(
|
|
"RESEND_FAILED", "An error occurred while processing your request. Please try again later."
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/mfa/request")
|
|
async def request_mfa(
|
|
request: MFARequest,
|
|
background_tasks: BackgroundTasks,
|
|
http_request: Request,
|
|
database: RedisDatabase = Depends(get_database),
|
|
):
|
|
"""Request MFA for login from new device"""
|
|
try:
|
|
# Verify credentials first
|
|
auth_manager = AuthenticationManager(database)
|
|
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(request.email, request.password)
|
|
|
|
if not is_valid or not user_data:
|
|
return JSONResponse(status_code=401, content=create_error_response("AUTH_FAILED", "Invalid credentials"))
|
|
|
|
# Check if device is trusted
|
|
device_manager = DeviceManager(database)
|
|
device_manager.parse_device_info(http_request)
|
|
|
|
is_trusted = await device_manager.is_trusted_device(user_data["id"], request.device_id)
|
|
|
|
if is_trusted:
|
|
# Device is trusted, proceed with normal login
|
|
await device_manager.update_device_last_used(user_data["id"], request.device_id)
|
|
|
|
return create_success_response({"mfa_required": False, "message": "Device is trusted, proceed with login"})
|
|
|
|
# Generate MFA code
|
|
mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code
|
|
|
|
# Store MFA code
|
|
# Get user name for email
|
|
user_name = "User"
|
|
email = None
|
|
if user_data["type"] == "candidate":
|
|
candidate_data = await database.get_candidate(user_data["id"])
|
|
if candidate_data:
|
|
user_name = candidate_data.get("fullName", "User")
|
|
email = candidate_data.get("email", None)
|
|
elif user_data["type"] == "employer":
|
|
employer_data = await database.get_employer(user_data["id"])
|
|
if employer_data:
|
|
user_name = employer_data.get("companyName", "User")
|
|
email = employer_data.get("email", None)
|
|
|
|
if not email:
|
|
return JSONResponse(
|
|
status_code=400, content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA")
|
|
)
|
|
|
|
# Store MFA code
|
|
await database.store_mfa_code(email, mfa_code, request.device_id)
|
|
logger.info(f"🔐 MFA code generated for {email} on device {request.device_id}")
|
|
|
|
# Send MFA code via email
|
|
background_tasks.add_task(email_service.send_mfa_email, email, mfa_code, request.device_name, user_name)
|
|
|
|
logger.info(f"🔐 MFA requested for {request.email} from new device {request.device_name}")
|
|
|
|
mfa_data = MFAData(
|
|
message="New device detected. We've sent a security code to your email address.",
|
|
code_sent=mfa_code,
|
|
email=request.email,
|
|
device_id=request.device_id,
|
|
device_name=request.device_name,
|
|
)
|
|
mfa_response = MFARequestResponse(mfa_required=True, mfa_data=mfa_data)
|
|
return create_success_response(mfa_response)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ MFA request error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request")
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(
|
|
request: LoginRequest,
|
|
http_request: Request,
|
|
background_tasks: BackgroundTasks,
|
|
database: RedisDatabase = Depends(get_database),
|
|
):
|
|
"""login with automatic MFA email sending for new devices"""
|
|
try:
|
|
# Initialize managers
|
|
auth_manager = AuthenticationManager(database)
|
|
device_manager = DeviceManager(database)
|
|
|
|
# Parse device information
|
|
device_info = device_manager.parse_device_info(http_request)
|
|
device_id = device_info["device_id"]
|
|
|
|
# Verify credentials first
|
|
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(request.login, request.password)
|
|
|
|
if not is_valid or not user_data:
|
|
logger.warning(f"⚠️ Failed login attempt for: {request.login}")
|
|
return JSONResponse(
|
|
status_code=401, content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
|
|
)
|
|
|
|
# Check if device is trusted
|
|
is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id)
|
|
|
|
if not is_trusted:
|
|
# New device detected - automatically send MFA email
|
|
logger.info(f"🔐 New device detected for {request.login}, sending MFA email")
|
|
|
|
# Generate MFA code
|
|
mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code
|
|
|
|
# Get user name and details for email
|
|
user_name = "User"
|
|
email = None
|
|
if user_data["type"] == "candidate":
|
|
candidate_data = await database.get_candidate(user_data["id"])
|
|
if candidate_data:
|
|
user_name = candidate_data.get("full_name", "User")
|
|
email = candidate_data.get("email", None)
|
|
elif user_data["type"] == "employer":
|
|
employer_data = await database.get_employer(user_data["id"])
|
|
if employer_data:
|
|
user_name = employer_data.get("company_name", "User")
|
|
email = employer_data.get("email", None)
|
|
|
|
if not email:
|
|
return JSONResponse(
|
|
status_code=400, content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA")
|
|
)
|
|
|
|
# Store MFA code
|
|
await database.store_mfa_code(email, mfa_code, device_id)
|
|
|
|
# Ensure email is lowercase
|
|
# Get IP address for security info
|
|
ip_address = http_request.client.host if http_request.client else "Unknown"
|
|
|
|
# Send MFA code via email in background
|
|
background_tasks.add_task(
|
|
email_service.send_mfa_email, email, mfa_code, device_info["device_name"], user_name, ip_address
|
|
)
|
|
|
|
# Log security event
|
|
await database.log_security_event(
|
|
user_data["id"],
|
|
"mfa_request",
|
|
{
|
|
"device_id": device_id,
|
|
"device_name": device_info["device_name"],
|
|
"ip_address": ip_address,
|
|
"user_agent": device_info.get("user_agent", ""),
|
|
"auto_sent": True,
|
|
},
|
|
)
|
|
|
|
logger.info(f"🔐 MFA code automatically sent to {request.login} for device {device_info['device_name']}")
|
|
|
|
mfa_response = MFARequestResponse(
|
|
mfa_required=True,
|
|
mfa_data=MFAData(
|
|
message="New device detected. We've sent a security code to your email address.",
|
|
email=email,
|
|
device_id=device_id,
|
|
device_name=device_info["device_name"],
|
|
code_sent=mfa_code,
|
|
),
|
|
)
|
|
return create_success_response(mfa_response.model_dump(by_alias=True))
|
|
|
|
# Trusted device - proceed with normal login
|
|
await device_manager.update_device_last_used(user_data["id"], device_id)
|
|
await auth_manager.update_last_login(user_data["id"])
|
|
|
|
# Create tokens
|
|
access_token = create_access_token(data={"sub": user_data["id"]})
|
|
refresh_token = create_access_token(
|
|
data={"sub": user_data["id"], "type": "refresh"},
|
|
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS),
|
|
)
|
|
|
|
# Get user object
|
|
user = None
|
|
if user_data["type"] == "candidate":
|
|
candidate_data = await database.get_candidate(user_data["id"])
|
|
if candidate_data:
|
|
user = Candidate.model_validate(candidate_data)
|
|
elif user_data["type"] == "employer":
|
|
employer_data = await database.get_employer(user_data["id"])
|
|
if employer_data:
|
|
user = Employer.model_validate(employer_data)
|
|
|
|
if not user:
|
|
return JSONResponse(
|
|
status_code=404, content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
|
)
|
|
|
|
# Log successful login from trusted device
|
|
await database.log_security_event(
|
|
user_data["id"],
|
|
"login",
|
|
{
|
|
"device_id": device_id,
|
|
"device_name": device_info["device_name"],
|
|
"ip_address": http_request.client.host if http_request.client else "Unknown",
|
|
"trusted_device": True,
|
|
},
|
|
)
|
|
|
|
# Create response
|
|
auth_response = AuthResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=user,
|
|
expires_at=int(
|
|
(datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()
|
|
),
|
|
)
|
|
|
|
logger.info(f"🔑 User {request.login} logged in successfully from trusted device")
|
|
|
|
return create_success_response(auth_response.model_dump(by_alias=True))
|
|
|
|
except Exception as e:
|
|
logger.error(backstory_traceback.format_exc())
|
|
logger.error(f"❌ Login error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
|
)
|
|
|
|
|
|
@router.post("/mfa/verify")
|
|
async def verify_mfa(request: MFAVerifyRequest, http_request: Request, database: RedisDatabase = Depends(get_database)):
|
|
"""Verify MFA code and complete login with error handling"""
|
|
try:
|
|
# Get MFA data
|
|
mfa_data = await database.get_mfa_code(request.email, request.device_id)
|
|
|
|
if not mfa_data:
|
|
logger.warning(f"⚠️ No MFA session found for {request.email} on device {request.device_id}")
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content=create_error_response(
|
|
"NO_MFA_SESSION", "No active MFA session found. Please try logging in again."
|
|
),
|
|
)
|
|
|
|
if mfa_data.get("verified"):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content=create_error_response(
|
|
"ALREADY_VERIFIED", "This MFA code has already been used. Please login again."
|
|
),
|
|
)
|
|
|
|
# Check expiration
|
|
expires_at = datetime.fromisoformat(mfa_data["expires_at"])
|
|
if datetime.now(timezone.utc) > expires_at:
|
|
# Clean up expired MFA session
|
|
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content=create_error_response("MFA_EXPIRED", "MFA code has expired. Please try logging in again."),
|
|
)
|
|
|
|
# Check attempts
|
|
current_attempts = mfa_data.get("attempts", 0)
|
|
if current_attempts >= 5:
|
|
# Clean up after too many attempts
|
|
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content=create_error_response(
|
|
"TOO_MANY_ATTEMPTS", "Too many incorrect attempts. Please try logging in again."
|
|
),
|
|
)
|
|
|
|
# Verify code
|
|
if mfa_data["code"] != request.code:
|
|
await database.increment_mfa_attempts(request.email, request.device_id)
|
|
remaining_attempts = 5 - (current_attempts + 1)
|
|
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content=create_error_response(
|
|
"INVALID_CODE", f"Invalid MFA code. {remaining_attempts} attempts remaining."
|
|
),
|
|
)
|
|
|
|
# Mark as verified
|
|
await database.mark_mfa_verified(request.email, request.device_id)
|
|
|
|
# Get user data
|
|
user_data = await database.get_user(request.email)
|
|
if not user_data:
|
|
return JSONResponse(status_code=404, content=create_error_response("USER_NOT_FOUND", "User not found"))
|
|
|
|
# Add device to trusted devices if requested
|
|
if request.remember_device:
|
|
device_manager = DeviceManager(database)
|
|
device_info = device_manager.parse_device_info(http_request)
|
|
await device_manager.add_trusted_device(user_data["id"], request.device_id, device_info)
|
|
logger.info(f"🔒 Device {request.device_id} added to trusted devices for user {user_data['id']}")
|
|
|
|
# Update last login
|
|
auth_manager = AuthenticationManager(database)
|
|
await auth_manager.update_last_login(user_data["id"])
|
|
|
|
# Create tokens
|
|
access_token = create_access_token(data={"sub": user_data["id"]})
|
|
refresh_token = create_access_token(
|
|
data={"sub": user_data["id"], "type": "refresh"},
|
|
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS),
|
|
)
|
|
|
|
# Get user object
|
|
user = None
|
|
if user_data["type"] == "candidate":
|
|
candidate_data = await database.get_candidate(user_data["id"])
|
|
if candidate_data:
|
|
user = Candidate.model_validate(candidate_data)
|
|
elif user_data["type"] == "employer":
|
|
employer_data = await database.get_employer(user_data["id"])
|
|
if employer_data:
|
|
user = Employer.model_validate(employer_data)
|
|
|
|
if not user:
|
|
return JSONResponse(
|
|
status_code=404, content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
|
)
|
|
|
|
# Log successful MFA verification and login
|
|
await database.log_security_event(
|
|
user_data["id"],
|
|
"mfa_verify_success",
|
|
{
|
|
"device_id": request.device_id,
|
|
"ip_address": http_request.client.host if http_request.client else "Unknown",
|
|
"device_remembered": request.remember_device,
|
|
"attempts_used": current_attempts + 1,
|
|
},
|
|
)
|
|
|
|
await database.log_security_event(
|
|
user_data["id"],
|
|
"login",
|
|
{
|
|
"device_id": request.device_id,
|
|
"ip_address": http_request.client.host if http_request.client else "Unknown",
|
|
"mfa_verified": True,
|
|
"new_device": True,
|
|
},
|
|
)
|
|
|
|
# Clean up MFA session
|
|
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
|
|
|
|
# Create response
|
|
auth_response = AuthResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=user,
|
|
expires_at=int(
|
|
(datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()
|
|
),
|
|
)
|
|
|
|
logger.info(f"✅ MFA verified and login completed for {request.email}")
|
|
|
|
return create_success_response(auth_response.model_dump(by_alias=True))
|
|
|
|
except Exception as e:
|
|
logger.error(backstory_traceback.format_exc())
|
|
logger.error(f"❌ MFA verification error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA")
|
|
)
|
|
|
|
|
|
@router.post("/password-reset/request")
|
|
async def request_password_reset(request: PasswordResetRequest, database: RedisDatabase = Depends(get_database)):
|
|
"""Request password reset"""
|
|
try:
|
|
# Check if user exists
|
|
user_data = await database.get_user(request.email)
|
|
if not user_data:
|
|
# Don't reveal whether email exists or not
|
|
return create_success_response({"message": "If the email exists, a reset link will be sent"})
|
|
|
|
auth_manager = AuthenticationManager(database)
|
|
|
|
# Generate reset token
|
|
reset_token = auth_manager.password_security.generate_secure_token()
|
|
reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry
|
|
|
|
# Update authentication record
|
|
auth_record = await database.get_authentication(user_data["id"])
|
|
if auth_record:
|
|
auth_record["resetPasswordToken"] = reset_token
|
|
auth_record["resetPasswordExpiry"] = reset_expiry.isoformat()
|
|
await database.set_authentication(user_data["id"], auth_record)
|
|
|
|
# TODO: Send email with reset token
|
|
logger.info(f"🔐 Password reset requested for: {request.email}")
|
|
|
|
return create_success_response({"message": "If the email exists, a reset link will be sent"})
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Password reset request error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("RESET_ERROR", "An error occurred processing the request")
|
|
)
|
|
|
|
|
|
@router.post("/password-reset/confirm")
|
|
async def confirm_password_reset(request: PasswordResetConfirm, database: RedisDatabase = Depends(get_database)):
|
|
"""Confirm password reset with token"""
|
|
try:
|
|
# Find user by reset token
|
|
# This would require a way to lookup by token - you might need to modify your database structure
|
|
|
|
# For now, this is a placeholder - you'd need to implement token lookup
|
|
# in your Redis database structure
|
|
|
|
return create_success_response({"message": "Password reset successfully"})
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Password reset confirm error: {e}")
|
|
return JSONResponse(
|
|
status_code=500, content=create_error_response("RESET_ERROR", "An error occurred resetting the password")
|
|
)
|