feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 44s
CI / unit (push) Failing after 1m5s
CI / publish (push) Skipped

#5036 "SSO and LDAP: users keep their auth source, no merging into local
accounts, is_active checked, LDAP TLS real and filter escaped".

- users gain auth_source (local/ldap/oidc) and external_id (migration 0027).
  A provider account is found by (source, subject) only: the LDAP entry DN
  or the OIDC sub. A first sign-in whose username or email another account
  holds is refused with a message naming why; a disabled user stays out.
- Local passwords sign in local accounts only.
- LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server
  certificate is checked against the system CAs or a CA pasted in
  Settings -> Auth. The login name is escaped into the filter (RFC 4515),
  and an empty password is refused before the directory sees it.
- OIDC: the ID token must carry this sign-in's nonce and the userinfo sub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:10:40 -04:00
co-authored by Claude Opus 5.5
parent b1da073677
commit f944cdb9b0
11 changed files with 405 additions and 41 deletions
+42 -7
View File
@@ -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
+19
View File
@@ -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:
+72 -33
View File
@@ -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})