From 4b777958acc9b8f6706d8067d6a1ac637fa0c8a6 Mon Sep 17 00:00:00 2001 From: minhajuddin2510 Date: Mon, 27 Jan 2025 04:58:32 -0500 Subject: [PATCH 1/6] Added Signup logic --- ckanext/sso/ssoclient.py | 6 +++--- ckanext/sso/views.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/ckanext/sso/ssoclient.py b/ckanext/sso/ssoclient.py index 963841b..4b60c69 100644 --- a/ckanext/sso/ssoclient.py +++ b/ckanext/sso/ssoclient.py @@ -19,11 +19,11 @@ def __init__(self, client_id, client_secret, authorize_url, token_url, self.user_info_url = user_info_url self.scope = scope - def get_authorize_url(self): + def get_authorize_url(self, **kwargs): log.debug('get_authorize_url') oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_url, - scope=self.scope) - authorization_url, state = oauth.authorization_url(self.authorize_url) + scope=self.scope) + authorization_url, state = oauth.authorization_url(self.authorize_url, **kwargs) return authorization_url def get_token(self, code): diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 6031967..76ed0b7 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -20,6 +20,7 @@ blueprint = Blueprint('sso', __name__) + authorization_endpoint = tk.config.get('ckanext.sso.authorization_endpoint') login_url = tk.config.get('ckanext.sso.login_url') client_id = tk.config.get('ckanext.sso.client_id') @@ -41,8 +42,11 @@ @blueprint.before_app_request def before_app_request(): bp, action = tk.get_endpoint() - if bp == 'user' and action == 'login' and helpers.check_default_login(): + if bp == 'user' and action == 'login' and helpers.check_default_login(): return tk.redirect_to(h.url_for('sso.sso')) + if bp == 'user' and action == 'register': + return tk.redirect_to(h.url_for('sso.sso_register')) + def _log_user_into_ckan(resp): @@ -77,6 +81,16 @@ def sso(): return tk.abort(500, "Error getting auth url: {}".format(e)) return tk.redirect_to(auth_url) +def sso_register(): + log.info("SSO Register") + auth_url = None + try: + auth_url = sso_client.get_authorize_url(screen_hint="signup") + except Exception as e: + log.error("Error getting auth url: {}".format(e)) + return tk.abort(500, "Error getting auth url: {}".format(e)) + return tk.redirect_to(auth_url) + def dashboard(): data = tk.request.args @@ -130,6 +144,7 @@ def reset_password(): blueprint.add_url_rule('/sso', view_func=sso) +blueprint.add_url_rule('/sso_register', view_func=sso_register) blueprint.add_url_rule('/dashboard', view_func=dashboard) blueprint.add_url_rule('/reset_password', view_func=reset_password, methods=['POST']) From 1280e6061d213b97086106eeedc36e45af226985 Mon Sep 17 00:00:00 2001 From: minhajuddin2510 Date: Tue, 28 Jan 2025 06:33:27 -0500 Subject: [PATCH 2/6] fix signup page --- ckanext/sso/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 76ed0b7..173b3f6 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -85,7 +85,7 @@ def sso_register(): log.info("SSO Register") auth_url = None try: - auth_url = sso_client.get_authorize_url(screen_hint="signup") + auth_url = sso_client.get_authorize_url() except Exception as e: log.error("Error getting auth url: {}".format(e)) return tk.abort(500, "Error getting auth url: {}".format(e)) From d45018154a2d4b159411e808a9cd05dea4466fa0 Mon Sep 17 00:00:00 2001 From: minhajuddin2510 Date: Tue, 28 Jan 2025 07:56:21 -0500 Subject: [PATCH 3/6] Fixed the nickname issue --- ckanext/sso/views.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 173b3f6..1767b77 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -98,9 +98,14 @@ def dashboard(): userinfo = sso_client.get_user_info(token, user_info_url) log.info("SSO Login: {}".format(userinfo)) if userinfo: + # Get username from given_name or nickname + username = userinfo.get('given_name') or userinfo.get('nickname') + if not username: + log.error("No given_name or nickname provided by SSO") + return tk.abort(400, "Missing required user information") + user_dict = { - 'name': helpers.ensure_unique_username( - userinfo['given_name']), + 'name': helpers.ensure_unique_username(username), 'email': userinfo['email'], 'password': helpers.generate_password(), 'fullname': userinfo['name'], @@ -108,6 +113,7 @@ def dashboard(): 'idp': userinfo['sub'] } } + # Rest of the function remains the same context = {"model": model, "session": model.Session} g.user_obj = helpers.process_user(user_dict) g.user = g.user_obj.name From 3f37f037f225af480af97c4b5b5115907e872af5 Mon Sep 17 00:00:00 2001 From: Mohammed Minhajuddin <68331751+minhajuddin2510@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:59:35 -0500 Subject: [PATCH 4/6] Update views.py --- ckanext/sso/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 1767b77..ea60702 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -98,7 +98,6 @@ def dashboard(): userinfo = sso_client.get_user_info(token, user_info_url) log.info("SSO Login: {}".format(userinfo)) if userinfo: - # Get username from given_name or nickname username = userinfo.get('given_name') or userinfo.get('nickname') if not username: log.error("No given_name or nickname provided by SSO") @@ -113,7 +112,6 @@ def dashboard(): 'idp': userinfo['sub'] } } - # Rest of the function remains the same context = {"model": model, "session": model.Session} g.user_obj = helpers.process_user(user_dict) g.user = g.user_obj.name From c5a5358d1bc24017b8d01042476094a7cc515313 Mon Sep 17 00:00:00 2001 From: minhajuddin2510 Date: Wed, 29 Jan 2025 08:14:48 -0500 Subject: [PATCH 5/6] Added logout logic --- ckanext/sso/ssoclient.py | 13 +++++++++- ckanext/sso/views.py | 54 ++++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/ckanext/sso/ssoclient.py b/ckanext/sso/ssoclient.py index 4b60c69..05a402a 100644 --- a/ckanext/sso/ssoclient.py +++ b/ckanext/sso/ssoclient.py @@ -10,7 +10,7 @@ class SSOClient(object): def __init__(self, client_id, client_secret, authorize_url, token_url, - redirect_url, user_info_url, scope): + redirect_url, user_info_url, scope, logout_url=None): self.client_id = client_id self.client_secret = client_secret self.authorize_url = authorize_url @@ -18,6 +18,7 @@ def __init__(self, client_id, client_secret, authorize_url, token_url, self.redirect_url = redirect_url self.user_info_url = user_info_url self.scope = scope + self.logout_url = logout_url def get_authorize_url(self, **kwargs): log.debug('get_authorize_url') @@ -25,6 +26,15 @@ def get_authorize_url(self, **kwargs): scope=self.scope) authorization_url, state = oauth.authorization_url(self.authorize_url, **kwargs) return authorization_url + + + def get_logout_url(self, return_to=None): + """Get Auth0 logout URL""" + params = {'client_id': self.client_id} + if return_to: + params['returnTo'] = return_to + from urllib.parse import urlencode + return f"{self.logout_url}?{urlencode(params)}" def get_token(self, code): log.debug('get_token') @@ -39,3 +49,4 @@ def get_user_info(self, token, user_info_url): oauth = OAuth2Session(self.client_id, token=token) user_info = oauth.get(user_info_url) return user_info.json() + diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 1767b77..ac68253 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -7,8 +7,11 @@ import ckan.lib.helpers as h import ckan.model as model from ckan.plugins import toolkit as tk - +import ckan.plugins as plugins from ckan.views.user import set_repoze_user, RequestResetView +from ckan.common import ( + _, config, g, request, current_user, logout_user, session, login_user +) from ckanext.sso.ssoclient import SSOClient @@ -30,25 +33,30 @@ scope = tk.config.get('ckanext.sso.scope') access_token_url = tk.config.get('ckanext.sso.access_token_url') user_info_url = tk.config.get('ckanext.sso.user_info') +logout_url = tk.config.get('ckanext.sso.logout_url') +logout_redirect_url = tk.config.get('ckanext.sso.logout_redirect_url') -sso_client = SSOClient(client_id=client_id, client_secret=client_secret, +sso_client = SSOClient(client_id=client_id, + client_secret=client_secret, authorize_url=authorization_endpoint, token_url=access_token_url, redirect_url=redirect_url, user_info_url=user_info_url, - scope=scope) + scope=scope, + logout_url=logout_url) @blueprint.before_app_request def before_app_request(): bp, action = tk.get_endpoint() - if bp == 'user' and action == 'login' and helpers.check_default_login(): + if bp == 'user' and action == 'login' and helpers.check_default_login(): return tk.redirect_to(h.url_for('sso.sso')) if bp == 'user' and action == 'register': return tk.redirect_to(h.url_for('sso.sso_register')) + if bp == 'user' and action == 'logout': + return tk.redirect_to(h.url_for('sso.sso_logout')) - def _log_user_into_ckan(resp): """ Log the user into different CKAN versions. CKAN 2.10 introduces flask-login and login_user method. @@ -57,7 +65,6 @@ def _log_user_into_ckan(resp): CKAN <= 2.9.5 identifies the user only using the internal id. """ if tk.check_ckan_version(min_version="2.10"): - from ckan.common import login_user login_user(g.user_obj) return @@ -113,7 +120,14 @@ def dashboard(): 'idp': userinfo['sub'] } } - # Rest of the function remains the same + + + picture_url = (userinfo.get('picture') or + userinfo.get('avatar') or + userinfo.get('image')) + if picture_url: + user_dict['image_url'] = picture_url + context = {"model": model, "session": model.Session} g.user_obj = helpers.process_user(user_dict) g.user = g.user_obj.name @@ -127,6 +141,30 @@ def dashboard(): return response else: return tk.redirect_to(tk.url_for('user.login')) + + +def sso_logout(): + for item in plugins.PluginImplementations(plugins.IAuthenticator): + response = item.logout() + if response: + return response + user = current_user.name + if not user: + return h.redirect_to('user.login') + + came_from = request.args.get('came_from', '') + logout_user() + + field_name = config.get("WTF_CSRF_FIELD_NAME") + if session.get(field_name): + session.pop(field_name) + + if h.url_is_local(came_from): + return h.redirect_to(str(came_from)) + + logout_url = sso_client.get_logout_url(return_to=logout_redirect_url) + return tk.redirect_to(logout_url) + def reset_password(): @@ -152,9 +190,11 @@ def reset_password(): blueprint.add_url_rule('/sso', view_func=sso) blueprint.add_url_rule('/sso_register', view_func=sso_register) blueprint.add_url_rule('/dashboard', view_func=dashboard) +blueprint.add_url_rule('/sso_logout', view_func=sso_logout) blueprint.add_url_rule('/reset_password', view_func=reset_password, methods=['POST']) def get_blueprint(): return blueprint + From c3e54f678b629ceae085ba0ccab19304ff30c98f Mon Sep 17 00:00:00 2001 From: Thomas Hanke Date: Thu, 3 Jul 2025 09:19:07 +0200 Subject: [PATCH 6/6] added a specific ldap group lookup and define able default role for useres added to groups --- ckanext/sso/ldap_client.py | 53 +++++++++++++++++++++++++++++++++++++ ckanext/sso/plugin.py | 5 ++++ ckanext/sso/views.py | 54 +++++++++++++++++++++++++------------- requirements.txt | 1 + 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 ckanext/sso/ldap_client.py diff --git a/ckanext/sso/ldap_client.py b/ckanext/sso/ldap_client.py new file mode 100644 index 0000000..51b7b54 --- /dev/null +++ b/ckanext/sso/ldap_client.py @@ -0,0 +1,53 @@ +import logging +from ldap3 import Server, Connection, ALL, SUBTREE, ALL_ATTRIBUTES +import ckan.plugins.toolkit as tk + +log = logging.getLogger(__name__) + + + +def build_ldap_connection(server_address, username, password): + """Builds and returns an LDAP connection.""" + server = Server(server_address, get_info=ALL) + conn = Connection(server, user=username, password=password, auto_bind=True) + return conn + +class LDAPClient(object): + def __init__(self): + self.server_address = tk.config.get("ckanext.sso.ldap_server") + self.ldap_base_dn = tk.config.get("ckanext.sso.ldap_base_dn") + self.ldap_user = tk.config.get("ckanext.sso.ldap_user") + self.ldap_pass = tk.config.get("ckanext.sso.ldap_pass") + self.conn = build_ldap_connection(self.server_address,self.ldap_user,self.ldap_pass) + if not self.conn.bound: + raise Exception("LDAP Connection could not be established") + else: + log.debug(f"LDAP connection established") + + + def query_user_by_email(self, email): + """Queries LDAP for a user based on their email address.""" + search_filter = f'(&(objectClass=Person)(mail={email}))' + self.conn.search(self.ldap_base_dn, search_filter, SUBTREE, attributes=ALL_ATTRIBUTES) + + # Print the results + if self.conn.entries: + return self.conn.entries[0].entry_attributes_as_dict # Return the first matching group + + def get_distinct_departments(self): + """Fetches all distinct department values from all user entries in the LDAP.""" + search_filter = '(objectClass=Person)' # Adjust this filter based on your LDAP schema + attributes = ['department','departmentNumber'] + + self.conn.search(self.ldap_base_dn, search_filter, SUBTREE, attributes=attributes) + + departments = {} + + # Collect distinct department values + for entry in self.conn.entries: + department = entry.department.value if 'department' in entry else None + if department: # Only add non-empty departments + #departments[department]=get_group_by_display_name(conn,base_dn,department) + departments[department]=entry.departmentNumber.value + return departments + diff --git a/ckanext/sso/plugin.py b/ckanext/sso/plugin.py index b757995..f2757b0 100644 --- a/ckanext/sso/plugin.py +++ b/ckanext/sso/plugin.py @@ -57,6 +57,11 @@ def declare_config_options(self, declaration: Declaration, key: Key): declaration.declare(group.redirect_url, "http://localhost/dashboard") declaration.declare(group.response_type, "code") declaration.declare(group.scope, "openid profile email") + declaration.declare(group.role, "member") + declaration.declare(group.ldap_server, "ldap://ad.example.com") + declaration.declare(group.ldap_base_dn, "ou=People,ou=IWM,dc=iwm,dc=fraunhofer,dc=de") + declaration.declare(group.ldap_user, "ldap-user") + declaration.declare(group.ldap_pass, "password") def get_blueprint(self): return get_blueprint() diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 0e2b9ab..8e99382 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -10,6 +10,7 @@ import ckanext.sso.helpers as helpers from ckanext.sso.ssoclient import SSOClient +from ckanext.sso.ldap_client import LDAPClient g = tk.g @@ -67,9 +68,12 @@ def dashboard(): data = tk.request.args sso_client = SSOClient() - token = sso_client.get_token(data["code"]) - userinfo = sso_client.get_user_info(token) - log.debug("SSO Login: {}".format(userinfo)) + default_role = tk.config.get("ckanext.sso.role","member") + userinfo=None + if data.get('code',None): + token = sso_client.get_token(data["code"]) + userinfo = sso_client.get_user_info(token) + log.debug("SSO Login: {}".format(userinfo)) if userinfo: pref_username = userinfo.get("preferred_username", "") @@ -84,6 +88,20 @@ def dashboard(): "fullname": userinfo["name"], "plugin_extras": {"idp": userinfo["sub"]}, } + log.debug(f"User Info: {user_dict}") + #ldap info + ldap_department_num=None + if "email" in userinfo.keys(): + try: + ldap_client = LDAPClient() + except Exception as e: + log.debug(f"{e}") + ldap_info=None + else: + ldap_info=ldap_client.query_user_by_email(userinfo["email"]) + ldap_department=ldap_info.get("department",None)[0] + ldap_department_num=ldap_info.get("departmentNumber",None)[0] + log.debug(f"LDAP department: {ldap_department}-{ldap_department_num}") context = {"model": model, "session": model.Session} g.user_obj = helpers.process_user(user_dict) @@ -93,15 +111,15 @@ def dashboard(): keycloak_groups = userinfo.get('groups', []) - - - clean_keycloak_groups = [group.strip('/') for group in keycloak_groups] - + user_groups = [group.strip('/') for group in keycloak_groups] + # add ldap department + if ldap_department_num: + user_groups.append(ldap_department_num) ckan_organizations = tk.get_action('organization_list')(context, {}) ckan_groups = tk.get_action('group_list')(context, {}) - log.debug(f"cleaanedKeycloak Groups: {clean_keycloak_groups}") + log.debug(f"cleaned User Groups: {user_groups}") log.debug(f"ckan Orga: {ckan_organizations}") log.debug(f"ckan groups: {ckan_groups}") @@ -111,35 +129,35 @@ def dashboard(): 'ignore_auth': True } - for keycloak_group in clean_keycloak_groups: - if keycloak_group in ckan_organizations: + for group in user_groups: + if group in ckan_organizations: try: tk.get_action('organization_member_create')( admin_context, { - 'id': keycloak_group, + 'id': group, 'username': g.user, - 'role': 'member' + 'role': default_role } ) - log.info(f"User {g.user} added to org {keycloak_group}") + log.info(f"User {g.user} added to org {group}") except Exception as e: - log.error(f"Failed to add user {g.user} to organization {keycloak_group}: {e}") - elif keycloak_group in ckan_groups: + log.error(f"Failed to add user {g.user} to organization {group}: {e}") + elif group in ckan_groups: try: tk.get_action('group_member_create')( admin_context, { - 'id': keycloak_group, + 'id': group, 'username': g.user, 'role': 'member' } ) - log.info(f"User {g.user} added to org {keycloak_group}") + log.info(f"User {g.user} added to org {group}") except Exception as e: - log.error(f"Failed to add user {g.user} to group {keycloak_group}: {e}") + log.error(f"Failed to add user {g.user} to group {group}: {e}") response = tk.redirect_to(tk.url_for('user.me', context)) diff --git a/requirements.txt b/requirements.txt index f98058b..2f51274 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests-oauthlib==1.3.1 PyJWT==1.7.1 +ldap3