2025-06-18 13:53:07 -07:00

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")
)