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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b718c7..2c3fdc8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -99,6 +99,11 @@ jobs: requirements.txt requirements-test.txt + - name: Install LDAP build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev + - name: Install dependencies run: | python -m pip install --require-hashes -r requirements-test.txt @@ -146,6 +151,11 @@ jobs: requirements.txt requirements-test.txt + - name: Install LDAP build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev + - name: Install dependencies run: | python -m pip install --require-hashes -r requirements-test.txt @@ -173,6 +183,11 @@ jobs: requirements.txt requirements-test.txt + - name: Install LDAP build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev + - name: Install dependencies run: | python -m pip install --require-hashes -r requirements-test.txt @@ -200,6 +215,11 @@ jobs: requirements.txt requirements-test.txt + - name: Install LDAP build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev + - name: Install Python dependencies run: | python -m pip install --require-hashes -r requirements-test.txt 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..4064cc3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -93,8 +93,25 @@ def check_reauth_rate_limit(user_id, ip_address, endpoint, limit_str): @login_manager.user_loader 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: + raw_identifier = str(user_id) + if ':' in raw_identifier: + raw_user_id, raw_generation = raw_identifier.split(':', 1) + else: + raw_user_id, raw_generation = raw_identifier, '0' + try: + parsed_user_id = int(raw_user_id) + parsed_generation = int(raw_generation) + except (TypeError, ValueError): + return None + if parsed_user_id <= 0 or parsed_generation < 0: + return None + + user = db.session.get(User, parsed_user_id) + if user is None or user.is_locked or ( + user.is_admin and user.is_ldap_managed + ): + return None + if int(user.auth_generation or 0) != parsed_generation: return None return user @@ -177,13 +194,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 +239,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 +265,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..b629321 --- /dev/null +++ b/app/ldap_routes.py @@ -0,0 +1,384 @@ +"""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)) + target.auth_generation = int(target.auth_generation or 0) + 1 + 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) + target.auth_generation = int(target.auth_generation or 0) + 1 + 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..cb354c7 100644 --- a/app/models.py +++ b/app/models.py @@ -24,6 +24,7 @@ class User(db.Model, UserMixin): last_login = db.Column(db.DateTime) is_admin = db.Column(db.Boolean, nullable=False, default=False) is_locked = db.Column(db.Boolean, nullable=False, default=False) + auth_generation = db.Column(db.Integer, nullable=False, default=0) socket_sessions = db.relationship('SocketSession', backref='user', cascade='all, delete-orphan', lazy='dynamic') ssh_sessions = db.relationship('SSHSession', backref='user', cascade='all, delete-orphan', lazy='dynamic') @@ -45,6 +46,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 +61,15 @@ def check_password(self, password): """Verify password against stored hash.""" return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8')) + def get_id(self): + """Bind Flask-Login sessions to the current authentication boundary.""" + return f'{self.id}:{int(self.auth_generation or 0)}' + + @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 @@ -69,7 +85,7 @@ def ensure_user_columns(): """Additive, idempotent schema migration for the users table. db.create_all() only creates missing TABLES, never missing COLUMNS, so new - columns (is_admin, is_locked) added above would be absent on an existing + columns added above would be absent on an existing production database. This adds them in place via ALTER TABLE without touching existing rows. Must run inside an app context, after db.create_all(). """ @@ -84,6 +100,11 @@ def ensure_user_columns(): additions.append("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0") if 'is_locked' not in existing: additions.append("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0") + if 'auth_generation' not in existing: + additions.append( + "ALTER TABLE users ADD COLUMN auth_generation " + "INTEGER NOT NULL DEFAULT 0" + ) for stmt in additions: db.session.execute(text(stmt)) # First-time migration of an existing install: there was no role separation @@ -227,6 +248,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/socket_events.py b/app/socket_events.py index 338d5f8..f3cc868 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -4,7 +4,12 @@ sftp_handler, jump_host_manager, post_connect_manager, session_insights, runtime_inventory) from .decorators import socket_login_required -from .auth import register_socket_session, get_user_from_socket, check_socket_rate_limit +from .auth import ( + check_socket_rate_limit, + get_user_from_socket, + load_user, + register_socket_session, +) from .models import db, SSHSession, SocketSession from .user_settings import save_user_settings, get_user_settings from .audit_logger import (log_info, log_warning, log_error, log_debug, @@ -149,9 +154,8 @@ def handle_connect(): emit('connected', {'status': 'unavailable'}) return False - from .models import User - user = db.session.get(User, int(user_id)) - if not user or user.is_locked: + user = load_user(user_id) + if not user: log_warning("User not found during connect", user_id=user_id, sid=request.sid) emit('connected', {'status': 'unauthenticated'}) disconnect() 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..e3cd3b8 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,106 @@ 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' + ) + + ldap_url_error = ( + 'SECURITY ERROR: LDAP_URL must be an exact ldap:// or ' + 'ldaps:// server URL without credentials, path, query, or ' + 'fragment and with a valid port' + ) + try: + parsed_ldap_url = urlsplit(LDAP_URL) + ldap_hostname = parsed_ldap_url.hostname + ldap_port = parsed_ldap_url.port + except ValueError as exc: + raise RuntimeError(ldap_url_error) from exc + if ( + parsed_ldap_url.scheme not in {'ldap', 'ldaps'} + or not ldap_hostname + or (ldap_port is not None and ldap_port < 1) + 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(ldap_url_error) + 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/deployment/ldap_secret_cli.py b/deployment/ldap_secret_cli.py new file mode 100644 index 0000000..eca75d3 --- /dev/null +++ b/deployment/ldap_secret_cli.py @@ -0,0 +1,163 @@ +"""Standalone LDAP secret-volume management. + +This deployment helper 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/docker-compose.ldap.yml b/docker-compose.ldap.yml new file mode 100644 index 0000000..d1123fd --- /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/deployment/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/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/css/auth.css b/static/css/auth.css index bce287d..a614045 100644 --- a/static/css/auth.css +++ b/static/css/auth.css @@ -171,6 +171,48 @@ body::after { font-weight: 500; } +.login-mode-heading { + margin: 4px 0 28px; + text-align: center; +} + +.login-mode-heading h2 { + margin-bottom: 6px; + color: var(--text-primary); + font-size: 20px; +} + +.login-mode-heading p { + color: var(--text-secondary); + font-size: 13px; +} + +.login-mode-back { + display: inline-flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + margin: -8px 0 4px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 45%, transparent); + color: var(--text-primary); + cursor: pointer; + font-size: 22px; + line-height: 1; + transition: all 0.2s ease; +} + +.login-mode-back:hover, +.login-mode-back:focus-visible { + border-color: var(--accent-primary); + background: var(--bg-hover); + color: var(--accent-primary); + outline: none; + box-shadow: 0 0 0 3px var(--accent-primary-glow); +} + .alert { padding: 14px 18px; border-radius: 8px; 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/static/js/auth.js b/static/js/auth.js index f8f8bae..e3f8874 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,6 +1,74 @@ (function() { 'use strict'; + function createLoginModeController(elements) { + const { + defaultPanel, + ldapPanel, + trigger, + localPassword, + ldapPassword, + ldapUsername, + localUsername + } = elements; + + function applyMode(mode, { clearPassword = true, focus = true } = {}) { + const ldapActive = mode === 'ldap'; + defaultPanel.classList.toggle('hidden', ldapActive); + ldapPanel.classList.toggle('hidden', !ldapActive); + trigger.setAttribute('aria-expanded', String(ldapActive)); + + if (clearPassword) { + const passwordToClear = ldapActive ? localPassword : ldapPassword; + if (passwordToClear) { + passwordToClear.value = ''; + } + } + if (focus) { + const fieldToFocus = ldapActive ? ldapUsername : localUsername; + fieldToFocus?.focus(); + } + } + + return { + showLdap() { + applyMode('ldap'); + }, + showDefault() { + applyMode('default'); + }, + sync() { + const initialMode = ldapPanel.classList.contains('hidden') + ? 'default' + : 'ldap'; + applyMode(initialMode, { clearPassword: false }); + } + }; + } + + function setupLoginModes() { + const defaultPanel = document.getElementById('defaultLoginMode'); + const ldapPanel = document.getElementById('ldapLoginMode'); + const trigger = document.getElementById('ldapLoginBtn'); + const backButton = document.getElementById('ldapBackBtn'); + if (!defaultPanel || !ldapPanel || !trigger || !backButton) { + return; + } + + const controller = createLoginModeController({ + defaultPanel, + ldapPanel, + trigger, + localPassword: document.getElementById('password'), + ldapPassword: document.getElementById('ldapPassword'), + ldapUsername: document.getElementById('ldapUsername'), + localUsername: document.getElementById('username') + }); + trigger.addEventListener('click', controller.showLdap); + backButton.addEventListener('click', controller.showDefault); + controller.sync(); + } + function setFieldState(input, hintEl, message, isValid) { if (!input || !hintEl) { return; @@ -119,9 +187,14 @@ } document.addEventListener('DOMContentLoaded', () => { + setupLoginModes(); setupPasswordToggles(); setupLoginValidation(); setupRegisterValidation(); setupChangePasswordValidation(); }); + + if (typeof module === 'object' && module.exports) { + module.exports = { createLoginModeController }; + } })(); 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 %}