diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d009032fe7601c866668a032b1043e0dd18c2418..e33418c1cdaa57681a0bfe868902c833b7b72683 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,6 +19,8 @@ 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 ~~~~~ @@ -41,6 +43,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/forms.py b/aleksis/core/forms.py index eb2465870a36174997c73a967b04690b30d60051..4bd7e1b94419420d969b5690f31a4a7c89b85ae2 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -775,6 +775,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 133af202290421dd215b568cd6a1133adc239571..91b45a1df196edb8cb777a3ea61a84fa984fca3d 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1248,6 +1248,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/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/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 cfb9cd5b3050af79341958aa60f72ad88d097923..6354751aa93e0d75326036ff165a02c4174dee64 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 @@ -1460,3 +1464,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