diff --git a/api/forms.py b/api/forms.py new file mode 100644 index 00000000..1f6d7de0 --- /dev/null +++ b/api/forms.py @@ -0,0 +1,35 @@ +from django import forms +from django.forms import HiddenInput +from django.utils.translation import gettext_lazy as _ + + +class ThirdPartyAuthForm(forms.Form): + """Form to complete to authenticate on the sith from a third-party app. + + For the form to be valid, the user approve the EULA (french: CGU) + and give its username from the third-party app. + """ + + cgu_accepted = forms.BooleanField( + required=True, + label=_("I have read and I accept the terms and conditions of use"), + error_messages={ + "required": _("You must approve the terms and conditions of use.") + }, + ) + is_username_valid = forms.BooleanField( + required=True, + error_messages={"required": _("You must confirm that this is your username.")}, + ) + client_id = forms.IntegerField(widget=HiddenInput()) + third_party_app = forms.CharField(widget=HiddenInput()) + cgu_link = forms.URLField(widget=HiddenInput()) + username = forms.CharField(widget=HiddenInput()) + callback_url = forms.URLField(widget=HiddenInput()) + signature = forms.CharField(widget=HiddenInput()) + + def __init__(self, *args, label_suffix: str = "", initial, **kwargs): + super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs) + self.fields["is_username_valid"].label = _( + "I confirm that %(username)s is my username on %(app)s" + ) % {"username": initial.get("username"), "app": initial.get("third_party_app")} diff --git a/api/urls.py b/api/urls.py index 2c3f12ff..63ad7052 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,5 +1,9 @@ +from django.urls import path, register_converter from ninja_extra import NinjaExtraAPI +from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView +from core.converters import ResultConverter + api = NinjaExtraAPI( title="PICON", description="Portail Interactif de Communication avec les Outils Numériques", @@ -8,3 +12,14 @@ api = NinjaExtraAPI( csrf=True, ) api.auto_discover_controllers() + +register_converter(ResultConverter, "res") + +urlpatterns = [ + path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"), + path( + "auth//", + ThirdPartyAuthResultView.as_view(), + name="third-party-auth-result", + ), +] diff --git a/api/views.py b/api/views.py new file mode 100644 index 00000000..34e52732 --- /dev/null +++ b/api/views.py @@ -0,0 +1,129 @@ +import hmac +from urllib.parse import unquote + +import pydantic +import requests +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import FormView, TemplateView +from ninja import Schema +from ninja_extra.shortcuts import get_object_or_none +from pydantic import HttpUrl + +from api.forms import ThirdPartyAuthForm +from api.models import ApiClient +from core.models import SithFile +from core.schemas import UserProfileSchema +from core.utils import hmac_hexdigest + + +class ThirdPartyAuthParamsSchema(Schema): + client_id: int + third_party_app: str + cgu_link: HttpUrl + username: str + callback_url: HttpUrl + signature: str + + +class ThirdPartyAuthView(LoginRequiredMixin, FormView): + form_class = ThirdPartyAuthForm + template_name = "api/third_party/auth.jinja" + success_url = reverse_lazy("core:index") + + def parse_params(self) -> ThirdPartyAuthParamsSchema: + """Parse and check the authentication parameters. + + Raises: + PermissionDenied: if the verification failed. + """ + # This is here rather than in ThirdPartyAuthForm because + # the given parameters and their signature are checked during both + # POST (for obvious reasons) and GET (in order not to make + # the user fill a form just to get an error he won't understand) + params = self.request.GET or self.request.POST + params = {key: unquote(val) for key, val in params.items()} + try: + params = ThirdPartyAuthParamsSchema(**params) + except pydantic.ValidationError as e: + raise PermissionDenied("Wrong data format") from e + client: ApiClient = get_object_or_none(ApiClient, id=params.client_id) + if not client: + raise PermissionDenied + if not hmac.compare_digest( + hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})), + params.signature, + ): + raise PermissionDenied("Bad signature") + return params + + def dispatch(self, request, *args, **kwargs): + self.params = self.parse_params() + return super().dispatch(request, *args, **kwargs) + + def get(self, *args, **kwargs): + messages.warning( + self.request, + _( + "You are going to link your AE account and your %(app)s account. " + "Continue only if this page was opened from %(app)s." + ) + % {"app": self.params.third_party_app}, + ) + return super().get(*args, **kwargs) + + def get_initial(self): + return self.params.model_dump() + + def form_valid(self, form): + client = ApiClient.objects.get(id=form.cleaned_data["client_id"]) + user = UserProfileSchema.from_orm(self.request.user).model_dump() + data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)} + response = requests.post(form.cleaned_data["callback_url"], data=data) + self.success_url = reverse( + "api-link:third-party-auth-result", + kwargs={"result": "success" if response.ok else "failure"}, + ) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "third_party_app": self.params.third_party_app, + "third_party_cgu": self.params.cgu_link, + "sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID), + } + + +class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView): + """View that the user will see if its authentication on sith was successful. + + This can show either a success or a failure message : + - success : everything is good, the user is successfully authenticated + and can close the page + - failure : the authentication has been processed on the sith side, + but the request to the callback url received an error. + In such a case, there is nothing much we can do but to advice + the user to contact the developers of the third-party app. + """ + + template_name = "core/base.jinja" + success_message = _( + "You have been successfully authenticated. You can now close this page." + ) + error_message = _( + "Your authentication on the AE website was successful, " + "but an error happened during the interaction " + "with the third-party application. " + "Please contact the managers of the latter." + ) + + def get(self, request, *args, **kwargs): + if self.kwargs.get("result") == "success": + messages.success(request, self.success_message) + else: + messages.error(request, self.error_message) + return super().get(request, *args, **kwargs) diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index d761331c..d2e6e032 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -141,7 +141,6 @@ form { display: block; margin: calc(var(--nf-input-size) * 1.5) auto 10px; line-height: 1; - white-space: nowrap; .helptext { margin-top: .25rem; diff --git a/sith/urls.py b/sith/urls.py index e6629373..af3203b3 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path("", include(("core.urls", "core"), namespace="core")), path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}), path("api/", api.urls), + path("api-link/", include(("api.urls", "api-link"), namespace="api-link")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path( "subscription/",