Skip to content

Commit

Permalink
update invite handling (wip), update docs (#1367)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikkonie committed Jul 10, 2024
1 parent 4b4e8b0 commit 18092ec
Show file tree
Hide file tree
Showing 11 changed files with 97 additions and 45 deletions.
14 changes: 13 additions & 1 deletion docs/source/app_projectroles_settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ Critical settings which should be provided through environment variables:
Endpoint URL for the OIDC provider. The configuration file
``.well-known/openid-configuration`` is expected to be found under this URL.
``SOCIAL_AUTH_OIDC_KEY``
Key for the OIDC provider.
Your client ID in the OIDC provider.
``SOCIAL_AUTH_OIDC_SECRET``
Secret for the OIDC provider.
``SOCIAL_AUTH_OIDC_USERNAME_KEY``
Expand All @@ -538,6 +538,18 @@ provider, add it as ``{PROJECTROLES_TEMPLATE_INCLUDE_PATH}/_login_oidc.html``
(by default: ``your_site/templates/include/_login_oidc.html``). The include will
be displayed as an element in the login view.

Below is an example of a custom template. You can e.g. change the content of the
link to the logo of your OIDC provider. Note that the login URL must equal
``{% url 'social:begin' 'oidc' %}?next={{ oidc_redirect_url|default:'/' }}`` to
ensure it works in all views.

.. code-block:: django
<a role="button" class="btn btn-md btn-info btn-block" id="sodar-login-oidc-link"
href="{% url 'social:begin' 'oidc' %}?next={{ oidc_redirect_url|default:'/' }}">
<i class="iconify" data-icon="mdi:login-variant"></i> OpenID Connect Login
</a>
SAML SSO Configuration (Removed in v1.0)
========================================
Expand Down
8 changes: 8 additions & 0 deletions docs/source/major_changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ Django Settings Changed
The ``PROJECTROLES_HIDE_APP_LINKS`` Django setting, which was deprecated in
v0.13, has been removed. Use ``PROJECTROLES_HIDE_PROJECT_APPS`` instead.

Login Template Updated
----------------------

The default login template ``login.html`` has been updated by adding OpenID
Connect (OIDC) controls and removing SAML controls. If you have overridden the
login template with your own and wish to use OIDC authentication, make sure to
update your template accordingly.

Base Test Classes Renamed
-------------------------

Expand Down
12 changes: 5 additions & 7 deletions example_site/templates/include/_login_oidc.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
{# Place custom OpenIDC login link and/or info into this template #}

<div class="col-md-4 mx-auto mt-4">
<a role="button" class="btn btn-md btn-info btn-block"
id="sodar-login-oidc-link"
href="{% url 'social:begin' 'oidc' %}">
<i class="iconify" data-icon="mdi:login-variant"></i> OpenID Connect Login
</a>
</div>
<a role="button" class="btn btn-md btn-success btn-block"
id="sodar-login-oidc-link"
href="{% url 'social:begin' 'oidc' %}?next={{ oidc_redirect_url|default:'/' }}">
<i class="iconify" data-icon="mdi:login-variant"></i> OpenID Connect Login
</a>
3 changes: 3 additions & 0 deletions projectroles/static/projectroles/css/projectroles.css
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,9 @@ span.select2-selection__rendered {

/* Misc --------------------------------------------------------------------- */

hr {
border-color: #dfdfdf;
}

img.sodar-navbar-logo {
height: 36px;
Expand Down
13 changes: 9 additions & 4 deletions projectroles/templates/projectroles/_login_oidc.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{# Default OIDC login element shown if no custom template is provided #}
{# Display custom or default OIDC login element #}
{% load projectroles_common_tags %}

<div class="col-md-4 mx-auto mt-4">
{% template_exists template_include_path|add:'/_login_oidc.html' as tpl_oidc %}

{% if tpl_oidc %}
{% include template_include_path|add:'/_login_oidc.html' %}
{% else %}
<a role="button" class="btn btn-md btn-info btn-block"
id="sodar-login-oidc-link"
href="{% url 'social:begin' 'oidc' %}">
href="{% url 'social:begin' 'oidc' %}?next={{ oidc_redirect_url|default:'/' }}">
<i class="iconify" data-icon="mdi:login-variant"></i> OpenID Connect Login
</a>
</div>
{% endif %}
16 changes: 6 additions & 10 deletions projectroles/templates/projectroles/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{% block title %}Login{% endblock title %}

{% block content %}
{% get_django_setting 'PROJECTROLES_TEMPLATE_INCLUDE_PATH' as template_include_path %}

<div class="container-fluid">
{# Django messages / site app messages #}
Expand All @@ -26,7 +27,6 @@

<div class="col-md-4 mx-auto mt-5">
<h2 class="sodar-pr-content-title">Login</h2>

{% autoescape off %}
{% get_login_info %}
{% endautoescape %}
Expand All @@ -48,16 +48,12 @@ <h2 class="sodar-pr-content-title">Login</h2>
</form>
</div>

{# Path for template includes #}
{% get_django_setting 'PROJECTROLES_TEMPLATE_INCLUDE_PATH' as template_include_path %}

{# OpenID Connect auth #}
{# OpenID Connect (OIDC) auth #}
{% get_django_setting 'ENABLE_OIDC' as enable_oidc %}
{% template_exists template_include_path|add:'/_login_oidc.html' as tpl_oidc %}
{% if enable_oidc and tpl_oidc %}
{% include template_include_path|add:'/_login_oidc.html' %}
{% elif enable_oidc %}
{% include 'projectroles/_login_oidc.html' %} {# Defaut template #}
{% if enable_oidc %}
<div class="col-md-4 mx-auto mt-4">
{% include 'projectroles/_login_oidc.html' %}
</div>
{% endif %}

{# Optional template for additional login page HTML #}
Expand Down
14 changes: 13 additions & 1 deletion projectroles/templates/projectroles/user_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@
{% load crispy_forms_filters %}

{% block title %}
{% get_django_setting 'ENABLE_OIDC' as enable_oidc %}
{% if not object and enable_oidc %}Login or{% endif %}
{% if object %}Update{% else %}Create{% endif %} Local User
{% endblock title %}

{% block projectroles %}
{% get_django_setting 'ENABLE_OIDC' as enable_oidc %}
{% get_django_setting 'PROJECTROLES_TEMPLATE_INCLUDE_PATH' as template_include_path %}

<div class="container-fluid sodar-subtitle-container">
<h2>
<i class="iconify" data-icon="mdi:account"></i>
{% if not object and enable_oidc %}Login or{% endif %}
{% if object %}Update{% else %}Create{% endif %} Local User
</h2>
</div>

<div class="container-fluid sodar-page-container">
{# TODO: Add link to OIDC login if new user and OIDC enabled #}
{% if not object and enable_oidc %}
<div class="col-md-4 mt-1 mb-4 ml-0 pl-0">
<p>Please log in if you have an existing account.</p>
{% url 'projectroles:invite_process_ldap' secret=invite.secret as oidc_redirect_url %}
{% include 'projectroles/_login_oidc.html' %}
</div>
<hr />
{% endif %}
<form method="post">
{% csrf_token %}
{{ form | crispy }}
Expand Down
3 changes: 3 additions & 0 deletions projectroles/tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2361,6 +2361,9 @@ def test_invite_preview(self):
)


# TODO: Test OIDC element and URL on local invite process view


class TestRemoteSiteListView(RemoteSiteMixin, UITestBase):
"""Tests for RemoteSiteListView UI"""

Expand Down
56 changes: 36 additions & 20 deletions projectroles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@
)
USER_PROFILE_UPDATE_MSG = 'User profile updated, please log in again.'
USER_PROFILE_LDAP_MSG = 'Error: Profile editing not allowed for LDAP users.'
INVITE_TYPE_LDAP = 'LDAP'
INVITE_TYPE_LOCAL_OIDC = 'LOCAL-OIDC'
INVITE_LDAP_LOCAL_VIEW_MSG = (
'Error: Invite was issued for LDAP user, but local invite view '
'was requested.'
Expand Down Expand Up @@ -2549,10 +2551,14 @@ def form_valid(self, form):
class ProjectInviteProcessMixin(ProjectModifyPluginViewMixin):
"""Mixin for accepting and processing project invites"""

# TODO: Handle OIDC invite together with local
@classmethod
def get_invite_type(cls, invite):
"""Return invite type ("ldap", "local" or "error")"""
"""
Return invite type: LDAP or local/OIDC.
:param invite: ProjectInvite object
:return: String
"""
# Check if domain is associated with LDAP
domain = invite.email.split('@')[1].lower()
domain_no_tld = domain.split('.')[0].lower()
Expand All @@ -2566,10 +2572,10 @@ def get_invite_type(cls, invite):
if settings.ENABLE_LDAP and (
domain_no_tld in ldap_domains or domain in alt_domains
):
return 'ldap'
elif settings.PROJECTROLES_ALLOW_LOCAL_USERS:
return 'local'
return 'error'
return INVITE_TYPE_LDAP
elif settings.ENABLE_OIDC or settings.PROJECTROLES_ALLOW_LOCAL_USERS:
return INVITE_TYPE_LOCAL_OIDC
# Return None for no auth for invite user

@classmethod
def revoke_invite(
Expand Down Expand Up @@ -2733,24 +2739,25 @@ def get(self, *args, **kwargs):
kwargs={'project': invite.project.sodar_uuid},
)
)

# TODO: Handle OIDC invite together with local
invite_type = self.get_invite_type(invite)
if invite_type == 'ldap':
if invite_type == INVITE_TYPE_LDAP:
return redirect(
reverse(
'projectroles:invite_process_ldap',
kwargs={'secret': kwargs['secret']},
)
)
elif invite_type == 'local':
# TODO: OIDC is enabled but local invites are disabled, redirect to
# process_logged_in
elif invite_type == INVITE_TYPE_LOCAL_OIDC:
return redirect(
reverse(
'projectroles:invite_process_local',
kwargs={'secret': kwargs['secret']},
)
)
# Error
# TODO: Add OIDC note here
messages.error(
self.request, 'Local users are not allowed on this site.'
)
Expand All @@ -2769,15 +2776,19 @@ def get(self, *args, **kwargs):
if not invite:
return redirect(reverse('home'))
timeline = get_backend_api('timeline_backend')
# TODO: Check for email to match in case of other user
# TODO: Ensure all relevant checks are performed after refactoring
'''
# Check if invite has correct type
if self.get_invite_type(invite) == 'local':
if self.get_invite_type(invite) == INVITE_TYPE_LOCAL_OIDC:
messages.error(
self.request,
'Invite was issued for local user, but LDAP invite view was '
'requested.',
'Invite was issued for local/OIDC user, but LDAP invite view '
'was requested.',
)
return redirect(reverse('home'))
# Check if user already accepted the invite
'''
# Check if user has already accepted the invite
if self.user_role_exists(invite, self.request.user):
return redirect(
reverse(
Expand All @@ -2801,26 +2812,29 @@ def get(self, *args, **kwargs):
)


# TODO: Handle OIDC invite together with local, forward to logged-in view once
# logged in
class ProjectInviteProcessLocalView(ProjectInviteProcessMixin, FormView):
"""View to handle accepting a project local invite"""

form_class = LocalUserForm
template_name = 'projectroles/user_form.html'

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['invite'] = self.get_invite(self.kwargs['secret'])
return context

def get(self, *args, **kwargs):
# TODO: Remove double invite retrieval
invite = self.get_invite(self.kwargs['secret'])
if not invite:
return redirect(reverse('home'))
timeline = get_backend_api('timeline_backend')
# Check if local users are enabled
# TODO: Also allow OIDC
if not settings.PROJECTROLES_ALLOW_LOCAL_USERS:
messages.error(self.request, INVITE_LOCAL_NOT_ALLOWED_MSG)
return redirect(reverse('home'))
# Check invite for correct type
if self.get_invite_type(invite) == 'ldap':
if self.get_invite_type(invite) == INVITE_TYPE_LDAP:
messages.error(self.request, INVITE_LDAP_LOCAL_VIEW_MSG)
return redirect(reverse('home'))

Expand Down Expand Up @@ -2857,7 +2871,6 @@ def get(self, *args, **kwargs):
messages.error(self.request, INVITE_USER_NOT_EQUAL_MSG)
return redirect(reverse('home'))
# User exists but is not local
# TODO: This should instead check if LDAP user
if not user.is_local():
messages.error(self.request, 'User exists, but is not local.')
return redirect(reverse('home'))
Expand Down Expand Up @@ -2896,6 +2909,9 @@ def form_valid(self, form):
return redirect(reverse('home'))
timeline = get_backend_api('timeline_backend')

# TODO: Remove redundant checks
# TODO: Redirect to logged in process view
# TODO: Ensure all checks are performed there
# Check if local users are allowed
if not settings.PROJECTROLES_ALLOW_LOCAL_USERS:
messages.error(
Expand All @@ -2906,7 +2922,7 @@ def form_valid(self, form):
return redirect(reverse('home'))

# Check invite for correct type
if self.get_invite_type(invite) == 'ldap':
if self.get_invite_type(invite) == INVITE_TYPE_LDAP:
messages.error(
self.request,
'Invite was issued for LDAP user, but local invite view was '
Expand Down
2 changes: 1 addition & 1 deletion userprofile/templates/userprofile/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h2 class="sodar-pr-content-title">{{ request.user.get_full_name }}</h2>
<div class="card-header">
<h4>
<i class="iconify" data-icon="mdi:account-details"></i> Details
{% if local_user %}
{% if request.user.is_local %}
<span class="sodar-header-input-group pull-right">
<a role="button"
class="btn btn-primary"
Expand Down
1 change: 0 additions & 1 deletion userprofile/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ def _get_user_settings(self):
def get_context_data(self, **kwargs):
result = super().get_context_data(**kwargs)
result['user_settings'] = list(self._get_user_settings())
result['local_user'] = self.request.user.is_local()
result['add_emails'] = SODARUserAdditionalEmail.objects.filter(
user=self.request.user
).order_by('email')
Expand Down

0 comments on commit 18092ec

Please sign in to comment.