Skip to content
53 changes: 53 additions & 0 deletions ckanext/sso/ldap_client.py
Original file line number Diff line number Diff line change
@@ -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

5 changes: 5 additions & 0 deletions ckanext/sso/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
42 changes: 26 additions & 16 deletions ckanext/sso/ssoclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()

174 changes: 126 additions & 48 deletions ckanext/sso/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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}")

Expand All @@ -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))

Expand All @@ -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():
Expand All @@ -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

1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
requests-oauthlib==1.3.1
PyJWT==1.7.1
ldap3
Loading