Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
8 changes: 6 additions & 2 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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():
Expand Down
25 changes: 21 additions & 4 deletions app/auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -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.'
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 23 additions & 15 deletions app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 '
Expand All @@ -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}')
Expand Down
113 changes: 107 additions & 6 deletions app/ldap_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import secrets
import time
import unicodedata
from datetime import datetime, timezone
from urllib.parse import urlsplit

Expand All @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -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',
Expand All @@ -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(
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions docker-compose.ldap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
webssh:
environment:
LDAP_ENABLED: "true"
LDAP_AUTO_PROVISION: "false"
LDAP_PROVIDER_ID: default
LDAP_URL: ""
LDAP_BASE_DN: ""
Expand Down
Loading
Loading