diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d37a25abde92bc57987d08e325d97a8cff9b4f8..e79c84b72de33640303c9b1f01044e5d3521c9b7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,9 @@ Added ~~~~~ * OpenID Connect RSA keys can now be passed as string in config files +* Views filtering for person names now also search the username of a linked user +* OAuth2 applications now take an icon which is shown in the authorization progress. +* Add support for hiding the main side nav in ``base.html``. Fixed ~~~~~ @@ -26,6 +29,8 @@ Fixed * OAuth was broken by a non-semver-adhering django-oauth-toolkit update * Too long texts in chips didn't result in a larger chip. * The ``Person`` model had an ``is_active`` flag that was used in unclear ways; it is now removed +* The data check results list view didn't work if a related object had been deleted in the meanwhile. +* Socialaccount login template was not overriden Changed ~~~~~~~ @@ -40,6 +45,8 @@ Changed * [Docker] Base image now contains curl, grep, less, sed, and pspg * Views raising a 404 error can now customise the message that is displayed on the error page * OpenID Connect is enabled by default now, without RSA support +* Login and authorization pages for OAuth2/OpenID Connect now indicate that the user is in progress + to authorize an external application. `2.5`_ – 2022-01-02 ------------------- diff --git a/aleksis/core/filters.py b/aleksis/core/filters.py index ea8a7cacdf93e5b7c45355fc0ea8b3322cbb680e..0bccc2664489f3698fc77bbe7299a9210ed81dac 100644 --- a/aleksis/core/filters.py +++ b/aleksis/core/filters.py @@ -54,6 +54,7 @@ class PersonFilter(FilterSet): "additional_name__icontains", "last_name__icontains", "short_name__icontains", + "user__username__icontains", ], label=_("Search by name"), ) diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 680da9d59f7dead43379925d67dbc1975f01a2e2..f3bf6218cf19e3f4a7ad002b05526dd389dd714c 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -773,6 +773,7 @@ class OAuthApplicationForm(forms.ModelForm): model = OAuthApplication fields = ( "name", + "icon", "client_id", "client_secret", "client_type", diff --git a/aleksis/core/migrations/0031_oauthapplication_icon.py b/aleksis/core/migrations/0031_oauthapplication_icon.py new file mode 100644 index 0000000000000000000000000000000000000000..26f2d6575e768578e653088c582fd3dadd147dc5 --- /dev/null +++ b/aleksis/core/migrations/0031_oauthapplication_icon.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-08 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0030_user_attributes'), + ] + + operations = [ + migrations.AddField( + model_name='oauthapplication', + name='icon', + field=models.ImageField(blank=True, help_text='This image will be shown as icon in the authorization flow. It should be squared.', null=True, upload_to='', verbose_name='Icon'), + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index dbc5ed5bf83af53f8806dc98aa66ce9f41be1c27..d24f9f29b9178bed7c857020669fc5369ee73a91 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1247,6 +1247,15 @@ class OAuthApplication(AbstractApplication): blank=True, ) + icon = models.ImageField( + verbose_name=_("Icon"), + blank=True, + null=True, + help_text=_( + "This image will be shown as icon in the authorization flow. It should be squared." + ), + ) + def allows_grant_type(self, *grant_types: set[str]) -> bool: allowed_grants = get_site_preferences()["auth__oauth_allowed_grants"] diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index 618b846dc956af8ed62db357f2867a9988b58846..4fb6d9b2815555082d18cfd7ef18025704010035 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -64,6 +64,10 @@ header, main, footer { margin-left: 300px; } +.without-menu header, .without-menu main, .without-menu footer { + margin-left: 0; +} + @media only screen and (max-width: 992px) { header, main, footer { margin-left: 0; @@ -800,3 +804,15 @@ main figure.alert { height: 24px; margin-bottom: 8px; } + +.application-circle { + border-radius: 50%; + width: 20vh; + height: 20vh; +} + + +.application-circle img { + @extend .application-circle; + object-fit: cover; +} diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index 06aebc4533369e9b7af39051dcb80713193f50d5..7fd4e38f8449b0a649fcd01e08051a2332070a15 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -58,7 +58,7 @@ {% block extra_head %}{% endblock %} </head> -<body> +<body {% if no_menu %}class="without-menu"{% endif %}> <header> <!-- Menu button (sidenav) --> @@ -88,32 +88,36 @@ </nav> <!-- Main nav (sidenav) --> - <ul id="slide-out" class="sidenav sidenav-fixed"> - <li class="logo"> - {% static "img/aleksis-banner.svg" as aleksis_banner %} - <a id="logo-container" href="/" class="brand-logo"> - <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}" - alt="{{ request.site.preferences.general__title }} – Logo"> - </a> - </li> - {% has_perm 'core.search_rule' user as search %} - {% if search %} - <li class="search"> - <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> - <div class="search-wrapper"> - <input id="search" name="q" type="search" enterkeyhint="search" placeholder="{% trans "Search" %}"> - <button class="btn btn-flat search-button" type="submit" aria-label="{% trans "Search" %}"> - <i class="material-icons">search</i> - </button> - <div class="progress" id="search-loader"><div class="indeterminate"></div></div> - </div> - </form> + {% if not no_menu %} + <ul id="slide-out" class="sidenav sidenav-fixed"> + <li class="logo"> + {% static "img/aleksis-banner.svg" as aleksis_banner %} + <a id="logo-container" href="/" class="brand-logo"> + <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}" + alt="{{ request.site.preferences.general__title }} – Logo"> + </a> </li> - {% endif %} - <li class="no-padding"> - {% include "core/partials/sidenav.html" %} - </li> - </ul> + {% has_perm 'core.search_rule' user as search %} + {% if search %} + <li class="search"> + <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> + <div class="search-wrapper"> + <input id="search" name="q" type="search" enterkeyhint="search" placeholder="{% trans "Search" %}"> + <button class="btn btn-flat search-button" type="submit" aria-label="{% trans "Search" %}"> + <i class="material-icons">search</i> + </button> + <div class="progress" id="search-loader"> + <div class="indeterminate"></div> + </div> + </div> + </form> + </li> + {% endif %} + <li class="no-padding"> + {% include "core/partials/sidenav.html" %} + </li> + </ul> + {% endif %} </header> diff --git a/aleksis/core/templates/core/data_check/list.html b/aleksis/core/templates/core/data_check/list.html index 5a510fdd92f550ad1b7ff6b0b81983ea225420ba..36d83d4cd7d3c917db98c104586bd2aa9c82c139 100644 --- a/aleksis/core/templates/core/data_check/list.html +++ b/aleksis/core/templates/core/data_check/list.html @@ -50,27 +50,29 @@ </thead> <tbody> {% for result in results %} - <tr> - <td> - <code>{{ result.id }}</code> - </td> - <td>{% verbose_name_object result.related_object %}</td> - <td>{{ result.related_object }}</td> - <td>{{ result.related_check.problem_name }}</td> - <td> - <a class="btn-flat waves-effect waves-light" href="{{ result.related_object.get_absolute_url }}"> - {% trans "Show object" %} - </a> - </td> - <td> - {% for option_name, option in result.related_check.solve_options.items %} - <a class="btn btn-margin waves-effect waves-light" - href="{% url "data_check_solve" result.pk option_name %}"> - {{ option.verbose_name }} + {% if result.related_object %} + <tr> + <td> + <code>{{ result.id }}</code> + </td> + <td>{% verbose_name_object result.related_object %}</td> + <td>{{ result.related_object }}</td> + <td>{{ result.related_check.problem_name }}</td> + <td> + <a class="btn-flat waves-effect waves-light" href="{{ result.related_object.get_absolute_url }}"> + {% trans "Show object" %} </a> - {% endfor %} - </td> - </tr> + </td> + <td> + {% for option_name, option in result.related_check.solve_options.items %} + <a class="btn btn-margin waves-effect waves-light" + href="{% url "data_check_solve" result.pk option_name %}"> + {{ option.verbose_name }} + </a> + {% endfor %} + </td> + </tr> + {% endif %} {% endfor %} </tbody> </table> diff --git a/aleksis/core/templates/oauth2_provider/application/create.html b/aleksis/core/templates/oauth2_provider/application/create.html index d81489e922a76de8d7a8e92a7f48686714a3b3a7..73b94677206d38fca163858551536d575b2dce3d 100644 --- a/aleksis/core/templates/oauth2_provider/application/create.html +++ b/aleksis/core/templates/oauth2_provider/application/create.html @@ -6,7 +6,7 @@ {% block page_title %}{% blocktrans %}Register OAuth2 Application{% endblocktrans %}{% endblock %} {% block content %} - <form method="post"> + <form method="post" enctype="multipart/form-data"> {% csrf_token %} {% form form=form %}{% endform %} {% include "core/partials/save_button.html" %} diff --git a/aleksis/core/templates/oauth2_provider/application/detail.html b/aleksis/core/templates/oauth2_provider/application/detail.html index da6b8abf81ddc1916de7b0c7feb5a7262f453037..28e2af7d70ff4b6dd5d93b46b8ec52d31bc36538 100644 --- a/aleksis/core/templates/oauth2_provider/application/detail.html +++ b/aleksis/core/templates/oauth2_provider/application/detail.html @@ -22,6 +22,18 @@ </a> <table class="responsive-table"> <tbody> + <tr> + <th>{% trans "Icon" %}</th> + <td> + {% if application.icon %} + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ application.icon.url }}" alt="{{ oauth_application.name }}" class="hundred-percent"> + </div> + {% else %} + – + {% endif %} + </td> + </tr> <tr> <th> {% trans "Client id" %} diff --git a/aleksis/core/templates/oauth2_provider/application/edit.html b/aleksis/core/templates/oauth2_provider/application/edit.html index 6755d2420fb6a181f671b825475afb4ec9581521..30f50fff94e330e941d7b4730fe7d875b039d74e 100644 --- a/aleksis/core/templates/oauth2_provider/application/edit.html +++ b/aleksis/core/templates/oauth2_provider/application/edit.html @@ -6,7 +6,7 @@ {% block page_title %}{% blocktrans %}Edit OAuth2 Application{% endblocktrans %}{% endblock %} {% block content %} - <form method="post"> + <form method="post" enctype="multipart/form-data"> {% csrf_token %} {% form form=form %}{% endform %} {% include "core/partials/save_button.html" %} diff --git a/aleksis/core/templates/oauth2_provider/application/list.html b/aleksis/core/templates/oauth2_provider/application/list.html index 06f1a95c4e05c14dcb5efe69f2416040d184f0bb..ced7d718dfe1b561c2ea8253e25658a9f449024d 100644 --- a/aleksis/core/templates/oauth2_provider/application/list.html +++ b/aleksis/core/templates/oauth2_provider/application/list.html @@ -12,8 +12,13 @@ </a> <div class="collection"> {% for application in applications %} - <a class="collection-item" href="{% url "oauth2_application" application.id %}"> - {{ application.name }} + <a class="collection-item avatar" href="{% url "oauth2_application" application.id %}"> + {% if application.icon %} + <img src="{{ application.icon.url }}" alt="{{ application.name }}" class="circle"> + {% endif %} + <span class="title"> + {{ application.name }} + </span> </a> {% empty %} <div class="collection-item flow-text"> diff --git a/aleksis/core/templates/oauth2_provider/authorize.html b/aleksis/core/templates/oauth2_provider/authorize.html index 48b996837b8721ef6ba74337d286236e91729f5b..c90d5e8dd9d3d72cff9e19ce167487900e904636 100644 --- a/aleksis/core/templates/oauth2_provider/authorize.html +++ b/aleksis/core/templates/oauth2_provider/authorize.html @@ -12,8 +12,15 @@ <div class="col s12 m10 l8 xl6"> <div class="card"> <div class="card-content"> - <div class="card-title"> - {% trans "Authorize" %} {{ application.name }} + {% if application.icon %} + <div class="center-via-flex margin-bottom"> + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ application.icon.url }}" alt="{{ application.name }}" class="hundred-percent"> + </div> + </div> + {% endif %} + <div class="card-title {% if application.icon %}center{% endif %}"> + {% blocktrans with name=application.name %}Authorize {{ name }}{% endblocktrans %} </div> <p class="margin-bottom">{% trans "The application requests access to the following scopes:" %}</p> {% for scope in scopes_descriptions %} diff --git a/aleksis/core/templates/socialaccount/login.html b/aleksis/core/templates/socialaccount/login.html new file mode 100644 index 0000000000000000000000000000000000000000..1630b01fa4104f563a92663bc7fd386a2abd5266 --- /dev/null +++ b/aleksis/core/templates/socialaccount/login.html @@ -0,0 +1,33 @@ +{% extends "core/base.html" %} + +{% load i18n material_form account %} + +{% block browser_title %}{% trans "Authorize" %}{% endblock %} +{% block page_title %}{% trans "Authorize" %}{% endblock %} + +{% block content %} +{% if process == "connect" %} + <p class="flow-text"> + <i class="material-icons left">info</i> + {% blocktrans with provider.name as provider %}You are about to connect a new third party account from {{ provider }}.{% endblocktrans %} + </p> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Confirm" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="how_to_reg" %} + </form> +{% else %} + <p class="flow-text"> + <i class="material-icons left small">info</i> + {% blocktrans with provider.name as provider %}You are about to sign in using a third party account from {{ provider }}.{% endblocktrans %} + </p> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Continue" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="how_to_reg" %} + </form> + +{% endif %} +{% endblock %} diff --git a/aleksis/core/templates/two_factor/core/login.html b/aleksis/core/templates/two_factor/core/login.html index 9ea4bc6c24b7d9e764742bbddf75ceb86b785868..834c4b98b543bd994542b105aec1b99932f7d186 100644 --- a/aleksis/core/templates/two_factor/core/login.html +++ b/aleksis/core/templates/two_factor/core/login.html @@ -16,7 +16,17 @@ <div class="col s12 m10 l8 xl6"> <div class="card"> <div class="card-content"> - {% if wizard.steps.current == 'auth' and socialaccount_providers %} + {% if oauth and oauth_application.icon %} + <div class="center-via-flex margin-bottom"> + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ oauth_application.icon.url }}" alt="{{ oauth_application.name }}" + class="hundred-percent"> + </div> + </div> + <div class="card-title center"> + {% blocktrans with name=oauth_application.name %}Login for {{ name }}{% endblocktrans %} + </div> + {% elif wizard.steps.current == 'auth' and socialaccount_providers %} <div class="card-title">{% trans "Login with username and password" %}</div> {% else %} <div class="card-title">{% trans "Login" %}</div> @@ -30,12 +40,21 @@ </p> </div> {% elif wizard.steps.current == 'auth' %} - <div class="alert primary"> - <p> - <i class="material-icons left">info</i> - {% blocktrans %}Please login to see this page.{% endblocktrans %} - </p> - </div> + {% if oauth %} + <div class="alert primary"> + <p> + <i class="material-icons left">info</i> + {% blocktrans %}Please login with your account to use the external application.{% endblocktrans %} + </p> + </div> + {% else %} + <div class="alert primary"> + <p> + <i class="material-icons left">info</i> + {% blocktrans %}Please login to see this page.{% endblocktrans %} + </p> + </div> + {% endif %} {% endif %} {% if not wizard.steps.current == "auth" %} <div class="alert primary"> diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 753d95085b6c8812927f2db376a6f3e2e6429c4b..b5dcb004d040b64b22242b977702a104fb2ea33a 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -134,6 +134,11 @@ urlpatterns = [ views.OAuth2EditView.as_view(), name="edit_oauth2_application", ), + path( + "oauth/authorize/", + views.CustomAuthorizationView.as_view(), + name="oauth2_provider:authorize", + ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("__i18n__/", include("django.conf.urls.i18n")), path( diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 0d78123845e3aeca17ef3bfff14c2b5dd6d64d8d..49ef723df8d5fdb27d810c8668d9ec3559d9272e 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -1,6 +1,6 @@ from textwrap import wrap from typing import Any, Dict, Optional, Type -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse, urlunparse from django.apps import apps from django.conf import settings @@ -18,6 +18,7 @@ from django.http import ( HttpResponseRedirect, HttpResponseServerError, JsonResponse, + QueryDict, ) from django.shortcuts import get_object_or_404, redirect, render from django.template import loader @@ -50,6 +51,9 @@ from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex from health_check.views import MainView from invitations.views import SendInvite, accept_invitation +from oauth2_provider.exceptions import OAuthToolkitError +from oauth2_provider.models import get_application_model +from oauth2_provider.views import AuthorizationView from reversion import set_user from reversion.views import RevisionMixin from rules import test_rule @@ -1458,3 +1462,42 @@ class LoginView(AllAuthLoginView): return render(self.request, "account/verification_sent.html") return super().done(form_list, **kwargs) + + def get_context_data(self, form, **kwargs): + """Override context data to hide side menu and include OAuth2 application if given.""" + context = super().get_context_data(form, **kwargs) + if self.request.GET.get("oauth"): + context["no_menu"] = True + + if self.request.GET.get("client_id"): + application = get_application_model().objects.get( + client_id=self.request.GET["client_id"] + ) + context["oauth_application"] = application + return context + + +class CustomAuthorizationView(AuthorizationView): + def handle_no_permission(self): + """Override handle_no_permission to provide OAuth2 information to login page.""" + redirect_obj = super().handle_no_permission() + + try: + scopes, credentials = self.validate_authorization_request(self.request) + except OAuthToolkitError as error: + # Application is not available at this time. + return self.error_response(error, application=None) + + login_url_parts = list(urlparse(redirect_obj.url)) + querystring = QueryDict(login_url_parts[4], mutable=True) + querystring["oauth"] = "yes" + querystring["client_id"] = credentials["client_id"] + login_url_parts[4] = querystring.urlencode(safe="/") + + return HttpResponseRedirect(urlunparse(login_url_parts)) + + def get_context_data(self, **kwargs): + """Override context data to hide side menu.""" + context = super().get_context_data(**kwargs) + context["no_menu"] = True + return context diff --git a/docs/_static/create_social_application.png b/docs/_static/create_social_application.png new file mode 100644 index 0000000000000000000000000000000000000000..c28c5c30a6d71f8aa0f1177b92048449c688d113 Binary files /dev/null and b/docs/_static/create_social_application.png differ diff --git a/docs/admin/03_socialaccounts.rst b/docs/admin/03_socialaccounts.rst new file mode 100644 index 0000000000000000000000000000000000000000..97b023947a27768b825c693b7c5cd8bff938a49e --- /dev/null +++ b/docs/admin/03_socialaccounts.rst @@ -0,0 +1,34 @@ +Social accounts +=============== + +AlekSIS can authenticate users against third party applications using OAuth2 +or OpenID. + + +.. warning:: + Social accounts are **not** working with two factor authentication! If a user + authenticates with a social account, the two factor authentication is + ignored on login (but enforced for views that require two factor authentication later). + +Configuring social account provider +----------------------------------- + +For available providers, see documentation of `django-allauth +<https://django-allauth.readthedocs.io/en/latest/providers.html>`_. + +A new social account provider can be configured in your configuration file +(located in ``/etc/aleksis/``). + +Configuration example:: + + [auth.providers.gitlab] + GITLAB_URL = "https://gitlab.exmaple.com" + +After configuring a new auth provider, you have to restart AlekSIS and configure client id and secret in the Backend Admin interface. +Click "Social applications" and add a new application. Choose your +provider and enter client id and secret from your application and choose +your site: + +.. image:: ../_static/create_social_application.png + :width: 400 + :alt: Create social application diff --git a/pyproject.toml b/pyproject.toml index 9e797a44440c2d44b0b9d082ffcaf0ac4f373f9a..b5c5929b32289fccc65b3ad0f67c57994e06eb2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,14 @@ packages = [ { include = "aleksis" } ] readme = "README.rst" -include = ["CHANGELOG.rst", "LICENCE.rst", "docs/*", "docs/**/*", "aleksis/**/*.mo", "conftest.py", "tox.ini"] +include = [ + { path = "aleksis/**/*.mo", format = ["sdist", "wheel"] }, + { path = "*.rst", format = "sdist" }, + { path = "docs/*", format = "sdist" }, + { path = "docs/**/*", format = "sdist" }, + { path = "conftest.py", format = "sdist" }, + { path = "tox.ini", format = "sdist" } +] description = "AlekSIS (School Information System) — Core" authors = [