diff --git a/ephios/api/access/auth.py b/ephios/api/access/auth.py index 574f6bfbe..0054113b0 100644 --- a/ephios/api/access/auth.py +++ b/ephios/api/access/auth.py @@ -14,7 +14,7 @@ def authenticate(self, request): if oauth_result is None: return None user, token = oauth_result - if not user.is_active: + if user is not None and not user.is_active: return None return user, token diff --git a/ephios/api/templates/api/access_token_list.html b/ephios/api/templates/api/access_token_list.html index 2c3833d39..28ee8cf0d 100644 --- a/ephios/api/templates/api/access_token_list.html +++ b/ephios/api/templates/api/access_token_list.html @@ -25,6 +25,10 @@
{{ token.application.name }}

+ {# this belongs to the federation plugin, but we display the information here for better understanding #} + {% if token.application.federatedhost %} + {% translate "ephios instance" %} + {% endif %}

diff --git a/ephios/api/templates/oauth2_provider/application_list.html b/ephios/api/templates/oauth2_provider/application_list.html index 4aaee7da3..616be4b89 100644 --- a/ephios/api/templates/oauth2_provider/application_list.html +++ b/ephios/api/templates/oauth2_provider/application_list.html @@ -33,21 +33,29 @@

{% translate "OAuth2 applications" %}

{{ application.created }}
- - - {% translate "View" %} - - - - {% translate "Edit" %} - - - - {% translate "Delete" %} - + {% if application.federatedhost %} + + + {% translate "Manage federation" %} + + {% else %} + + + {% translate "View" %} + + + + {% translate "Edit" %} + + + + {% translate "Delete" %} + + {% endif %}
diff --git a/ephios/api/templates/oauth2_provider/authorize.html b/ephios/api/templates/oauth2_provider/authorize.html index 1f398d60e..cbf41ac81 100644 --- a/ephios/api/templates/oauth2_provider/authorize.html +++ b/ephios/api/templates/oauth2_provider/authorize.html @@ -4,7 +4,11 @@ {% block content %} {% if not error %}
-

{% translate "Authorize" %} {{ application.name }}?

+

+ {% blocktranslate trimmed with app_name=application.name %} + Authorize {{ app_name }}? + {% endblocktranslate %} +

{% csrf_token %} {# pass query params through the submit process for saving #} diff --git a/ephios/api/urls.py b/ephios/api/urls.py index 92cefbcf1..2da1e9f9f 100644 --- a/ephios/api/urls.py +++ b/ephios/api/urls.py @@ -13,6 +13,7 @@ ApplicationDelete, ) from ephios.api.views.events import EventViewSet +from ephios.api.views.users import UserProfileMeView from ephios.extra.permissions import staff_required router = routers.DefaultRouter() @@ -20,6 +21,7 @@ app_name = "api" urlpatterns = [ + path("users/me/", UserProfileMeView.as_view(), name="user-profile-me"), path( "settings/", include( diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py new file mode 100644 index 000000000..4c61c6448 --- /dev/null +++ b/ephios/api/views/users.py @@ -0,0 +1,64 @@ +from django.db.models import Q +from django.utils import timezone +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework.exceptions import PermissionDenied +from rest_framework.fields import SerializerMethodField +from rest_framework.generics import RetrieveAPIView +from rest_framework.relations import SlugRelatedField +from rest_framework.serializers import ModelSerializer + +from ephios.core.models import Qualification, UserProfile + + +class QualificationSerializer(ModelSerializer): + category = SlugRelatedField(slug_field="uuid", read_only=True) + includes = SerializerMethodField() + + class Meta: + model = Qualification + fields = [ + "uuid", + "title", + "abbreviation", + "category", + "includes", + ] + + def get_includes(self, obj): + qualifications = Qualification.collect_all_included_qualifications(obj.includes.all()) + return [q.uuid for q in qualifications] + + +class UserProfileSerializer(ModelSerializer): + qualifications = SerializerMethodField() + + class Meta: + model = UserProfile + fields = [ + "first_name", + "last_name", + "date_of_birth", + "email", + "qualifications", + ] + + def get_qualifications(self, obj): + return QualificationSerializer( + Qualification.objects.filter( + Q(grants__user=obj) + & (Q(grants__expires__gte=timezone.now()) | Q(grants__expires__isnull=True)) + ), + many=True, + ).data + + +class UserProfileMeView(RetrieveAPIView): + serializer_class = UserProfileSerializer + queryset = UserProfile.objects.all() + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ["ME_READ"] + + def get_object(self): + if self.request.user is None: + raise PermissionDenied() + return self.request.user diff --git a/ephios/core/context.py b/ephios/core/context.py index 84ee559c6..51fd607e7 100644 --- a/ephios/core/context.py +++ b/ephios/core/context.py @@ -1,5 +1,6 @@ from django.conf import settings from django.utils.translation import get_language +from dynamic_preferences.registries import global_preferences_registry from ephios.core.models import AbstractParticipation from ephios.core.signals import footer_link, nav_link @@ -22,4 +23,5 @@ def ephios_base_context(request): "LANGUAGE_CODE": get_language(), "ephios_version": settings.EPHIOS_VERSION, "PWA_APP_ICONS": settings.PWA_APP_ICONS, + "organization_name": global_preferences_registry.manager()["general__organization_name"], } diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index 9c87e43a2..d63f9304b 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-30 17:06+0200\n" +"POT-Creation-Date: 2023-10-07 20:08+0200\n" "PO-Revision-Date: 2023-09-30 17:12+0200\n" "Last-Translator: Felix Rindt \n" "Language-Team: German datetime.timedelta(hours=48) + + def get_share_string(self): + return base64.b64encode( + json.dumps( + {"guest_url": self.url, "code": self.code, "host_url": settings.GET_SITE_URL()} + ).encode("ascii") + ).decode("ascii") + + +class FederatedEventShare(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE) + shared_with = models.ManyToManyField(FederatedGuest) + + def __str__(self): + return _("Federated event share for {event}").format(event=self.event) + + +class FederatedUser(models.Model): + email = models.EmailField(_("email address")) + first_name = models.CharField(_("first name"), max_length=254) + last_name = models.CharField(_("last name"), max_length=254) + date_of_birth = models.DateField(_("date of birth")) + phone = models.CharField(_("phone number"), max_length=254, blank=True) + qualifications = models.ManyToManyField(Qualification) + federated_instance = models.ForeignKey(FederatedGuest, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return _("Federated user {first_name} {last_name}").format( + first_name=self.first_name, last_name=self.last_name + ) + + def as_participant(self) -> "FederatedParticipant": + return FederatedParticipant( + first_name=self.first_name, + last_name=self.last_name, + qualifications=self.qualifications.all(), + date_of_birth=self.date_of_birth, + email=self.email, + federated_user=self, + ) + + +@dataclasses.dataclass(frozen=True) +class FederatedParticipant(AbstractParticipant): + federated_user: FederatedUser + + def new_participation(self, shift): + return FederatedParticipation(shift=shift, federated_user=self.federated_user) + + def participation_for(self, shift): + try: + return FederatedParticipation.objects.get( + shift=shift, federated_user=self.federated_user + ) + except FederatedParticipation.DoesNotExist: + return None + + def all_participations(self): + return FederatedParticipation.objects.filter(federated_user=self.federated_user) + + def reverse_signup_action(self, shift): + return reverse("federation:shift_signup", kwargs={"pk": shift.pk}) + + def reverse_event_detail(self, event): + return reverse("federation:event_detail", kwargs={"pk": event.pk}) + + @property + def icon(self): + title = _("Federated user") + return mark_safe( + f'' + ) + + +class FederatedParticipation(AbstractParticipation): + federated_user = models.ForeignKey( + FederatedUser, on_delete=models.CASCADE, verbose_name=_("federated participant") + ) + + def __str__(self): + return f"{self.federated_user.first_name} {self.federated_user.last_name} @ {self.shift}" + + @property + def participant(self) -> "AbstractParticipant": + return self.federated_user.as_participant() diff --git a/ephios/plugins/federation/serializers.py b/ephios/plugins/federation/serializers.py new file mode 100644 index 000000000..482acfb3b --- /dev/null +++ b/ephios/plugins/federation/serializers.py @@ -0,0 +1,75 @@ +import secrets +from urllib.parse import urljoin + +from django.conf import settings +from django.urls import reverse +from dynamic_preferences.registries import global_preferences_registry +from rest_framework import serializers + +from ephios.api.models import AccessToken +from ephios.api.views.events import EventSerializer +from ephios.core.models import Event +from ephios.plugins.federation.models import FederatedGuest, InviteCode + + +class FederatedGuestCreateSerializer(serializers.ModelSerializer): + code = serializers.CharField(write_only=True) + access_token = serializers.SlugRelatedField(slug_field="token", read_only=True) + host_name = serializers.SerializerMethodField(method_name="get_host_name", read_only=True) + + class Meta: + model = FederatedGuest + fields = [ + "id", + "name", + "url", + "client_id", + "client_secret", + "code", + "access_token", + "host_name", + ] + + def get_host_name(self, obj): + return global_preferences_registry.manager()["general__organization_name"] + + def validate(self, attrs): + try: + attrs["code"] = InviteCode.objects.get(code=attrs["code"], url=attrs["url"]) + if attrs["code"].is_expired: + raise serializers.ValidationError("Invite code is expired") + return attrs + except InviteCode.DoesNotExist as exc: + raise serializers.ValidationError("Invite code is not valid") from exc + + def create(self, validated_data): + code = validated_data.pop("code") + validated_data["access_token"] = AccessToken.objects.create( + token=secrets.token_hex(), + description=f"Access token for federated guest {validated_data['name']}", + ) + obj = super().create(validated_data) + code.delete() + return obj + + +class SharedEventSerializer(EventSerializer): + signup_url = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + "id", + "title", + "description", + "location", + "type", + "start_time", + "end_time", + "signup_url", + ] + + def get_signup_url(self, obj): + return urljoin( + settings.GET_SITE_URL(), reverse("federation:event_detail", kwargs={"pk": obj.pk}) + ) diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py new file mode 100644 index 000000000..7fb4d0eaf --- /dev/null +++ b/ephios/plugins/federation/signals.py @@ -0,0 +1,76 @@ +from django.dispatch import receiver +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from ephios.core.signals import ( + event_forms, + management_settings_sections, + nav_link, + participant_from_request, + periodic_signal, +) +from ephios.plugins.federation.forms import EventAllowFederationForm +from ephios.plugins.federation.models import FederatedHost, FederatedUser + + +@receiver(nav_link, dispatch_uid="ephios.plugins.federation.signals.nav_link") +def add_nav_link(sender, request, **kwargs): + return ( + [ + { + "label": _("External events"), + "url": reverse("federation:external_event_list"), + "active": request.resolver_match + and request.resolver_match.app_name == "federation", + } + ] + if FederatedHost.objects.exists() + else [] + ) + + +@receiver( + participant_from_request, + dispatch_uid="ephios.plugins.federation.signals.federated_participant_from_request", +) +def federated_participant_from_request(sender, request, **kwargs): + if "federated_user" in request.session.keys(): + try: + return FederatedUser.objects.get(pk=request.session["federated_user"]).as_participant() + except FederatedUser.DoesNotExist: + pass + + +@receiver( + event_forms, + dispatch_uid="ephios.plugins.federation.signals.federation_event_forms", +) +def guests_event_forms(sender, event, request, **kwargs): + return [EventAllowFederationForm(request.POST or None, event=event, request=request)] + + +@receiver( + management_settings_sections, + dispatch_uid="ephios.plugins.federation.signals.federation_settings_section", +) +def federation_settings_section(sender, request, **kwargs): + return ( + [ + { + "label": _("Federation"), + "url": reverse("federation:settings"), + "active": request.resolver_match.app_name == "federation", + }, + ] + if request.user.is_staff + else [] + ) + + +@receiver(periodic_signal, dispatch_uid="ephios.plugins.federation.signals.periodic_signal") +def delete_expired_invites(sender, **kwargs): + from ephios.plugins.federation.models import InviteCode + + for invite in InviteCode.objects.all(): + if invite.is_expired: + invite.delete() diff --git a/ephios/plugins/federation/templates/federation/event_detail.html b/ephios/plugins/federation/templates/federation/event_detail.html new file mode 100644 index 000000000..e3353cd59 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/event_detail.html @@ -0,0 +1,10 @@ +{% extends "core/event_detail.html" %} +{% load settings_extras %} +{% load i18n %} + +{% block messages %} + +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/external_event_list.html b/ephios/plugins/federation/templates/federation/external_event_list.html new file mode 100644 index 000000000..ceb0bbccc --- /dev/null +++ b/ephios/plugins/federation/templates/federation/external_event_list.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %} + {% translate "External events" %} +{% endblock %} + +{% block content %} +

{% translate "External events" %}

+ +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html b/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html new file mode 100644 index 000000000..4645eec79 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/federatedguest_confirm_delete.html @@ -0,0 +1,16 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_filters %} +{% load i18n %} + +{% block settings_content %} +

{% translate "Stop sharing events" %}

+ + {% csrf_token %} +

{% blocktranslate trimmed with name=federatedguest.name %} + Are you sure you want to remove the connection to "{{ name }}" and stop sharing all events with them? This will also delete all participations that originated from there. + {% endblocktranslate %}

+ {{ form|crispy }} + {% translate "Back" %} + + +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html b/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html new file mode 100644 index 000000000..2ba45bf00 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/federatedhost_confirm_delete.html @@ -0,0 +1,16 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_filters %} +{% load i18n %} + +{% block settings_content %} +

{% translate "Stop receiving events" %}

+ +
{% csrf_token %} +

{% blocktranslate trimmed with name=federatedhost.name %} + Are you sure you want to remove the connection to "{{ name }}" and stop receiving events from them? + {% endblocktranslate %}

+ {{ form|crispy }} + {% translate "Back" %} + +
+{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/federation_settings.html b/ephios/plugins/federation/templates/federation/federation_settings.html new file mode 100644 index 000000000..59ae9c531 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/federation_settings.html @@ -0,0 +1,69 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} + +{% block settings_content %} + +

{% translate "ephios instances that you share events with" %}

+ Share events with another instance + + + + + + + + + + {% for guest in federation_guests %} + + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ guest.name }}{{ guest.url }}{% translate "Stop sharing" %}
+ + {% if federation_invites %} +

{% translate "Pending invites" %}

+ + + + + + + + + {% for invite in federation_invites %} + + + + + {% endfor %} + +
{% translate "URL" %}{% translate "Action" %}
{{ invite.url }}{% translate "Show invite code" %}
+ {% endif %} + +

{% translate "ephios instances that share events with you" %}

+ {% translate "Enter invite code" %} + + + + + + + + + + {% for host in federation_hosts %} + + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "URL" %}{% translate "Action" %}
{{ host.name }}{{ host.url }}{% translate "Stop receiving" %}
+{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_form.html b/ephios/plugins/federation/templates/federation/invitecode_form.html new file mode 100644 index 000000000..264031775 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/invitecode_form.html @@ -0,0 +1,21 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} +{% load crispy_forms_filters %} + +{% block settings_content %} +

{% translate "Share events with another instance" %}

+ + +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/invitecode_reveal.html b/ephios/plugins/federation/templates/federation/invitecode_reveal.html new file mode 100644 index 000000000..d9ba97e3b --- /dev/null +++ b/ephios/plugins/federation/templates/federation/invitecode_reveal.html @@ -0,0 +1,50 @@ +{% extends "core/settings/settings_base.html" %} +{% load utils %} +{% load static %} +{% load crispy_forms_filters %} +{% load i18n %} + +{% block settings_content %} +

+ + {% translate "Invite code" %} +

+ + +
+ +
+ {% url "federation:frontend_redeem_invite_code" as redeem_invite_code_url %} + + +
+
+
+ +
+ + +
+
+ + {% translate "Done" %} +{% endblock %} + + +{% block javascript %} + + +{% endblock %} diff --git a/ephios/plugins/federation/templates/federation/redeem_invite_code.html b/ephios/plugins/federation/templates/federation/redeem_invite_code.html new file mode 100644 index 000000000..6fc9a8f75 --- /dev/null +++ b/ephios/plugins/federation/templates/federation/redeem_invite_code.html @@ -0,0 +1,22 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} +{% load crispy_forms_filters %} + +{% block settings_content %} +

{% translate "Enter invite code" %}

+ + +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/ephios/plugins/federation/urls.py b/ephios/plugins/federation/urls.py new file mode 100644 index 000000000..01cc67a78 --- /dev/null +++ b/ephios/plugins/federation/urls.py @@ -0,0 +1,67 @@ +from django.urls import path + +from ephios.plugins.federation.views import api, frontend + +app_name = "federation" + +urlpatterns = [ + path( + "events/external/", + frontend.ExternalEventListView.as_view(), + name="external_event_list", + ), + path( + "events/shared//", frontend.FederatedEventDetailView.as_view(), name="event_detail" + ), + path( + "shifts/shared//signup/", + frontend.FederatedUserShiftActionView.as_view(), + name="shift_signup", + ), + path("settings/federation/", frontend.FederationSettingsView.as_view(), name="settings"), + path( + "settings/federation/hosts/add/", + frontend.RedeemInviteCodeView.as_view(), + name="frontend_redeem_invite_code", + ), + path( + "settings/federation/hosts//delete/", + frontend.FederatedHostDeleteView.as_view(), + name="delete_host", + ), + path( + "settings/federation/guests/add/", + frontend.CreateInviteCodeView.as_view(), + name="create_invite_code", + ), + path( + "settings/federation/guests//delete/", + frontend.FederatedGuestDeleteView.as_view(), + name="delete_guest", + ), + path( + "settings/federation/invite//", + frontend.InviteCodeRevealView.as_view(), + name="reveal_invite_code", + ), + path( + "api/federation/events/", + api.SharedEventListView.as_view(), + name="shared_event_list_view", + ), + path( + "api/federation/oauth-callback/", + api.FederationOAuthView.as_view(), + name="oauth_callback", + ), + path( + "api/federation/setup/", + api.RedeemInviteCodeView.as_view(), + name="redeem_invite_code", + ), + path( + "api/federation/guest/delete/", + api.FederatedGuestDeleteView.as_view(), + name="api_delete_guest", + ), +] diff --git a/ephios/plugins/federation/views/api.py b/ephios/plugins/federation/views/api.py new file mode 100644 index 000000000..b577171d0 --- /dev/null +++ b/ephios/plugins/federation/views/api.py @@ -0,0 +1,163 @@ +from urllib.parse import urljoin + +import requests +from django.conf import settings +from django.core.exceptions import MultipleObjectsReturned +from django.shortcuts import redirect +from django.urls import reverse +from django.views import View +from oauth2_provider.contrib.rest_framework import TokenHasScope +from oauthlib.oauth2 import WebApplicationClient +from requests_oauthlib import OAuth2Session +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import CreateAPIView, DestroyAPIView, ListAPIView +from rest_framework.permissions import AllowAny + +from ephios.core.models import Event, Qualification +from ephios.plugins.federation.models import FederatedGuest, FederatedUser +from ephios.plugins.federation.serializers import ( + FederatedGuestCreateSerializer, + SharedEventSerializer, +) + + +class RedeemInviteCodeView(CreateAPIView): + """ + API view that accepts an InviteCode and creates a FederatedGuest (to start sharing events with that instance). + """ + + serializer_class = FederatedGuestCreateSerializer + queryset = FederatedGuest.objects.all() + authentication_classes = [] + permission_classes = [AllowAny] + + +class FederatedGuestDeleteView(DestroyAPIView): + """ + API view that deletes a FederatedGuest (to stop sharing events with that instance). + """ + + queryset = FederatedGuest.objects.all() + permission_classes = [TokenHasScope] + required_scopes = [] + + def get_object(self): + try: + # request.auth is an auth token, federatedguest is the reverse relation + return self.request.auth.federatedguest + except FederatedGuest.DoesNotExist as exc: + raise PermissionDenied from exc + + +class SharedEventListView(ListAPIView): + """ + API view that lists all events that are shared with the instance corresponding to the access token. + """ + + serializer_class = SharedEventSerializer + permission_classes = [TokenHasScope] + required_scopes = [] + + def get_queryset(self): + try: + # request.auth is an auth token, federatedguest is the reverse relation + guest = self.request.auth.federatedguest + except FederatedGuest.DoesNotExist as exc: + raise PermissionDenied from exc + return Event.objects.filter(federatedeventshare__shared_with=guest) + + +class FederationOAuthView(View): + """ + View that handles the OAuth2 flow for federated users from another instance. + """ + + def get(self, request, *args, **kwargs): + try: + self.guest = ( + FederatedGuest.objects.get(pk=self.request.session["federation_guest_pk"]) + if "federation_guest_pk" in self.request.session.keys() + else FederatedGuest.objects.get(url=self.request.GET["referrer"]) + ) + except (KeyError, FederatedGuest.DoesNotExist, MultipleObjectsReturned) as exc: + raise PermissionDenied from exc + if "error" in request.GET.keys(): + return redirect(urljoin(self.guest.url, reverse("federation:external_event_list"))) + elif "code" in request.GET.keys(): + self._oauth_callback() + return redirect( + "federation:event_detail", pk=self.request.session.pop("federation_event") + ) + else: + return redirect(self._get_authorization_url()) + + def _get_authorization_url(self): + oauth_client = WebApplicationClient(client_id=self.guest.client_id) + oauth = OAuth2Session( + client=oauth_client, + redirect_uri=urljoin(settings.GET_SITE_URL(), reverse("federation:oauth_callback")), + scope=["ME_READ"], + ) + verifier = oauth_client.create_code_verifier(64) + self.request.session["code_verifier"] = verifier + self.request.session["federation_event"] = self.kwargs["pk"] + self.request.session["federation_guest_pk"] = self.guest.pk + challenge = oauth_client.create_code_challenge(verifier, "S256") + authorization_url, _ = oauth.authorization_url( + urljoin(self.guest.url, "api/oauth/authorize/"), + code_challenge=challenge, + code_challenge_method="S256", + ) + return authorization_url + + def _oauth_callback(self): + oauth_client = WebApplicationClient( + client_id=self.guest.client_id, code_verifier=self.request.session["code_verifier"] + ) + oauth = OAuth2Session(client=oauth_client) + token = oauth.fetch_token( + urljoin(self.guest.url, "api/oauth/token/"), + authorization_response=urljoin(settings.GET_SITE_URL(), self.request.get_full_path()), + client_secret=self.guest.client_secret, + code_verifier=self.request.session["code_verifier"], + ) + self.request.session["federation_access_token"] = token["access_token"] + self.request.session.set_expiry(token["expires_in"]) + user_data = requests.get( + urljoin(self.guest.url, "api/users/me/"), + headers={"Authorization": f"Bearer {token['access_token']}"}, + timeout=5, + ) + try: + user = FederatedUser.objects.get( + federated_instance=self.guest, email=user_data.json()["email"] + ) + except FederatedUser.DoesNotExist: + user = self._create_user(user_data) + self.request.session["federation_user"] = user.pk + + def _create_user(self, user_data): + user = FederatedUser.objects.create( + federated_instance=self.guest, + email=user_data.json()["email"], + first_name=user_data.json()["first_name"], + last_name=user_data.json()["last_name"], + date_of_birth=user_data.json()["date_of_birth"], + ) + for qualification in user_data.json()["qualifications"]: + try: + # Note that we assign the qualification on the host instance without further checks. + # This may lead to incorrect qualifications if the inclusions for the qualification + # are defined differently on the guest instance. We are accepting this as it should + # not happen with the pre-defined qualifications as we are displaying a warning if + # the user adapt these and custom qualifications will have different uuids anyway. + user.qualifications.add(Qualification.objects.get(uuid=qualification["uuid"])) + except Qualification.DoesNotExist: + for included_qualification in qualification["includes"]: + try: + user.qualifications.add( + Qualification.objects.get(uuid=included_qualification["uuid"]) + ) + except Qualification.DoesNotExist: + continue + return user diff --git a/ephios/plugins/federation/views/frontend.py b/ephios/plugins/federation/views/frontend.py new file mode 100644 index 000000000..b75318c41 --- /dev/null +++ b/ephios/plugins/federation/views/frontend.py @@ -0,0 +1,223 @@ +from datetime import datetime +from urllib.parse import urljoin + +import requests +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import CreateView, DeleteView, DetailView, FormView, TemplateView +from guardian.mixins import LoginRequiredMixin +from requests import HTTPError, JSONDecodeError, ReadTimeout +from rest_framework.exceptions import PermissionDenied + +from ephios.core.models import Event +from ephios.core.views.signup import BaseShiftActionView +from ephios.extra.mixins import StaffRequiredMixin +from ephios.plugins.federation.forms import InviteCodeForm, RedeemInviteCodeForm +from ephios.plugins.federation.models import ( + FederatedEventShare, + FederatedGuest, + FederatedHost, + FederatedUser, + InviteCode, +) +from ephios.plugins.federation.views.api import FederationOAuthView + + +class ExternalEventListView(LoginRequiredMixin, TemplateView): + """ + View that lists all events that are shared with this instance. + """ + + template_name = "federation/external_event_list.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + events = [] + for host in FederatedHost.objects.all(): + try: + r = requests.get( + urljoin(host.url, reverse("federation:shared_event_list_view")), + headers={"Authorization": f"Bearer {host.access_token}"}, + timeout=5, + ) + r.raise_for_status() + results = r.json()["results"] + for event in results: + event["type"] = event["type"]["title"] + event["start_time"] = datetime.fromisoformat(event["start_time"]) + event["end_time"] = datetime.fromisoformat(event["end_time"]) + event["host"] = host.name + events += results + except HTTPError as exc: + if exc.response.status_code == 403: + # the host does not accept our token anymore, so the share needs to be set up again + host.oauth_application.delete() + host.delete() + except (JSONDecodeError, ReadTimeout): + continue + events.sort(key=lambda e: e["start_time"]) + context["events"] = events + return context + + +class CheckFederatedAccessTokenMixin: + def dispatch(self, request, *args, **kwargs): + if "federation_access_token" not in request.session.keys(): + return FederationOAuthView.as_view()(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, object): + context = super().get_context_data() + context["federation_guest"] = FederatedGuest.objects.get( + pk=self.request.session["federation_guest_pk"] + ) + return context + + +class FederatedEventDetailView(CheckFederatedAccessTokenMixin, DetailView): + """ + View that displays a shared event to a federated user from another instance + """ + + model = Event + template_name = "federation/event_detail.html" + + def get_object(self, queryset=None): + obj = super().get_object(queryset) + try: + guest = self.request.session["federation_guest_pk"] + FederatedEventShare.objects.get( + event=obj, + shared_with__in=[FederatedGuest.objects.get(pk=guest)], + ) + except (KeyError, FederatedEventShare.DoesNotExist, FederatedGuest.DoesNotExist) as exc: + self.request.session.flush() + raise PermissionDenied from exc + return obj + + +class FederatedUserShiftActionView(CheckFederatedAccessTokenMixin, BaseShiftActionView): + """ + View that allows a federated user from another instanceto sign up for a shift + """ + + def get_participant(self): + try: + return FederatedUser.objects.get( + pk=self.request.session["federation_user"] + ).as_participant() + except FederatedUser.DoesNotExist as e: + raise PermissionDenied from e + + +class FederationSettingsView(StaffRequiredMixin, TemplateView): + """ + View that displays the federation settings page where new instances can be connected + """ + + template_name = "federation/federation_settings.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["federation_guests"] = FederatedGuest.objects.all() + context["federation_hosts"] = FederatedHost.objects.all() + context["federation_invites"] = InviteCode.objects.all() + return context + + +class CreateInviteCodeView(StaffRequiredMixin, CreateView): + """ + View that allows staff users to create new invite codes (to share events with another instance) + """ + + model = InviteCode + form_class = InviteCodeForm + + def get_success_url(self): + return reverse("federation:reveal_invite_code", kwargs={"pk": self.object.pk}) + + +class InviteCodeRevealView(StaffRequiredMixin, TemplateView): + """ + View that displays an invite code to a staff user + """ + + template_name = "federation/invitecode_reveal.html" + + def get(self, request, *args, **kwargs): + invite = get_object_or_404(InviteCode, pk=kwargs["pk"]) + if invite.is_expired: + messages.error(request, _("Invite code has expired.")) + return redirect("federation:settings") + context = self.get_context_data(invite=invite, **kwargs) + return self.render_to_response(context) + + +class RedeemInviteCodeView(StaffRequiredMixin, FormView): + """ + View that allows staff users to redeem an invite code (to receive events from another instance) + """ + + form_class = RedeemInviteCodeForm + template_name = "federation/redeem_invite_code.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + if "code" in self.request.GET: + kwargs["initial"] = {"code": self.request.GET["code"]} + return kwargs + + def form_valid(self, form): + messages.success( + self.request, + _( + "Invite code redeemded successfully. You are now receiving events from this instance." + ), + ) + return redirect(reverse("federation:settings")) + + +class FederatedGuestDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView): + """ + View that allows staff users to remove a guest instance (to stop sharing events with another instance) + """ + + model = FederatedGuest + success_url = reverse_lazy("federation:settings") + success_message = _("You are no longer sharing events with this instance.") + + def form_valid(self, form): + access_token = self.object.access_token + response = super().form_valid(form) + access_token.delete() + return response + + +class FederatedHostDeleteView(StaffRequiredMixin, SuccessMessageMixin, DeleteView): + """ + View that allows staff users to remove a host instance (to stop receiving events from another instance) + """ + + model = FederatedHost + success_url = reverse_lazy("federation:settings") + success_message = _("You are no longer receiving events from this instance.") + + def form_valid(self, form): + oauth_app = self.object.oauth_application + guest_response = requests.delete( + urljoin(self.object.url, reverse("federation:api_delete_guest")), + headers={"Authorization": f"Bearer {self.object.access_token}"}, + timeout=5, + ) + if guest_response.status_code != 204: + messages.error( + self.request, + _("Failed to remove this instance. Please try again later."), + ) + return redirect("federation:settings") + response = super().form_valid(form) + oauth_app.delete() + return response diff --git a/ephios/settings.py b/ephios/settings.py index 0bf7e6d23..b54b4f135 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -108,6 +108,7 @@ "ephios.plugins.guests.apps.PluginApp", "ephios.plugins.eventautoqualification.apps.PluginApp", "ephios.plugins.simpleresource.apps.PluginApp", + "ephios.plugins.federation.apps.PluginApp", ] PLUGINS = copy.copy(CORE_PLUGINS) for ep in importlib_metadata.entry_points(group="ephios.plugins"): diff --git a/ephios/templates/base.html b/ephios/templates/base.html index 8dc515ce4..d37a6e7e9 100644 --- a/ephios/templates/base.html +++ b/ephios/templates/base.html @@ -153,6 +153,7 @@ {{ message }}
{% endfor %} + {% block messages %}{% endblock %} {% block content %}{% endblock %} diff --git a/poetry.lock b/poetry.lock index 983992ea6..7b701e32b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -1492,16 +1492,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1852,8 +1842,8 @@ astroid = ">=3.0.0,<=3.1.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.8" @@ -2083,7 +2073,6 @@ optional = false python-versions = ">=3.7,<4" files = [ {file = "reportlab-4.0.5-py3-none-any.whl", hash = "sha256:1344dbe779b9049a1888105503837d0e5b62163bf5c6b33bd1fbe84bad484f50"}, - {file = "reportlab-4.0.5.tar.gz", hash = "sha256:9c68f277736f585c5c9938755b826dd57c877fcaeb203e21cefea12b3b1db4f5"}, ] [package.dependencies] @@ -2115,6 +2104,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "rjsmin" version = "1.2.1" @@ -2664,4 +2671,4 @@ redis = ["redis"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "609d596255a0cea33acbaced24cd945db0e33696b26ffc55c741682ffc7a3425" +content-hash = "07e917ddcfa0fac6711d12bfab02bd3eb70828a496fb1a20cd993cce4284ea4b" diff --git a/pyproject.toml b/pyproject.toml index 0828ddd95..8c091d565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ pyyaml = "^6.0.1" lxml = "^4.9.3" beautifulsoup4 = "^4.12.2" css-inline = ">=0.10.3,<0.12.0" +requests = "^2.31.0" +requests-oauthlib = "^1.3.1" [tool.poetry.extras] pgsql = ["psycopg2"] @@ -90,7 +92,7 @@ disable = """C0301, duplicate-code, attribute-defined-outside-init, missing-module-docstring, missing-class-docstring, no-member, invalid-name, import-outside-toplevel, unused-argument, too-many-ancestors, missing-function-docstring, too-few-public-methods, too-many-arguments, cyclic-import, inconsistent-return-statements, -useless-object-inheritance, logging-fstring-interpolation +useless-object-inheritance, logging-fstring-interpolation, consider-using-f-string """ [tool.pylint.format] diff --git a/tests/api/test_oauth_views.py b/tests/api/test_oauth_views.py index 0beebbd85..474290527 100644 --- a/tests/api/test_oauth_views.py +++ b/tests/api/test_oauth_views.py @@ -5,8 +5,7 @@ def test_creating_an_oauth_application(django_app, superuser): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("New Application") + response = django_app.get(reverse("api:settings-oauth-app-register"), user=superuser) response.form["name"] = "Test Application" response.form["client_type"] = "public" response.form["authorization_grant_type"] = "authorization-code" @@ -28,15 +27,17 @@ def application(superuser): def test_deleting_oauth_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("Delete") + response = django_app.get( + reverse("api:settings-oauth-app-delete", kwargs={"pk": application.pk}), user=superuser + ) response = response.form.submit().follow() assert Application.objects.count() == 0 def test_editing_an_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("Edit") + response = django_app.get( + reverse("api:settings-oauth-app-update", kwargs={"pk": application.pk}), user=superuser + ) response.form["name"] = "New Name" response = response.form.submit().follow() assert "New Name" in response @@ -44,6 +45,7 @@ def test_editing_an_application(django_app, superuser, application): def test_viewing_an_application(django_app, superuser, application): - response = django_app.get(reverse("api:settings-oauth-app-list"), user=superuser) - response = response.click("View") - assert application.client_id in response + response = django_app.get( + reverse("api:settings-oauth-app-detail", kwargs={"pk": application.pk}), user=superuser + ) + assert application.name in response diff --git a/tests/conftest.py b/tests/conftest.py index 62f896616..f1f5e79d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def csrf_exempt_django_app(django_app_factory): def pytest_collection_modifyitems(items): # mark all tests for use with django_db, as we basically need it everywhere for item in items: - item.add_marker("django_db") + item.add_marker(pytest.mark.django_db(transaction=True, serialized_rollback=True)) @pytest.fixture(autouse=True) diff --git a/tests/plugins/federation/conftest.py b/tests/plugins/federation/conftest.py new file mode 100644 index 000000000..04948d503 --- /dev/null +++ b/tests/plugins/federation/conftest.py @@ -0,0 +1,42 @@ +import secrets + +import pytest +from django.urls import reverse +from oauth2_provider.generators import generate_client_secret + +from ephios.api.models import AccessToken, Application +from ephios.plugins.federation.models import FederatedGuest, FederatedHost, InviteCode + + +@pytest.fixture +def invite_code(): + return InviteCode.objects.create(url="http://localhost:8000") + + +@pytest.fixture +def federation(): + def _setup(url): + access_token = AccessToken.objects.create(token=secrets.token_hex()) + oauth_client_secret = generate_client_secret() + oauth_app = Application.objects.create( + client_secret=oauth_client_secret, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + redirect_uris=f"{url}{reverse('federation:oauth_callback')}", + ) + host = FederatedHost.objects.create( + name="Test", + url=url, + access_token=access_token.token, + oauth_application=oauth_app, + ) + guest = FederatedGuest.objects.create( + name="Test", + url=url, + access_token=access_token, + client_id=oauth_app.client_id, + client_secret=oauth_client_secret, + ) + return host, guest + + return _setup diff --git a/tests/plugins/federation/test_federation_setup.py b/tests/plugins/federation/test_federation_setup.py new file mode 100644 index 000000000..7d44c9980 --- /dev/null +++ b/tests/plugins/federation/test_federation_setup.py @@ -0,0 +1,45 @@ +import base64 +import json + +from django.urls import reverse +from dynamic_preferences.registries import global_preferences_registry + +from ephios.plugins.federation.models import FederatedGuest, FederatedHost, InviteCode + + +def test_create_invitecode(django_app, superuser): + form = django_app.get(reverse("federation:create_invite_code"), user=superuser).form + form["url"] = "https://example.com" + form.submit().follow() + assert InviteCode.objects.get().url == "https://example.com" + + +def test_redeem_invitecode_frontend( + django_app, superuser, invite_code, live_server, django_db_serialized_rollback +): + global_preferences_registry.manager()["general__organization_name"] = "Test" + form = django_app.get(reverse("federation:frontend_redeem_invite_code"), user=superuser).form + form["code"] = base64.b64encode( + json.dumps( + {"guest_url": invite_code.url, "code": invite_code.code, "host_url": live_server.url} + ).encode("ascii") + ).decode("ascii") + response = form.submit().follow() + assert FederatedGuest.objects.count() == 1 + assert FederatedHost.objects.count() == 1 + + +def test_redeem_invitecode_api(django_app, superuser, invite_code): + response = django_app.post_json( + reverse("federation:redeem_invite_code"), + { + "code": invite_code.code, + "url": "http://localhost:8000", + "name": "Test", + "client_id": "test", + "client_secret": "test", + }, + user=superuser, + ) + assert response.json["url"] == invite_code.url + assert FederatedGuest.objects.count() == 1 diff --git a/tests/plugins/federation/test_federation_views.py b/tests/plugins/federation/test_federation_views.py new file mode 100644 index 000000000..656c83f62 --- /dev/null +++ b/tests/plugins/federation/test_federation_views.py @@ -0,0 +1,35 @@ +from django.urls import reverse + +from ephios.plugins.federation.models import FederatedEventShare + + +def test_federation_get_shared_events( + django_app, volunteer, live_server, federation, event, django_db_serialized_rollback +): + host, guest = federation(live_server.url) + share = FederatedEventShare.objects.create(event=event) + share.shared_with.add(guest) + + response = django_app.get( + reverse("federation:shared_event_list_view"), + headers={"Authorization": f"Bearer {host.access_token}"}, + ) + assert event.title in response.text + + +def test_federation_shared_event_detail( + django_app, volunteer, live_server, federation, event, settings, django_db_serialized_rollback +): + settings.GET_SITE_URL = lambda: live_server.url + host, guest = federation(live_server.url) + share = FederatedEventShare.objects.create(event=event) + share.shared_with.add(guest) + + event_url = reverse("federation:event_detail", kwargs={"pk": event.pk}) + response = django_app.get(f"{event_url}?referrer={live_server.url}", user=volunteer) + response = response.follow() + assert response.status_code == 200 + assert "Authorize" in response.text + response = response.form.submit("allow").follow().follow() + assert response.status_code == 200 + assert event.title in response.text diff --git a/tests/settings.py b/tests/settings.py index 6c7535182..3ef841c87 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,4 @@ from ephios.settings import * # no-qa LANGUAGE_CODE = "en" +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"