import os import json import logging import random import string import psutil from datetime import datetime, timedelta, timezone import requests import functools def to_aware(dt: datetime) -> datetime: if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt import redis from constants import KEYS_FILE, MASTER_KEY, OUTPUT_DIR @functools.lru_cache(maxsize=1024) def get_geo_location(ip: str) -> str: if ip in ("127.0.0.1", "::1", "localhost", "Unknown"): return "" try: res = requests.get(f"http://ip-api.com/json/{ip}?fields=status,country,city", timeout=2) if res.ok: data = res.json() if data.get("status") == "success": city = data.get("city", "") country = data.get("country", "") if city and country: return f" ({city}, {country})" elif country: return f" ({country})" except Exception: pass return "" import logging.handlers # Set up access logger access_logger = logging.getLogger("wealth_access") access_logger.setLevel(logging.INFO) # Rotate every 30 days, keep 1 backup handler = logging.handlers.TimedRotatingFileHandler(os.path.join(OUTPUT_DIR, "access.log"), when='D', interval=30, backupCount=1, encoding='utf-8') formatter = logging.Formatter('%(asctime)s - %(message)s') handler.setFormatter(formatter) access_logger.addHandler(handler) # Initialize Redis client if REDIS_URL is provided redis_url = os.getenv("REDIS_URL") redis_client = None if redis_url: try: redis_client = redis.from_url(redis_url, decode_responses=True) access_logger.info("Successfully connected to Redis for access key storage.") except Exception as e: access_logger.error(f"Failed to connect to Redis: {e}") def load_keys(): if redis_client: try: data = redis_client.get("access_keys") if data: return json.loads(data) except Exception as e: access_logger.error(f"Redis read error: {e}") # Fallback to local JSON if not os.path.exists(KEYS_FILE): return {"otk": {}} try: with open(KEYS_FILE, 'r') as f: return json.load(f) except: return {"otk": {}} def save_keys(data): if redis_client: try: redis_client.set("access_keys", json.dumps(data)) return except Exception as e: access_logger.error(f"Redis write error: {e}") # Fallback to local JSON with open(KEYS_FILE, 'w') as f: json.dump(data, f, indent=4) def is_master_key(key: str) -> bool: if not key: return False return key.strip() == MASTER_KEY def validate_key(key: str, username: str = None, ip: str = "Unknown", silent: bool = False) -> bool: if not key: return False key = key.strip() if username: username = username.strip() data = load_keys() if "rate_limits" not in data: data["rate_limits"] = {} now = datetime.now(timezone.utc) if ip != "Unknown" and ip != "": ip_state = data["rate_limits"].get(ip, {"failed": 0, "cooldown_until": None, "cooldown_count": 0, "flagged": False}) if ip_state.get("flagged", False): raise ValueError("IP permanently flagged due to excessive failed attempts.") cooldown_until = ip_state.get("cooldown_until") if cooldown_until: cooldown_dt = to_aware(datetime.fromisoformat(cooldown_until)) if now < cooldown_dt: raise ValueError("Too many failed attempts. Please try again in 30 minutes.") def handle_failure(): if ip != "Unknown" and ip != "": ip_state["failed"] = ip_state.get("failed", 0) + 1 if ip_state["failed"] >= 5: ip_state["failed"] = 0 ip_state["cooldown_count"] = ip_state.get("cooldown_count", 0) + 1 ip_state["cooldown_until"] = (now + timedelta(minutes=30)).isoformat() if ip_state["cooldown_count"] >= 3: ip_state["flagged"] = True try: requests.post( "https://formsubmit.co/ajax/mkoustis05@gmail.com", json={ "email": "security@wealthengine.local", "_subject": f"SECURITY ALERT: IP Flagged ({ip})", "message": f"IP Address {ip} has failed to authenticate 15 times and has been permanently flagged." }, timeout=5 ) except Exception as e: access_logger.error(f"Failed to send security alert: {e}") data["rate_limits"][ip] = ip_state save_keys(data) if not silent: import threading def _log_fail(): geo = get_geo_location(ip) msg = f"⛔ **DENIED:** Invalid, expired, or revoked key '{key}' attempted by IP: `{ip}`{geo}" access_logger.warning(msg) threading.Thread(target=_log_fail, daemon=True).start() return False def handle_success(template: str): if ip != "Unknown" and ip != "": if ip in data.get("rate_limits", {}): del data["rate_limits"][ip] save_keys(data) if not silent and template: import threading def _log_succ(): geo = get_geo_location(ip) msg = template.format(ip=ip, geo=geo, key=key) access_logger.info(msg) threading.Thread(target=_log_succ, daemon=True).start() return True # Check Master Key if key == MASTER_KEY: return handle_success("✅ **MASTER_KEY** used by IP: `{ip}`{geo}") # Check Time-Based Keys if key in data.get("otk", {}) and data["otk"][key].get("revoked", False) == False: key_data = data["otk"][key] # Validate that the key belongs to the requested username if username and key_data.get("username") != username: return handle_failure() created_at = to_aware(datetime.fromisoformat(key_data["created_at"])) if "expires_at" in key_data: expires_at = to_aware(datetime.fromisoformat(key_data["expires_at"])) else: expires_at = created_at + timedelta(hours=1) if now <= expires_at: is_first_use = "used_at" not in key_data if is_first_use or key_data.get("used_by_ip") != ip: key_data["used_at"] = datetime.now().isoformat() key_data["used_by_ip"] = ip save_keys(data) return handle_success("✅ **Time-Key** '{key}' used by IP: `{ip}`{geo}") return handle_success("") return handle_failure() def cleanup_expired_keys(): try: data = load_keys() if "otk" not in data: return now = datetime.now(timezone.utc) cutoff = now - timedelta(days=30) keys_to_delete = [] for key, info in data["otk"].items(): expires_str = info.get("expires_at") if expires_str: expires_dt = to_aware(datetime.fromisoformat(expires_str)) if expires_dt < cutoff: keys_to_delete.append(key) elif info.get("revoked", False): created_str = info.get("created_at") if created_str: created_dt = to_aware(datetime.fromisoformat(created_str)) if created_dt < cutoff: keys_to_delete.append(key) if keys_to_delete: for k in keys_to_delete: del data["otk"][k] save_keys(data) access_logger.info(f"Cleaned up {len(keys_to_delete)} old/expired keys.") except Exception as e: access_logger.error(f"Failed to cleanup keys: {e}") def generate_otk(admin_key: str, username: str = "anonymous", new_key: str = None, hours: int = 1) -> str: if admin_key != MASTER_KEY: return None cleanup_expired_keys() if not new_key: new_key = "OTK-" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) data = load_keys() data["otk"][new_key] = { "username": username, "created_at": datetime.now(timezone.utc).isoformat(), "expires_at": (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat(), "revoked": False } save_keys(data) access_logger.info(f"ADMIN: New OTK generated: '{new_key}', valid for {hours} hours.") return new_key def revoke_otk(admin_key: str, target_key: str) -> bool: if admin_key != MASTER_KEY: return False data = load_keys() if target_key in data["otk"]: data["otk"][target_key]["revoked"] = True save_keys(data) access_logger.info(f"ADMIN: OTK revoked: '{target_key}'") return True return False def get_all_keys(admin_key: str) -> dict: if admin_key != MASTER_KEY: return {} cleanup_expired_keys() return load_keys().get("otk", {})