Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cilogon oauth integration #333

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions invenio_oauthclient/contrib/cilogon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
""" Toolkit for creating remote apps that enable sign in/up with cilogon. This was originally adapted from the keycloak plugin by
Robert Hancock of BNL. Anil Panta of JLAB helped clean it up and added some code to convert CILogon groups to Invenio roles.

1. Register you invenio instance to cilogon via comanage registry and make sure it is configured appropriately,
like in your comanage registry, set the callabck URI as
"https://myinveniohost/oauth/authorized/cilogon/".
Make sure to grab the *Client ID* and *Client Secret* .
Minimum scope/claim should be "openid", "email", "org.cilogon.userinfo", "profile".
If you want allow certain group from cilogon to login you need to enable clain "isMemberOf".


2. Add the following items to your configuration (``invenio.cfg``).
The ``CilogonSettingsHelper`` class can be used to help with setting up
the configuration values:

.. code-block:: python

from invenio_oauthclient.contrib import cilogon

helper = cilogon.CilogonSettingsHelper(
title="CILOGON",
description="CILOGON Comanage Registry",
base_url="https://cilogon.org",
precedence_mask={"email":True, "profile": {"username": False, "full_name": False, "affiliations": False}}
)

# precendence mask is added and email is set to true so that user's email is taken from cilogon not from user input.

# create the configuration for cilogon
# because the URLs usually follow a certain schema, the settings helper
# can be used to more easily build the configuration values:
OAUTHCLIENT_CILOGON_USER_INFO_URL = helper.user_info_url
OAUTHCLIENT_CILOGON_JWKS_URL = helper.jwks_url
OAUTHCLIENT_CILOGON_CONFIG_URL = helper.base_url+'/.well-known/openid-configuration'

# CILOGON tokens, contains information about the target audience (AUD)
# verification of the expected AUD value can be configured with:
OAUTHCLIENT_CILOGON_VERIFY_AUD = True
OAUTHCLIENT_CILOGON_AUD = "client audience"(same as client ID usually)

# enable/disable checking if the JWT signature has expired
OAUTHCLIENT_CILOGON_VERIFY_EXP = True

# Cilogon role values (i.e. groups) that are allowed to be used
OAUTHCLIENT_CILOGON_ALLOWED_ROLES = '["CO:COU:eic:members:all"]'
# error direct when user role/grup from cilogon doesn't match to allowed.
OAUTHCLIENT_CILOGON_ROLES_ERROR_URL = "/"

# if you want to allow users from any group without check of allowed roles
# set the following to True (default is False)
OAUTHCLIENT_CILOGON_ALLOW_ANY_ROLES=False

# oidc claim name for LDAP Atrribute "isMemberOf". Default "isMemberOf")
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM = "isMemberOf"

# add CILOGON as external login providers to the dictionary of remote apps
OAUTHCLIENT_REMOTE_APPS = dict(
cilogon=helper.remote_app,
)
OAUTHCLIENT_REST_REMOTE_APPS = dict(
cilogon=helper.remote_rest_app,
)

# set the following configuration to True to automatically use the
# user's email address as account email
USERPROFILES_EXTEND_SECURITY_FORMS = True

By default, the title will be displayed as label for the login button,
for example ``CILOGON``. The description will be
displayed in the user account section.

3. Grab the *Client ID* and *Client Secret* from the
Comanage Registry and add them to your instance configuration (``invenio.cfg``):

.. code-block:: python

CILOGON_APP_CREDENTIALS = dict(
consumer_key='<CLIENT ID>',
consumer_secret='<CLIENT SECRET>',
)

4. Now go to ``CFG_SITE_SECURE_URL/oauth/login/cilogon/`` (e.g.
https://localhost:5000/oauth/login/cilogon/) and log in.

5. After authenticating successfully, you should see cilogon listed under
Linked accounts: https://localhost:5000/account/settings/linkedaccounts/
"""



from .handlers import (
disconnect_handler,
disconnect_rest_handler,
info_handler,
setup_handler,
)
from .settings import CilogonSettingsHelper

__all__ = (
"disconnect_handler",
"disconnect_rest_handler",
"info_handler",
"setup_handler",
"CilogonSettingsHelper",
)
234 changes: 234 additions & 0 deletions invenio_oauthclient/contrib/cilogon/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
from flask import session, g, current_app, redirect, url_for
from flask_login import current_user
from invenio_db import db
from invenio_i18n import gettext as _


from flask_principal import (
AnonymousIdentity,
RoleNeed,
UserNeed,
)

from invenio_oauthclient import current_oauthclient
from invenio_oauthclient.handlers.rest import response_handler
from invenio_oauthclient.handlers.utils import require_more_than_one_external_account
from invenio_oauthclient.models import RemoteAccount
from invenio_oauthclient.oauth import oauth_link_external_id, oauth_unlink_external_id
from invenio_oauthclient.errors import OAuthCilogonRejectedAccountError

from .helpers import get_user_info, get_groups, filter_groups

OAUTHCLIENT_CILOGON_SESSION_KEY = "identity.cilogon_provides"
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM = "isMemberOf"


def extend_identity(identity, roles):
"""Extend identity with roles based on CILOGON groups."""
if not roles:
provides = set([UserNeed(current_user.email)])
else:
provides = set([UserNeed(current_user.email)] + [RoleNeed(name) for name in roles])
identity.provides |= provides
key = current_app.config.get(
"OAUTHCLIENT_CILOGON_SESSION_KEY",
OAUTHCLIENT_CILOGON_SESSION_KEY,
)
session[key] = provides

def disconnect_identity(identity):
"""Disconnect identity from CILOGON groups."""
session.pop("cern_resource", None)
key = current_app.config.get(
"OAUTHCLIENT_CILOGON_SESSION_KEY",
OAUTHCLIENT_CILOGON_SESSION_KEY,
)
provides = session.pop(key, set())
identity.provides -= provides

def info_serializer_handler(remote, resp, token_user_info, user_info=None, **kwargs):
"""Serialize the account info response object.

:param remote: The remote application.
:param resp: The response of the `authorized` endpoint.
:param token_user_info: The content of the authorization token response.
:param user_info: The response of the `user info` endpoint.
:returns: A dictionary with serialized user information.
"""
# fill out the information required by
# 'invenio-accounts' and 'invenio-userprofiles'.

user_info = user_info or {} # prevent errors when accessing None.get(...)

email = token_user_info.get("email") or user_info.get("email")
full_name = token_user_info.get("name") or user_info.get("name")
username = token_user_info.get("preferred_username") or user_info.get(
"preferred_username"
)
cilogonid = token_user_info.get("sub") or user_info.get("sub")

# check for matching group
group_claim_name = current_app.config.get(
"OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM",
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM,
)
group_names = token_user_info.get(group_claim_name) or user_info.get(group_claim_name)
filter_groups(remote, resp, group_names)
return {
"user": {
"active": True,
"email": email,
"profile": {
"full_name": full_name,
"username": username,
},
},
"external_id": cilogonid,
"external_method": remote.name,
}


def info_handler(remote, resp):
"""Retrieve remote account information for finding matching local users.

:param remote: The remote application.
:param resp: The response of the `authorized` endpoint.
:returns: A dictionary with the user information.
"""
token_user_info, user_info = get_user_info(remote, resp)
handlers = current_oauthclient.signup_handlers[remote.name]
# `remote` param automatically injected via `make_handler` helper
return handlers["info_serializer"](resp, token_user_info, user_info)

def group_serializer_handler(remote, resp, token_user_info, user_info=None, **kwargs):
"""Retrieve remote account information for group for finding matching local groups.

:param remote: The remote application.
:param resp: The response of the `authorized` endpoint.
:returns: A dictionary with the user information.
"""
user_info = user_info or {} # prevent errors when accessing None.get(...)
group_claim_name = current_app.config.get(
"OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM",
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM,
)
group_names = token_user_info.get(group_claim_name) or user_info.get(group_claim_name)
groups_dict_list = []
# check for matching group
try:
matching_groups = filter_groups(remote, resp, group_names)
for group in matching_groups:
group_dict = {
"id" : group,
"name": group,
"description": ""
}
groups_dict_list.append(group_dict)
return groups_dict_list

except OAuthCilogonRejectedAccountError as e:
current_app.logger.warning(e.message, exc_info=False)
return groups_dict_list

def group_rest_serializer_handler(remote, resp, token_user_info, user_info=None, **kwargs):
"""Retrieve remote account information for group for finding matching local groups.

:param remote: The remote application.
:param resp: The response of the `authorized` endpoint.
:returns: A dictionary with the user information.
"""
user_info = user_info or {} # prevent errors when accessing None.get(...)
group_claim_name = current_app.config.get(
"OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM",
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM,
)
group_names = token_user_info.get(group_claim_name) or user_info.get(group_claim_name)
groups_dict_list = []
# check for matching group
try:
matching_groups = filter_groups(remote, resp, group_names)
for group in matching_groups:
group_dict = {
"id" : group,
"name": group,
"description": ""
}
groups_dict_list.append(group_dict)
return groups_dict_list

except OAuthCilogonRejectedAccountError as e:
current_app.logger.warning(e.message, exc_info=False)
return groups_dict_list

def group_handler(remote, resp):
"""Retrieve remote account information for finding matching local users.

:param remote: The remote application.
:param resp: The response of the `authorized` endpoint.
:returns: A dictionary with the user information.
"""
token_user_info, user_info = get_user_info(remote, resp)
handlers = current_oauthclient.signup_handlers[remote.name]
# `remote` param automatically injected via `make_handler` helper
return handlers["groups_serializer"](resp, token_user_info, user_info)


def setup_handler(remote, token, resp):
"""Perform additional setup after the user has been logged in."""
token_user_info, _ = get_user_info(remote, resp, from_token_only=True)

with db.session.begin_nested():
# fetch the user's cilogon ID (sub) and set it in extra_data
cilogonid = token_user_info["sub"]
token.remote_account.extra_data = {
"cilogonid": cilogonid,
}

user = token.remote_account.user
external_id = {"id": cilogonid, "method": remote.name}

group_claim_name = current_app.config.get(
"OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM",
OAUTHCLIENT_CILOGON_GROUP_OIDC_CLAIM,
)
group_names = token_user_info.get(group_claim_name)
roles = get_groups(remote, resp, token.remote_account, group_names)
assert not isinstance(g.identity, AnonymousIdentity)
extend_identity(g.identity, roles)

# link account with external cilogon ID
oauth_link_external_id(user, external_id)

@require_more_than_one_external_account
def _disconnect(remote, *args, **kwargs):
"""Common logic for handling disconnection of remote accounts."""
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()

account = RemoteAccount.get(
user_id=current_user.get_id(), client_id=remote.consumer_key
)

cilogonid = account.extra_data.get("cilogonid")

if cilogonid:
external_id = {"id": cilogonid, "method": remote.name}

oauth_unlink_external_id(external_id)

if account:
with db.session.begin_nested():
account.delete()
disconnect_identity(g.identity)

def disconnect_handler(remote, *args, **kwargs):
"""Handle unlinking of the remote account."""
_disconnect(remote, *args, **kwargs)
return redirect(url_for("invenio_oauthclient_settings.index"))

def disconnect_rest_handler(remote, *args, **kwargs):
"""Handle unlinking of the remote account."""
_disconnect(remote, *args, **kwargs)
rconfig = current_app.config["OAUTHCLIENT_REST_REMOTE_APPS"][remote.name]
redirect_url = rconfig["disconnect_redirect_url"]
return response_handler(remote, redirect_url)
Loading