From e34a668ddc1beb33695c69ad4a1f4c116980286a Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 27 Apr 2026 19:08:08 +0200 Subject: [PATCH] feat(auth): add LDAP directory authentication Introduce AUTH_METHOD values ldap and all, with LDAP_* environment settings, ldap3-based LDAPService (search, optional groupOfNames checks, user bind, DB sync), and users.auth_provider (local|oidc|ldap) via migration 153_add_user_auth_provider. Login supports LDAP-only and combined all (local then LDAP where appropriate); OIDC callback sets auth_provider. Forgot/reset/change password flows skip LDAP-managed accounts. Admin System Settings gains a read-only LDAP summary and POST /admin/ldap/test. Production env validation requires core LDAP variables when LDAP is enabled; OIDC registration and docs recognize all. Documentation: new docs/admin/configuration/LDAP_SETUP.md; updates to OIDC_SETUP, GETTING_STARTED, Docker guides, Render deploy notes, docs README, and CHANGELOG. Tests: tests/test_ldap_auth.py; test_oidc_logout allows auth_method all. --- CHANGELOG.md | 3 + app/__init__.py | 4 +- app/config.py | 34 +- app/models/user.py | 2 + app/routes/admin.py | 71 +++- app/routes/auth.py | 244 +++++++---- app/routes/kiosk.py | 11 +- app/services/ldap_service.py | 390 ++++++++++++++++++ app/templates/admin/settings.html | 83 ++++ app/templates/auth/login.html | 8 +- app/templates/integrations/list.html | 2 +- app/utils/auth_method.py | 42 ++ app/utils/env_validation.py | 17 +- docs/GETTING_STARTED.md | 8 +- docs/README.md | 1 + .../configuration/DOCKER_COMPOSE_SETUP.md | 6 +- .../DOCKER_STARTUP_TROUBLESHOOTING.md | 1 + docs/admin/configuration/LDAP_SETUP.md | 29 ++ docs/admin/configuration/OIDC_SETUP.md | 59 +-- docs/deploy/RENDER.md | 2 +- env.example | 29 +- .../versions/153_add_user_auth_provider.py | 53 +++ requirements.txt | 3 + tests/test_ldap_auth.py | 245 +++++++++++ tests/test_oidc_logout.py | 2 +- 25 files changed, 1199 insertions(+), 150 deletions(-) create mode 100644 app/services/ldap_service.py create mode 100644 app/utils/auth_method.py create mode 100644 docs/admin/configuration/LDAP_SETUP.md create mode 100644 migrations/versions/153_add_user_auth_provider.py create mode 100644 tests/test_ldap_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b96ee..e6ccd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **LDAP authentication** — Optional directory login via `AUTH_METHOD=ldap` or combined `AUTH_METHOD=all` (with local + OIDC). New `LDAP_*` settings in `app/config.py`, `LDAPService` (`app/services/ldap_service.py`), login and password-reset behaviour keyed off `users.auth_provider` (`local` | `oidc` | `ldap`), admin **System Settings** LDAP panel and `POST /admin/ldap/test`, production env validation for required LDAP variables, Alembic `153_add_user_auth_provider`, and tests in `tests/test_ldap_auth.py`. Dependency: `ldap3`. Documentation: [docs/admin/configuration/LDAP_SETUP.md](docs/admin/configuration/LDAP_SETUP.md); OIDC and getting-started guides updated for `ldap` / `all`. + ### Fixed - **API integration test for project tasks** — `tests/test_api_comprehensive.py` now matches `GET /api/projects//tasks`, which returns **all** tasks (including done and cancelled) for the time-entry UI. - **Quote create returned HTTP 500 after save (#583)** — The quote was saved, but the redirect to the quote detail page crashed when **Valid until** was set: the template compared `valid_until` to `now()`, and `now` was never defined in the Jinja context. The expired badge now uses `Quote.is_expired` (same rule, app timezone). Regression coverage in `tests/test_routes/test_quotes_web.py` posts `valid_until` so the view path is exercised. diff --git a/app/__init__.py b/app/__init__.py index 7b2eb50..6f803d7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1009,12 +1009,14 @@ def create_app(config=None): initialize_ip_cache(ip_cache_ttl) # Register OAuth OIDC client if enabled + from app.utils.auth_method import auth_includes_oidc + try: auth_method = (app.config.get("AUTH_METHOD") or "local").strip().lower() except Exception: auth_method = "local" - if auth_method in ("oidc", "both"): + if auth_includes_oidc(auth_method): issuer = app.config.get("OIDC_ISSUER") client_id = app.config.get("OIDC_CLIENT_ID") client_secret = app.config.get("OIDC_CLIENT_SECRET") diff --git a/app/config.py b/app/config.py index e6602a7..86ebf80 100644 --- a/app/config.py +++ b/app/config.py @@ -57,14 +57,40 @@ class Config: API_TOKEN_RATE_LIMIT_PER_MINUTE = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_MINUTE", "100")) API_TOKEN_RATE_LIMIT_PER_HOUR = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_HOUR", "1000")) - # Authentication method: 'none' | 'local' | 'oidc' | 'both' + # Authentication method: 'none' | 'local' | 'oidc' | 'ldap' | 'both' | 'all' # 'none' = no password authentication (username only) # 'local' = password authentication required # 'oidc' = OIDC/Single Sign-On only - # 'both' = OIDC + local password authentication - AUTH_METHOD = os.getenv("AUTH_METHOD", "local").strip().lower() + # 'ldap' = LDAP bind only + # 'both' = OIDC + local password (backwards compatible) + # 'all' = local + OIDC + LDAP + _auth_method_raw = os.getenv("AUTH_METHOD", "local").strip().lower() + _auth_method_valid = frozenset({"none", "local", "oidc", "ldap", "both", "all"}) + AUTH_METHOD = _auth_method_raw if _auth_method_raw in _auth_method_valid else "local" - # OIDC settings (used when AUTH_METHOD is 'oidc' or 'both') + # LDAP settings (used when AUTH_METHOD is 'ldap' or 'all') + LDAP_ENABLED = AUTH_METHOD in ("ldap", "all") + LDAP_HOST = os.environ.get("LDAP_HOST", "localhost") + LDAP_PORT = int(os.environ.get("LDAP_PORT", "389")) + LDAP_USE_SSL = os.environ.get("LDAP_USE_SSL", "false").lower() == "true" + LDAP_USE_TLS = os.environ.get("LDAP_USE_TLS", "false").lower() == "true" + LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", "") + LDAP_BIND_PASSWORD = os.environ.get("LDAP_BIND_PASSWORD", "") + LDAP_BASE_DN = os.environ.get("LDAP_BASE_DN", "dc=example,dc=com") + LDAP_USER_DN = os.environ.get("LDAP_USER_DN", "ou=users") + LDAP_USER_OBJECT_CLASS = os.environ.get("LDAP_USER_OBJECT_CLASS", "inetOrgPerson") + LDAP_USER_LOGIN_ATTR = os.environ.get("LDAP_USER_LOGIN_ATTR", "uid") + LDAP_USER_EMAIL_ATTR = os.environ.get("LDAP_USER_EMAIL_ATTR", "mail") + LDAP_USER_FNAME_ATTR = os.environ.get("LDAP_USER_FNAME_ATTR", "givenName") + LDAP_USER_LNAME_ATTR = os.environ.get("LDAP_USER_LNAME_ATTR", "sn") + LDAP_GROUP_DN = os.environ.get("LDAP_GROUP_DN", "ou=groups") + LDAP_GROUP_OBJECT_CLASS = os.environ.get("LDAP_GROUP_OBJECT_CLASS", "groupOfNames") + LDAP_ADMIN_GROUP = os.environ.get("LDAP_ADMIN_GROUP", "") + LDAP_REQUIRED_GROUP = os.environ.get("LDAP_REQUIRED_GROUP", "") + LDAP_TLS_CA_CERT_FILE = os.environ.get("LDAP_TLS_CA_CERT_FILE", "") + LDAP_TIMEOUT = int(os.environ.get("LDAP_TIMEOUT", "10")) + + # OIDC settings (used when AUTH_METHOD is 'oidc', 'both', or 'all') OIDC_ISSUER = os.getenv("OIDC_ISSUER") # e.g., https://login.microsoftonline.com//v2.0 OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID") OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET") diff --git a/app/models/user.py b/app/models/user.py index 7a451eb..c99d259 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -28,6 +28,8 @@ class User(UserMixin, db.Model): dismissed_release_version = db.Column(db.String(64), nullable=True) oidc_sub = db.Column(db.String(255), nullable=True) oidc_issuer = db.Column(db.String(255), nullable=True) + # Authentication source: local password, OIDC, or LDAP (synced on login where applicable) + auth_provider = db.Column(db.String(20), nullable=False, default="local", server_default="local") avatar_filename = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True) password_change_required = db.Column( diff --git a/app/routes/admin.py b/app/routes/admin.py index 3158031..4dabe7f 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -44,12 +44,52 @@ from app.utils.db import safe_commit from app.utils.error_handling import safe_file_remove, safe_log from app.utils.installation import get_installation_config from app.utils.invoice_numbering import sanitize_invoice_pattern, sanitize_invoice_prefix, validate_invoice_pattern +from app.utils.auth_method import auth_includes_oidc, normalize_auth_method from app.utils.permissions import admin_or_permission_required from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled from app.utils.timezone import get_available_timezones admin_bp = Blueprint("admin", __name__) + +def _ldap_admin_display(): + """Read-only LDAP config summary for admin settings (from env / app config).""" + try: + cfg = current_app.config + ag = (cfg.get("LDAP_ADMIN_GROUP") or "").strip() + rg = (cfg.get("LDAP_REQUIRED_GROUP") or "").strip() + return { + "enabled": bool(cfg.get("LDAP_ENABLED")), + "host": cfg.get("LDAP_HOST") or "", + "port": int(cfg.get("LDAP_PORT") or 389), + "use_ssl": bool(cfg.get("LDAP_USE_SSL")), + "use_tls": bool(cfg.get("LDAP_USE_TLS")), + "base_dn": cfg.get("LDAP_BASE_DN") or "", + "user_dn": cfg.get("LDAP_USER_DN") or "", + "login_attr": cfg.get("LDAP_USER_LOGIN_ATTR") or "", + "admin_group": ag or "—", + "required_group": rg or "—", + } + except Exception: + return { + "enabled": False, + "host": "", + "port": 389, + "use_ssl": False, + "use_tls": False, + "base_dn": "", + "user_dn": "", + "login_attr": "", + "admin_group": "—", + "required_group": "—", + } + + +@admin_bp.context_processor +def _inject_ldap_admin_display(): + return {"ldap_settings": _ldap_admin_display()} + + # In-memory restore progress tracking (simple, per-process) RESTORE_PROGRESS = {} @@ -619,8 +659,8 @@ def admin_dashboard(): ) # Get OIDC status - auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() - oidc_enabled = auth_method in ("oidc", "both") + auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local")) + oidc_enabled = auth_includes_oidc(auth_method) oidc_issuer = getattr(Config, "OIDC_ISSUER", None) oidc_configured = ( oidc_enabled @@ -1560,6 +1600,17 @@ def settings(): ) +@admin_bp.route("/admin/ldap/test", methods=["POST"]) +@limiter.limit("10 per minute") +@login_required +@admin_or_permission_required("manage_settings") +def admin_ldap_test(): + """Test LDAP connectivity (service bind + user subtree count). Returns JSON only.""" + from app.services.ldap_service import LDAPService + + return jsonify(LDAPService.test_connection()) + + @admin_bp.route("/admin/settings/verify-donate-hide-code", methods=["POST"]) @login_required @admin_or_permission_required("manage_settings") @@ -4246,8 +4297,8 @@ def oidc_debug(): } # Check if OIDC is enabled - auth_method = (oidc_config["auth_method"] or "local").strip().lower() - oidc_config["enabled"] = auth_method in ("oidc", "both") + auth_method = normalize_auth_method(oidc_config["auth_method"] or "local") + oidc_config["enabled"] = auth_includes_oidc(auth_method) # Try to get OIDC client metadata metadata = None @@ -4306,9 +4357,9 @@ def oidc_test(): test_dns_resolution, ) - auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() - if auth_method not in ("oidc", "both"): - flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc" or "both".'), "warning") + auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local")) + if not auth_includes_oidc(auth_method): + flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc", "both", or "all".'), "warning") return redirect(url_for("admin.oidc_debug")) issuer = getattr(Config, "OIDC_ISSUER", None) @@ -4633,9 +4684,9 @@ def oidc_wizard_validate_config(): errors.append({"field": "client_secret", "message": "Client Secret is required"}) # Validate auth method - auth_method = data.get("auth_method", "").strip().lower() - if auth_method not in ("oidc", "both"): - errors.append({"field": "auth_method", "message": "Auth method must be 'oidc' or 'both'"}) + auth_method = normalize_auth_method(data.get("auth_method", "")) + if not auth_includes_oidc(auth_method): + errors.append({"field": "auth_method", "message": "Auth method must be 'oidc', 'both', or 'all'"}) # Validate redirect URI if provided redirect_uri = data.get("redirect_uri", "").strip() diff --git a/app/routes/auth.py b/app/routes/auth.py index 65f3b66..a995a31 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -16,6 +16,14 @@ from app import db, limiter, log_event, oauth, track_event from app.config import Config from app.models import User from app.utils.cache import get_cache +from app.utils.auth_method import ( + auth_includes_ldap, + auth_includes_local, + auth_includes_oidc, + forgot_password_available, + normalize_auth_method, + requires_password_form, +) from app.utils.config_manager import ConfigManager from app.utils.db import safe_commit from app.utils.posthog_funnels import track_onboarding_started @@ -43,12 +51,15 @@ def get_avatar_upload_folder() -> str: def _login_template_vars(): """Common template variables for auth/login.html, including demo mode when enabled.""" allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() - requires_password = auth_method in ("local", "both") + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) + requires_password = requires_password_form(auth_method) vars = { "allow_self_register": allow_self_register, "auth_method": auth_method, "requires_password": requires_password, + "auth_includes_ldap": auth_includes_ldap(auth_method), + "auth_includes_oidc": auth_includes_oidc(auth_method), + "show_forgot_password": forgot_password_available(auth_method) and auth_method != "ldap", } if current_app.config.get("DEMO_MODE"): vars["demo_mode"] = True @@ -92,18 +103,78 @@ def _verify_password_reset_token(token: str, *, max_age_seconds: int) -> User | return user +def _finalize_login_after_verification(user: User, *, log_auth_method: str): + """After successful local or LDAP verification: 2FA gate or establish session and redirect.""" + if getattr(user, "two_factor_enabled", False): + session["pre_2fa_user_id"] = user.id + next_page = request.args.get("next") + if next_page and next_page.startswith("/"): + session["pre_2fa_next"] = next_page + else: + session.pop("pre_2fa_next", None) + return redirect(url_for("auth.two_factor")) + + from app.telemetry.otel_setup import business_span + + with business_span("auth.login", user_id=user.id, auth_method=log_auth_method): + login_user(user, remember=True) + + if not user.roles and user.role: + from app.utils.role_migration import migrate_single_user + + if migrate_single_user(user.id): + current_app.logger.info( + "Auto-migrated user '%s' from legacy role '%s' to new role system", + user.username, + user.role, + ) + + user.update_last_login() + current_app.logger.info("User '%s' logged in successfully", user.username) + log_event("auth.login", user_id=user.id, auth_method=log_auth_method) + + import threading + + def track_login_async(): + try: + track_event(user.id, "auth.login", {"auth_method": log_auth_method}) + except Exception: + pass + + threading.Thread(target=track_login_async, daemon=True).start() + + if user.password_change_required: + flash(_("You must change your password before continuing."), "warning") + return redirect(url_for("auth.change_password")) + + try: + require_admin_2fa = bool(getattr(Config, "REQUIRE_2FA_FOR_ADMINS", False)) + except Exception: + require_admin_2fa = False + if require_admin_2fa and user.role == "admin" and not getattr(user, "two_factor_enabled", False): + flash(_("Administrator accounts must enable two-factor authentication."), "warning") + return redirect(url_for("auth.two_factor_setup")) + + next_page = request.args.get("next") + if not next_page or not next_page.startswith("/"): + next_page = url_for("main.dashboard") + current_app.logger.info("Redirecting '%s' to %s", user.username, next_page) + flash(_("Welcome back, %(username)s!", username=user.username), "success") + return redirect(next_page) + + @auth_bp.route("/forgot-password", methods=["GET", "POST"]) @limiter.limit("5 per minute", methods=["POST"]) def forgot_password(): if current_user.is_authenticated: return redirect(url_for("main.dashboard")) - # Password reset only makes sense for password-based modes. + # Password reset only when local accounts may exist (not pure LDAP / OIDC-only). try: - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) except Exception: auth_method = "local" - if auth_method not in ("local", "both"): + if not forgot_password_available(auth_method): flash(_("Password reset is not available for this authentication method."), "warning") return redirect(url_for("auth.login")) @@ -132,7 +203,7 @@ def forgot_password(): if identifier: user = User.query.filter((User.username == identifier) | (User.email == identifier)).first() - if user and user.is_active and user.email: + if user and user.is_active and user.email and getattr(user, "auth_provider", "local") == "local": try: token = _make_password_reset_token(user) reset_url = url_for("auth.reset_password", token=token, _external=True) @@ -175,6 +246,10 @@ def reset_password(token: str): flash(_("This reset link is invalid or has expired. Please request a new one."), "error") return redirect(url_for("auth.forgot_password")) + if getattr(user, "auth_provider", "local") == "ldap": + flash(_("Password reset is not available for this account."), "warning") + return redirect(url_for("auth.login")) + if request.method == "POST": new_password = (request.form.get("new_password") or "").strip() confirm_password = (request.form.get("confirm_password") or "").strip() @@ -217,13 +292,13 @@ def login(): # Get authentication method from Flask app config (reads from environment) try: - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) except Exception: auth_method = "local" - # Determine if password authentication is required - # 'none' = no password, 'local' = password required, 'oidc' = OIDC only, 'both' = OIDC + password - requires_password = auth_method in ("local", "both") + requires_password = requires_password_form(auth_method) + include_ldap = auth_includes_ldap(auth_method) + include_local = auth_includes_local(auth_method) # If OIDC-only mode, redirect to OIDC login start if auth_method == "oidc": @@ -274,6 +349,20 @@ def login(): flash(_("Only the demo account can be used. Please use the credentials shown below."), "error") return render_template("auth/login.html", **_login_template_vars()) + if auth_method == "ldap": + from app.services.ldap_service import LDAPService + + if requires_password and not password: + log_event("auth.login_failed", reason="password_required", auth_method=auth_method) + flash(_("Password is required"), "error") + return render_template("auth/login.html", **_login_template_vars()) + ldap_user = LDAPService.authenticate(username, password) + if ldap_user: + return _finalize_login_after_verification(ldap_user, log_auth_method="ldap") + log_event("auth.login_failed", username=username, reason="ldap_auth_failed", auth_method=auth_method) + flash(_("Invalid username or password"), "error") + return render_template("auth/login.html", **_login_template_vars()) + # Normalize admin usernames from config try: admin_usernames = [u.strip().lower() for u in (Config.ADMIN_USERNAMES or [])] @@ -284,10 +373,18 @@ def login(): user = User.query.filter_by(username=username).first() current_app.logger.info("User lookup for '%s': %s", username, "found" if user else "not found") + if auth_method == "all" and not user and include_ldap: + from app.services.ldap_service import LDAPService + + if password: + ldap_user = LDAPService.authenticate(username, password) + if ldap_user: + return _finalize_login_after_verification(ldap_user, log_auth_method="ldap") + if not user: # Check if self-registration is allowed (use ConfigManager to respect database settings) allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) - if allow_self_register: + if allow_self_register and (include_local or auth_method == "none"): # If password auth is required, validate password during self-registration if requires_password: if not password: @@ -338,7 +435,10 @@ def login(): flash(_("Welcome! Your account has been created."), "success") else: log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method) - flash(_("User not found. Please contact an administrator."), "error") + if auth_method == "all" and include_ldap: + flash(_("Invalid username or password"), "error") + else: + flash(_("User not found. Please contact an administrator."), "error") return render_template("auth/login.html", **_login_template_vars()) else: # If existing user matches admin usernames, ensure admin role @@ -355,6 +455,22 @@ def login(): flash(_("Account is disabled. Please contact an administrator."), "error") return render_template("auth/login.html", **_login_template_vars()) + if auth_method == "all" and include_ldap and getattr(user, "auth_provider", "local") == "ldap": + from app.services.ldap_service import LDAPService + + if requires_password and not password: + log_event( + "auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method + ) + flash(_("Password is required"), "error") + return render_template("auth/login.html", **_login_template_vars()) + lu = LDAPService.authenticate(username, password) + if lu: + return _finalize_login_after_verification(lu, log_auth_method="ldap") + log_event("auth.login_failed", user_id=user.id, reason="ldap_auth_failed", auth_method=auth_method) + flash(_("Invalid username or password"), "error") + return render_template("auth/login.html", **_login_template_vars()) + # Handle password authentication based on mode if requires_password: # Password authentication is required @@ -371,6 +487,12 @@ def login(): log_event( "auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method ) + if auth_method == "all" and include_ldap: + from app.services.ldap_service import LDAPService + + lu = LDAPService.authenticate(username, password) + if lu: + return _finalize_login_after_verification(lu, log_auth_method="ldap") flash(_("Invalid username or password"), "error") return render_template("auth/login.html", **_login_template_vars()) else: @@ -409,76 +531,7 @@ def login(): # This mode is for trusted environments only pass - # If 2FA is enabled for this user, require TOTP verification before creating a session. - if getattr(user, "two_factor_enabled", False): - session["pre_2fa_user_id"] = user.id - # Preserve intended redirect - next_page = request.args.get("next") - if next_page and next_page.startswith("/"): - session["pre_2fa_next"] = next_page - else: - session.pop("pre_2fa_next", None) - return redirect(url_for("auth.two_factor")) - - from app.telemetry.otel_setup import business_span - - with business_span("auth.login", user_id=user.id, auth_method=auth_method): - # Log in the user (password validation passed or password not required) - login_user(user, remember=True) - - # Auto-migrate user from legacy role to new role system if needed - if not user.roles and user.role: - from app.utils.role_migration import migrate_single_user - - if migrate_single_user(user.id): - current_app.logger.info( - "Auto-migrated user '%s' from legacy role '%s' to new role system", - user.username, - user.role, - ) - - user.update_last_login() - current_app.logger.info("User '%s' logged in successfully", user.username) - - # Track successful login (log_event is fast, track_event is deferred to avoid blocking) - log_event("auth.login", user_id=user.id, auth_method=auth_method) - # Defer track_event to avoid blocking redirect - PostHog calls can be slow/timeout - import threading - - def track_login_async(): - try: - track_event(user.id, "auth.login", {"auth_method": auth_method}) - except Exception: - pass # Don't let analytics errors affect login - - threading.Thread(target=track_login_async, daemon=True).start() - - # Note: identify_user_with_segments and set_super_properties are deferred to dashboard - # to avoid blocking the login redirect. The dashboard calls update_user_segments_if_needed - # which has caching logic and will handle this efficiently. - - # Check if password change is required - if user.password_change_required: - flash(_("You must change your password before continuing."), "warning") - return redirect(url_for("auth.change_password")) - - # Optionally enforce 2FA for admins (after login; they will be prompted to enroll). - try: - require_admin_2fa = bool(getattr(Config, "REQUIRE_2FA_FOR_ADMINS", False)) - except Exception: - require_admin_2fa = False - if require_admin_2fa and user.role == "admin" and not getattr(user, "two_factor_enabled", False): - flash(_("Administrator accounts must enable two-factor authentication."), "warning") - return redirect(url_for("auth.two_factor_setup")) - - # Redirect to intended page or dashboard - next_page = request.args.get("next") - if not next_page or not next_page.startswith("/"): - next_page = url_for("main.dashboard") - current_app.logger.info("Redirecting '%s' to %s", user.username, next_page) - - flash(_("Welcome back, %(username)s!", username=user.username), "success") - return redirect(next_page) + return _finalize_login_after_verification(user, log_auth_method=auth_method) except Exception as e: current_app.logger.exception("Login error: %s", e) flash(_("Unexpected error during login. Please try again or check server logs."), "error") @@ -641,7 +694,7 @@ def logout(): # Try OIDC end-session if enabled and configured try: - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) except Exception: auth_method = "local" @@ -673,7 +726,7 @@ def logout(): pass flash(_("Goodbye, %(username)s!", username=username), "info") - if auth_method in ("oidc", "both"): + if auth_includes_oidc(auth_method): # Only perform RP-Initiated Logout if OIDC_POST_LOGOUT_REDIRECT_URI is explicitly configured post_logout = getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None) if post_logout: @@ -710,11 +763,12 @@ def edit_profile(): """Edit user profile""" # Get authentication method from Flask app config (reads from environment) try: - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) except Exception: auth_method = "local" - requires_password = auth_method in ("local", "both") + requires_password = requires_password_form(auth_method) + allow_password_change = requires_password and getattr(current_user, "auth_provider", "local") != "ldap" if request.method == "POST": from app.utils.validation import sanitize_input @@ -730,8 +784,8 @@ def edit_profile(): # Also set session so it applies immediately session["preferred_language"] = preferred_language - # Handle password update if password auth is required - if requires_password: + # Handle password update if password auth is required (not LDAP-managed accounts) + if allow_password_change: password = request.form.get("password", "").strip() password_confirm = request.form.get("password_confirm", "").strip() @@ -808,13 +862,17 @@ def edit_profile(): flash(_("Could not update your profile due to a database error."), "error") return redirect(url_for("auth.profile")) - return render_template("auth/edit_profile.html", requires_password=requires_password) + return render_template("auth/edit_profile.html", requires_password=allow_password_change) @auth_bp.route("/change-password", methods=["GET", "POST"]) @login_required def change_password(): """Change password page - required when password_change_required is True""" + if getattr(current_user, "auth_provider", "local") == "ldap": + flash(_("Password cannot be changed here for your account."), "warning") + return redirect(url_for("main.dashboard")) + if request.method == "POST": current_password = request.form.get("current_password", "").strip() new_password = request.form.get("new_password", "").strip() @@ -925,11 +983,11 @@ def login_oidc(): return redirect(url_for("auth.login")) try: - auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower() + auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local")) except Exception: auth_method = "local" - if auth_method not in ("oidc", "both"): + if not auth_includes_oidc(auth_method): return redirect(url_for("auth.login")) client = oauth.create_client("oidc") @@ -1294,6 +1352,7 @@ def oidc_callback(): user.is_active = True user.oidc_issuer = issuer user.oidc_sub = sub + user.auth_provider = "oidc" # Apply company default for daily working hours (overtime) try: from app.models import Settings @@ -1334,6 +1393,9 @@ def oidc_callback(): else: # Update linkage and profile fields changed = False + if getattr(user, "auth_provider", "local") != "oidc": + user.auth_provider = "oidc" + changed = True if not user.oidc_issuer or not user.oidc_sub: user.oidc_issuer = issuer user.oidc_sub = sub diff --git a/app/routes/kiosk.py b/app/routes/kiosk.py index 97bf634..0f53dcd 100644 --- a/app/routes/kiosk.py +++ b/app/routes/kiosk.py @@ -98,15 +98,16 @@ def kiosk_login(): return redirect(url_for("kiosk.kiosk_dashboard")) # Get authentication method - try: - from app.config import Config + from app.config import Config + from app.utils.auth_method import normalize_auth_method, requires_password_form - auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() + try: + auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local")) except Exception: auth_method = "local" - # Determine if password authentication is required (kiosk doesn't support OIDC) - requires_password = auth_method in ("local", "both") + # Determine if password authentication is required (kiosk doesn't support OIDC/LDAP flows) + requires_password = requires_password_form(auth_method) if request.method == "POST": username = request.form.get("username", "").strip() diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py new file mode 100644 index 0000000..a54472d --- /dev/null +++ b/app/services/ldap_service.py @@ -0,0 +1,390 @@ +""" +LDAP authentication: service-account search, optional group checks, user bind, DB sync. +""" + +from __future__ import annotations + +import logging +import secrets +from typing import Any, Mapping, MutableMapping, Optional + +from flask import current_app + +from app import db +from app.models import User +from app.utils.db import safe_commit + +logger = logging.getLogger(__name__) + +try: + from ldap3 import Connection, SIMPLE, SUBTREE, Tls + from ldap3 import Server + from ldap3.core.exceptions import LDAPException + from ldap3.utils.conv import escape_filter_chars +except ImportError: # pragma: no cover - exercised when ldap3 missing + Connection = None # type: ignore[misc, assignment] + Server = None # type: ignore[misc, assignment] + LDAPException = Exception # type: ignore[misc, assignment] + + +def _config() -> MutableMapping[str, Any]: + return current_app.config + + +def _group_search_base(cfg: Mapping[str, Any]) -> str: + gdn = (cfg.get("LDAP_GROUP_DN") or "").strip().strip(",") + bdn = (cfg.get("LDAP_BASE_DN") or "").strip().strip(",") + if not gdn: + return bdn + if not bdn: + return gdn + return f"{gdn},{bdn}" + + +def _user_search_base(cfg: Mapping[str, Any]) -> str: + udn = (cfg.get("LDAP_USER_DN") or "").strip().strip(",") + bdn = (cfg.get("LDAP_BASE_DN") or "").strip().strip(",") + if not udn: + return bdn + if not bdn: + return udn + return f"{udn},{bdn}" + + +def _make_server(cfg: Mapping[str, Any]) -> Any: + host = (cfg.get("LDAP_HOST") or "localhost").strip() + port = int(cfg.get("LDAP_PORT") or 389) + use_ssl = bool(cfg.get("LDAP_USE_SSL")) + timeout = int(cfg.get("LDAP_TIMEOUT") or 10) + ca_file = (cfg.get("LDAP_TLS_CA_CERT_FILE") or "").strip() + tls = None + if ca_file: + tls = Tls(ca_certs_file=ca_file) + return Server( + host, + port=port, + use_ssl=use_ssl, + get_info=None, + connect_timeout=timeout, + tls=tls, + ) + + +def _service_connection(cfg: Mapping[str, Any]) -> Optional[Any]: + if Connection is None or Server is None: + return None + server = _make_server(cfg) + bind_dn = (cfg.get("LDAP_BIND_DN") or "").strip() + bind_pw = cfg.get("LDAP_BIND_PASSWORD") or "" + timeout = int(cfg.get("LDAP_TIMEOUT") or 10) + conn = Connection( + server, + user=bind_dn, + password=bind_pw, + authentication=SIMPLE, + receive_timeout=timeout, + auto_bind=False, + ) + conn.open() + if cfg.get("LDAP_USE_TLS") and not cfg.get("LDAP_USE_SSL"): + conn.start_tls(read_server_info=False) + conn.bind() + return conn + + +def _user_dn_member_of_group( + conn: Any, + cfg: Mapping[str, Any], + group_cn: str, + user_dn: str, +) -> bool: + group_cn = (group_cn or "").strip() + if not group_cn or not user_dn: + return False + base = _group_search_base(cfg) + oc = escape_filter_chars((cfg.get("LDAP_GROUP_OBJECT_CLASS") or "groupOfNames").strip()) + cn_esc = escape_filter_chars(group_cn) + ud_esc = escape_filter_chars(user_dn) + filt = f"(&(objectClass={oc})(cn={cn_esc})(member={ud_esc}))" + conn.search(search_base=base, search_filter=filt, search_scope=SUBTREE, size_limit=1, attributes=["1.1"]) + return bool(conn.entries) + + +class LDAPService: + """LDAP bind-authenticate and sync users to the local User model.""" + + @staticmethod + def authenticate(username: str, password: str) -> Optional[User]: + """ + Validate credentials against LDAP and return the linked User, or None. + + Never raises LDAP errors to callers; failures are logged at WARNING without passwords. + """ + if Connection is None: + logger.warning("LDAP authenticate skipped: ldap3 is not installed") + return None + + username = (username or "").strip() + password = password or "" + if not username or not password: + return None + + cfg = _config() + svc_conn = None + try: + try: + svc_conn = _service_connection(cfg) + except LDAPException: + logger.warning("LDAP service bind failed") + return None + except Exception: + logger.warning("LDAP service connection error") + return None + + if not svc_conn: + return None + + user_base = _user_search_base(cfg) + login_attr = (cfg.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() + user_oc = (cfg.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip() + u_esc = escape_filter_chars(username.lower()) + la_esc = escape_filter_chars(login_attr) + oc_esc = escape_filter_chars(user_oc) + filt = f"(&(objectClass={oc_esc})({la_esc}={u_esc}))" + fetch_attrs = { + (cfg.get("LDAP_USER_LOGIN_ATTR") or "uid").strip(), + (cfg.get("LDAP_USER_EMAIL_ATTR") or "mail").strip(), + (cfg.get("LDAP_USER_FNAME_ATTR") or "givenName").strip(), + (cfg.get("LDAP_USER_LNAME_ATTR") or "sn").strip(), + } + svc_conn.search( + search_base=user_base, + search_filter=filt, + search_scope=SUBTREE, + size_limit=2, + attributes=list(fetch_attrs), + ) + + if not svc_conn.entries: + logger.warning("LDAP user not found for login attribute match") + return None + if len(svc_conn.entries) > 1: + logger.warning("LDAP search returned multiple entries; refusing login") + return None + + entry = svc_conn.entries[0] + user_dn = entry.entry_dn + attrs = entry.entry_attributes_as_dict + + req_group = (cfg.get("LDAP_REQUIRED_GROUP") or "").strip() + if req_group and not _user_dn_member_of_group(svc_conn, cfg, req_group, user_dn): + logger.warning("LDAP user not in required group") + return None + + try: + if svc_conn.bound: + svc_conn.unbind() + except Exception: + pass + svc_conn = None + + try: + user_conn = Connection( + _make_server(cfg), + user=user_dn, + password=password, + authentication=SIMPLE, + receive_timeout=int(cfg.get("LDAP_TIMEOUT") or 10), + auto_bind=True, + ) + user_conn.unbind() + except LDAPException: + logger.warning("LDAP user bind failed") + return None + except Exception: + logger.warning("LDAP user bind error") + return None + + ldap_attrs = LDAPService._entry_to_attrs(cfg, attrs, username.lower()) + if not ldap_attrs.get("email"): + logger.warning("LDAP user has no email; cannot provision local user") + return None + + admin_group = (cfg.get("LDAP_ADMIN_GROUP") or "").strip() + is_admin = False + if admin_group: + try: + c2 = _service_connection(cfg) + if c2: + is_admin = _user_dn_member_of_group(c2, cfg, admin_group, user_dn) + c2.unbind() + except LDAPException: + pass + + synced = LDAPService._get_or_create_user(cfg, ldap_attrs, is_admin_member=is_admin) + if not synced: + logger.warning("LDAP user could not be persisted to the database") + return None + return synced + except LDAPException: + logger.warning("LDAP authenticate failed") + return None + except Exception: + logger.warning("LDAP authenticate unexpected error") + return None + finally: + if svc_conn is not None: + try: + if svc_conn.bound: + svc_conn.unbind() + except Exception: + pass + + @staticmethod + def _entry_to_attrs( + cfg: Mapping[str, Any], + raw_attrs: Mapping[str, Any], + username_lower: str, + ) -> dict[str, Optional[str]]: + def first(attr: str) -> Optional[str]: + if not attr: + return None + vals = raw_attrs.get(attr) or [] + if not vals: + return None + v = vals[0] + if hasattr(v, "value"): + v = v.value + s = str(v).strip() + return s or None + + login_attr = (cfg.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() + email_attr = (cfg.get("LDAP_USER_EMAIL_ATTR") or "mail").strip() + fn_attr = (cfg.get("LDAP_USER_FNAME_ATTR") or "givenName").strip() + ln_attr = (cfg.get("LDAP_USER_LNAME_ATTR") or "sn").strip() + + email = first(email_attr) + if email: + email = email.lower() + fn = first(fn_attr) or "" + ln = first(ln_attr) or "" + parts = [p for p in (fn.strip(), ln.strip()) if p] + full_name = " ".join(parts).strip() or None + + un = first(login_attr) or username_lower + if un: + un = un.lower().strip() + + return { + "username": un or username_lower, + "email": email, + "full_name": full_name, + } + + @staticmethod + def _get_or_create_user( + cfg: Mapping[str, Any], + ldap_attrs: Mapping[str, Any], + *, + is_admin_member: bool, + ) -> Optional[User]: + """Create or update a User from LDAP attributes; commit and return user, or None on DB failure.""" + email = ldap_attrs.get("email") + username = ldap_attrs.get("username") or "" + full_name = ldap_attrs.get("full_name") + + user = User.query.filter_by(email=email).first() if email else None + if not user: + role_name = "admin" if is_admin_member else "user" + user = User(username=username, role=role_name, email=email, full_name=full_name) + user.auth_provider = "ldap" + user.set_password(secrets.token_urlsafe(48)) + user.is_active = True + try: + from app.models import Role + + role_obj = Role.query.filter_by(name=role_name).first() + if role_obj: + user.roles.append(role_obj) + except Exception: + pass + try: + from app.models import Settings + + settings = Settings.get_settings() + user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) + except Exception: + pass + db.session.add(user) + else: + user.auth_provider = "ldap" + if username and user.username != username: + user.username = username + if full_name is not None: + user.full_name = full_name + if email and user.email != email: + user.email = email + + if is_admin_member: + if user.role != "admin": + user.role = "admin" + else: + if user.role == "admin" and getattr(user, "auth_provider", None) == "ldap": + user.role = "user" + + if not safe_commit("ldap_sync_user", {"user_id": getattr(user, "id", None), "email": email}): + db.session.rollback() + logger.warning("LDAP user DB commit failed") + return None + + return User.query.filter_by(email=email).first() or user + + @staticmethod + def test_connection() -> dict[str, Any]: + """ + Verify service bind and count users under the user subtree. + + Returns dict: success (bool), message (str), user_count (int|None). + Never raises. + """ + if Connection is None: + return {"success": False, "message": "ldap3 is not installed", "user_count": None} + conn = None + cfg = _config() + try: + conn = _service_connection(cfg) + if not conn: + return {"success": False, "message": "Could not create LDAP connection", "user_count": None} + except LDAPException as e: + return {"success": False, "message": f"LDAP error: {type(e).__name__}", "user_count": None} + except Exception as e: + return {"success": False, "message": f"Error: {type(e).__name__}", "user_count": None} + + try: + user_base = _user_search_base(cfg) + user_oc = escape_filter_chars((cfg.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip()) + filt = f"(objectClass={user_oc})" + conn.search( + search_base=user_base, + search_filter=filt, + search_scope=SUBTREE, + attributes=["1.1"], + size_limit=2001, + ) + n = len(conn.entries) + if n > 2000: + return { + "success": True, + "message": "Connected; user count exceeds 2000 (showing as 2000+)", + "user_count": n, + } + return {"success": True, "message": "Connected successfully", "user_count": n} + except LDAPException as e: + return {"success": False, "message": f"LDAP search failed: {type(e).__name__}", "user_count": None} + except Exception as e: + return {"success": False, "message": f"Search error: {type(e).__name__}", "user_count": None} + finally: + try: + conn.unbind() + except Exception: + pass diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 8c00b4c..b074b20 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -36,6 +36,7 @@ Invoices Peppol Backup + LDAP Kiosk AI Helper Analytics @@ -400,6 +401,55 @@ + +
+

{{ _('LDAP') }}

+

+ {{ _('LDAP authentication is configured with environment variables. Values below are read-only.') }} +

+
+
+ {{ _('Enabled for auth') }} +

{{ _('Yes') if ldap_settings.enabled else _('No') }}

+
+
+ {{ _('Host') }} / {{ _('Port') }} +

{{ ldap_settings.host }}:{{ ldap_settings.port }}

+
+
+ {{ _('SSL') }} / {{ _('TLS') }} +

{{ _('SSL') }}: {% if ldap_settings.use_ssl %}{{ _('on') }}{% else %}{{ _('off') }}{% endif %}, + {{ _('TLS') }}: {% if ldap_settings.use_tls %}{{ _('on') }}{% else %}{{ _('off') }}{% endif %}

+
+
+ {{ _('Base DN') }} +

{{ ldap_settings.base_dn }}

+
+
+ {{ _('User DN') }} +

{{ ldap_settings.user_dn }}

+
+
+ {{ _('User login attribute') }} +

{{ ldap_settings.login_attr }}

+
+
+ {{ _('Admin group (CN)') }} +

{{ ldap_settings.admin_group }}

+
+
+ {{ _('Required group (CN)') }} +

{{ ldap_settings.required_group }}

+
+
+
+ + +
+
+

{{ _('Export Settings') }}

@@ -757,6 +807,39 @@ document.addEventListener('DOMContentLoaded', function() { togglePeppolTransport(); } + const ldapTestBtn = document.getElementById('ldapTestConnectionBtn'); + const ldapTestResult = document.getElementById('ldapTestConnectionResult'); + if (ldapTestBtn && ldapTestResult) { + ldapTestBtn.addEventListener('click', async function() { + ldapTestBtn.disabled = true; + ldapTestResult.textContent = '{{ _("Testing connection...") }}'; + ldapTestResult.className = 'text-sm text-text-muted-light dark:text-text-muted-dark'; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + try { + const response = await fetch('{{ url_for("admin.admin_ldap_test") }}', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, + body: JSON.stringify({}) + }); + const data = await response.json().catch(function() { return {}; }); + if (data.success) { + const cnt = data.user_count != null ? ' (' + data.user_count + ' {{ _("users") }})' : ''; + ldapTestResult.textContent = (data.message || '{{ _("OK") }}') + cnt; + ldapTestResult.className = 'text-sm text-green-600 dark:text-green-400'; + } else { + ldapTestResult.textContent = data.message || '{{ _("Connection failed.") }}'; + ldapTestResult.className = 'text-sm text-red-600 dark:text-red-400'; + } + } catch (err) { + ldapTestResult.textContent = '{{ _("Request failed.") }}'; + ldapTestResult.className = 'text-sm text-red-600 dark:text-red-400'; + } finally { + ldapTestBtn.disabled = false; + } + }); + } + const aiTestBtn = document.getElementById('aiTestConnectionBtn'); const aiTestResult = document.getElementById('aiTestConnectionResult'); if (aiTestBtn && aiTestResult) { diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 16d5269..9a47486 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -66,7 +66,7 @@
- {% if not demo_mode %} + {% if not demo_mode and show_forgot_password %}
{{ _('Forgot your password?') }} @@ -75,6 +75,10 @@ {% endif %} {% endif %} + {% if auth_includes_ldap %} +

{{ _('LDAP authentication is enabled') }}

+ {% endif %} + {% if demo_mode %} @@ -83,7 +87,7 @@

{{ _('Tip: Enter a new username to create your account.') }}

{% endif %} - {% if not demo_mode and auth_method and auth_method|string|lower == 'both' %} + {% if not demo_mode and auth_includes_oidc %}