Files
tfm_ainventory/backend/routers/users.py
Daniel Bedeleanu 247ea45408 security: audit complet + patch vulnerabilitati critice v1.3.5
Audit de securitate executat pe Backend (FastAPI) si Frontend (Next.js/Dexie).
12 vulnerabilitati identificate (4 CRITICE, 4 HIGH, 3 MEDIUM, 1 LOW).

Patch-uri aplicate direct:
- [C-02] Eliminat bypass autentificare pentru useri fara parola (users.py)
- [C-03] Parola default Admin inlocuita cu secrets.token_urlsafe(16) (users.py)
- [H-01] LDAP injection fix: escape_filter_chars pe username (users.py)
- [H-03] Validare MIME + limita 10MB pe /items/extract-label (items.py)
- [M-01] CORS fix: allow_origins din env ALLOWED_ORIGINS, nu wildcard (main.py)
- [M-03] Toate print() LDAP inlocuite cu log.debug() (users.py)

Raport complet: dev_docs/SECURITY_REPORT.md
Actiuni arhitecturale ramase (JWT enforcement, rate limiting): SESSION_STATE.md

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:20:05 +03:00

287 lines
11 KiB
Python

import secrets
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from passlib.context import CryptContext
import ldap3
from ldap3.utils.conv import escape_filter_chars
import json
import os
from .. import models, schemas, database
from ..logger import log
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)
log.debug(f"LDAP: Attempting bind for DN: {user_dn}")
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
log.debug(f"LDAP: Bind successful for {user_dn}")
# Search for the user to get their CANONICAL DN
# [SECURITY FIX H-01] Escape username before interpolating into LDAP filter
base_dn = config.get("base_dn", "dc=example,dc=org")
safe_username = escape_filter_chars(username)
search_filter = f"(|(cn={safe_username})(uid={safe_username}))"
conn.search(base_dn, search_filter, attributes=['cn', 'uid'])
if not conn.entries:
log.debug(f"LDAP: User not found in search after bind.")
return None
real_user_dn = conn.entries[0].entry_dn
log.debug(f"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
log.debug(f"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):
log.debug(f"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:
log.debug(f"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:
# [SECURITY FIX C-03] Generare parolă aleatoare în loc de "admin" hardcodat
initial_password = secrets.token_urlsafe(16)
new_user = models.User(
username="Admin",
role="admin",
origin="local",
hashed_password=get_password_hash(initial_password)
)
db.add(new_user)
db.commit()
db.refresh(new_user)
log.warning(f"[SECURITY] Admin initial seeded. Temporary password: {initial_password} — SCHIMBAȚI IMEDIAT!")
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:
# [SECURITY FIX C-02] Bypass-ul pentru utilizatori fără parolă a fost eliminat.
# Utilizatorii LDAP trebuie să se autentifice prin fluxul LDAP de mai jos.
pass
# 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
log.debug(f"LDAP test: 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"}