From cc767517e9afe513a52082e16ef4cc017528e4b0 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 15 Aug 2026 10:15:55 +0200 Subject: [PATCH 1/2] Add safe LDAP auto-provisioning --- .env.example | 1 + README.md | 22 +- app/__init__.py | 8 +- app/auth.py | 25 +- app/cli.py | 38 +-- app/ldap_routes.py | 113 ++++++++- config.py | 3 + docker-compose.ldap.yml | 1 + docs/ldap-authentication.md | 30 ++- static/js/auth.js | 61 +++-- templates/login.html | 72 +++--- tests/js/login-modes.test.js | 50 ++-- tests/test_admin_cli.py | 35 +++ tests/test_ldap_auth.py | 410 +++++++++++++++++++++++++++++++- tests/test_production_config.py | 20 ++ tests/test_security_ui.py | 3 +- 16 files changed, 752 insertions(+), 140 deletions(-) diff --git a/.env.example b/.env.example index c82e706..68dbf51 100644 --- a/.env.example +++ b/.env.example @@ -121,6 +121,7 @@ MAX_RECOVERY_JSON_SIZE=4096 # the documented `docker-compose.ldap.yml` helper instead of placing secrets # here. LDAP_ENABLED=false +LDAP_AUTO_PROVISION=false LDAP_PROVIDER_ID=default LDAP_URL= LDAP_BASE_DN= diff --git a/README.md b/README.md index a27314d..af60260 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,7 @@ docker build -t webssh:local . | `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_AUTO_PROVISION` | No | `false` | Create a non-admin LDAP-managed account after the first successful directory sign-in; existing local usernames are never claimed automatically | | `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 | @@ -557,6 +558,7 @@ and keep it on the same WebSSH release or commit as `docker-compose.yml`. ```yaml LDAP_ENABLED: "true" + LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: primary-directory LDAP_URL: ldaps://ldap.example.com:636 LDAP_BASE_DN: ou=people,dc=example,dc=com @@ -603,11 +605,21 @@ and keep it on the same WebSSH release or commit as `docker-compose.yml`. 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. +5. Sign in with the existing local break-glass administrator. With the safe + default `LDAP_AUTO_PROVISION: "false"`, create or select a non-admin WebSSH + account in **Admin** and use **Link LDAP** to attach the directory's stable + identity. For larger directories, set `LDAP_AUTO_PROVISION: "true"` to + create a non-admin LDAP-managed account only after its first successful + directory password bind. Automatic provisioning never claims an existing + local username and still requires an active local break-glass administrator. + +The login page selects the configured `LDAP_PROVIDER_ID` by default whenever +LDAP is enabled. Local password sign-in remains available from the +**Authentication Source** selector. Auto-provisioned directory usernames must +fit the 80-character local account field and contain no control characters; +they are never silently truncated. Removed directory accounts lose access via +the normal fail-closed revalidation, but WebSSH does not automatically delete +their stored user data. To disable LDAP again, recreate WebSSH from the standard Compose file only: diff --git a/app/__init__.py b/app/__init__.py index a11bcb5..00aee5a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -128,6 +128,7 @@ def inject_url_prefix(): 'webauthn_enabled': config.WEBAUTHN_ENABLED, 'oidc_enabled': config.OIDC_ENABLED, 'ldap_enabled': config.LDAP_ENABLED, + 'ldap_provider_id': config.LDAP_PROVIDER_ID, 'ldap_managed': bool( current_user.is_authenticated and current_user.is_ldap_managed @@ -421,7 +422,7 @@ def login(): ): log_rate_limit_exceeded('login', client_ip) flash('Too many login attempts. Please try again later.', 'error') - return render_template('login.html') + return render_template('login.html', auth_source='local') username = request.form.get('username') password = request.form.get('password') @@ -435,7 +436,10 @@ def login(): else: log_login_attempt(username, False, client_ip, request.user_agent.string) flash(error, 'error') - return render_template('login.html') + return render_template( + 'login.html', + auth_source='local' if request.method == 'POST' else None, + ) @app.route('/register', methods=['GET', 'POST']) def register(): diff --git a/app/auth.py b/app/auth.py index 4064cc3..2007aa3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,8 +1,10 @@ import bcrypt import config +from contextlib import contextmanager from threading import Lock from flask_login import LoginManager from datetime import datetime, timedelta, timezone +from sqlalchemy import text from .models import db, User, SocketSession from .rate_limiter import create_rate_limiter @@ -16,7 +18,20 @@ # Rate limiter instance — initialized in init_auth(). # Falls back to in-memory automatically if Redis is unavailable. _rate_limiter = None -_registration_lock = Lock() +user_creation_lock = Lock() + + +@contextmanager +def user_creation_transaction(): + """Serialize account-name checks and writes across SQLite connections.""" + with user_creation_lock: + db.session.rollback() + db.session.execute(text('BEGIN IMMEDIATE')) + try: + yield + finally: + if db.session().in_transaction(): + db.session.rollback() def _get_rate_limiter(): @@ -136,7 +151,9 @@ def validate_new_user(username, password): if not username.replace('_', '').isalnum(): return "Username can only contain letters, numbers, and underscores" - if User.query.filter_by(username=username).first(): + normalized_username = username.casefold() + existing_names = User.query.with_entities(User.username).all() + if any(row.username.casefold() == normalized_username for row in existing_names): return "Username already exists" if not password or len(password) < 8: @@ -161,7 +178,7 @@ def register_user(username, password, *, first_user_only=False): Returns: tuple: (User object, error message) - one will be None """ - with _registration_lock: + with user_creation_transaction(): is_first_user = User.query.order_by(User.id).first() is None if first_user_only and not is_first_user: return None, 'Registration is currently disabled.' @@ -193,7 +210,7 @@ def is_bootstrap_registration_available(): def ensure_initial_admin(): """Return an existing admin or promote the oldest user if none exists.""" - with _registration_lock: + with user_creation_lock: existing_admin = ( User.query .filter_by(is_admin=True) diff --git a/app/cli.py b/app/cli.py index 707d085..741022f 100644 --- a/app/cli.py +++ b/app/cli.py @@ -9,7 +9,10 @@ import config from .audit_logger import audit_logger, log_warning -from .auth import validate_new_user +from .auth import ( + user_creation_transaction, + validate_new_user, +) from .models import User, db @@ -124,6 +127,10 @@ def create_admin(username, password_file): user = User.query.filter_by(username=username).first() if user is not None: + if user.is_ldap_managed: + raise click.ClickException( + 'LDAP-managed accounts cannot be administrators.' + ) if password_file is not None: raise click.ClickException( '--password-file cannot be used when promoting an existing ' @@ -148,20 +155,21 @@ def create_admin(username, password_file): else: password = _read_password_file(password_file) - error = validate_new_user(username, password) - if error: - raise click.ClickException(error) - - user = User(username=username, is_admin=True) - user.set_password(password) - db.session.add(user) - try: - db.session.flush() - user.get_data_dir() - db.session.commit() - except Exception: - db.session.rollback() - raise + with user_creation_transaction(): + error = validate_new_user(username, password) + if error: + raise click.ClickException(error) + + user = User(username=username, is_admin=True) + user.set_password(password) + db.session.add(user) + try: + db.session.flush() + user.get_data_dir() + db.session.commit() + except Exception: + db.session.rollback() + raise _audit_admin_bootstrap(username, 'created') click.echo(f'Administrator created: {username}') diff --git a/app/ldap_routes.py b/app/ldap_routes.py index b629321..abe2bf0 100644 --- a/app/ldap_routes.py +++ b/app/ldap_routes.py @@ -3,6 +3,7 @@ import logging import secrets import time +import unicodedata from datetime import datetime, timezone from urllib.parse import urlsplit @@ -19,6 +20,7 @@ check_rate_limit, check_reauth_rate_limit, password_exceeds_bcrypt_limit, + user_creation_transaction, ) from .decorators import admin_required from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable @@ -35,6 +37,7 @@ ldap_blueprint = Blueprint('ldap', __name__) _MAX_LDAP_FORM_BYTES = 4096 _MAX_LDAP_JSON_BYTES = 4096 +_MAX_AUTO_PROVISIONED_USERNAME_LENGTH = 80 def _bounded_json(): @@ -84,14 +87,103 @@ def _rate_limited(endpoint): return False +def _auto_provision_ldap_identity(username, resolved, *, presented_username=None): + """Create one non-admin LDAP account after its credentials are verified.""" + presented_username = ( + username if presented_username is None else presented_username + ) + if ( + not username + or len(username) > _MAX_AUTO_PROVISIONED_USERNAME_LENGTH + or any( + unicodedata.category(character).startswith('C') + for character in presented_username + ) + ): + raise LDAPLookupRejected('Directory username cannot be provisioned safely') + + with user_creation_transaction(): + local_admin = ( + User.query + .filter_by(is_admin=True, is_locked=False) + .filter(~User.ldap_identity.has()) + .first() + ) + if local_admin is None: + raise LDAPLookupRejected( + 'A local break-glass administrator is required' + ) + + normalized_username = username.casefold() + existing_names = User.query.with_entities(User.username).all() + if any( + row.username.casefold() == normalized_username + for row in existing_names + ): + raise LDAPLookupRejected( + 'Directory username conflicts with a local account' + ) + + user = User(username=username, is_admin=False, is_locked=False) + user.set_password(secrets.token_urlsafe(48)) + mapping = LDAPIdentity( + user=user, + provider=resolved.provider, + subject=resolved.subject, + directory_username=username, + distinguished_name=resolved.distinguished_name, + last_verified_at=datetime.now(timezone.utc), + ) + db.session.add_all((user, mapping)) + try: + db.session.flush() + user.get_data_dir() + db.session.commit() + except IntegrityError: + db.session.rollback() + 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( + 'Directory identity could not be provisioned uniquely' + ) + return mapping + except Exception as exc: + db.session.rollback() + log_security_event( + 'LDAP_AUTO_PROVISION_STORAGE_FAILED', + level=logging.ERROR, + user=username, + provider=resolved.provider, + error=type(exc).__name__, + ) + raise LDAPUnavailable( + 'LDAP account provisioning storage is unavailable' + ) from exc + + log_security_event( + 'LDAP_USER_AUTO_PROVISIONED', + user=user.username, + provider=mapping.provider, + ) + return mapping + + @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 + return render_template('login.html', auth_source='ldap'), 413 if _rate_limited('ldap_login'): - return render_template('login.html'), 429 + return render_template('login.html', auth_source='ldap'), 429 - username = str(request.form.get('username') or '').strip() + presented_username = str(request.form.get('username') or '') + username = presented_username.strip() password = request.form.get('password') or '' client_ip = request.remote_addr or 'unknown' try: @@ -102,16 +194,23 @@ def ldap_login(): subject=resolved.subject, ).first() if ( - mapping is None - or mapping.user.is_locked - or mapping.user.is_admin + mapping is not None + and (mapping.user.is_locked or mapping.user.is_admin) ): raise LDAPLookupRejected('Identity is not linked to an active user') + if mapping is None and not config.LDAP_AUTO_PROVISION: + 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') + if mapping is None: + mapping = _auto_provision_ldap_identity( + username, + resolved, + presented_username=presented_username, + ) except LDAPLookupRejected: log_security_event( 'LDAP_LOGIN_REJECTED', @@ -123,6 +222,7 @@ def ldap_login(): return render_template( 'login.html', ldap_error='Invalid username or password', + auth_source='ldap', ), 401 except LDAPUnavailable as exc: log_security_event( @@ -135,6 +235,7 @@ def ldap_login(): return render_template( 'login.html', ldap_error='Directory sign-in is temporarily unavailable', + auth_source='ldap', ), 503 user = mapping.user diff --git a/config.py b/config.py index e3cd3b8..284743d 100644 --- a/config.py +++ b/config.py @@ -86,6 +86,9 @@ # 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_AUTO_PROVISION = ( + os.environ.get('LDAP_AUTO_PROVISION', '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() diff --git a/docker-compose.ldap.yml b/docker-compose.ldap.yml index d1123fd..2fb3de1 100644 --- a/docker-compose.ldap.yml +++ b/docker-compose.ldap.yml @@ -8,6 +8,7 @@ services: webssh: environment: LDAP_ENABLED: "true" + LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: default LDAP_URL: "" LDAP_BASE_DN: "" diff --git a/docs/ldap-authentication.md b/docs/ldap-authentication.md index a77a0d1..813cce3 100644 --- a/docs/ldap-authentication.md +++ b/docs/ldap-authentication.md @@ -16,14 +16,20 @@ the provided `docker-compose.ldap.yml` overlay with the same WebSSH image; no 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. +- By default, an administrator explicitly links a directory identity to an + existing local WebSSH user. Optional auto-provisioning still requires a + successful password bind and never trusts a username by itself. - 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. +- `LDAP_AUTO_PROVISION=true` creates a non-admin LDAP-managed account only + after the first successful directory sign-in. It requires an active local + break-glass administrator and never attaches LDAP to an existing local + username. Case-insensitive collisions, control characters, and names longer + than 80 characters are rejected without truncation. - 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 @@ -58,6 +64,7 @@ files from the same WebSSH release. ```yaml LDAP_ENABLED: "true" +LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: corp-ad LDAP_URL: ldaps://dc01.ad.example.com:636 LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com @@ -74,6 +81,7 @@ Nested group semantics vary and must be validated by the AD administrator. ```yaml LDAP_ENABLED: "true" +LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: primary-openldap LDAP_URL: ldap://ldap.example.com:389 LDAP_BASE_DN: ou=people,dc=example,dc=com @@ -134,10 +142,17 @@ incomplete LDAP configuration prevents the Flask application from starting. 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. +6. Keep `LDAP_AUTO_PROVISION: "false"` for explicit account lifecycle control. + Create the target local WebSSH user, then choose **Link LDAP** on the Users + tab and provide the directory username, administrator password, and exact + target WebSSH username. +7. For larger directories, optionally set `LDAP_AUTO_PROVISION: "true"` and + recreate WebSSH with the overlay. A verified first sign-in then creates a + non-admin LDAP-managed account. Existing local usernames still require the + explicit administrator linking flow. +8. Sign out and test the directory account. When LDAP is enabled, its + `LDAP_PROVIDER_ID` is the default **Authentication Source**; local sign-in + remains selectable. Do one non-administrator pilot account before migrating more users. @@ -160,6 +175,9 @@ 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. +Directory removal or filter exclusion revokes access without deleting the +account's stored WebSSH data; delete that data only through the normal explicit +administrator lifecycle. To return one account to local authentication while LDAP is working, choose **Manage LDAP**, provide the administrator password, exact target username, and diff --git a/static/js/auth.js b/static/js/auth.js index e3f8874..6a2d4ea 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,22 +1,22 @@ (function() { 'use strict'; - function createLoginModeController(elements) { + function createAuthenticationSourceController(elements) { const { - defaultPanel, - ldapPanel, - trigger, + sourceSelect, + localForm, + ldapForm, 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)); + function applySource(source, { clearPassword = true, focus = true } = {}) { + const ldapActive = source === 'ldap'; + sourceSelect.value = ldapActive ? 'ldap' : 'local'; + localForm.classList.toggle('hidden', ldapActive); + ldapForm.classList.toggle('hidden', !ldapActive); if (clearPassword) { const passwordToClear = ldapActive ? localPassword : ldapPassword; @@ -31,41 +31,38 @@ } return { - showLdap() { - applyMode('ldap'); - }, - showDefault() { - applyMode('default'); + select(source) { + applySource(source); }, sync() { - const initialMode = ldapPanel.classList.contains('hidden') - ? 'default' - : 'ldap'; - applyMode(initialMode, { clearPassword: false }); + applySource(sourceSelect.value, { + clearPassword: false, + focus: 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) { + function setupAuthenticationSources() { + const sourceSelect = document.getElementById('authenticationSource'); + const localForm = document.getElementById('localLoginForm'); + const ldapForm = document.getElementById('ldapLoginForm'); + if (!sourceSelect || !localForm || !ldapForm) { return; } - const controller = createLoginModeController({ - defaultPanel, - ldapPanel, - trigger, + const controller = createAuthenticationSourceController({ + sourceSelect, + localForm, + ldapForm, 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); + sourceSelect.addEventListener('change', () => { + controller.select(sourceSelect.value); + }); controller.sync(); } @@ -187,7 +184,7 @@ } document.addEventListener('DOMContentLoaded', () => { - setupLoginModes(); + setupAuthenticationSources(); setupPasswordToggles(); setupLoginValidation(); setupRegisterValidation(); @@ -195,6 +192,6 @@ }); if (typeof module === 'object' && module.exports) { - module.exports = { createLoginModeController }; + module.exports = { createAuthenticationSourceController }; } })(); diff --git a/templates/login.html b/templates/login.html index 3040fe1..afea2f9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -42,15 +42,26 @@

Web SSH Terminal

{% endif %} {% endwith %} -
-
+ {% set selected_auth_source = auth_source if auth_source in ('local', 'ldap') else ('ldap' if ldap_enabled else 'local') %} +
- - {% if ldap_enabled %} - - {% endif %} - +