""" Authentication router - Simplified for MVP """ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, status, Request from pydantic import BaseModel from typing import Optional import structlog import jwt import json from database.postgresql import database from config import settings from services.activity_service import activity_service, ActivityService logger = structlog.get_logger() router = APIRouter() # JWT secret JWT_SECRET = getattr(settings, 'SECRET_KEY', 'super-secret-key-change-me-in-production') # Pydantic schemas class LoginRequest(BaseModel): username: str password: str client_ip: str | None = None # Optional: sent from frontend two_factor_code: str | None = None # Optional: 2FA code class LoginResponse(BaseModel): access_token: str token_type: str expires_in: int user: dict requires_2fa: bool = False two_fa_sent: bool = False async def check_2fa_required(user_id: int) -> tuple[bool, bool]: """ Check if 2FA is required for a user. Returns (is_required, user_has_2fa_enabled) """ try: # Check global 2FA settings settings_query = """ SELECT value FROM system_settings WHERE key = 'security_policies' """ settings_row = await database.fetch_one(settings_query) global_2fa_enabled = False global_2fa_required = False if settings_row: settings_value = settings_row['value'] if isinstance(settings_value, str): settings_value = json.loads(settings_value) global_2fa_enabled = settings_value.get('two_factor_enabled', False) global_2fa_required = settings_value.get('two_factor_required_for_all', False) # If 2FA is not enabled globally, skip if not global_2fa_enabled: return False, False # Check user's 2FA setting user_query = "SELECT two_factor_enabled FROM users WHERE id = :user_id" user_row = await database.fetch_one(user_query, {"user_id": user_id}) user_2fa_enabled = False if user_row and user_row['two_factor_enabled']: user_2fa_enabled = True # 2FA is required if: globally required OR user has it enabled is_required = global_2fa_required or user_2fa_enabled return is_required, user_2fa_enabled except Exception as e: logger.error("Error checking 2FA requirement", error=str(e)) return False, False async def send_2fa_code_internal(user_id: int, email: str, username: str) -> bool: """Send 2FA code to user's email""" try: import secrets import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart # Generate 6-digit code code = ''.join([str(secrets.randbelow(10)) for _ in range(6)]) # Get expiry from settings settings_query = """ SELECT value FROM system_settings WHERE key = 'security_policies' """ settings_row = await database.fetch_one(settings_query) expiry_minutes = 5 if settings_row: settings_value = settings_row['value'] if isinstance(settings_value, str): settings_value = json.loads(settings_value) expiry_minutes = settings_value.get('two_factor_code_expiry_minutes', 5) expires_at = datetime.utcnow() + timedelta(minutes=expiry_minutes) # Store code store_query = """ INSERT INTO two_factor_codes (user_id, code, expires_at, created_at) VALUES (:user_id, :code, :expires_at, NOW()) ON CONFLICT (user_id) DO UPDATE SET code = :code, expires_at = :expires_at, created_at = NOW(), attempts = 0 """ await database.execute(store_query, { "user_id": user_id, "code": code, "expires_at": expires_at }) # Get SMTP settings smtp_query = """ SELECT value FROM system_settings WHERE key = 'smtp_settings' """ smtp_row = await database.fetch_one(smtp_query) if not smtp_row: logger.warning("SMTP not configured for 2FA") return False smtp_settings = smtp_row['value'] if isinstance(smtp_settings, str): smtp_settings = json.loads(smtp_settings) if not smtp_settings.get('enabled'): logger.warning("Email notifications disabled") return False # Create email msg = MIMEMultipart('alternative') msg['From'] = f"{smtp_settings.get('from_name', 'Flowfish')} <{smtp_settings.get('from_email')}>" msg['To'] = email msg['Subject'] = f"Flowfish Login Verification: {code}" html_body = f"""

Hello {username},

Your login verification code is:

{code}
⏱️ This code expires in {expiry_minutes} minutes

If you didn't attempt to log in, please contact your administrator immediately.

""" msg.attach(MIMEText(html_body, 'html')) # Send email if smtp_settings.get('use_ssl'): server = smtplib.SMTP_SSL(smtp_settings['host'], smtp_settings['port']) else: server = smtplib.SMTP(smtp_settings['host'], smtp_settings['port']) if smtp_settings.get('use_tls'): server.starttls() if smtp_settings.get('username') and smtp_settings.get('password'): server.login(smtp_settings['username'], smtp_settings['password']) server.send_message(msg) server.quit() logger.info("2FA code sent for login", user_id=user_id) return True except Exception as e: logger.error("Failed to send 2FA code", error=str(e)) return False async def verify_2fa_code_internal(user_id: int, code: str) -> tuple[bool, str]: """Verify 2FA code. Returns (success, error_message)""" try: # Get stored code query = """ SELECT code, expires_at, attempts FROM two_factor_codes WHERE user_id = :user_id """ row = await database.fetch_one(query, {"user_id": user_id}) if not row: return False, "No verification code found. Please request a new code." # Check expiry expires_at = row['expires_at'] if expires_at.tzinfo: expires_at = expires_at.replace(tzinfo=None) if datetime.utcnow() > expires_at: return False, "Verification code expired. Please request a new code." # Check max attempts max_attempts = 3 if row['attempts'] >= max_attempts: return False, "Too many failed attempts. Please request a new code." # Verify code if row['code'] != code: # Increment attempts await database.execute( "UPDATE two_factor_codes SET attempts = attempts + 1 WHERE user_id = :user_id", {"user_id": user_id} ) remaining = max_attempts - row['attempts'] - 1 return False, f"Invalid verification code. {remaining} attempts remaining." # Code is valid - delete it await database.execute( "DELETE FROM two_factor_codes WHERE user_id = :user_id", {"user_id": user_id} ) return True, "" except Exception as e: logger.error("2FA verification error", error=str(e)) return False, "Verification failed. Please try again." @router.post("/auth/login", response_model=LoginResponse) async def login(login_data: LoginRequest, request: Request): """User login with username and password, with optional 2FA""" try: # Get client IP - priority order: # 1. Frontend-provided IP (most reliable in complex proxy setups) # 2. X-Real-IP header # 3. X-Forwarded-For header (first IP) # 4. Direct connection IP client_ip = "0.0.0.0" if login_data.client_ip: client_ip = login_data.client_ip elif request.headers.get("X-Real-IP"): client_ip = request.headers.get("X-Real-IP") elif request.headers.get("X-Forwarded-For"): client_ip = request.headers.get("X-Forwarded-For").split(",")[0].strip() elif request.client: client_ip = request.client.host logger.info("Login attempt", username=login_data.username, ip=client_ip) # Query user from database with 2FA status query = """ SELECT id, username, email, is_active, two_factor_enabled FROM users WHERE username = :username AND is_active = true """ user = await database.fetch_one(query, {"username": login_data.username}) if not user: logger.warning("User not found", username=login_data.username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password" ) # Verify password using bcrypt hash from database password_valid = False try: from passlib.context import CryptContext pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_query = "SELECT password_hash FROM users WHERE id = :user_id" pwd_row = await database.fetch_one(pwd_query, {"user_id": user["id"]}) if pwd_row and pwd_row['password_hash']: # Truncate password to 72 bytes (bcrypt limit) password_truncated = login_data.password[:72] password_valid = pwd_context.verify(password_truncated, pwd_row['password_hash']) except Exception as e: logger.warning("Password verification error", error=str(e)) if not password_valid: logger.warning("Invalid password", username=login_data.username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password" ) # Check if 2FA is required tfa_required, user_has_2fa = await check_2fa_required(user["id"]) if tfa_required: # If 2FA code is provided, verify it if login_data.two_factor_code: success, error_msg = await verify_2fa_code_internal(user["id"], login_data.two_factor_code) if not success: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=error_msg ) # 2FA verified, continue with login else: # No 2FA code provided, send one and return if user["email"]: sent = await send_2fa_code_internal(user["id"], user["email"], user["username"]) if sent: return LoginResponse( access_token="", token_type="bearer", expires_in=0, user={"id": user["id"], "username": user["username"]}, requires_2fa=True, two_fa_sent=True ) else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send verification code. Please contact administrator." ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is required but no email is configured for this user." ) # Update last_login_at and last_login_ip await database.execute( "UPDATE users SET last_login_at = NOW(), last_login_ip = :ip WHERE id = :user_id", {"user_id": user["id"], "ip": client_ip} ) # Get user roles roles_query = """ SELECT r.name FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = :user_id """ roles_result = await database.fetch_all(roles_query, {"user_id": user["id"]}) roles = [r["name"] for r in roles_result] if roles_result else ["Super Admin"] # Create JWT token token_payload = { "user_id": user["id"], "username": user["username"], "roles": roles, "type": "access", # Required for token verification "exp": datetime.utcnow() + timedelta(hours=8), "iat": datetime.utcnow() } access_token = jwt.encode(token_payload, JWT_SECRET, algorithm="HS256") logger.info("Login successful", username=user["username"]) # Log activity await activity_service.log_activity( user_id=user["id"], username=user["username"], action=ActivityService.ACTION_LOGIN, resource_type=ActivityService.RESOURCE_SESSION, resource_id=str(user["id"]), resource_name=user["username"], ip_address=client_ip, user_agent=request.headers.get("User-Agent") ) return LoginResponse( access_token=access_token, token_type="bearer", expires_in=28800, # 8 hours user={ "id": user["id"], "username": user["username"], "email": user["email"], "roles": roles }, requires_2fa=False, two_fa_sent=False ) except HTTPException: raise except Exception as e: logger.error("Login error", error=str(e), exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Authentication service error: {str(e)}" ) @router.get("/auth/me") async def get_current_user_info(): """Get current user information (requires authentication)""" # TODO: Implement JWT verification middleware return { "message": "Authentication middleware not yet implemented", "status": "coming_soon" }