Skip to content

Commit

Permalink
Merge pull request #371 from kikkomep/CU-8693c8jjd_Link-connected-IDs…
Browse files Browse the repository at this point in the history
…-to-profile-page-on-IdP-service

feat: link connected account IDs to profile page on IdP service
  • Loading branch information
kikkomep authored Jan 11, 2024
2 parents 4876494 + 5a5ab7f commit 419a32d
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 26 deletions.
14 changes: 13 additions & 1 deletion lifemonitor/auth/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from flask_login import login_required, login_user, logout_user
from wtforms import ValidationError

from lifemonitor.auth.oauth2.client.models import OAuth2IdentityProvider
from lifemonitor.cache import Timeout, cached, clear_cache
from lifemonitor.utils import (NextRouteRegistry, next_route_aware,
split_by_crlf)
Expand Down Expand Up @@ -191,7 +192,17 @@ def profile(form=None, passwordForm=None, currentView=None,
logger.debug("detected back param: %s", back_param)
from lifemonitor.api.models.registries.forms import RegistrySettingsForm
from lifemonitor.integrations.github.forms import GithubSettingsForm
logger.warning("Request args: %r", request.args)
logger.debug("Request args: %r", request.args)
logger.debug("Providers: %r", get_providers())
logger.debug("Current user: %r", current_user)
user_identities = [{
"name": p.name,
"identity": current_user.oauth_identity.get(p.client_name, None)
if current_user and current_user.is_authenticated else None,
"provider": p
} for p in OAuth2IdentityProvider.all()
]
logger.debug("User identities: %r", user_identities)
return render_template("auth/profile.j2",
passwordForm=passwordForm or SetPasswordForm(),
emailForm=emailForm or EmailForm(),
Expand All @@ -204,6 +215,7 @@ def profile(form=None, passwordForm=None, currentView=None,
providers=get_providers(), currentView=currentView,
oauth2_generic_client_scopes=OpenApiSpecs.get_instance().authorization_code_scopes,
api_base_url=get_external_server_url(),
user_identities=user_identities,
back_param=back_param)


Expand Down
31 changes: 26 additions & 5 deletions lifemonitor/auth/oauth2/client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
from authlib.oauth2.rfc6749 import OAuth2Token as OAuth2TokenBase
from flask import current_app
from flask_login import current_user
from sqlalchemy import DateTime, inspect
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import NoResultFound

from lifemonitor.auth import models
from lifemonitor.cache import Timeout
from lifemonitor.db import db
Expand All @@ -41,11 +47,8 @@
NotAuthorizedException)
from lifemonitor.models import JSON, ModelMixin
from lifemonitor.utils import assert_service_is_alive, to_snake_case
from sqlalchemy import DateTime, inspect
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import NoResultFound

from .providers import new_instance as provider_config_helper_new_instance

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -257,6 +260,11 @@ def user_info(self):
def user_info(self, value):
self._user_info = value

@property
def profile_page(self) -> str:
logger.debug("Trying to get the user profile page for provider %r...", self.provider.name)
return self.provider.get_user_profile_page(self)

def __repr__(self):
parts = []
parts.append(self.__class__.__name__)
Expand Down Expand Up @@ -445,6 +453,19 @@ def get_user_info(self, provider_user_id, token, normalized=True):
return data if not normalized \
else self.normalize_userinfo(OAuth2Registry.get_instance().get_client(self.name), data)

@property
def _provider_config_helper(self):
return provider_config_helper_new_instance(self.client_name)

def get_user_profile_page(self, user_identity: OAuthIdentity) -> str:
try:
logger.warning("user info: %r", user_identity)
return self._provider_config_helper.get_user_profile_page(user_identity)
except AttributeError as e:
if logger.isEnabledFor(logging.DEBUG):
logger.exception("Unable to get the user profile page for provider: %r", e)
return None

@property
def api_base_url(self):
return self.api_resource.uri
Expand Down
8 changes: 7 additions & 1 deletion lifemonitor/auth/oauth2/client/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,17 @@ def decorated_view(*args, **kwargs):
return decorated_view


def __find_module_attribute__(m, attribute_name: str):
attribute_map = {k.lower(): k for k in dir(m) if not k.startswith('__')}
return attribute_map[attribute_name.lower()]


def new_instance(provider_type, **kwargs):
m = f"lifemonitor.auth.oauth2.client.providers.{provider_type.lower()}"
try:
mod = import_module(m)
return getattr(mod, provider_type.capitalize())(**kwargs)
provider_type_name = __find_module_attribute__(mod, provider_type)
return getattr(mod, provider_type_name)(**kwargs)
except (ModuleNotFoundError, AttributeError) as e:
logger.exception(e)
raise OAuth2ProviderNotSupportedException(provider_type=provider_type, orig=e)
Expand Down
10 changes: 10 additions & 0 deletions lifemonitor/auth/oauth2/client/providers/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
# SOFTWARE.

import logging

from flask import current_app

from lifemonitor.auth.oauth2.client.models import OAuthIdentity

# Config a module level logger
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -66,11 +69,18 @@ class GitHub:
'client_kwargs': {'scope': 'read:user user:email'},
'userinfo_endpoint': 'https://api.github.com/user',
'userinfo_compliance_fix': normalize_userinfo,
'user_profile_html': 'https://github.com/settings/profile'
}

def __repr__(self) -> str:
return f"Github Provider {self.name}"

@classmethod
def get_user_profile_page(cls, user_identity: OAuthIdentity):
logger.warning("user: %r", user_identity)
# the user profile page can be retrieved without user_provider_id
return cls.oauth_config['user_profile_html']

@staticmethod
def normalize_userinfo(client, data):
return normalize_userinfo(client, data)
10 changes: 10 additions & 0 deletions lifemonitor/auth/oauth2/client/providers/lsaai.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
# SOFTWARE.

import logging

from flask import current_app

from lifemonitor.auth.oauth2.client.models import OAuthIdentity

# Config a module level logger
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -69,12 +72,19 @@ class LsAAI:
'client_kwargs': {'scope': 'openid profile email orcid eduperson_principal_name'},
'userinfo_endpoint': 'https://proxy.aai.lifescience-ri.eu/OIDC/userinfo',
'userinfo_compliance_fix': normalize_userinfo,
'user_profile_html': 'https://profile.aai.lifescience-ri.eu/profile',
'server_metadata_url': 'https://proxy.aai.lifescience-ri.eu/.well-known/openid-configuration'
}

def __repr__(self) -> str:
return "LSAAI Provider"

@classmethod
def get_user_profile_page(cls, user_identity: OAuthIdentity):
logger.warning("user: %r", user_identity)
# the user profile page can be retrieved without user_provider_id
return cls.oauth_config['user_profile_html']

@staticmethod
def normalize_userinfo(client, data):
return normalize_userinfo(client, data)
26 changes: 22 additions & 4 deletions lifemonitor/auth/oauth2/client/providers/seek.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
# SOFTWARE.

import logging
import requests
from urllib.parse import urljoin

import requests

from lifemonitor import exceptions
from ..models import OAuth2IdentityProvider

from ..models import OAuth2IdentityProvider, OAuthIdentity

# Config a module level logger
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -74,9 +77,14 @@ def normalize_userinfo(client, data):
}
return params

def __get_user_profile__(self, provider_user_id, json_format: bool = False) -> str:
format_opt = '?format=json' if json_format else ''
return urljoin(self.api_base_url,
f'/people/{provider_user_id}{format_opt}')

def get_user_info(self, provider_user_id, token, normalized=True):
response = requests.get(urljoin(self.api_base_url,
f'/people/{provider_user_id}?format=json'),
response = requests.get(self.__get_user_profile__(provider_user_id,
json_format=True),
headers={'Authorization': f'Bearer {token["access_token"]}'})
if response.status_code != 200:
try:
Expand All @@ -91,6 +99,16 @@ def get_user_info(self, provider_user_id, token, normalized=True):
logger.debug("USER info: %r", user_info)
return user_info['data'] if not normalized else self.normalize_userinfo(None, user_info)

@classmethod
def get_user_profile_page(cls, user_identity: OAuthIdentity):
logger.debug("user: %r", user_identity)
# the user profile page can require user_provider_id
if not user_identity:
logger.warning("No identity found for user %r", user_identity)
return None
assert isinstance(user_identity.provider, cls), "Invalid provider"
return user_identity.provider.__get_user_profile__(user_identity.provider_user_id)


def refresh_oauth2_token(func):
from . import refresh_oauth2_provider_token
Expand Down
46 changes: 31 additions & 15 deletions lifemonitor/auth/templates/auth/account_tab.j2
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,39 @@
<table class="table table-striped">
<thead>
<tr>
<th style="width: 5px"></th>
<th>Provider</th>
<th style="width: 100px">User ID</th>
<th style="width: 120px"></th>
<th style="width: 60px; height: 80px;"></th>
<th style="width: 300px; height: 80px;">Provider</th>
<th style="height: 80px; text-align: center;">User ID</th>
<th style="width: 150px;"></th>
</tr>
</thead>
<tbody>
{% for p in providers %}
{% for user_identity in user_identities %}
<tr>
<td>{{ macros.render_provider_fa_icon(p, color="black") }}</td>
<td>{{p.name}}</td>
<td>{% if p.client_name in current_user.oauth_identity %}
{{ current_user.oauth_identity[p.client_name].provider_user_id }}
{% endif %}</td>
{% if p.client_name in current_user.oauth_identity %}
<td>
<td style="height: 60px; vertical-align: middle; text-align: center;">
{{ macros.render_provider_fa_icon(user_identity.provider, color="black") }}
</td>
<td style="width: 300px; vertical-align: middle;">
{{user_identity.provider.name}}
</td>
<td style="height: 100px; max-width: 150px; padding: 20px; text-align: center; vertical-align: middle;">
{% if user_identity.identity %}
<div>
{{ user_identity.identity.provider_user_id }}
</div>
<div class="text-muted small" style="font-size: 0.7em;">
<a class="fw-light" href="{{user_identity.provider.get_user_profile_page(user_identity.identity)}}" target="_blank"
title="Click to open '{{user_identity.provider.name}}' website">
see <b>{{user_identity.provider.name}}</b> profile
</a>
</div>
{% endif %}
</td>
{% if user_identity.identity %}
<td style="vertical-align: middle;">
{% if current_user.oauth_identity|length > 1 or current_user.has_password %}
<a title="Click to unlink your '{{p.client_name}}' identity" onclick="disconnect('{{p.name}}', '{{p.client_name}}')">
<a title="Click to unlink your '{{user_identity.provider.client_name}}' identity"
onclick="disconnect('{{user_identity.provider.name}}', '{{user_identity.provider.client_name}}')">
<span class="badge bg-success" style="width: 75px; margin-right: 5px;">CONNECTED</span>
<i class="fas fa-link fa-xs" style="color: black;"></i>
</a>
Expand All @@ -39,8 +54,9 @@

</td>
{% else %}
<td>
<a href="/oauth2/login/{{p.client_name}}" title="Click to link your '{{p.client_name}}' identity">
<td style="vertical-align: middle;">
<a href="/oauth2/login/{{user_identity.provider.client_name}}"
title="Click to link your '{{user_identity.provider.client_name}}' identity">
<span class="badge bg-primary" style="width: 75px; margin-right: 5px;">CONNECT</span>
<i class="fas fa-unlink fa-xs" style="color: black;"></i>
</a>
Expand Down

0 comments on commit 419a32d

Please sign in to comment.