from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List from passlib.context import CryptContext import ldap3 import json import os from .. import models, schemas, database router = APIRouter(prefix="/users", tags=["users"]) pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def get_ldap_config(): config_path = os.path.join(database.DATA_DIR, "ldap_config.json") if os.path.exists(config_path): with open(config_path, "r") as f: return json.load(f) return {"ldap_enabled": False} def authenticate_ldap(username, password): config = get_ldap_config() if not config.get("ldap_enabled"): return None try: server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL) user_dn = config["user_template"].format(username=username) print(f"DEBUG LDAP: Attempting bind for DN: {user_dn}") conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True) print(f"DEBUG LDAP: Bind successful for {user_dn}") # Search for the user to get their CANONICAL DN base_dn = config.get("base_dn", "dc=example,dc=org") search_filter = f"(|(cn={username})(uid={username}))" conn.search(base_dn, search_filter, attributes=['cn', 'uid']) if not conn.entries: print(f"DEBUG LDAP: User {username} not found in search after bind.") return None real_user_dn = conn.entries[0].entry_dn print(f"DEBUG LDAP: Canonical DN found: {real_user_dn}") # Check roles based on group membership assigned_role = None # New multi-group mapping support role_mappings = config.get("role_mappings", []) if not role_mappings and config.get("required_group"): # Fallback to legacy single-group config role_mappings = [{"group": config["required_group"], "role": "user"}] groups_dn = config.get("groups_dn", "ou=groups") # Iterate through mappings to find the highest role # Priority: admin > user potential_roles = [] for mapping in role_mappings: group_name = mapping["group"] target_role = mapping["role"] # Construct group DN if it's just a common name, else use as is if "=" not in group_name: full_group_dn = f"cn={group_name},{groups_dn},{base_dn}" else: full_group_dn = group_name print(f"DEBUG LDAP: Checking membership in group: {full_group_dn}") conn.search(full_group_dn, '(objectClass=*)', attributes=['member']) if conn.entries: members = conn.entries[0].member.values if real_user_dn in members or user_dn in members or \ any(m.lower().replace(" ", "") == real_user_dn.lower().replace(" ", "") for m in members): print(f"DEBUG LDAP: User is in group {group_name}, assigning role: {target_role}") potential_roles.append(target_role) if "admin" in potential_roles: assigned_role = "admin" elif "user" in potential_roles: assigned_role = "user" elif potential_roles: assigned_role = potential_roles[0] return assigned_role except Exception as e: print(f"DEBUG LDAP: Auth Error: {str(e)}") return None pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def get_db(): db = database.SessionLocal() try: yield db finally: db.close() def get_password_hash(password): return pwd_context.hash(password) def verify_password(plain_password, hashed_password): if not hashed_password: return False return pwd_context.verify(plain_password, hashed_password) @router.get("/", response_model=List[schemas.User]) def get_users(db: Session = Depends(get_db)): users = db.query(models.User).all() # Auto-seed if empty if not users: new_user = models.User( username="Admin", role="admin", origin="local", hashed_password=get_password_hash("admin") # Default password ) db.add(new_user) db.commit() db.refresh(new_user) return [new_user] return users @router.post("/", response_model=schemas.User) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): existing = db.query(models.User).filter(models.User.username == user.username).first() if existing: raise HTTPException(status_code=400, detail="Username already exists") hashed = get_password_hash(user.password) if user.password else None new_user = models.User(username=user.username, role=user.role, origin="local", hashed_password=hashed) db.add(new_user) db.commit() db.refresh(new_user) return new_user @router.post("/login") def login(form_data: schemas.UserLogin, db: Session = Depends(get_db)): user = db.query(models.User).filter(models.User.username == form_data.username).first() # Try local authentication authenticated = False if user and user.hashed_password: if verify_password(form_data.password, user.hashed_password): authenticated = True elif user and not user.hashed_password: # Legacy user without password - allow skip for now or force set authenticated = True # If local failed, try LDAP if not authenticated: ldap_role = authenticate_ldap(form_data.username, form_data.password) if ldap_role: authenticated = True # Cache hash for offline support new_hash = get_password_hash(form_data.password) # If user doesn't exist locally, create a stub for role management if not user: user = models.User( username=form_data.username, role=ldap_role, origin="ldap", hashed_password=new_hash ) db.add(user) db.commit() db.refresh(user) else: # Update role if it changed in LDAP and refresh cached hash user.role = ldap_role user.hashed_password = new_hash db.commit() db.refresh(user) else: raise HTTPException(status_code=400, detail="Invalid username or password, or insufficient permissions") return user @router.put("/{user_id}", response_model=schemas.User) def update_user(user_id: int, user_update: schemas.UserUpdate, db: Session = Depends(get_db)): db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") if user_update.username and db_user.username == "Admin" and user_update.username != "Admin": raise HTTPException(status_code=400, detail="Cannot change Admin username") if user_update.username: # Check if username already taken by another user existing = db.query(models.User).filter(models.User.username == user_update.username, models.User.id != user_id).first() if existing: raise HTTPException(status_code=400, detail="Username already exists") db_user.username = user_update.username if user_update.password: db_user.hashed_password = get_password_hash(user_update.password) if user_update.role: db_user.role = user_update.role db.commit() db.refresh(db_user) return db_user @router.get("/ldap-config") def get_ldap_settings(): return get_ldap_config() @router.post("/ldap-config") def update_ldap_settings(config: dict): config_path = os.path.join(database.DATA_DIR, "ldap_config.json") with open(config_path, "w") as f: json.dump(config, f) return {"message": "Config saved"} @router.post("/test-ldap") def test_ldap_connection(config: dict): import socket try: # Extract host and port uri = config["server_uri"] host = uri.replace("ldap://", "").replace("ldaps://", "") port = 389 if ":" in host: host, port_str = host.split(":") port = int(port_str) elif "ldaps://" in uri: port = 636 elif uri.endswith(":3890"): # Special case for LLDAP port = 3890 # Try raw socket first print(f"DEBUG: Probing raw socket {host}:{port}") s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) result = s.connect_ex((host, port)) s.close() if result == 0: # Socket is open! Now try LDAP library try: server = ldap3.Server(config["server_uri"], connect_timeout=5) conn = ldap3.Connection(server, auto_bind=False) if conn.open(): return {"status": "success", "message": "Connection Successful"} return {"status": "success", "message": "Server reachable (Socket open, but LDAP probe failed)"} except: return {"status": "success", "message": "Server reachable (Socket open)"} else: # Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic import subprocess try: # We just try to reach the server with a 2s timeout cmd = ["ldapsearch", "-h", host, "-p", str(port), "-x", "-s", "base", "-b", "", "namingContexts"] proc = subprocess.run(cmd, capture_output=True, timeout=2) if proc.returncode == 0 or b"namingContexts" in proc.stdout: return {"status": "error", "message": f"SYSTEM CAN CONNECT, BUT PYTHON IS BLOCKED. Check Mac Firewall settings for Python."} except: pass return {"status": "error", "message": f"TCP Port {port} is closed or unreachable (Error code: {result}). Check firewall on {host}."} except Exception as e: return {"status": "error", "message": f"Network Error: {str(e)}"} @router.delete("/{user_id}") def delete_user(user_id: int, db: Session = Depends(get_db)): user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if user.username == "Admin": raise HTTPException(status_code=400, detail="Cannot delete default Admin") db.delete(user) db.commit() return {"message": "User deleted"}