diff --git a/apps/_scaffold/settings.py b/apps/_scaffold/settings.py index fb497af8d..c745d2181 100644 --- a/apps/_scaffold/settings.py +++ b/apps/_scaffold/settings.py @@ -104,6 +104,15 @@ "mode": "ad", # Microsoft Active Directory "server": "mydc.domain.com", # FQDN or IP of one Domain Controller "base_dn": "cn=Users,dc=domain,dc=com", # base dn, i.e. where the users are located + + # the following are only needed if you want to use groups + "group_member_attrib": "member", # for AD, attribute that contains the user DN in the group + "bind_dn": "CN=LdapBindUser,CN=users,DC=test,DC=local", # bind user DN + "bind_pw": "P@ssw0rd", # bind user password + "group_dn": "DC=test,DC=local", # group DN, where the groups are located, default = base_dn + "allowed_groups": ["allowed_login_group"], # list of groups that are allowed to log in, default = everyone + "denied_login_popup": True, # show an error at login if not in allowed groups + # default = False } # i18n settings diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index ef40fbaef..4986abdac 100644 --- a/py4web/utils/auth.py +++ b/py4web/utils/auth.py @@ -702,7 +702,16 @@ def login(self, email, password): user_info["sso_id"] = plugin.name + ":" + email if self.use_username or "@" not in email: user_info["username"] = email - if "@" in email: + # --- LDAP/AD: use real email if available --- + if ( + plugin.name == "ldap" + and getattr(plugin, "mode", None) == "ad" + and getattr(plugin, "last_user_mail", None) + ): + self.logger.debug(f"Using AD email from LDAP plugin: {plugin.last_user_mail}") + user_info["email"] = plugin.last_user_mail + elif "@" in email: + self.logger.debug(f"Using email from login: {email}") user_info["email"] = email else: self.logger.debug( @@ -1303,11 +1312,11 @@ def login(auth): if "pam" in auth.plugins or "ldap" in auth.plugins: plugin_name = "pam" if "pam" in auth.plugins else "ldap" plugin = auth.plugins[plugin_name] - self.logger.debug( + auth.logger.debug( f"AuthAPI.login: Trying plugin {plugin_name} for user {username}" ) check = plugin.check_credentials(username, password) - self.logger.debug( + auth.logger.debug( f"AuthAPI.login: plugin.check_credentials returned {check}" ) if check: @@ -1316,19 +1325,29 @@ def login(auth): # "email": username + "@localhost", "sso_id": plugin_name + ":" + username, } - # and register the user if we have one, just in case + # For AD, use the real email if available + if ( + plugin_name == "ldap" + and getattr(plugin, "mode", None) == "ad" + and getattr(plugin, "last_user_mail", None) + ): + auth.logger.debug(f"AuthAPI.login: Using AD email from LDAP plugin: {plugin.last_user_mail}") + # save the real email from AD to database + data["email"] = plugin.last_user_mail + else: + auth.logger.debug(f"AuthAPI.login: Not using AD email, plugin_name={plugin_name}, mode={getattr(plugin, 'mode', None)}, last_user_mail={getattr(plugin, 'last_user_mail', None)}") if auth.db: - self.logger.debug( + auth.logger.debug( f"AuthAPI.login: Calling get_or_register_user with data={data}" ) user = auth.get_or_register_user(data) - self.logger.debug( + auth.logger.debug( f"AuthAPI.login: User after get_or_register_user: {user}" ) auth.store_user_in_session(user["id"]) # else: if we're here - check is OK, but user is not in the session - is it right? else: - self.logger.debug( + auth.logger.debug( f"AuthAPI.login: plugin.check_credentials failed for {username}" ) data = auth._error( diff --git a/py4web/utils/auth_plugins/ldap_plugin.py b/py4web/utils/auth_plugins/ldap_plugin.py index a7cc55cca..a766e669a 100644 --- a/py4web/utils/auth_plugins/ldap_plugin.py +++ b/py4web/utils/auth_plugins/ldap_plugin.py @@ -6,8 +6,9 @@ import logging import sys -import ldap # python-ldap -import ldap.filter +import ldap # python-ldap # type: ignore # pylance: ignore undefined +import ldap.filter # type: ignore # pylance: ignore undefined +from py4web import HTTP from . import UsernamePassword @@ -90,7 +91,7 @@ class LDAPPlugin(UsernamePassword): Where: manage_user: bool - If True py4web will fetch and update user profile + If True or AD is used, py4web will fetch and update user profile fields (first name, last name, email) from LDAP/AD on each login and keep them in sync with its db. If False, only authentication is performed and user profile fields @@ -126,7 +127,6 @@ class LDAPPlugin(UsernamePassword): group_member_attrib - the attribute containing the group members name group_filterstr - as the filterstr but for group select - **allowed_groups still to be implemented in py4web** You can restrict login access to specific groups if you specify: auth.register_plugin(LDAPPlugin( @@ -169,6 +169,7 @@ def __init__( username_attrib="uid", custom_scope="subtree", allowed_groups=None, + denied_login_popup=False, manage_user=False, user_firstname_attrib="cn:1", user_lastname_attrib="cn:2", @@ -202,6 +203,7 @@ def __init__( self.username_attrib = username_attrib self.custom_scope = custom_scope self.allowed_groups = allowed_groups + self.denied_login_popup = denied_login_popup self.manage_user = manage_user self.user_firstname_attrib = user_firstname_attrib self.user_lastname_attrib = user_lastname_attrib @@ -219,6 +221,7 @@ def __init__( # rfc4515 syntax self.filterstr = self.filterstr.lstrip("(").rstrip(")") self.groups = groups + self.last_user_mail = None def check_credentials(self, username, password): base_dn = self.base_dn @@ -266,11 +269,14 @@ def check_credentials(self, username, password): user_lastname_attrib = ldap.filter.escape_filter_chars(user_lastname_attrib) user_mail_attrib = ldap.filter.escape_filter_chars(user_mail_attrib) try: - # if allowed_groups: - # if not self.is_user_in_allowed_groups( - # username, password, allowed_groups - # ): - # return False + allowed_groups = self.allowed_groups + if allowed_groups and mode == "ad": + if not self.is_user_in_allowed_groups( + username + ): + logger.warning(f"[{username}] refused login because not in allowed groups!") + + return False con = self._init_ldap() if mode == "ad": # Microsoft Active Directory @@ -295,7 +301,7 @@ def check_credentials(self, username, password): # this will throw an index error if the account is not found # in the base_dn requested_attrs = ["sAMAccountName"] - if manage_user: + if manage_user or mode == "ad": requested_attrs.extend( [user_firstname_attrib, user_lastname_attrib, user_mail_attrib] ) @@ -306,7 +312,14 @@ def check_credentials(self, username, password): f"(&(sAMAccountName={ldap.filter.escape_filter_chars(username_bare)})({filterstr}))", requested_attrs, )[0][1] + + # set last_user_mail from ldap result for further use + if 'mail' in result and result['mail']: + self.last_user_mail = str(result['mail'][0].decode() if isinstance(result['mail'][0], bytes) else result['mail'][0]) + else: + self.last_user_mail = None logger.info(f"Login result: {result}") + logger.debug(f"LDAPPlugin: Set last_user_mail to {self.last_user_mail} for {username}") if not isinstance(result, dict): # result should be a dict in the form # {'sAMAccountName': [username_bare]} @@ -533,6 +546,8 @@ def check_credentials(self, username, password): def is_user_in_allowed_groups(self, username, password=None): allowed_groups = self.allowed_groups + logger = self.logger + logger.debug(f"[{str(username)}] Check if user is in allowed groups") """ Figure out if the username is a member of an allowed group @@ -549,8 +564,12 @@ def is_user_in_allowed_groups(self, username, password=None): for group in allowed_groups: if group in self.groups: # Match + logger.debug(f"[{str(username)}] allowed login because in allowed groups") return True # No match + logger.warning(f"[{str(username)}] denied login because not in allowed groups") + if self.denied_login_popup: + raise HTTP(401, body=f"{str(username)} : you're not authorized to login!") return False def do_manage_groups(self, con, username, group_mapping={}): @@ -729,6 +748,8 @@ def get_user_groups_from_ldap(self, username=None, password=None): if "DC=" in x.upper(): domain.append(x.split("=")[-1]) username = f"{username}@{'.'.join(domain)}" + if not group_dn: + group_dn = base_dn username_bare = username.split("@")[0] con = self._init_ldap() con.set_option(ldap.OPT_PROTOCOL_VERSION, 3) @@ -758,6 +779,11 @@ def get_user_groups_from_ldap(self, username=None, password=None): if username is None: return [] # search for groups where user is in + filter_full = ( + ldap.filter.escape_filter_chars(group_member_attrib), + ldap.filter.escape_filter_chars(username), + group_filterstr, + ) filter = f"(&({ldap.filter.escape_filter_chars(group_member_attrib)}=\ {ldap.filter.escape_filter_chars(username)})({group_filterstr}))" diff --git a/uv.lock b/uv.lock index 2d6615b3c..df2ba63f3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.11'", @@ -850,7 +850,7 @@ requires-dist = [ { name = "pluralize", specifier = ">=20250901.2" }, { name = "portalocker" }, { name = "pycryptodome" }, - { name = "pydal", specifier = ">=20251012.3" }, + { name = "pydal", specifier = ">=20251018.1" }, { name = "pyjwt", specifier = ">=2.0.1" }, { name = "pystemmer", marker = "extra == 'docs'", specifier = ">=2.2" }, { name = "pytest", marker = "extra == 'test'" }, @@ -924,11 +924,11 @@ wheels = [ [[package]] name = "pydal" -version = "20251012.3" +version = "20251018.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/48/7b3d37498d9836c5bd4cd1d00212c6074923d15fec298c44e0264990d1ab/pydal-20251012.3.tar.gz", hash = "sha256:bb9b53d61a9df3681c58d222564c7857b13a5f0a5cfd30a6f750ffb733e5fc2e", size = 633948, upload-time = "2025-10-12T18:05:56.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f5/c27279808928dc7d67dccab9ff1b494586e046833016d61b943f93e2a623/pydal-20251018.1.tar.gz", hash = "sha256:58631cc43636480f4b620a925047faaec0530675e9620a93b9119806903f8609", size = 634625, upload-time = "2025-10-19T00:56:14.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/0b/3718b23ab9f1c22a84353fee9ff709366f2982a37d200d51b33499d3128d/pydal-20251012.3-py2.py3-none-any.whl", hash = "sha256:e260de8b79478c595261b726c2af1e2c5fbb0df670ad8fd33c85d0007cbfbfa8", size = 252399, upload-time = "2025-10-12T18:05:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f3/1f62b2dc9f7198051f433efa184a400c532342fc0b1662da80f709e10276/pydal-20251018.1-py2.py3-none-any.whl", hash = "sha256:95ffed4f0eeedb4cf95fd3f6ceebf19f0eb8afc3ebb0e4324cbba785e81f2fb6", size = 252402, upload-time = "2025-10-19T00:56:12.198Z" }, ] [[package]]