From 4ed798fc8c287b7b0c197e26e664b9ef26399652 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Fri, 14 Aug 2026 08:28:46 +0200 Subject: [PATCH 1/4] feat: add optional LDAP authentication --- .dockerignore | 2 + .env.example | 19 + .gitignore | 2 + Dockerfile | 30 +- README.md | 92 ++++ app/__init__.py | 77 ++++ app/auth.py | 31 +- app/ldap_routes.py | 382 ++++++++++++++++ app/ldap_service.py | 292 ++++++++++++ app/ldap_session.py | 48 ++ app/models.py | 43 ++ app/oidc_routes.py | 8 +- app/recovery_routes.py | 16 +- app/webauthn_routes.py | 15 +- config.py | 135 +++++- docker-compose.ldap.yml | 44 ++ docker-compose.yml | 2 +- docs/ldap-authentication.md | 208 +++++++++ ldap_secret_cli.py | 163 +++++++ requirements-test.txt | 23 + requirements.in | 3 + requirements.txt | 23 + static/js/admin.js | 95 +++- templates/admin.html | 22 +- templates/login.html | 18 + templates/security.html | 9 + tests/integration/ldap/README.md | 22 + tests/integration/ldap/acl.ldif | 1 + tests/integration/ldap/bootstrap.ldif | 22 + tests/integration/ldap/docker-compose.yml | 92 ++++ tests/integration/ldap/generate_certs.py | 109 +++++ tests/test_dependency_policy.py | 1 + tests/test_ldap_auth.py | 527 ++++++++++++++++++++++ tests/test_ldap_secret_cli.py | 87 ++++ tests/test_ldap_service.py | 336 ++++++++++++++ tests/test_production_config.py | 212 +++++++++ tests/test_security_ui.py | 40 ++ 37 files changed, 3223 insertions(+), 28 deletions(-) create mode 100644 app/ldap_routes.py create mode 100644 app/ldap_service.py create mode 100644 app/ldap_session.py create mode 100644 docker-compose.ldap.yml create mode 100644 docs/ldap-authentication.md create mode 100644 ldap_secret_cli.py create mode 100644 tests/integration/ldap/README.md create mode 100644 tests/integration/ldap/acl.ldif create mode 100644 tests/integration/ldap/bootstrap.ldif create mode 100644 tests/integration/ldap/docker-compose.yml create mode 100644 tests/integration/ldap/generate_certs.py create mode 100644 tests/test_ldap_auth.py create mode 100644 tests/test_ldap_secret_cli.py create mode 100644 tests/test_ldap_service.py diff --git a/.dockerignore b/.dockerignore index 8ea4d32..33c7b9a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ ENV/ .venv*/ .uv-cache*/ .task*/ +.test-tmp/ +.e2e-tmp/ .test-run.tmp/ .worktrees/ diff --git a/.env.example b/.env.example index ad5d897..c82e706 100644 --- a/.env.example +++ b/.env.example @@ -114,6 +114,25 @@ MAX_RECOVERY_JSON_SIZE=4096 # OIDC_ALLOWED_SUBJECTS= # OIDC_ALLOWED_DOMAINS=example.com # OIDC_LOGIN_RATE_LIMIT=10 per minute + +# Optional LDAP/LDAPS authentication (disabled by default). The ldap:// scheme +# always uses StartTLS; ldaps:// uses TLS from connection start. Both verify the +# server certificate against LDAP_CA_FILE. Store the bind password and CA with +# the documented `docker-compose.ldap.yml` helper instead of placing secrets +# here. +LDAP_ENABLED=false +LDAP_PROVIDER_ID=default +LDAP_URL= +LDAP_BASE_DN= +LDAP_BIND_DN= +LDAP_BIND_PASSWORD_FILE=/run/webssh-auth/ldap_bind_password +LDAP_CA_FILE=/run/webssh-auth/ldap_ca.pem +LDAP_USER_FILTER= +LDAP_UNIQUE_ID_ATTRIBUTE= +LDAP_CONNECT_TIMEOUT=5 +LDAP_OPERATION_TIMEOUT=5 +LDAP_SESSION_REVALIDATION_SECONDS=300 +LDAP_LOGIN_RATE_LIMIT=5 per minute # Block SSH connections to loopback/link-local addresses (SSRF protection). # Keep false for homelab use where connecting to internal IPs is intended. BLOCK_INTERNAL_SSH=false diff --git a/.gitignore b/.gitignore index 8841216..c8698cb 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ plans/ # Knowledge graph graphify-out/ +.test-tmp/ +.e2e-tmp/ AGENTS.md .codex/hooks.json diff --git a/Dockerfile b/Dockerfile index 3bfcacc..d26019a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,19 @@ +FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc AS ldap-builder + +WORKDIR /build + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + build-essential \ + libldap-dev \ + libsasl2-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /build/ +RUN pip install --no-cache-dir --require-hashes \ + --prefix=/install -r requirements.txt + + FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc ARG VCS_REF=unknown @@ -21,7 +37,13 @@ WORKDIR /app RUN adduser --disabled-password --gecos "" appuser COPY requirements.txt /app/ -RUN pip install --no-cache-dir -r requirements.txt \ +COPY --from=ldap-builder /install /usr/local +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + libldap2 \ + libsasl2-2 \ + && rm -rf /var/lib/apt/lists/* \ && python -m pip check \ && python -m pip uninstall --yes pip \ && rm -rf /usr/local/lib/python*/ensurepip @@ -29,11 +51,13 @@ RUN pip install --no-cache-dir -r requirements.txt \ COPY . /app RUN chown -R appuser:appuser /app && \ - mkdir -p /app/data/logs /app/data/keys && \ + mkdir -p /app/data/logs /app/data/keys /run/webssh-auth && \ chown -R appuser:appuser /app/data && \ + chown appuser:appuser /run/webssh-auth && \ chmod 700 /app/data && \ chmod 700 /app/data/logs && \ - chmod 700 /app/data/keys + chmod 700 /app/data/keys && \ + chmod 700 /run/webssh-auth COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/README.md b/README.md index df00204..a27314d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat - **Host Key Auditing** - Persistent `known_hosts` policy with change detection - **Host Trust Center** - Users can inspect and revoke their SSH trust records; administrators manage the global trust store - **Passkeys** - Optional username-less WebAuthn sign-in with discoverable credentials and a safe legacy-passkey replacement flow +- **LDAP / Active Directory** - Optional fail-closed LDAP or LDAPS sign-in with explicit stable-identity linking, strict TLS verification, and an opt-in Compose overlay - **Recovery Codes** - One-time account recovery codes stored only as hashes - **OpenID Connect** - Optional authorization-code flow with PKCE and explicit administrator linking by stable issuer and subject - **Audit Logging & Export** - Structured JSON logs for auth, SSH, and file events, plus bounded administrator export and configurable retention @@ -495,6 +496,19 @@ docker build -t webssh:local . | `OIDC_ALLOWED_SUBJECTS` | No | - | Optional comma-separated subject allowlist | | `OIDC_ALLOWED_DOMAINS` | No | - | Optional comma-separated email-domain policy; identity linking still uses issuer and subject only | | `OIDC_LOGIN_RATE_LIMIT` | No | `10 per minute` | Per-IP rate limit for starting OIDC login | +| `LDAP_ENABLED` | No | `false` | Enable optional LDAP/LDAPS authentication; the provided `docker-compose.ldap.yml` overlay sets this to `true` | +| `LDAP_PROVIDER_ID` | With LDAP | `default` | Stable local identifier for this directory; do not change it after linking users | +| `LDAP_URL` | With LDAP | - | Exact `ldap://host:port` (mandatory StartTLS) or `ldaps://host:port` URL | +| `LDAP_BASE_DN` | With LDAP | - | Subtree base for directory user searches | +| `LDAP_BIND_DN` | With LDAP | - | DN of the least-privilege read-only search account | +| `LDAP_BIND_PASSWORD_FILE` | With LDAP | `/run/webssh-auth/ldap_bind_password` | Private bind-password file populated through the bundled helper | +| `LDAP_CA_FILE` | With LDAP | `/run/webssh-auth/ldap_ca.pem` | PEM CA bundle used for mandatory server-certificate verification | +| `LDAP_USER_FILTER` | With LDAP | - | LDAP filter containing exactly one `{username}` placeholder | +| `LDAP_UNIQUE_ID_ATTRIBUTE` | With LDAP | - | Stable identity attribute, normally `entryUUID` or `objectGUID` | +| `LDAP_CONNECT_TIMEOUT` | No | `5` | Bounded LDAP connect/bind timeout in seconds (1-15) | +| `LDAP_OPERATION_TIMEOUT` | No | `5` | Bounded LDAP operation timeout in seconds (1-30) | +| `LDAP_SESSION_REVALIDATION_SECONDS` | No | `300` | Fail-closed revalidation interval for active LDAP sessions (60-3600) | +| `LDAP_LOGIN_RATE_LIMIT` | No | `5 per minute` | Per-IP LDAP login and diagnostic limit | | `ADMIN_USERS` | No | - | Compatibility option: comma-separated existing usernames granted admin on startup. Prefer `create-admin` for explicit bootstrap | | `ADMIN_PANEL_ENABLED` | No | `True` | Expose the role-gated Admin Panel and its API routes | | `SESSION_TIMEOUT` | No | `1800` | Idle SSH session timeout in seconds (30 minutes) | @@ -531,6 +545,83 @@ provider's stable `(issuer, subject)` identity to an existing local account. When OIDC runs in Docker, mount the client-secret file read-only and point `OIDC_CLIENT_SECRET_FILE` at its path inside the container. +#### Enable LDAP or LDAPS with Docker Compose + +LDAP is disabled by default. A normal `docker compose up -d` creates no LDAP +volume, mount, or helper service. Enabling it does not require a `.env` file or +a hand-written secret mount: use the supplied `docker-compose.ldap.yml` overlay +and keep it on the same WebSSH release or commit as `docker-compose.yml`. + +1. Edit `docker-compose.ldap.yml` and fill in the required values under + `services.webssh.environment`. For example: + + ```yaml + LDAP_ENABLED: "true" + LDAP_PROVIDER_ID: primary-directory + LDAP_URL: ldaps://ldap.example.com:636 + LDAP_BASE_DN: ou=people,dc=example,dc=com + LDAP_BIND_DN: cn=svc-webssh,ou=services,dc=example,dc=com + LDAP_USER_FILTER: "(&(objectClass=inetOrgPerson)(uid={username}))" + LDAP_UNIQUE_ID_ATTRIBUTE: entryUUID + ``` + + For Active Directory, the usual filter attribute is `sAMAccountName` and + the stable ID is `objectGUID`. For OpenLDAP, they are commonly `uid` and + `entryUUID`. Use a dedicated, least-privilege, read-only bind account. Keep + `LDAP_PROVIDER_ID` stable after users have been linked. + +2. Choose one encrypted transport. `ldap://ldap.example.com:389` means + mandatory StartTLS before any bind; `ldaps://ldap.example.com:636` starts TLS + immediately. WebSSH rejects plaintext LDAP and invalid certificates. The + DNS name in `LDAP_URL` must match the server certificate. + +3. Store the bind password and the issuing CA certificate in WebSSH's managed + secret volume. The password is prompted without being placed in Compose or + shell history: + + ```bash + docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools set-password + docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin < company-ca.pem + docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools status + ``` + + PowerShell users can install the same PEM-encoded CA with: + + ```powershell + Get-Content -Raw .\company-ca.pem | docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin + ``` + +4. Start WebSSH with the LDAP overlay: + + ```bash + docker compose -f docker-compose.yml -f docker-compose.ldap.yml up -d + ``` + + For the production reverse-proxy profile, apply the production overlay last: + + ```bash + docker compose -f docker-compose.yml -f docker-compose.ldap.yml -f docker-compose.production.yml up -d + ``` + +5. Sign in with the existing local break-glass administrator. In **Admin**, + create or select a non-admin WebSSH account and use **Link LDAP** to attach + the directory's stable identity. WebSSH deliberately does not auto-provision + accounts and never grants administrator rights through LDAP. Test **Sign in + with LDAP** before relying on it. + +To disable LDAP again, recreate WebSSH from the standard Compose file only: + +```bash +docker compose -f docker-compose.yml up -d --force-recreate +``` + +The LDAP UI and routes then disappear. Existing users and links remain stored, +but a linked account does not regain an old local password as a fallback. Keep +the local break-glass administrator and its recovery material available. See +[Optional LDAP and Active Directory authentication](docs/ldap-authentication.md) +for complete Active Directory/OpenLDAP filters, certificate requirements, +troubleshooting, rollback details, and the disposable local test laboratory. + Passkey sign-in uses username-less discoverable credentials so the authentication-options endpoint does not reveal whether an account exists. Passkeys created by an older release as non-discoverable credentials cannot be @@ -1186,6 +1277,7 @@ webssh/ ├── start.py # Native and Gunicorn entry point ├── Dockerfile # Non-root production image ├── docker-compose.yml # Zero-config homelab deployment +├── docker-compose.ldap.yml # Optional LDAP/AD overlay and secret helper └── docker-compose.production.yml # Strict reverse-proxy overlay ``` diff --git a/app/__init__.py b/app/__init__.py index b021f5b..a11bcb5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,6 +5,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix import config import os +import time from .models import db from .auth import (init_auth, authenticate_user, register_user, check_rate_limit, is_bootstrap_registration_available, @@ -126,6 +127,11 @@ def inject_url_prefix(): 'admin_panel_enabled': config.ADMIN_PANEL_ENABLED, 'webauthn_enabled': config.WEBAUTHN_ENABLED, 'oidc_enabled': config.OIDC_ENABLED, + 'ldap_enabled': config.LDAP_ENABLED, + 'ldap_managed': bool( + current_user.is_authenticated + and current_user.is_ldap_managed + ), 'recovery_codes_enabled': config.RECOVERY_CODES_ENABLED, 'host_key_management_enabled': ( config.HOST_KEY_MANAGEMENT_ENABLED @@ -152,6 +158,43 @@ def enforce_restore_maintenance_and_session_epoch(): 'error': 'WebSSH is in restore maintenance mode', 'code': 'maintenance', }), 503 + if ( + current_user.is_authenticated + and current_user.is_ldap_managed + and not config.LDAP_ENABLED + ): + from . import user_lifecycle + + user_id = current_user.id + user_lifecycle.revoke_user_access(user_id, socketio) + logout_user() + session.clear() + return redirect(url_for('login')) + if ( + current_user.is_authenticated + and current_user.is_ldap_managed + and config.LDAP_ENABLED + and int(time.time()) - int(session.get('_ldap_verified_at', 0)) + >= config.LDAP_SESSION_REVALIDATION_SECONDS + ): + from . import user_lifecycle + from .ldap_service import LDAPLookupRejected, LDAPUnavailable + from .ldap_session import revalidate_user + + try: + revalidate_user(current_user) + except (LDAPLookupRejected, LDAPUnavailable) as exc: + user_id = current_user.id + log_warning( + 'LDAP session revalidation rejected', + user=current_user.username, + error=type(exc).__name__, + ) + user_lifecycle.revoke_user_access(user_id, socketio) + logout_user() + session.clear() + return redirect(url_for('login')) + session['_ldap_verified_at'] = int(time.time()) if initialize_storage and current_user.is_authenticated: from .session_epoch import current_epoch epoch = current_epoch() @@ -213,6 +256,11 @@ def enforce_restore_maintenance_and_session_epoch(): app.register_blueprint(recovery_blueprint) app.register_blueprint(transfer_blueprint) app.register_blueprint(webauthn_blueprint) + if config.LDAP_ENABLED: + from .ldap_routes import ldap_blueprint + from .ldap_service import validate_runtime_files + validate_runtime_files() + app.register_blueprint(ldap_blueprint) if initialize_storage: _initialize_persistent_storage(app) if start_runtime: @@ -305,11 +353,30 @@ def ssh_cleanup_task(cancel_event): except Exception as e: log_error("SSH cleanup error", error=str(e)) + def ldap_revalidation_task(cancel_event): + from .ldap_session import revalidate_all_linked_users + + while not cancel_event.wait( + config.LDAP_SESSION_REVALIDATION_SECONDS + ): + try: + revalidate_all_linked_users(app, socketio) + except Exception as e: + log_error( + "LDAP background revalidation error", + error=type(e).__name__, + ) + try: lifecycle.start_job( 'inactive_socket_session_cleanup', db_cleanup_task ) lifecycle.start_job('idle_ssh_session_cleanup', ssh_cleanup_task) + if config.LDAP_ENABLED: + lifecycle.start_job( + 'ldap_session_revalidation', + ldap_revalidation_task, + ) connection_pool.bind_temp_connection_pool(lifecycle) except Exception: lifecycle.begin_shutdown(config.RUNTIME_SHUTDOWN_GRACE_SECONDS) @@ -427,6 +494,8 @@ def logout(): @app.route('/change-password', methods=['GET', 'POST']) @login_required def change_password(): + if current_user.is_ldap_managed: + abort(403) if request.method == 'POST': client_ip = get_client_ip() if config.RATELIMIT_ENABLED and check_rate_limit( @@ -491,6 +560,7 @@ def _user_to_dict(u): 'username': u.username, 'is_admin': bool(u.is_admin), 'is_locked': bool(u.is_locked), + 'ldap_managed': bool(u.is_ldap_managed), 'created_at': u.created_at.isoformat() if u.created_at else None, 'last_login': u.last_login.isoformat() if u.last_login else None, } @@ -551,6 +621,13 @@ def _is_last_admin(): elif action == 'unlock': target.is_locked = False elif action == 'promote': + if target.is_ldap_managed: + return jsonify({ + 'error': ( + 'LDAP accounts cannot be administrators; keep a ' + 'local break-glass administrator' + ) + }), 400 target.is_admin = True elif action == 'demote': if is_self: diff --git a/app/auth.py b/app/auth.py index a10eec3..85790d4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -94,7 +94,9 @@ def check_reauth_rate_limit(user_id, ip_address, endpoint, limit_str): def load_user(user_id): """Load user by ID for Flask-Login.""" user = db.session.get(User, int(user_id)) - if user is None or user.is_locked: + if user is None or user.is_locked or ( + user.is_admin and user.is_ldap_managed + ): return None return user @@ -177,13 +179,22 @@ def is_bootstrap_registration_available(): def ensure_initial_admin(): """Return an existing admin or promote the oldest user if none exists.""" with _registration_lock: - existing_admin = User.query.filter_by( - is_admin=True - ).order_by(User.id).first() + existing_admin = ( + User.query + .filter_by(is_admin=True) + .filter(~User.ldap_identity.has()) + .order_by(User.id) + .first() + ) if existing_admin is not None: return existing_admin - oldest_user = User.query.order_by(User.id).first() + oldest_user = ( + User.query + .filter(~User.ldap_identity.has()) + .order_by(User.id) + .first() + ) if oldest_user is None: return None @@ -213,7 +224,13 @@ def authenticate_user(username, password): # as a wrong password, preventing username enumeration by timing. bcrypt.checkpw(password.encode('utf-8'), _DUMMY_PASSWORD_HASH) return None, "Invalid username or password" - if user.check_password(password): + password_matches = user.check_password(password) + # A directory-managed identity must never silently fall back to the local + # password database. Keep this boundary in the shared authenticator so it + # also protects reauthentication and future password-based entry points. + if user.ldap_identity is not None: + return None, "Invalid username or password" + if password_matches: if getattr(user, 'is_locked', False): return None, "This account is locked. Please contact an administrator." user.last_login = datetime.now(timezone.utc) @@ -233,7 +250,7 @@ def sync_admin_users(): changed = False for name in getattr(config, 'ADMIN_USERS', []): u = User.query.filter_by(username=name).first() - if u and not u.is_admin: + if u and not u.is_admin and not u.is_ldap_managed: u.is_admin = True changed = True log_info("Admin granted via ADMIN_USERS", user=name) diff --git a/app/ldap_routes.py b/app/ldap_routes.py new file mode 100644 index 0000000..e9c2d61 --- /dev/null +++ b/app/ldap_routes.py @@ -0,0 +1,382 @@ +"""Feature-gated LDAP login and explicit administrator identity mapping.""" + +import logging +import secrets +import time +from datetime import datetime, timezone +from urllib.parse import urlsplit + +from flask import Blueprint, jsonify, redirect, render_template, request, session, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy.exc import IntegrityError +from werkzeug.exceptions import RequestEntityTooLarge + +import config + +from . import socketio, user_lifecycle +from .audit_logger import log_rate_limit_exceeded, log_security_event +from .auth import ( + check_rate_limit, + check_reauth_rate_limit, + password_exceeds_bcrypt_limit, +) +from .decorators import admin_required +from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable +from .models import ( + LDAPIdentity, + OIDCIdentity, + RecoveryCode, + User, + WebAuthnCredential, + db, +) + + +ldap_blueprint = Blueprint('ldap', __name__) +_MAX_LDAP_FORM_BYTES = 4096 +_MAX_LDAP_JSON_BYTES = 4096 + + +def _bounded_json(): + request.max_content_length = _MAX_LDAP_JSON_BYTES + if ( + request.content_length is not None + and request.content_length > _MAX_LDAP_JSON_BYTES + ): + return None + try: + raw_data = request.get_data(cache=True) + except RequestEntityTooLarge: + return None + if len(raw_data) > _MAX_LDAP_JSON_BYTES: + return None + return request.get_json(silent=True) or {} + + +def _request_body_too_large(): + return jsonify({'error': 'Request body too large'}), 413 + + +def get_directory(): + return LDAPDirectory() + + +def _local_password_matches(user, password): + if user.ldap_identity is not None: + return False + try: + return not password_exceeds_bcrypt_limit(password) and user.check_password( + password + ) + except (TypeError, ValueError, UnicodeError): + return False + + +def _rate_limited(endpoint): + client_ip = request.remote_addr or 'unknown' + if config.RATELIMIT_ENABLED and check_rate_limit( + client_ip, + endpoint, + config.LDAP_LOGIN_RATE_LIMIT, + ): + log_rate_limit_exceeded(endpoint, client_ip) + return True + return False + + +@ldap_blueprint.post('/login/ldap') +def ldap_login(): + if request.content_length and request.content_length > _MAX_LDAP_FORM_BYTES: + return render_template('login.html'), 413 + if _rate_limited('ldap_login'): + return render_template('login.html'), 429 + + username = str(request.form.get('username') or '').strip() + password = request.form.get('password') or '' + client_ip = request.remote_addr or 'unknown' + try: + directory = get_directory() + resolved = directory.lookup(username) + mapping = LDAPIdentity.query.filter_by( + provider=resolved.provider, + subject=resolved.subject, + ).first() + if ( + mapping is None + or mapping.user.is_locked + or mapping.user.is_admin + ): + raise LDAPLookupRejected('Identity is not linked to an active user') + if not directory.verify_password( + resolved.distinguished_name, + password, + ): + raise LDAPLookupRejected('LDAP credentials are invalid') + except LDAPLookupRejected: + log_security_event( + 'LDAP_LOGIN_REJECTED', + level=logging.WARNING, + user=username or None, + ip=client_ip, + reason='invalid_credentials_or_mapping', + ) + return render_template( + 'login.html', + ldap_error='Invalid username or password', + ), 401 + except LDAPUnavailable as exc: + log_security_event( + 'LDAP_LOGIN_UNAVAILABLE', + level=logging.ERROR, + user=username or None, + ip=client_ip, + error=type(exc).__name__, + ) + return render_template( + 'login.html', + ldap_error='Directory sign-in is temporarily unavailable', + ), 503 + + user = mapping.user + mapping.directory_username = username + mapping.distinguished_name = resolved.distinguished_name + mapping.last_verified_at = datetime.now(timezone.utc) + user.last_login = datetime.now(timezone.utc) + db.session.commit() + session.clear() + session['_ldap_verified_at'] = int(time.time()) + login_user(user, remember=False) + log_security_event( + 'LDAP_LOGIN_SUCCESS', + user=user.username, + provider=mapping.provider, + ip=client_ip, + ) + return redirect(url_for('index')) + + +def _admin_reauthenticated(data, endpoint): + client_ip = request.remote_addr or 'unknown' + if config.RATELIMIT_ENABLED and check_reauth_rate_limit( + current_user.id, + client_ip, + endpoint, + config.RATELIMIT_REAUTH, + ): + log_rate_limit_exceeded(endpoint, client_ip) + return None, (jsonify({'error': 'Too many password attempts'}), 429) + if not _local_password_matches(current_user, data.get('password', '')): + return None, (jsonify({ + 'error': 'Administrator password is incorrect' + }), 403) + return client_ip, None + + +@ldap_blueprint.post('/admin/api/users//ldap-link') +@admin_required +@login_required +def link_ldap_identity(user_id): + data = _bounded_json() + if data is None: + return _request_body_too_large() + _client_ip, rejection = _admin_reauthenticated(data, 'ldap_link_reauth') + if rejection is not None: + return rejection + target = db.session.get(User, user_id) + if target is None: + return jsonify({'error': 'User not found'}), 404 + if target.is_admin: + return jsonify({ + 'error': 'Administrator accounts must remain local break-glass accounts' + }), 400 + if data.get('confirm_username') != target.username: + return jsonify({'error': 'Target confirmation does not match'}), 400 + directory_username = str(data.get('directory_username') or '').strip() + if not directory_username or len(directory_username) > 256: + return jsonify({'error': 'Directory username is required'}), 400 + if target.ldap_identity is not None: + return jsonify({'error': 'User already has an LDAP identity'}), 409 + + try: + resolved = get_directory().lookup(directory_username) + except LDAPLookupRejected: + return jsonify({'error': 'Directory identity was not found uniquely'}), 400 + except LDAPUnavailable as exc: + log_security_event( + 'LDAP_LINK_UNAVAILABLE', + level=logging.ERROR, + admin=current_user.username, + user=target.username, + error=type(exc).__name__, + ) + return jsonify({'error': 'Directory is temporarily unavailable'}), 503 + + row = LDAPIdentity( + user_id=target.id, + provider=resolved.provider, + subject=resolved.subject, + directory_username=directory_username, + distinguished_name=resolved.distinguished_name, + last_verified_at=datetime.now(timezone.utc), + ) + # Destroy the dormant local credential and every alternative login factor. + # Unlinking requires a fresh local password, so no old password silently + # becomes valid again after a directory outage or rollback. + target.set_password(secrets.token_urlsafe(48)) + WebAuthnCredential.query.filter_by(user_id=target.id).delete() + RecoveryCode.query.filter_by(user_id=target.id).delete() + OIDCIdentity.query.filter_by(user_id=target.id).delete() + db.session.add(row) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return jsonify({'error': 'LDAP identity is already linked'}), 409 + except Exception as exc: + db.session.rollback() + log_security_event( + 'LDAP_IDENTITY_STORAGE_FAILED', + level=logging.ERROR, + admin=current_user.username, + user=target.username, + error=type(exc).__name__, + ) + return jsonify({ + 'error': 'LDAP identity storage is temporarily unavailable' + }), 503 + + user_lifecycle.revoke_user_access(target.id, socketio) + log_security_event( + 'LDAP_IDENTITY_LINKED', + admin=current_user.username, + user=target.username, + provider=row.provider, + ) + return jsonify({'id': row.id}), 201 + + +@ldap_blueprint.get('/admin/api/users//ldap-identity') +@admin_required +@login_required +def get_ldap_identity(user_id): + target = db.session.get(User, user_id) + if target is None: + return jsonify({'error': 'User not found'}), 404 + row = target.ldap_identity + if row is None: + return jsonify({'identity': None}) + return jsonify({'identity': { + 'id': row.id, + 'provider': row.provider, + 'directory_username': row.directory_username, + 'created_at': row.created_at.isoformat(), + 'last_verified_at': ( + row.last_verified_at.isoformat() if row.last_verified_at else None + ), + }}) + + +@ldap_blueprint.get('/admin/api/ldap/status') +@admin_required +@login_required +def ldap_status(): + client_ip = request.remote_addr or 'unknown' + if config.RATELIMIT_ENABLED and check_rate_limit( + f'{current_user.id}:{client_ip}', + 'ldap_status', + config.LDAP_LOGIN_RATE_LIMIT, + ): + return jsonify({'error': 'Too many LDAP diagnostic requests'}), 429 + try: + get_directory().probe() + except LDAPUnavailable as exc: + log_security_event( + 'LDAP_READINESS_FAILED', + level=logging.ERROR, + admin=current_user.username, + error=type(exc).__name__, + ) + return jsonify({ + 'enabled': True, + 'provider': config.LDAP_PROVIDER_ID, + 'ready': False, + 'transport': ( + 'ldap+StartTLS' + if urlsplit(config.LDAP_URL).scheme == 'ldap' + else 'ldaps' + ), + }), 503 + return jsonify({ + 'enabled': True, + 'provider': config.LDAP_PROVIDER_ID, + 'ready': True, + 'transport': ( + 'ldap+StartTLS' + if urlsplit(config.LDAP_URL).scheme == 'ldap' + else 'ldaps' + ), + }) + + +@ldap_blueprint.delete( + '/admin/api/users//ldap-identities/' +) +@admin_required +@login_required +def unlink_ldap_identity(user_id, identity_id): + data = _bounded_json() + if data is None: + return _request_body_too_large() + _client_ip, rejection = _admin_reauthenticated(data, 'ldap_unlink_reauth') + if rejection is not None: + return rejection + target = db.session.get(User, user_id) + if target is None: + return jsonify({'error': 'User not found'}), 404 + if data.get('confirm_username') != target.username: + return jsonify({'error': 'Target confirmation does not match'}), 400 + row = db.session.get(LDAPIdentity, identity_id) + if row is None or row.user_id != target.id: + return jsonify({'error': 'LDAP identity not found'}), 404 + new_password = data.get('new_password') or '' + if len(new_password) < config.MIN_PASSWORD_LENGTH: + return jsonify({ + 'error': ( + f'New password must be at least {config.MIN_PASSWORD_LENGTH} ' + 'characters' + ) + }), 400 + if password_exceeds_bcrypt_limit(new_password): + return jsonify({ + 'error': ( + f'New password must not exceed {config.MAX_PASSWORD_LENGTH} ' + 'bytes when encoded as UTF-8' + ) + }), 400 + + provider = row.provider + db.session.delete(row) + target.set_password(new_password) + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + log_security_event( + 'LDAP_IDENTITY_STORAGE_FAILED', + level=logging.ERROR, + admin=current_user.username, + user=target.username, + error=type(exc).__name__, + ) + return jsonify({ + 'error': 'LDAP identity storage is temporarily unavailable' + }), 503 + user_lifecycle.revoke_user_access(target.id, socketio) + log_security_event( + 'LDAP_IDENTITY_UNLINKED', + level=logging.WARNING, + admin=current_user.username, + user=target.username, + provider=provider, + ) + return jsonify({'ok': True}) diff --git a/app/ldap_service.py b/app/ldap_service.py new file mode 100644 index 0000000..d185299 --- /dev/null +++ b/app/ldap_service.py @@ -0,0 +1,292 @@ +"""Fail-closed LDAP lookup and bind boundary. + +The optional Bonsai dependency is imported lazily so disabled installations do +not initialize LDAP code or touch directory secrets. +""" + +from __future__ import annotations + +import base64 +import os +import ssl +import stat +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + + +_MAX_SECRET_BYTES = 16 * 1024 +_MAX_CA_BYTES = 1024 * 1024 + + +class LDAPUnavailable(RuntimeError): + """Directory configuration, secret, or transport is unavailable.""" + + +class LDAPLookupRejected(RuntimeError): + """A user lookup did not resolve to exactly one safe identity.""" + + +@dataclass(frozen=True) +class LDAPSettings: + provider: str + url: str + base_dn: str + bind_dn: str + bind_password_file: Path + ca_file: Path + user_filter: str + unique_id_attribute: str + connect_timeout: int + operation_timeout: int + + @classmethod + def from_config(cls): + import config + + return cls( + provider=getattr(config, 'LDAP_PROVIDER_ID', 'default'), + url=config.LDAP_URL, + base_dn=config.LDAP_BASE_DN, + bind_dn=config.LDAP_BIND_DN, + bind_password_file=Path(config.LDAP_BIND_PASSWORD_FILE), + ca_file=Path(config.LDAP_CA_FILE), + user_filter=config.LDAP_USER_FILTER, + unique_id_attribute=config.LDAP_UNIQUE_ID_ATTRIBUTE, + connect_timeout=config.LDAP_CONNECT_TIMEOUT, + operation_timeout=config.LDAP_OPERATION_TIMEOUT, + ) + + +@dataclass(frozen=True) +class LDAPIdentity: + provider: str + subject: str + distinguished_name: str + + +def escape_filter_value(value): + """Escape one assertion value according to RFC 4515 section 3.""" + escaped = [] + for byte in str(value).encode('utf-8'): + if byte in {0x00, 0x28, 0x29, 0x2A, 0x5C} or byte >= 0x80: + escaped.append(f'\\{byte:02x}') + else: + escaped.append(chr(byte)) + return ''.join(escaped) + + +def _read_secret(path): + secret_path = Path(path) + try: + metadata = secret_path.lstat() + if secret_path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + raise LDAPUnavailable('LDAP secret path is not a regular file') + if os.name != 'nt' and stat.S_IMODE(metadata.st_mode) & 0o077: + raise LDAPUnavailable('LDAP secret file permissions are too broad') + with secret_path.open('rb') as secret_file: + content = secret_file.read(_MAX_SECRET_BYTES + 1) + except LDAPUnavailable: + raise + except OSError as exc: + raise LDAPUnavailable('LDAP secret file is unavailable') from exc + if len(content) > _MAX_SECRET_BYTES: + raise LDAPUnavailable('LDAP secret file exceeds the size limit') + content = content.rstrip(b'\r\n') + if not content or b'\x00' in content: + raise LDAPUnavailable('LDAP secret file is invalid') + try: + return content.decode('utf-8') + except UnicodeDecodeError as exc: + raise LDAPUnavailable('LDAP secret file is not UTF-8') from exc + + +def _canonical_subject(value): + if isinstance(value, (bytes, bytearray, memoryview)): + encoded = base64.urlsafe_b64encode(bytes(value)).decode('ascii') + result = f'b64:{encoded.rstrip("=")}' + else: + result = str(value).strip() + if not result or len(result) > 512 or '\x00' in result: + raise LDAPLookupRejected('LDAP identity attribute is invalid') + return result + + +def _canonical_distinguished_name(value): + result = str(value).strip() + if not result or len(result) > 2048 or '\x00' in result: + raise LDAPLookupRejected('LDAP distinguished name is invalid') + return result + + +def validate_runtime_files(settings=None): + """Fail before serving when enabled LDAP secrets are unusable.""" + active_settings = settings or LDAPSettings.from_config() + _read_secret(active_settings.bind_password_file) + ca_path = Path(active_settings.ca_file) + try: + metadata = ca_path.lstat() + if ca_path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + raise LDAPUnavailable('LDAP CA path is not a regular file') + if metadata.st_size <= 0 or metadata.st_size > _MAX_CA_BYTES: + raise LDAPUnavailable('LDAP CA bundle has an invalid size') + ca_data = ca_path.read_text(encoding='ascii') + ssl.create_default_context(cadata=ca_data) + except LDAPUnavailable: + raise + except (OSError, UnicodeDecodeError, ssl.SSLError, ValueError) as exc: + raise LDAPUnavailable('LDAP CA bundle is unavailable or invalid') from exc + + +class BonsaiBackend: + """Narrow synchronous adapter around Bonsai's libldap client.""" + + def __init__(self, bonsai_module=None): + self._bonsai_module = bonsai_module + + @property + def bonsai(self): + if self._bonsai_module is None: + try: + import bonsai + except ImportError as exc: + raise LDAPUnavailable('LDAP client dependency is unavailable') from exc + self._bonsai_module = bonsai + return self._bonsai_module + + def _client(self, settings, bind_dn, password): + bonsai = self.bonsai + use_starttls = urlsplit(settings.url).scheme == 'ldap' + client = bonsai.LDAPClient(settings.url, tls=use_starttls) + client.set_ca_cert(str(settings.ca_file)) + client.set_cert_policy('demand') + client.set_ignore_referrals(True) + client.set_server_chase_referrals(False) + client.set_credentials('SIMPLE', user=bind_dn, password=password) + return client + + def search_user(self, settings, bind_password, filter_expression): + bonsai = self.bonsai + client = self._client(settings, settings.bind_dn, bind_password) + connection = None + try: + connection = client.connect(timeout=settings.connect_timeout) + entries = connection.search( + settings.base_dn, + bonsai.LDAPSearchScope.SUBTREE, + filter_expression, + [settings.unique_id_attribute], + timeout=settings.operation_timeout, + sizelimit=2, + ) + except bonsai.LDAPError as exc: + raise LDAPUnavailable('LDAP lookup failed') from exc + finally: + if connection is not None: + connection.close(abandon_requests=True) + + identities = [] + for entry in entries: + try: + values = entry[settings.unique_id_attribute] + except (KeyError, TypeError) as exc: + raise LDAPLookupRejected( + 'LDAP identity attribute is missing' + ) from exc + if len(values) != 1: + raise LDAPLookupRejected( + 'LDAP identity attribute must have exactly one value' + ) + identities.append(LDAPIdentity( + provider=settings.provider, + subject=_canonical_subject(values[0]), + distinguished_name=str(entry.dn), + )) + return identities + + def probe(self, settings, bind_password): + bonsai = self.bonsai + client = self._client(settings, settings.bind_dn, bind_password) + connection = None + try: + connection = client.connect(timeout=settings.connect_timeout) + return True + except bonsai.LDAPError as exc: + raise LDAPUnavailable('LDAP readiness probe failed') from exc + finally: + if connection is not None: + connection.close(abandon_requests=True) + + def verify_password(self, settings, distinguished_name, password): + bonsai = self.bonsai + client = self._client(settings, distinguished_name, password) + connection = None + try: + connection = client.connect(timeout=settings.connect_timeout) + return True + except bonsai.AuthenticationError: + return False + except bonsai.LDAPError as exc: + raise LDAPUnavailable('LDAP bind failed') from exc + finally: + if connection is not None: + connection.close(abandon_requests=True) + + +class LDAPDirectory: + """Resolve and verify identities without retaining any credentials.""" + + def __init__(self, settings=None, *, backend=None): + self.settings = settings or LDAPSettings.from_config() + self.backend = backend or BonsaiBackend() + + def lookup(self, username): + normalized_username = str(username or '').strip() + if not normalized_username or len(normalized_username) > 256: + raise LDAPLookupRejected('LDAP username is invalid') + filter_expression = self.settings.user_filter.format( + username=escape_filter_value(normalized_username), + ) + bind_password = _read_secret(self.settings.bind_password_file) + try: + entries = self.backend.search_user( + self.settings, + bind_password, + filter_expression, + ) + finally: + bind_password = None + if len(entries) != 1: + raise LDAPLookupRejected( + 'LDAP lookup must return exactly one identity' + ) + entry = entries[0] + if isinstance(entry, LDAPIdentity): + subject = entry.subject + distinguished_name = entry.distinguished_name + else: + subject = entry.subject + distinguished_name = entry.dn + return LDAPIdentity( + provider=self.settings.provider, + subject=_canonical_subject(subject), + distinguished_name=_canonical_distinguished_name( + distinguished_name, + ), + ) + + def verify_password(self, distinguished_name, password): + if not password: + return False + return bool(self.backend.verify_password( + self.settings, + distinguished_name, + password, + )) + + def probe(self): + bind_password = _read_secret(self.settings.bind_password_file) + try: + return bool(self.backend.probe(self.settings, bind_password)) + finally: + bind_password = None diff --git a/app/ldap_session.py b/app/ldap_session.py new file mode 100644 index 0000000..89de2cd --- /dev/null +++ b/app/ldap_session.py @@ -0,0 +1,48 @@ +"""Periodic validation for authenticated LDAP-managed sessions.""" + +import logging + +from . import user_lifecycle +from .audit_logger import log_security_event +from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable +from .models import LDAPIdentity, User, db + + +def revalidate_user(user): + mapping = user.ldap_identity + if mapping is None or user.is_locked or user.is_admin: + raise LDAPLookupRejected('LDAP account is not eligible') + resolved = LDAPDirectory().lookup(mapping.directory_username) + if resolved.provider != mapping.provider or resolved.subject != mapping.subject: + raise LDAPLookupRejected('Stable LDAP identity no longer matches') + return resolved + + +def revalidate_all_linked_users(app, socketio_instance=None): + """Revoke live access for mappings that no longer validate.""" + with app.app_context(): + user_ids = [ + user_id + for (user_id,) in ( + db.session.query(LDAPIdentity.user_id) + .order_by(LDAPIdentity.user_id) + .all() + ) + ] + for user_id in user_ids: + user = db.session.get(User, user_id) + if user is None: + continue + try: + revalidate_user(user) + except (LDAPLookupRejected, LDAPUnavailable) as exc: + log_security_event( + 'LDAP_BACKGROUND_REVALIDATION_REJECTED', + level=logging.WARNING, + user=user.username, + error=type(exc).__name__, + ) + user_lifecycle.revoke_user_access( + user.id, + socketio_instance, + ) diff --git a/app/models.py b/app/models.py index 3f4c49b..f8d715b 100644 --- a/app/models.py +++ b/app/models.py @@ -45,6 +45,12 @@ class User(db.Model, UserMixin): cascade='all, delete-orphan', lazy='dynamic', ) + ldap_identity = db.relationship( + 'LDAPIdentity', + backref='user', + cascade='all, delete-orphan', + uselist=False, + ) def set_password(self, password): """Hash and set user password using bcrypt.""" @@ -54,6 +60,11 @@ def check_password(self, password): """Verify password against stored hash.""" return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8')) + @property + def is_ldap_managed(self): + """Return whether this account is exclusively directory-managed.""" + return self.ldap_identity is not None + def get_data_dir(self): """Get user-specific data directory.""" import config @@ -227,6 +238,38 @@ class OIDCLoginState(db.Model): code_verifier = db.Column(db.String(128), nullable=False) expires_at = db.Column(db.DateTime, nullable=False, index=True) + +class LDAPIdentity(db.Model): + """Administrator-approved stable LDAP identity mapping.""" + + __tablename__ = 'ldap_identities' + __table_args__ = ( + db.UniqueConstraint( + 'provider', + 'subject', + name='uq_ldap_provider_subject', + ), + ) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + nullable=False, + unique=True, + index=True, + ) + provider = db.Column(db.String(64), nullable=False) + subject = db.Column(db.String(512), nullable=False) + directory_username = db.Column(db.String(256), nullable=False) + distinguished_name = db.Column(db.String(2048), nullable=False) + created_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + last_verified_at = db.Column(db.DateTime) + class SSHSession(db.Model): """Tracks SSH connections for users (persistent across browser reconnects).""" __tablename__ = 'ssh_sessions' diff --git a/app/oidc_routes.py b/app/oidc_routes.py index e9f44af..19fc127 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -44,6 +44,8 @@ def _require_enabled(): def _password_matches(user, password): + if user.is_ldap_managed: + return False try: return user.check_password(password) except (TypeError, ValueError): @@ -177,7 +179,7 @@ def oidc_callback(): ): raise OIDCStateError("OIDC email domain is not allowed") user = resolve_identity(issuer, subject) - if user is None or user.is_locked: + if user is None or user.is_locked or user.is_ldap_managed: log_security_event( "OIDC_IDENTITY_REJECTED", level=logging.WARNING, @@ -234,6 +236,10 @@ def link_oidc_identity(user_id): target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 + if target.is_ldap_managed: + return jsonify({ + "error": "OIDC identities cannot be linked to LDAP accounts" + }), 400 if data.get("confirm_username") != target.username: return jsonify({"error": "Target confirmation does not match"}), 400 subject = str(data.get("subject") or "").strip() diff --git a/app/recovery_routes.py b/app/recovery_routes.py index 22390ee..3f2cdfd 100644 --- a/app/recovery_routes.py +++ b/app/recovery_routes.py @@ -23,6 +23,8 @@ def _require_enabled(): def _password_matches(user, password): + if user.is_ldap_managed: + return False try: return user.check_password(password) except (TypeError, ValueError): @@ -90,7 +92,15 @@ def recovery_login(): return _request_body_too_large() username = str(data.get("username") or "").strip() user = User.query.filter_by(username=username).first() - active_user = user if user is not None and not user.is_locked else None + active_user = ( + user + if ( + user is not None + and not user.is_locked + and not user.is_ldap_managed + ) + else None + ) code_valid = consume_code( active_user.id if active_user is not None else None, data.get("code", ""), @@ -136,6 +146,10 @@ def admin_recovery(user_id): target = db.session.get(User, user_id) if target is None: return jsonify({"error": "User not found"}), 404 + if target.is_ldap_managed: + return jsonify({ + "error": "Recovery codes are unavailable for LDAP accounts" + }), 400 if data.get("confirm_username") != target.username: return jsonify({"error": "Target confirmation does not match"}), 400 codes = generate_codes(target.id) diff --git a/app/webauthn_routes.py b/app/webauthn_routes.py index 7c83b3a..a4a99e6 100644 --- a/app/webauthn_routes.py +++ b/app/webauthn_routes.py @@ -84,6 +84,8 @@ def _credential_descriptor(row): @login_required def list_credentials(): _require_enabled() + if current_user.is_ldap_managed: + return jsonify({"error": "Passkeys are unavailable for LDAP accounts"}), 403 rows = WebAuthnCredential.query.filter_by( user_id=current_user.id ).order_by(WebAuthnCredential.id.asc()).all() @@ -106,6 +108,8 @@ def list_credentials(): @login_required def registration_options(): _require_enabled() + if current_user.is_ldap_managed: + return jsonify({"error": "Passkeys are unavailable for LDAP accounts"}), 403 client_ip = request.remote_addr or "unknown" if config.RATELIMIT_ENABLED and check_reauth_rate_limit( current_user.id, @@ -160,6 +164,8 @@ def registration_options(): @login_required def verify_registration(): _require_enabled() + if current_user.is_ldap_managed: + return jsonify({"error": "Passkeys are unavailable for LDAP accounts"}), 403 data = _bounded_json() if data is None: return _request_body_too_large() @@ -234,6 +240,8 @@ def verify_registration(): @login_required def delete_credential(credential_id): _require_enabled() + if current_user.is_ldap_managed: + return jsonify({"error": "Passkeys are unavailable for LDAP accounts"}), 403 client_ip = request.remote_addr or "unknown" if config.RATELIMIT_ENABLED and check_reauth_rate_limit( current_user.id, @@ -317,7 +325,12 @@ def verify_authentication(): credential_id=credential_id, ).first() user = db.session.get(User, row.user_id) if row is not None else None - if row is None or user is None or user.is_locked: + if ( + row is None + or user is None + or user.is_locked + or user.is_ldap_managed + ): raise ChallengeError("Credential is not available") username = user.username verified = verify_authentication_response( diff --git a/config.py b/config.py index 5aa31f3..9703785 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,9 @@ import os import secrets import ipaddress +import re import tempfile -from pathlib import Path +from pathlib import Path, PurePosixPath from datetime import timedelta from urllib.parse import urlsplit @@ -81,6 +82,32 @@ '10 per minute', ) +# Optional LDAP authentication. Configuration values remain inert until the +# explicit feature flag is enabled; in particular, secret files are never read +# while LDAP is disabled. +LDAP_ENABLED = os.environ.get('LDAP_ENABLED', 'false').lower() == 'true' +LDAP_PROVIDER_ID = os.environ.get('LDAP_PROVIDER_ID', 'default').strip() +LDAP_URL = os.environ.get('LDAP_URL', '').strip() +LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '').strip() +LDAP_BIND_DN = os.environ.get('LDAP_BIND_DN', '').strip() +LDAP_BIND_PASSWORD_FILE = os.environ.get( + 'LDAP_BIND_PASSWORD_FILE', + '/run/webssh-auth/ldap_bind_password', +).strip() +LDAP_CA_FILE = os.environ.get( + 'LDAP_CA_FILE', + '/run/webssh-auth/ldap_ca.pem', +).strip() +LDAP_USER_FILTER = os.environ.get('LDAP_USER_FILTER', '').strip() +LDAP_UNIQUE_ID_ATTRIBUTE = os.environ.get( + 'LDAP_UNIQUE_ID_ATTRIBUTE', + '', +).strip() +LDAP_LOGIN_RATE_LIMIT = os.environ.get( + 'LDAP_LOGIN_RATE_LIMIT', + '5 per minute', +) + def _positive_int_env(name, default): raw_value = os.environ.get(name, str(default)) @@ -129,6 +156,17 @@ def _non_negative_int_env(name, default): return value +LDAP_CONNECT_TIMEOUT = _bounded_int_env( + 'LDAP_CONNECT_TIMEOUT', 5, 1, 15 +) +LDAP_OPERATION_TIMEOUT = _bounded_int_env( + 'LDAP_OPERATION_TIMEOUT', 5, 1, 30 +) +LDAP_SESSION_REVALIDATION_SECONDS = _bounded_int_env( + 'LDAP_SESSION_REVALIDATION_SECONDS', 300, 60, 3600 +) + + AUDIT_LOG_MAX_BYTES = _positive_int_env( 'AUDIT_LOG_MAX_BYTES', 10 * 1024 * 1024 ) @@ -238,7 +276,7 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): # Three permanent cleanup jobs occupy executor slots for the app lifetime. # Reader and transfer capacity must stay available beyond those loops, otherwise # an idle cleanup job can starve an accepted SSH session or background transfer. -BACKGROUND_CLEANUP_JOBS = 3 +BACKGROUND_CLEANUP_JOBS = 3 + (1 if LDAP_ENABLED else 0) BACKGROUND_WORKERS_MAX = 128 BACKGROUND_WORKERS_MIN = ( BACKGROUND_CLEANUP_JOBS @@ -481,6 +519,99 @@ def _csv_env(name): def validate_security_config(): """Validate the selected deployment profile and return compatibility warnings.""" + if LDAP_ENABLED: + required_ldap_settings = { + 'LDAP_URL': LDAP_URL, + 'LDAP_PROVIDER_ID': LDAP_PROVIDER_ID, + 'LDAP_BASE_DN': LDAP_BASE_DN, + 'LDAP_BIND_DN': LDAP_BIND_DN, + 'LDAP_BIND_PASSWORD_FILE': LDAP_BIND_PASSWORD_FILE, + 'LDAP_CA_FILE': LDAP_CA_FILE, + 'LDAP_USER_FILTER': LDAP_USER_FILTER, + 'LDAP_UNIQUE_ID_ATTRIBUTE': LDAP_UNIQUE_ID_ATTRIBUTE, + } + for setting_name, setting_value in required_ldap_settings.items(): + if not setting_value: + raise RuntimeError( + f'SECURITY ERROR: {setting_name} is required when ' + 'LDAP_ENABLED is true' + ) + + parsed_ldap_url = urlsplit(LDAP_URL) + if ( + parsed_ldap_url.scheme not in {'ldap', 'ldaps'} + or not parsed_ldap_url.hostname + or parsed_ldap_url.username is not None + or parsed_ldap_url.password is not None + or parsed_ldap_url.path + or parsed_ldap_url.query + or parsed_ldap_url.fragment + ): + raise RuntimeError( + 'SECURITY ERROR: LDAP_URL must be an exact ldap:// or ' + 'ldaps:// server URL without credentials, path, query, or ' + 'fragment' + ) + if LDAP_USER_FILTER.count('{username}') != 1: + raise RuntimeError( + 'SECURITY ERROR: LDAP_USER_FILTER must contain exactly one ' + '{username} placeholder' + ) + if ( + len(LDAP_USER_FILTER) > 4096 + or '\x00' in LDAP_USER_FILTER + or '{' in LDAP_USER_FILTER.replace('{username}', '') + or '}' in LDAP_USER_FILTER.replace('{username}', '') + ): + raise RuntimeError( + 'SECURITY ERROR: LDAP_USER_FILTER contains an unsupported ' + 'template or exceeds the size limit' + ) + if not re.fullmatch( + r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}', + LDAP_PROVIDER_ID, + ): + raise RuntimeError( + 'SECURITY ERROR: LDAP_PROVIDER_ID must be a short, stable ' + 'identifier containing only letters, digits, dot, dash, or ' + 'underscore' + ) + ldap_attribute_pattern = ( + r'(?:[A-Za-z][A-Za-z0-9-]*|[0-9]+(?:\.[0-9]+)+)' + ) + if not re.fullmatch(ldap_attribute_pattern, LDAP_UNIQUE_ID_ATTRIBUTE): + raise RuntimeError( + 'SECURITY ERROR: LDAP_UNIQUE_ID_ATTRIBUTE must be an LDAP ' + 'attribute name or numeric OID' + ) + for setting_name, distinguished_name in ( + ('LDAP_BASE_DN', LDAP_BASE_DN), + ('LDAP_BIND_DN', LDAP_BIND_DN), + ): + if len(distinguished_name) > 2048 or '\x00' in distinguished_name: + raise RuntimeError( + f'SECURITY ERROR: {setting_name} is invalid or exceeds ' + 'the size limit' + ) + + def _is_absolute_secret_path(value): + return Path(value).is_absolute() or PurePosixPath(value).is_absolute() + + if not _is_absolute_secret_path(LDAP_BIND_PASSWORD_FILE): + raise RuntimeError( + 'SECURITY ERROR: LDAP_BIND_PASSWORD_FILE must be an ' + 'absolute path' + ) + if not _is_absolute_secret_path(LDAP_CA_FILE): + raise RuntimeError( + 'SECURITY ERROR: LDAP_CA_FILE must be an absolute path' + ) + if Path(LDAP_BIND_PASSWORD_FILE) == Path(LDAP_CA_FILE): + raise RuntimeError( + 'SECURITY ERROR: LDAP_BIND_PASSWORD_FILE and LDAP_CA_FILE ' + 'must be different files' + ) + if WEBAUTHN_ENABLED: parsed_webauthn_origin = urlsplit(WEBAUTHN_ORIGIN) origin_host = (parsed_webauthn_origin.hostname or '').lower() diff --git a/docker-compose.ldap.yml b/docker-compose.ldap.yml new file mode 100644 index 0000000..1ba5e38 --- /dev/null +++ b/docker-compose.ldap.yml @@ -0,0 +1,44 @@ +# Optional LDAP / Active Directory overlay for WebSSH. +# +# 1. Fill every empty directory setting below. +# 2. Populate the secret volume with the isolated ldap-tools helper. +# 3. Start WebSSH with both Compose files as documented in +# docs/ldap-authentication.md. +services: + webssh: + environment: + LDAP_ENABLED: "true" + LDAP_PROVIDER_ID: default + LDAP_URL: "" + LDAP_BASE_DN: "" + LDAP_BIND_DN: "" + LDAP_USER_FILTER: "" + LDAP_UNIQUE_ID_ATTRIBUTE: "" + # Safe bounded defaults are built in. Uncomment only to tune them. + # LDAP_CONNECT_TIMEOUT: "5" + # LDAP_OPERATION_TIMEOUT: "5" + # LDAP_SESSION_REVALIDATION_SECONDS: "300" + # LDAP_LOGIN_RATE_LIMIT: 5 per minute + volumes: + - webssh_auth_secrets:/run/webssh-auth:ro + + # Runs only when explicitly invoked. It uses the normal WebSSH image, has no + # network, and is the sole Compose service with write access to LDAP secrets. + ldap-tools: + image: ghcr.io/bifrost0x/webssh:latest + profiles: ["ldap-tools"] + restart: "no" + network_mode: none + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + entrypoint: ["python", "/app/ldap_secret_cli.py"] + command: ["status"] + volumes: + - webssh_auth_secrets:/run/webssh-auth + +volumes: + webssh_auth_secrets: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 12cff40..d9403ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -144,7 +144,7 @@ services: retries: 3 start_period: 10s - # ── Redis (optional — uncomment for external rate-limit counters) ────── + # Redis (optional - uncomment for external rate-limit counters) # redis: # image: redis:7-alpine # container_name: webssh-redis diff --git a/docs/ldap-authentication.md b/docs/ldap-authentication.md new file mode 100644 index 0000000..a77a0d1 --- /dev/null +++ b/docs/ldap-authentication.md @@ -0,0 +1,208 @@ +# Optional LDAP and Active Directory authentication + +LDAP authentication is optional and disabled by default. The regular +`docker-compose.yml` creates no LDAP volume, mount, or helper. Opting in uses +the provided `docker-compose.ldap.yml` overlay with the same WebSSH image; no +`.env` file or hand-written secret mount is required. + +## Security model + +- `LDAP_ENABLED=false` registers no LDAP routes, starts no LDAP job, reads no + LDAP file, and makes no directory connection. +- `ldap://` always performs StartTLS before any bind. `ldaps://` starts with + TLS. Plain LDAP authentication and disabled certificate verification are not + supported. +- A least-privilege service account performs read-only user searches. The + submitted user password is used only for the final bind and is never stored. +- Directory usernames are escaped as RFC 4515 filter values. Searches are + subtree-scoped, limited to two results, and must resolve to exactly one entry. +- An administrator explicitly links a directory identity to an existing local + WebSSH user. There is no automatic account creation or username-only trust. +- The stable `entryUUID` (OpenLDAP) or `objectGUID` (Active Directory) is the + identity key. A renamed DN can be updated after successful authentication + without changing account ownership. +- LDAP users cannot be administrators and cannot fall back to local passwords, + passkeys, recovery codes, or OIDC. Keep at least one unlinked local + break-glass administrator. +- Linking destroys the user's dormant local password and all alternative local + login factors. Unlinking requires a fresh local password. +- LDAP sessions are revalidated every five minutes by default. A disabled + feature, directory outage, missing user, changed stable ID, locked account + excluded by the configured filter, or certificate failure revokes the user's + browser, Socket.IO, SSH, transfer, and pooled-connection access. + +## Information you need from the directory administrator + +Collect these values before activation: + +1. An LDAP server DNS name whose TLS certificate matches that name. +2. Whether to use `ldap://host:389` with StartTLS or `ldaps://host:636`. +3. The user search base DN. +4. A read-only bind account DN and its password. It needs only enough access to + search the configured base and read the stable ID attribute. +5. A PEM CA bundle containing the issuing root and any required intermediates. +6. A user filter containing exactly one literal `{username}` placeholder. +7. A stable unique-ID attribute (`entryUUID` or `objectGUID`). + +DNS and system time must work inside the WebSSH container. Do not use an IP +address when the server certificate contains only a DNS name. + +## Configure the LDAP Compose overlay + +Edit `docker-compose.ldap.yml`. Selecting this overlay enables LDAP, so fill +every empty directory setting before starting WebSSH. The secret helper can be +run first without starting the WebSSH service. Always use base and LDAP Compose +files from the same WebSSH release. + +### Active Directory example + +```yaml +LDAP_ENABLED: "true" +LDAP_PROVIDER_ID: corp-ad +LDAP_URL: ldaps://dc01.ad.example.com:636 +LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com +LDAP_BIND_DN: CN=svc-webssh,OU=Service Accounts,DC=ad,DC=example,DC=com +LDAP_USER_FILTER: "(&(objectCategory=person)(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" +LDAP_UNIQUE_ID_ATTRIBUTE: objectGUID +``` + +The final filter clause excludes disabled AD accounts. If the organization has +a dedicated WebSSH access group, add a directory-approved `memberOf` clause. +Nested group semantics vary and must be validated by the AD administrator. + +### Generic OpenLDAP example + +```yaml +LDAP_ENABLED: "true" +LDAP_PROVIDER_ID: primary-openldap +LDAP_URL: ldap://ldap.example.com:389 +LDAP_BASE_DN: ou=people,dc=example,dc=com +LDAP_BIND_DN: cn=svc-webssh,ou=services,dc=example,dc=com +LDAP_USER_FILTER: "(&(objectClass=inetOrgPerson)(uid={username})(!(pwdAccountLockedTime=*)))" +LDAP_UNIQUE_ID_ATTRIBUTE: entryUUID +``` + +Remove the `pwdAccountLockedTime` clause if that operational attribute is not +available, and replace it with the directory's actual disabled-account rule. + +## Populate the opt-in secret volume + +The LDAP overlay creates `webssh_auth_secrets` and mounts it read-only at +`/run/webssh-auth` in WebSSH. The standard Compose deployment does not declare +or mount this volume. + +Set the bind password with a hidden interactive prompt: + +```bash +docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools set-password +``` + +Install and validate the CA bundle on Linux or macOS: + +```bash +docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin < company-ca.pem +``` + +PowerShell equivalent: + +```powershell +Get-Content -Raw .\company-ca.pem | docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin +``` + +Check only file presence; the helper never prints their contents: + +```bash +docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools status +``` + +The helper validates input, caps file sizes, writes atomically, and applies +private permissions. It is standalone, has no network access, and is the only +Compose service with write access to the LDAP volume. WebSSH mounts that volume +read-only whenever the LDAP overlay is selected. The helper still works if an +incomplete LDAP configuration prevents the Flask application from starting. + +## Activate and link users + +1. Verify that every empty value in `docker-compose.ldap.yml` is configured. +2. Start or recreate WebSSH with the overlay: + `docker compose -f docker-compose.yml -f docker-compose.ldap.yml up -d`. +3. Review startup logs with + `docker compose -f docker-compose.yml -f docker-compose.ldap.yml logs webssh`. + WebSSH deliberately stops + before serving if the secret, CA, URL, filter, or timeout configuration is + unsafe. +4. Sign in with the local break-glass administrator. +5. Open **Admin - Settings - LDAP directory** and run **Check connection**. + The browser receives only ready/unavailable, transport, and provider ID. +6. Create the target local WebSSH user if it does not exist. +7. On the Users tab choose **Link LDAP**, enter the directory username, the + administrator password, and the exact target WebSSH username. +8. Sign out and test **Sign in with LDAP** using that directory username. + +Do one non-administrator pilot account before migrating more users. + +For the strict production profile, keep the LDAP overlay before the production +overlay so the production settings remain authoritative: + +```bash +docker compose -f docker-compose.yml -f docker-compose.ldap.yml -f docker-compose.production.yml up -d +``` + +## Rollback and recovery + +To stop LDAP immediately, recreate WebSSH from the standard Compose file only: + +```bash +docker compose -f docker-compose.yml up -d --force-recreate +``` + +This removes the LDAP environment and mount from the container. The named +secret volume remains stored but detached until the overlay is selected again. +Existing LDAP sessions are invalidated. Local accounts continue normally, but +linked LDAP accounts intentionally do not regain their old passwords. + +To return one account to local authentication while LDAP is working, choose +**Manage LDAP**, provide the administrator password, exact target username, and +a new local password, then unlink it. + +To remove only LDAP secret files after all identities have been unlinked and +LDAP is disabled: + +```bash +docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools remove +``` + +Do not delete the normal WebSSH data volume. LDAP mappings live in the normal +database and are included in native backup/restore; bind credentials and the CA +remain in the separate secret volume and are intentionally not included. + +## Troubleshooting + +- **Application refuses to start:** run the standalone `status` helper and + check the exact configuration error in container logs. +- **Certificate failure:** verify DNS, certificate SAN, system time, the full CA + chain, and that the CA is PEM rather than DER or PKCS#12. +- **Zero or multiple matches:** test the search base/filter with the directory + administrator. WebSSH never chooses one result from an ambiguous search. +- **AD user is found but cannot bind:** check disabled/locked/expired state, + logon restrictions, and whether the DC accepts the supplied username flow. +- **LDAP outage signs users out:** this is intentional fail-closed behavior. + Restore directory/TLS service; do not enable a password fallback. +- **Provider ID changed:** restore the original `LDAP_PROVIDER_ID`. It is part + of every stable mapping and should remain constant for that directory. + +## Local test laboratory + +An Active Directory domain is not required for basic functional testing. The +opt-in lab under `tests/integration/ldap/` starts a TLS-enabled OpenLDAP server, +initializes the preconfigured secret volume, builds WebSSH, and exposes it on +`http://localhost:5050`. It uses test-only passwords and must never be deployed +as production infrastructure. + +```bash +docker compose -f tests/integration/ldap/docker-compose.yml up --build +``` + +The lab validates generic LDAP behavior. `objectGUID`, AD disabled-account +filters, AD certificate enrollment, and domain-controller policy still require +an AD-specific acceptance test before production rollout. diff --git a/ldap_secret_cli.py b/ldap_secret_cli.py new file mode 100644 index 0000000..a7e0915 --- /dev/null +++ b/ldap_secret_cli.py @@ -0,0 +1,163 @@ +"""Standalone LDAP secret-volume management. + +This file deliberately lives outside the ``app`` package so Python does not +import Flask or project configuration before a broken LDAP setup can be +repaired. +""" + +import getpass +import os +import ssl +import sys +import tempfile +from pathlib import Path + +import click + + +_PASSWORD_FILE = 'ldap_bind_password' +_CA_FILE = 'ldap_ca.pem' +_MAX_PASSWORD_BYTES = 16 * 1024 +_MAX_CA_BYTES = 1024 * 1024 + + +def _validated_directory(value): + directory = Path(value) + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if not directory.is_dir() or directory.is_symlink(): + raise click.ClickException('Secret directory must be a real directory') + try: + directory.chmod(0o700) + except OSError as exc: + raise click.ClickException('Cannot secure the secret directory') from exc + return directory + + +def _atomic_write(directory, filename, content, mode=0o600): + target = directory / filename + temporary_name = None + try: + with tempfile.NamedTemporaryFile( + mode='wb', + dir=directory, + prefix=f'.{filename}.', + suffix='.tmp', + delete=False, + ) as temporary: + temporary_name = Path(temporary.name) + os.chmod(temporary.name, mode) + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_name, target) + os.chmod(target, mode) + if os.name != 'nt': + directory_fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError as exc: + if temporary_name is not None: + try: + temporary_name.unlink(missing_ok=True) + except OSError: + pass + raise click.ClickException(f'Could not write {filename}') from exc + + +def _read_stdin(limit): + data = sys.stdin.buffer.read(limit + 1) + if len(data) > limit: + raise click.ClickException('Input exceeds the supported size limit') + return data + + +@click.group() +@click.option( + '--secret-dir', + type=click.Path(path_type=Path), + default=Path('/run/webssh-auth'), + show_default=True, +) +@click.pass_context +def cli(context, secret_dir): + """Manage LDAP files in WebSSH's dedicated secret volume.""" + context.obj = _validated_directory(secret_dir) + + +@cli.command('set-password') +@click.option('--stdin', 'from_stdin', is_flag=True, help='Read from standard input.') +@click.pass_obj +def set_password(secret_dir, from_stdin): + """Set the least-privilege LDAP search-account password.""" + if from_stdin: + raw = _read_stdin(_MAX_PASSWORD_BYTES).rstrip(b'\r\n') + else: + first = getpass.getpass('LDAP bind password: ') + second = getpass.getpass('Repeat LDAP bind password: ') + if first != second: + raise click.ClickException('Passwords do not match') + try: + raw = first.encode('utf-8') + except UnicodeEncodeError as exc: + raise click.ClickException('Password must be valid UTF-8') from exc + if not raw or len(raw) > _MAX_PASSWORD_BYTES or b'\x00' in raw: + raise click.ClickException('Password is empty or invalid') + try: + raw.decode('utf-8') + except UnicodeDecodeError as exc: + raise click.ClickException('Password must be valid UTF-8') from exc + _atomic_write(secret_dir, _PASSWORD_FILE, raw) + click.echo('LDAP bind password stored securely.') + + +@cli.command('install-ca') +@click.option('--stdin', 'from_stdin', is_flag=True, help='Read PEM from standard input.') +@click.argument('pem_file', required=False, type=click.Path(exists=True, path_type=Path)) +@click.pass_obj +def install_ca(secret_dir, from_stdin, pem_file): + """Validate and install the CA bundle used to verify LDAP TLS.""" + if from_stdin == (pem_file is not None): + raise click.ClickException('Use exactly one of --stdin or PEM_FILE') + if from_stdin: + raw = _read_stdin(_MAX_CA_BYTES) + else: + if pem_file.stat().st_size > _MAX_CA_BYTES: + raise click.ClickException('CA bundle exceeds the supported size limit') + raw = pem_file.read_bytes() + try: + pem = raw.decode('ascii') + ssl.create_default_context(cadata=pem) + except (UnicodeDecodeError, ssl.SSLError, ValueError) as exc: + raise click.ClickException('CA bundle is not valid PEM certificate data') from exc + _atomic_write(secret_dir, _CA_FILE, raw) + click.echo('LDAP CA bundle installed securely.') + + +@cli.command('status') +@click.pass_obj +def status(secret_dir): + """Show whether the required files exist without reading their content.""" + for filename in (_PASSWORD_FILE, _CA_FILE): + state = 'present' if (secret_dir / filename).is_file() else 'missing' + click.echo(f'{filename}: {state}') + + +@cli.command('remove') +@click.option('--yes', is_flag=True, help='Skip the destructive confirmation.') +@click.pass_obj +def remove(secret_dir, yes): + """Remove only the two LDAP files from the dedicated volume.""" + if not yes and not click.confirm('Remove LDAP bind password and CA bundle?'): + raise click.Abort() + for filename in (_PASSWORD_FILE, _CA_FILE): + try: + (secret_dir / filename).unlink(missing_ok=True) + except OSError as exc: + raise click.ClickException(f'Could not remove {filename}') from exc + click.echo('LDAP secret files removed.') + + +if __name__ == '__main__': + cli() diff --git a/requirements-test.txt b/requirements-test.txt index 43354f4..917f035 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -84,6 +84,29 @@ blinker==1.9.0 \ # via # flask # flask-socketio +bonsai==1.5.5 \ + --hash=sha256:12918054549ef567d792e7b5083bd257f98339b11b5a4f49dd717273c84f55f8 \ + --hash=sha256:1b9d8228b3ae334dd3f9bde6bcc81fc432b07da6aacd4b2de8085b40b6f3aa5b \ + --hash=sha256:2517590867ef77cdaaa1ceee2411703f5f1b14e1938786d7855d0a45ec835d3d \ + --hash=sha256:28b49661a8e7e0bdec58048c473154bec4fbe151cb4c91bb3de7c3b9acddcd6e \ + --hash=sha256:2e04a7d7c35c527e92b1835b43063a448c24e01b87b41cd54257ca0a04e24dd6 \ + --hash=sha256:404cf47b5da153a6a88fe7a6f34fbbbb14351e742547ea683953fdaee4b22ce3 \ + --hash=sha256:417d2c28ec91af0300c9715cc724b84a79ec3b471bc41d3a0a71009fae0cb022 \ + --hash=sha256:5c941199b8349761c207366013e4481df52d99c83e6c10ed1372de890ea230f3 \ + --hash=sha256:77ce92cee2136fe87643e5c5f89f096f384a8a801988416fad78c5fb98e4b746 \ + --hash=sha256:7da307dcf0d796ab6594aabe7dc2248a58146e60e1513f0349cc5d8c7102ee55 \ + --hash=sha256:850933a9cf5d918b14b9b2bfa4d28e8f39f55140f861a0163c8c7f7d7142d641 \ + --hash=sha256:8b8df73b61c74d819a06cacd9df8a61e1a13e84a9fca091fd1661bb2ca7aaa4f \ + --hash=sha256:8d0d41db8b6cf9e3f78122ae92c98f0785a8c87cf6e5929b59b36836df666b44 \ + --hash=sha256:8ebaedbc3c344887f70b890d3e38f28509254ce4c5f622f6b2cc96f209de949f \ + --hash=sha256:b9a6385e21c549b4ff75fa8e946bd5b08c8891e8cdf6b1c5a72baf47a3e1338b \ + --hash=sha256:c1aa689417325ef40fd20481b00bbf4478178dcda7dfdff7348a6cb172065f2d \ + --hash=sha256:c3c7845a4c7a361108d80c1ac6718a9d37593b8e975635e02bdd02000fc2511d \ + --hash=sha256:ca619376ac52c97cbe785e4ccfabda2a3a9d3c3f55da8cd304753de0eb356be0 \ + --hash=sha256:e3ea37384e6af87517b7fb467acca8001580afa47644b572c2a881206524c52b \ + --hash=sha256:e822a8e0a02a10ee2dc2276a43538d67ab1fa4974a33f34cb763b246db6d52f7 \ + --hash=sha256:fb9a1fcf149fb3fdabfc33a24aad0c1485a88bf619e5a36b7f0898d11b7cd03e + # via -r requirements.in cbor2==6.1.4 \ --hash=sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9 \ --hash=sha256:032ce71cbdc9267e9cec42132f6ac6c40f441ec27ebc87ca9dd209dcab6804ee \ diff --git a/requirements.in b/requirements.in index ad6d5af..d7948fb 100644 --- a/requirements.in +++ b/requirements.in @@ -26,3 +26,6 @@ webauthn>=3.0,<4 Authlib>=1.7,<2 # Compatibility: Authlib's Flask client uses the reviewed Requests 2.x transport API. requests>=2.34,<3 +# Compatibility: Bonsai 1.5.5 supports Python 3.10-3.14 and exposes the +# reviewed OpenLDAP StartTLS, certificate-policy, timeout, and referral APIs. +bonsai==1.5.5 diff --git a/requirements.txt b/requirements.txt index 8860855..619f7c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -84,6 +84,29 @@ blinker==1.9.0 \ # via # flask # flask-socketio +bonsai==1.5.5 \ + --hash=sha256:12918054549ef567d792e7b5083bd257f98339b11b5a4f49dd717273c84f55f8 \ + --hash=sha256:1b9d8228b3ae334dd3f9bde6bcc81fc432b07da6aacd4b2de8085b40b6f3aa5b \ + --hash=sha256:2517590867ef77cdaaa1ceee2411703f5f1b14e1938786d7855d0a45ec835d3d \ + --hash=sha256:28b49661a8e7e0bdec58048c473154bec4fbe151cb4c91bb3de7c3b9acddcd6e \ + --hash=sha256:2e04a7d7c35c527e92b1835b43063a448c24e01b87b41cd54257ca0a04e24dd6 \ + --hash=sha256:404cf47b5da153a6a88fe7a6f34fbbbb14351e742547ea683953fdaee4b22ce3 \ + --hash=sha256:417d2c28ec91af0300c9715cc724b84a79ec3b471bc41d3a0a71009fae0cb022 \ + --hash=sha256:5c941199b8349761c207366013e4481df52d99c83e6c10ed1372de890ea230f3 \ + --hash=sha256:77ce92cee2136fe87643e5c5f89f096f384a8a801988416fad78c5fb98e4b746 \ + --hash=sha256:7da307dcf0d796ab6594aabe7dc2248a58146e60e1513f0349cc5d8c7102ee55 \ + --hash=sha256:850933a9cf5d918b14b9b2bfa4d28e8f39f55140f861a0163c8c7f7d7142d641 \ + --hash=sha256:8b8df73b61c74d819a06cacd9df8a61e1a13e84a9fca091fd1661bb2ca7aaa4f \ + --hash=sha256:8d0d41db8b6cf9e3f78122ae92c98f0785a8c87cf6e5929b59b36836df666b44 \ + --hash=sha256:8ebaedbc3c344887f70b890d3e38f28509254ce4c5f622f6b2cc96f209de949f \ + --hash=sha256:b9a6385e21c549b4ff75fa8e946bd5b08c8891e8cdf6b1c5a72baf47a3e1338b \ + --hash=sha256:c1aa689417325ef40fd20481b00bbf4478178dcda7dfdff7348a6cb172065f2d \ + --hash=sha256:c3c7845a4c7a361108d80c1ac6718a9d37593b8e975635e02bdd02000fc2511d \ + --hash=sha256:ca619376ac52c97cbe785e4ccfabda2a3a9d3c3f55da8cd304753de0eb356be0 \ + --hash=sha256:e3ea37384e6af87517b7fb467acca8001580afa47644b572c2a881206524c52b \ + --hash=sha256:e822a8e0a02a10ee2dc2276a43538d67ab1fa4974a33f34cb763b246db6d52f7 \ + --hash=sha256:fb9a1fcf149fb3fdabfc33a24aad0c1485a88bf619e5a36b7f0898d11b7cd03e + # via -r requirements.in cbor2==6.1.4 \ --hash=sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9 \ --hash=sha256:032ce71cbdc9267e9cec42132f6ac6c40f441ec27ebc87ca9dd209dcab6804ee \ diff --git a/static/js/admin.js b/static/js/admin.js index 0c140fd..e6d637c 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -5,6 +5,7 @@ const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || ''; const CURRENT_USER = document.querySelector('meta[name="current-user"]')?.content || ''; const OIDC_ENABLED = document.querySelector('meta[name="oidc-enabled"]')?.content === 'true'; + const LDAP_ENABLED = document.querySelector('meta[name="ldap-enabled"]')?.content === 'true'; const RECOVERY_ENABLED = document.querySelector('meta[name="recovery-enabled"]')?.content === 'true'; const t = (key, fallback) => { @@ -78,7 +79,7 @@ const parts = []; if (u.is_admin) { parts.push(``); - } else { + } else if (!u.ldap_managed) { parts.push(``); } if (u.is_locked) { @@ -86,12 +87,16 @@ } else { parts.push(``); } - if (RECOVERY_ENABLED) { + if (RECOVERY_ENABLED && !u.ldap_managed) { parts.push(``); } - if (OIDC_ENABLED) { + if (OIDC_ENABLED && !u.ldap_managed) { parts.push(``); } + if (LDAP_ENABLED && !u.is_admin) { + const label = u.ldap_managed ? 'Manage LDAP' : 'Link LDAP'; + parts.push(``); + } parts.push(``); return `
${parts.join('')}
`; } @@ -111,7 +116,7 @@ : `${escapeHtml(t('admin.statusActive', 'Active'))}`; tr.innerHTML = `${u.id}` + - `${escapeHtml(u.username)}${u.username === CURRENT_USER ? ' (' + escapeHtml(t('admin.you', 'you')) + ')' : ''}` + + `${escapeHtml(u.username)}${u.ldap_managed ? ' (LDAP)' : ''}${u.username === CURRENT_USER ? ' (' + escapeHtml(t('admin.you', 'you')) + ')' : ''}` + `${role}` + `${status}` + `${escapeHtml(fmtDate(u.created_at))}` + @@ -143,6 +148,7 @@ const securityRequests = window.WebSSHSecurityUI.createRequestCoordinator({ persistentChannels: ['action'] }); + let currentLdapIdentityId = null; function clearSecurityReauthentication() { ['securityActionPassword', 'securityActionConfirmation'].forEach(id => { @@ -152,7 +158,7 @@ } function clearSecurityActionFields() { - ['securityActionPassword', 'securityActionConfirmation', 'securityActionSubject', 'securityActionResult'] + ['securityActionPassword', 'securityActionConfirmation', 'securityActionSubject', 'securityActionDirectoryUsername', 'securityActionNewPassword', 'securityActionResult'] .forEach(id => { const field = document.getElementById(id); if (field) { field.value = ''; } @@ -178,6 +184,48 @@ document.getElementById('securityActionOidcListGroup')?.classList.add('hidden'); const oidcList = document.getElementById('securityActionOidcList'); if (oidcList) { oidcList.textContent = ''; } + currentLdapIdentityId = null; + } + + async function checkLdapStatus() { + const button = document.getElementById('ldapStatusCheck'); + const result = document.getElementById('ldapStatusResult'); + if (!button || !result) { return; } + button.disabled = true; + result.textContent = 'Checking...'; + try { + const data = await api('/admin/api/ldap/status'); + result.textContent = `Ready (${data.transport}, provider ${data.provider})`; + } catch (e) { + result.textContent = 'Unavailable'; + notify(e.message, 'error'); + } finally { + button.disabled = false; + } + } + + async function loadLdapIdentity() { + const context = securityRequests.current(); + if (context?.mode !== 'ldap-link' || !context.userId) { return; } + const requestState = securityRequests.begin('list', { replace: true }); + if (!requestState) { return; } + try { + const data = await api(`/admin/api/users/${requestState.context.userId}/ldap-identity`); + if (!securityRequests.isCurrent(requestState)) { return; } + const identity = data.identity; + currentLdapIdentityId = identity?.id || null; + const usernameField = document.getElementById('securityActionDirectoryUsername'); + if (usernameField) { + usernameField.value = identity?.directory_username || requestState.context.username; + usernameField.disabled = Boolean(identity); + } + document.getElementById('securityActionNewPasswordGroup')?.classList.toggle('hidden', !identity); + document.getElementById('submitSecurityAction').textContent = identity ? 'Unlink LDAP' : 'Link LDAP'; + } catch (e) { + if (securityRequests.isCurrent(requestState)) { notify(e.message, 'error'); } + } finally { + securityRequests.finish(requestState); + } } async function loadOidcIdentities() { @@ -231,10 +279,15 @@ setSecurityActionPending(securityRequests.isPending('action')); clearSecurityActionFields(); const isOidc = mode === 'oidc-link'; - document.getElementById('securityActionTitle').textContent = isOidc + const isLdap = mode === 'ldap-link'; + document.getElementById('securityActionTitle').textContent = isLdap + ? 'Manage LDAP identity' + : isOidc ? t('admin.oidcManage', 'Manage OIDC identities') : t('admin.generateRecoveryCodes', 'Generate recovery codes'); - document.getElementById('securityActionHint').textContent = isOidc + document.getElementById('securityActionHint').textContent = isLdap + ? `Link ${username} to exactly one verified directory identity. LDAP accounts cannot use local fallback authentication.` + : isOidc ? t( 'admin.oidcManageHint', 'Manage stable provider subjects for {username}. Enter your password and the target username before linking or unlinking.' @@ -244,15 +297,20 @@ 'Generate a new one-time recovery set for {username}. Existing recovery codes will stop working.' ).replace('{username}', username); document.getElementById('securityActionSubjectGroup')?.classList.toggle('hidden', !isOidc); + document.getElementById('securityActionDirectoryUsernameGroup')?.classList.toggle('hidden', !isLdap); + document.getElementById('securityActionNewPasswordGroup')?.classList.add('hidden'); document.getElementById('securityActionOidcListGroup')?.classList.toggle('hidden', !isOidc); document.getElementById('securityActionResultGroup')?.classList.add('hidden'); - document.getElementById('submitSecurityAction').textContent = isOidc + document.getElementById('submitSecurityAction').textContent = isLdap + ? 'Link LDAP' + : isOidc ? t('admin.oidcLinkIdentity', 'Link OIDC identity') : t('admin.continue', 'Continue'); const modal = document.getElementById('securityActionModal'); modal?.classList.add('show'); modal?.setAttribute('aria-hidden', 'false'); if (isOidc) { loadOidcIdentities(); } + if (isLdap) { loadLdapIdentity(); } document.getElementById('securityActionPassword')?.focus(); } @@ -301,20 +359,34 @@ if (requestState.context.mode === 'oidc-link') { body.subject = document.getElementById('securityActionSubject').value.trim(); path = `/admin/api/users/${requestState.context.userId}/oidc-link`; + } else if (requestState.context.mode === 'ldap-link') { + body.directory_username = document.getElementById('securityActionDirectoryUsername').value.trim(); + if (currentLdapIdentityId) { + body.new_password = document.getElementById('securityActionNewPassword').value; + path = `/admin/api/users/${requestState.context.userId}/ldap-identities/${currentLdapIdentityId}`; + } else { + path = `/admin/api/users/${requestState.context.userId}/ldap-link`; + } } setSecurityActionPending(true); try { - const result = await api(path, { method: 'POST', body }); + const method = requestState.context.mode === 'ldap-link' && currentLdapIdentityId ? 'DELETE' : 'POST'; + const result = await api(path, { method, body }); if (!securityRequests.isCurrent(requestState)) { return; } clearSecurityReauthentication(); if (requestState.context.mode === 'recovery') { document.getElementById('securityActionResult').value = (result.codes || []).join('\n'); document.getElementById('securityActionResultGroup')?.classList.remove('hidden'); notify(t('admin.recoveryGenerated', 'Recovery codes generated'), 'success'); - } else { + } else if (requestState.context.mode === 'oidc-link') { notify(t('admin.oidcLinked', 'OIDC identity linked'), 'success'); document.getElementById('securityActionSubject').value = ''; await loadOidcIdentities(); + } else { + notify(currentLdapIdentityId ? 'LDAP identity unlinked' : 'LDAP identity linked', 'success'); + currentLdapIdentityId = null; + closeSecurityAction(); + await loadUsers(); } } catch (e) { if (securityRequests.isCurrent(requestState)) { @@ -338,7 +410,7 @@ const username = tr?.dataset.username; const action = btn.dataset.act; if (!userId) { return; } - if (action === 'recovery' || action === 'oidc-link') { + if (action === 'recovery' || action === 'oidc-link' || action === 'ldap-link') { openSecurityAction(action, userId, username); return; } @@ -491,6 +563,7 @@ } function initSettings() { + document.getElementById('ldapStatusCheck')?.addEventListener('click', checkLdapStatus); document.getElementById('settingRegistration')?.addEventListener('change', async (e) => { const target = e.target; try { diff --git a/templates/admin.html b/templates/admin.html index 0344801..31f445e 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -7,6 +7,7 @@ + Admin · Web SSH Terminal @@ -93,6 +94,16 @@

Admin Panel ⚙️ + {% if ldap_enabled %} +
+

LDAP directory

+

Run a read-only TLS and service-bind check. Credentials and directory names are never returned to the browser.

+
+ + Not checked +
+
+ {% endif %}
- {% if recovery_codes_enabled or oidc_enabled %} + {% if recovery_codes_enabled or oidc_enabled or ldap_enabled %} - +