feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
#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:
+72
-33
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user