diff --git a/steward/auth/ldap_auth.py b/steward/auth/ldap_auth.py index a79eb25..22f5920 100644 --- a/steward/auth/ldap_auth.py +++ b/steward/auth/ldap_auth.py @@ -18,6 +18,34 @@ def _ldap_available() -> bool: return False +def filter_value(value: str) -> str: + """Escape a value for an LDAP search filter (RFC 4515), so a login name + like `*)(uid=*` matches only itself.""" + out = [] + for ch in value: + if ch in "\\*()\x00": + out.append("\\%02x" % ord(ch)) + else: + out.append(ch) + return "".join(out) + + +def transport(cfg: dict) -> tuple[bool, bool]: + """(use_ssl, start_tls) for the configured server: port 636 is LDAPS, + otherwise the TLS setting means StartTLS before any bind.""" + if int(cfg.get("port", 389)) == 636: + return True, False + return False, bool(cfg.get("tls", False)) + + +def _tls(ldap3, cfg: dict): + """Certificate-checked TLS: against the CA the operator pasted in, else the + system's CAs. A server whose certificate does not check is refused.""" + import ssl + ca = (cfg.get("ca_cert") or "").strip() + return ldap3.Tls(validate=ssl.CERT_REQUIRED, ca_certs_data=ca or None) + + def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None: """Synchronous LDAP auth. Returns user info dict or None on failure. @@ -27,22 +55,23 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N host = cfg.get("host", "") port = int(cfg.get("port", 389)) - use_tls = cfg.get("tls", False) bind_dn = cfg.get("bind_dn", "") bind_password = cfg.get("bind_password", "") base_dn = cfg.get("base_dn", "") - user_filter = cfg.get("user_filter", "(uid={username})").format(username=username) + user_filter = cfg.get("user_filter", "(uid={username})").replace("{username}", filter_value(username)) admin_group_dn = cfg.get("admin_group_dn", "") operator_group_dn = cfg.get("operator_group_dn", "") attr_username = cfg.get("attr_username", "uid") attr_email = cfg.get("attr_email", "mail") - tls = ldap3.Tls() if use_tls else None - server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL) + use_ssl, start_tls = transport(cfg) + tls = _tls(ldap3, cfg) if (use_ssl or start_tls) else None + server = ldap3.Server(host, port=port, use_ssl=use_ssl, tls=tls, get_info=ldap3.ALL) + auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND if start_tls else ldap3.AUTO_BIND_NO_TLS # Service account bind to search for user DN try: - svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=auto_bind) except Exception as exc: logger.error("LDAP service bind failed: %s", exc) return None @@ -69,7 +98,7 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N # Bind as the user to verify password try: - user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True) + user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=auto_bind) user_conn.unbind() except Exception: logger.debug("LDAP password verify failed for %s", username) @@ -83,6 +112,7 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N role = "operator" return { + "subject": user_dn, "username": ldap_username, "email": ldap_email, "role": role, @@ -90,7 +120,12 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None: - """Async wrapper around synchronous LDAP auth. Returns user info or None.""" + """Async wrapper around synchronous LDAP auth. Returns user info or None. + + An empty password is refused here: LDAP treats a simple bind with a DN + and no password as an anonymous bind, which succeeds.""" + if not username or not password: + return None if not _ldap_available(): logger.warning("ldap3 not installed — LDAP auth unavailable") return None diff --git a/steward/auth/oidc.py b/steward/auth/oidc.py index 9f8697e..8f1cf8f 100644 --- a/steward/auth/oidc.py +++ b/steward/auth/oidc.py @@ -95,6 +95,25 @@ def decode_id_token_payload(id_token: str) -> dict: return {} +def id_token_refusal(id_token: str, nonce: str | None, userinfo_sub: str) -> str | None: + """Why this sign-in's ID token does not belong to it, or None when it does. + + The token must carry the nonce this browser was sent off with, so a token + from another sign-in cannot be replayed into this one, and name the same + subject as the userinfo response (OIDC Core 5.3.2). The signature is not + checked (see the module note).""" + if not userinfo_sub: + return "SSO returned no subject (sub) for this user." + claims = decode_id_token_payload(id_token) if id_token else {} + if not claims: + return "SSO returned no ID token. Check that the scopes include openid." + if not nonce or claims.get("nonce") != nonce: + return "This SSO sign-in does not match the one Steward started. Try again." + if claims.get("sub") != userinfo_sub: + return "SSO returned an ID token for a different user. Try again." + return None + + def map_role(groups: list[str], admin_group: str, operator_group: str) -> str: """Map IdP groups to a local role string.""" if admin_group and admin_group in groups: diff --git a/steward/auth/routes.py b/steward/auth/routes.py index e5175e9..a3d9e5e 100644 --- a/steward/auth/routes.py +++ b/steward/auth/routes.py @@ -10,31 +10,63 @@ from steward.models.users import User, UserRole auth_bp = Blueprint("auth", __name__) -async def _provision_external_user(app, username: str, email: str, role_str: str): - """Create or update a user from an external auth provider (OIDC/LDAP). +_SOURCE_LABEL = {"ldap": "LDAP", "oidc": "SSO"} - External users get a randomised unusable password so local login is blocked. - Their role is updated on every login to reflect current IdP group membership. + +async def _provision_external_user(app, source: str, subject: str, username: str, + email: str, role_str: str) -> tuple[User | None, str | None]: + """The Steward user for an identity a provider vouched for: (user, None), + or (None, the reason to show at the login page). + + Found by (source, subject) only. A first sign-in creates the user with an + unusable password; a username or email another account already holds is + refused rather than merged, so a directory entry named `admin` cannot sign + in as the local admin. The role follows the provider's groups on every + sign-in; a user disabled in Steward stays out. """ import secrets - from steward.models.users import UserRole + from sqlalchemy.exc import IntegrityError + label = _SOURCE_LABEL[source] + if not subject or len(subject) > 512 or not username or len(username) > 64: + return None, f"{label} returned a username or id Steward cannot store (at most 64 characters)." + new_role = UserRole(role_str) async with app.db_sessionmaker() as db: - result = await db.execute(select(User).where(User.username == username)) - user = result.scalar_one_or_none() - new_role = UserRole(role_str) - async with db.begin(): - if user is None: - unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode() - user = User(username=username, email=email or f"{username}@external", - password_hash=unusable_hash, role=new_role) - db.add(user) - else: - if user.role != new_role: + try: + async with db.begin(): + user = (await db.execute(select(User).where( + User.auth_source == source, User.external_id == subject))).scalar_one_or_none() + if user is not None: + if not user.is_active: + return None, "This account is disabled in Steward." user.role = new_role - # Re-fetch committed user - async with app.db_sessionmaker() as db: - result = await db.execute(select(User).where(User.username == username)) - return result.scalar_one() + else: + email = email or f"{username}@{source}" + clash = (await db.execute(select(User.username).where( + (User.username == username) | (User.email == email)))).first() + if clash is not None: + return None, ( + f"Steward already has an account named {clash[0]} with this username " + f"or email, and it does not sign in with {label}. An admin must rename " + f"or remove it before this {label} account can sign in.") + user = User(username=username, email=email, role=new_role, + password_hash=bcrypt.hashpw(secrets.token_bytes(32), + bcrypt.gensalt()).decode(), + auth_source=source, external_id=subject) + db.add(user) + except IntegrityError: + # Another sign-in created the same name between our check and insert. + return None, f"Could not create the {label} account; try signing in again." + db.expunge(user) + return user, None + + +async def _login_refused(message: str, next_url: str | None = None): + """The login page with why a provider-vouched sign-in was still refused.""" + from quart import current_app + return await render_template( + "auth/login.html", error=message, + oidc_enabled=current_app.config.get("OIDC", {}).get("enabled", False), + next_url=safe_next_url(next_url)), 403 async def get_user_count(app) -> int: @@ -89,7 +121,8 @@ async def login_post(): # Try local auth first user = await get_user_by_username(current_app, username) - if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()): + if (user and user.auth_source == "local" and user.is_active + and bcrypt.checkpw(password, user.password_hash.encode())): login_throttle.succeeded(ip) login_user(user) await log_audit(current_app, user.id, user.username, "auth.login", @@ -102,9 +135,11 @@ async def login_post(): from steward.auth.ldap_auth import ldap_authenticate ldap_info = await ldap_authenticate(ldap_cfg, username, password_str) if ldap_info: - user = await _provision_external_user( - current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"] - ) + user, refusal = await _provision_external_user( + current_app, "ldap", ldap_info["subject"], ldap_info["username"], + ldap_info["email"], ldap_info["role"]) + if refusal: + return await _login_refused(refusal, form.get("next")) login_throttle.succeeded(ip) login_user(user) await log_audit(current_app, user.id, user.username, "auth.login", @@ -153,7 +188,8 @@ async def login_oidc(): @public_route("SSO login callback") async def login_oidc_callback(): from quart import current_app - from steward.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role + from steward.auth.oidc import (get_discovery, exchange_code, get_userinfo, map_role, + id_token_refusal) from steward.core.audit import log_audit oidc_cfg = current_app.config.get("OIDC", {}) @@ -169,6 +205,7 @@ async def login_oidc_callback(): return await render_template("auth/login.html", error="Invalid SSO state — try again.", oidc_enabled=True), 400 + nonce = session.pop("oidc_nonce", None) try: doc = await get_discovery(oidc_cfg["discovery_url"]) redirect_uri = url_for("auth.login_oidc_callback", _external=True) @@ -180,23 +217,25 @@ async def login_oidc_callback(): return await render_template("auth/login.html", error=f"SSO login failed: {exc}", oidc_enabled=True), 502 + refusal = id_token_refusal(tokens.get("id_token", ""), nonce, userinfo.get("sub", "")) + if refusal: + return await render_template("auth/login.html", error=refusal, oidc_enabled=True), 400 + username_claim = oidc_cfg.get("username_claim", "preferred_username") email_claim = oidc_cfg.get("email_claim", "email") groups_claim = oidc_cfg.get("groups_claim", "groups") - username = userinfo.get(username_claim) or userinfo.get("sub", "") - email = userinfo.get(email_claim, "") or f"{username}@oidc" + subject = userinfo["sub"] + username = userinfo.get(username_claim) or subject + email = userinfo.get(email_claim, "") groups = userinfo.get(groups_claim, []) if not isinstance(groups, list): groups = [] role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", "")) - if not username: - return await render_template("auth/login.html", - error="SSO returned no username.", - oidc_enabled=True), 400 - - user = await _provision_external_user(current_app, username, email, role) + user, refusal = await _provision_external_user(current_app, "oidc", subject, username, email, role) + if refusal: + return await _login_refused(refusal, session.pop("oidc_next", None)) login_user(user) await log_audit(current_app, user.id, user.username, "auth.login", detail={"method": "oidc", "role": role}) diff --git a/steward/core/settings.py b/steward/core/settings.py index c63db3b..8e17c75 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -134,6 +134,7 @@ DEFAULTS: dict[str, Any] = { "ldap.host": "", "ldap.port": 389, "ldap.tls": False, + "ldap.ca_cert": "", "ldap.bind_dn": "", "ldap.bind_password": "", "ldap.base_dn": "", diff --git a/steward/migrations/versions/0027_user_auth_source.py b/steward/migrations/versions/0027_user_auth_source.py new file mode 100644 index 0000000..deae74a --- /dev/null +++ b/steward/migrations/versions/0027_user_auth_source.py @@ -0,0 +1,30 @@ +"""Users keep the auth source they came from + +Revision ID: 0027_user_auth_source +Revises: 0026_api_tokens +Create Date: 2026-10-06 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0027_user_auth_source" +down_revision: Union[str, None] = "0026_api_tokens" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Existing rows are local: nothing recorded which provider made them, and + # an SSO user who finds their name taken is told so at sign-in. + op.add_column("users", sa.Column("auth_source", sa.String(16), nullable=False, + server_default="local")) + op.add_column("users", sa.Column("external_id", sa.String(512), nullable=True)) + op.create_unique_constraint("uq_users_auth_source_external_id", "users", + ["auth_source", "external_id"]) + + +def downgrade() -> None: + op.drop_constraint("uq_users_auth_source_external_id", "users", type_="unique") + op.drop_column("users", "external_id") + op.drop_column("users", "auth_source") diff --git a/steward/models/users.py b/steward/models/users.py index d998487..a1e1ed1 100644 --- a/steward/models/users.py +++ b/steward/models/users.py @@ -2,7 +2,7 @@ from __future__ import annotations import enum import uuid from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, Enum, String +from sqlalchemy import Boolean, DateTime, Enum, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -13,8 +13,16 @@ class UserRole(str, enum.Enum): viewer = "viewer" +# Where a user's identity lives. A local user signs in with a Steward +# password; an ldap or oidc user is created at their first sign-in, holds an +# unusable password, and is found again only by (auth_source, external_id) — +# never by a matching username, so a directory cannot sign in as a local account. +AUTH_SOURCES = ("local", "ldap", "oidc") + + class User(Base): __tablename__ = "users" + __table_args__ = (UniqueConstraint("auth_source", "external_id", name="uq_users_auth_source_external_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) @@ -25,3 +33,6 @@ class User(Base): DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + auth_source: Mapped[str] = mapped_column(String(16), nullable=False, default="local", server_default="local") + # The provider's stable id: the LDAP entry DN, or the OIDC `sub`. None for local users. + external_id: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 4bd7706..6478eb7 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -466,6 +466,7 @@ async def save_auth_settings(): await set_setting(db, "ldap.host", form.get("ldap.host", "").strip()) await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389)) await set_setting(db, "ldap.tls", "ldap.tls" in form) + await set_setting(db, "ldap.ca_cert", form.get("ldap.ca_cert", "").strip()) await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip()) if form.get("ldap.bind_password"): await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip()) diff --git a/steward/templates/settings/auth.html b/steward/templates/settings/auth.html index a8784be..0a66232 100644 --- a/steward/templates/settings/auth.html +++ b/steward/templates/settings/auth.html @@ -117,6 +117,18 @@ +
Port 636 connects with LDAPS; any other port with StartTLS ticked + upgrades the connection before any password is sent. Without either, passwords cross + the network in the clear. The server's certificate is checked.
+ +The certificate + authority that signed the directory's certificate, when it is not one the system already + trusts (a private or self-signed CA).
+