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/ssoclient.py b/ckanext/sso/ssoclient.py index a74bec3..4f8c84c 100644 --- a/ckanext/sso/ssoclient.py +++ b/ckanext/sso/ssoclient.py @@ -9,23 +9,32 @@ class SSOClient(object): - def __init__(self): - self.authorize_url = tk.config.get("ckanext.sso.authorization_endpoint") - self.client_id = tk.config.get("ckanext.sso.client_id") - self.redirect_url = tk.config.get("ckanext.sso.redirect_url") - self.client_secret = tk.config.get("ckanext.sso.client_secret") - response_type = tk.config.get("ckanext.sso.response_type") - self.scope = tk.config.get("ckanext.sso.scope") - self.token_url = tk.config.get("ckanext.sso.access_token_url") - self.user_info_url = tk.config.get("ckanext.sso.user_info") - - def get_authorize_url(self): - 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) + def __init__(self, client_id, client_secret, authorize_url, token_url, + redirect_url, user_info_url, scope, logout_url=None): + self.client_id = client_id + self.client_secret = client_secret + self.authorize_url = authorize_url + self.token_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') + oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_url, + 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") @@ -42,3 +51,4 @@ def get_user_info(self, token): oauth = OAuth2Session(self.client_id, token=token) user_info = oauth.get(self.user_info_url) return user_info.json() + diff --git a/ckanext/sso/views.py b/ckanext/sso/views.py index 0e2b9ab..796e425 100644 --- a/ckanext/sso/views.py +++ b/ckanext/sso/views.py @@ -5,28 +5,52 @@ import ckan.lib.helpers as h import ckan.model as model from ckan.plugins import toolkit as tk -from ckan.views.user import RequestResetView, set_repoze_user -from flask import Blueprint -import ckanext.sso.helpers as helpers +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 +from ckanext.sso.ldap_client import LDAPClient g = tk.g log = logging.getLogger(__name__) -blueprint = Blueprint("sso", __name__) +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') +redirect_url = tk.config.get('ckanext.sso.redirect_url') +client_secret = tk.config.get('ckanext.sso.client_secret') +response_type = tk.config.get('ckanext.sso.response_type') +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, + authorize_url=authorization_endpoint, + token_url=access_token_url, + redirect_url=redirect_url, + user_info_url=user_info_url, + 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(): - return tk.redirect_to(h.url_for("sso.sso")) - - - - + 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. @@ -36,8 +60,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 @@ -62,29 +84,61 @@ 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() + 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 sso_client = SSOClient() - 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", "") - if pref_username: - user_name = helpers.ensure_unique_username(pref_username) - else: - user_name = helpers.ensure_unique_username(userinfo["name"]) + 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)) + 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': user_name, - "email": userinfo["email"], - "password": helpers.generate_password(), - "fullname": userinfo["name"], - "plugin_extras": {"idp": userinfo["sub"]}, + 'name': helpers.ensure_unique_username(username), + 'email': userinfo['email'], + 'password': helpers.generate_password(), + 'fullname': userinfo['name'], + 'plugin_extras': { + 'idp': userinfo['sub'] + } } - + if userinfo: + #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}") + + 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 @@ -93,15 +147,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 +165,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)) @@ -148,9 +202,30 @@ def dashboard(): return response else: - return tk.redirect_to(tk.url_for("user.login")) + 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(): @@ -170,11 +245,14 @@ def reset_password(): return tk.redirect_to(tk.url_for("user.login")) return RequestResetView().post() - -blueprint.add_url_rule("/sso", view_func=sso) -blueprint.add_url_rule("/dashboard", view_func=dashboard) -blueprint.add_url_rule("/reset_password", view_func=reset_password, methods=["POST"]) +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 + 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