diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 93138e9a5df1df3c8239c5d3740f1b2a5504a6ed..a40ff3047a33b3b66dc8ef1bf2342c7c082cfc0e 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -75,6 +75,10 @@ v1_patterns += [ r"^users/", include(("funkwhale_api.users.api_urls", "users"), namespace="users"), ), + url( + r"^oauth/", + include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"), + ), url(r"^token/$", jwt_views.obtain_jwt_token, name="token"), url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"), ] diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 5f69c36d55016c49efa0adffba1bd51afdeff2f1..6268d884dd59636c886b7a8b8517f5104ededa1c 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -121,6 +121,7 @@ THIRD_PARTY_APPS = ( "allauth.account", # registration "allauth.socialaccount", # registration "corsheaders", + "oauth2_provider", "rest_framework", "rest_framework.authtoken", "taggit", @@ -152,6 +153,7 @@ LOCAL_APPS = ( "funkwhale_api.common.apps.CommonConfig", "funkwhale_api.activity.apps.ActivityConfig", "funkwhale_api.users", # custom users app + "funkwhale_api.users.oauth", # Your stuff: custom apps go here "funkwhale_api.instance", "funkwhale_api.music", @@ -222,6 +224,14 @@ DATABASES = { "default": env.db("DATABASE_URL") } DATABASES["default"]["ATOMIC_REQUESTS"] = True + +MIGRATION_MODULES = { + # see https://github.com/jazzband/django-oauth-toolkit/issues/634 + # swappable models are badly designed in oauth2_provider + # ignore migrations and provide our own models. + "oauth2_provider": None +} + # # DATABASES = { # 'default': { @@ -343,6 +353,22 @@ AUTH_USER_MODEL = "users.User" LOGIN_REDIRECT_URL = "users:redirect" LOGIN_URL = "account_login" +# OAuth configuration +from funkwhale_api.users.oauth import scopes # noqa + +OAUTH2_PROVIDER = { + "SCOPES": {s.id: s.label for s in scopes.SCOPES_BY_ID.values()}, + "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https", "urn"], + # we keep expired tokens for 15 days, for tracability + "REFRESH_TOKEN_EXPIRE_SECONDS": 3600 * 24 * 15, + "AUTHORIZATION_CODE_EXPIRE_SECONDS": 5 * 60, + "ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60 * 10, +} +OAUTH2_PROVIDER_APPLICATION_MODEL = "users.Application" +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken" +OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken" + # LDAP AUTHENTICATION CONFIGURATION # ------------------------------------------------------------------------------ AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False) @@ -450,14 +476,19 @@ CELERY_TASK_TIME_LIMIT = 300 CELERY_BEAT_SCHEDULE = { "federation.clean_music_cache": { "task": "federation.clean_music_cache", - "schedule": crontab(hour="*/2"), + "schedule": crontab(minute="0", hour="*/2"), "options": {"expires": 60 * 2}, }, "music.clean_transcoding_cache": { "task": "music.clean_transcoding_cache", - "schedule": crontab(hour="*"), + "schedule": crontab(minute="0", hour="*"), "options": {"expires": 60 * 2}, }, + "oauth.clear_expired_tokens": { + "task": "oauth.clear_expired_tokens", + "schedule": crontab(minute="0", hour="0"), + "options": {"expires": 60 * 60 * 24}, + }, } JWT_AUTH = { @@ -477,7 +508,6 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_CREDENTIALS = True REST_FRAMEWORK = { - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination", "PAGE_SIZE": 25, "DEFAULT_PARSER_CLASSES": ( @@ -487,12 +517,16 @@ REST_FRAMEWORK = { "funkwhale_api.federation.parsers.ActivityParser", ), "DEFAULT_AUTHENTICATION_CLASSES": ( + "oauth2_provider.contrib.rest_framework.OAuth2Authentication", + "rest_framework.authentication.SessionAuthentication", "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", "funkwhale_api.common.authentication.BearerTokenHeaderAuth", "funkwhale_api.common.authentication.JSONWebTokenAuthentication", - "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.BasicAuthentication", ), + "DEFAULT_PERMISSION_CLASSES": ( + "funkwhale_api.users.oauth.permissions.ScopePermission", + ), "DEFAULT_FILTER_BACKENDS": ( "rest_framework.filters.OrderingFilter", "django_filters.rest_framework.DjangoFilterBackend", diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py index b93f149f0b0e6f001107d835264545659f42a715..49a2fb1939cd80d72111bcbec5139734e0ecd314 100644 --- a/api/funkwhale_api/common/decorators.py +++ b/api/funkwhale_api/common/decorators.py @@ -87,4 +87,6 @@ def mutations_route(types): ) return response.Response(serializer.data, status=status.HTTP_201_CREATED) - return decorators.action(methods=["get", "post"], detail=True)(mutations) + return decorators.action( + methods=["get", "post"], detail=True, required_scope="edits" + )(mutations) diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index d54b79cea376c6598012f707cbb50784adb6d33a..dce285d85c65061fe81a86ceaa1886a6efe35ee7 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -1,6 +1,5 @@ from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response from django.db.models import Prefetch @@ -9,6 +8,7 @@ from funkwhale_api.activity import record from funkwhale_api.common import fields, permissions from funkwhale_api.music.models import Track from funkwhale_api.music import utils as music_utils +from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters, models, serializers @@ -24,10 +24,11 @@ class TrackFavoriteViewSet( serializer_class = serializers.UserTrackFavoriteSerializer queryset = models.TrackFavorite.objects.all().select_related("user") permission_classes = [ - permissions.ConditionalAuthentication, + oauth_permissions.ScopePermission, permissions.OwnerPermission, - IsAuthenticatedOrReadOnly, ] + required_scope = "favorites" + anonymous_policy = "setting" owner_checks = ["write"] def get_serializer_class(self): diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 6b3f30e6660f578a216454e1f90f17a5ba4494fd..0fe044ec1921e36fc452389b62b90d4121229b19 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -5,11 +5,11 @@ from django.db.models import Count from rest_framework import decorators from rest_framework import mixins -from rest_framework import permissions from rest_framework import response from rest_framework import viewsets from funkwhale_api.music import models as music_models +from funkwhale_api.users.oauth import permissions as oauth_permissions from . import activity from . import api_serializers @@ -43,7 +43,8 @@ class LibraryFollowViewSet( .select_related("actor", "target__actor") ) serializer_class = api_serializers.LibraryFollowSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "follows" filterset_class = filters.LibraryFollowFilter ordering_fields = ("creation_date",) @@ -100,7 +101,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): .annotate(_uploads_count=Count("uploads")) ) serializer_class = api_serializers.LibrarySerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" def get_queryset(self): qs = super().get_queryset() @@ -169,7 +171,8 @@ class InboxItemViewSet( .order_by("-activity__creation_date") ) serializer_class = api_serializers.InboxItemSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "notifications" filterset_class = filters.InboxItemFilter ordering_fields = ("activity__creation_date",) diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index b03c85a8ef1a3834f934a299998ec7230d8631e1..30219629a4b0c5a2852d0cff1f9f04605604a654 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -1,5 +1,4 @@ from rest_framework import mixins, viewsets -from rest_framework.permissions import IsAuthenticatedOrReadOnly from django.db.models import Prefetch @@ -9,6 +8,8 @@ from funkwhale_api.music.models import Track from funkwhale_api.music import utils as music_utils from . import filters, models, serializers +from funkwhale_api.users.oauth import permissions as oauth_permissions + class ListeningViewSet( mixins.CreateModelMixin, @@ -19,11 +20,13 @@ class ListeningViewSet( serializer_class = serializers.ListeningSerializer queryset = models.Listening.objects.all().select_related("user") + permission_classes = [ - permissions.ConditionalAuthentication, + oauth_permissions.ScopePermission, permissions.OwnerPermission, - IsAuthenticatedOrReadOnly, ] + required_scope = "listenings" + anonymous_policy = "setting" owner_checks = ["write"] filterset_class = filters.ListeningFilter diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index ea6311033333ad0603a1f88d52fd51bda44ceeaa..1800c3dbc7e60bcc022f24dd3e64d6a0d45b8697 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -5,7 +5,7 @@ from rest_framework import views from rest_framework.response import Response from funkwhale_api.common import preferences -from funkwhale_api.users.permissions import HasUserPermission +from funkwhale_api.users.oauth import permissions as oauth_permissions from . import nodeinfo @@ -14,8 +14,8 @@ NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.so class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): pagination_class = None - permission_classes = (HasUserPermission,) - required_permissions = ["settings"] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "instance:settings" class InstanceSettings(views.APIView): diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 99d9020315882a3c461b3e8fe44eb1310635b0f5..c4a624e5c849d2b692ff066a5bcaf6ac3f0b8f74 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -8,7 +8,7 @@ from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.music import models as music_models from funkwhale_api.moderation import models as moderation_models from funkwhale_api.users import models as users_models -from funkwhale_api.users.permissions import HasUserPermission + from . import filters, serializers @@ -23,8 +23,7 @@ class ManageUploadViewSet( ) serializer_class = serializers.ManageUploadSerializer filterset_class = filters.ManageUploadFilterSet - permission_classes = (HasUserPermission,) - required_permissions = ["library"] + required_scope = "instance:libraries" ordering_fields = [ "accessed_date", "modification_date", @@ -55,8 +54,7 @@ class ManageUserViewSet( queryset = users_models.User.objects.all().order_by("-id") serializer_class = serializers.ManageUserSerializer filterset_class = filters.ManageUserFilterSet - permission_classes = (HasUserPermission,) - required_permissions = ["settings"] + required_scope = "instance:users" ordering_fields = ["date_joined", "last_activity", "username"] def get_serializer_context(self): @@ -80,8 +78,7 @@ class ManageInvitationViewSet( ) serializer_class = serializers.ManageInvitationSerializer filterset_class = filters.ManageInvitationFilterSet - permission_classes = (HasUserPermission,) - required_permissions = ["settings"] + required_scope = "instance:invitations" ordering_fields = ["creation_date", "expiration_date"] def perform_create(self, serializer): @@ -114,8 +111,7 @@ class ManageDomainViewSet( ) serializer_class = serializers.ManageDomainSerializer filterset_class = filters.ManageDomainFilterSet - permission_classes = (HasUserPermission,) - required_permissions = ["moderation"] + required_scope = "instance:domains" ordering_fields = [ "name", "creation_date", @@ -153,7 +149,7 @@ class ManageActorViewSet( ) serializer_class = serializers.ManageActorSerializer filterset_class = filters.ManageActorFilterSet - permission_classes = (HasUserPermission,) + required_scope = "instance:accounts" required_permissions = ["moderation"] ordering_fields = [ "name", @@ -199,8 +195,7 @@ class ManageInstancePolicyViewSet( ) serializer_class = serializers.ManageInstancePolicySerializer filterset_class = filters.ManageInstancePolicyFilterSet - permission_classes = (HasUserPermission,) - required_permissions = ["moderation"] + required_scope = "instance:policies" ordering_fields = ["id", "creation_date"] def perform_create(self, serializer): diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py index feeeccf016686bbe754f492a43cb998756ce76f8..4d4e3e039abdd68858fe96df79ad965a02e49771 100644 --- a/api/funkwhale_api/moderation/views.py +++ b/api/funkwhale_api/moderation/views.py @@ -1,7 +1,6 @@ from django.db import IntegrityError from rest_framework import mixins -from rest_framework import permissions from rest_framework import response from rest_framework import status from rest_framework import viewsets @@ -24,7 +23,7 @@ class UserFilterViewSet( .select_related("target_artist") ) serializer_class = serializers.UserFilterSerializer - permission_classes = [permissions.IsAuthenticated] + required_scope = "filters" ordering_fields = ("creation_date",) def create(self, request, *args, **kwargs): diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index d23f07ce6943778e3fecdfcff2cc2d4acd04a119..b5242eeb1c1e22a35c65c6390a0987a853e21e9d 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -8,7 +8,6 @@ from django.db.models.functions import Length from django.utils import timezone from rest_framework import mixins -from rest_framework import permissions from rest_framework import settings as rest_settings from rest_framework import views, viewsets from rest_framework.decorators import action @@ -24,6 +23,7 @@ from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation import actors from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import routes +from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters, licenses, models, serializers, tasks, utils @@ -64,7 +64,9 @@ class TagViewSetMixin(object): class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet): queryset = models.Artist.objects.all() serializer_class = serializers.ArtistWithAlbumsSerializer - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" filterset_class = filters.ArtistFilter ordering_fields = ("id", "name", "creation_date") @@ -90,7 +92,9 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi models.Album.objects.all().order_by("artist", "release_date").select_related() ) serializer_class = serializers.AlbumSerializer - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" ordering_fields = ("creation_date", "release_date", "title") filterset_class = filters.AlbumFilter @@ -126,9 +130,11 @@ class LibraryViewSet( ) serializer_class = serializers.LibraryForOwnerSerializer permission_classes = [ - permissions.IsAuthenticated, + oauth_permissions.ScopePermission, common_permissions.OwnerPermission, ] + required_scope = "libraries" + anonymous_policy = "setting" owner_field = "actor.user" owner_checks = ["read", "write"] @@ -178,7 +184,9 @@ class TrackViewSet( queryset = models.Track.objects.all().for_nested_serialization() serializer_class = serializers.TrackSerializer - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" filterset_class = filters.TrackFilter ordering_fields = ( "creation_date", @@ -350,7 +358,9 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [SignatureAuthentication] ) - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" lookup_field = "uuid" def retrieve(self, request, *args, **kwargs): @@ -385,9 +395,11 @@ class UploadViewSet( ) serializer_class = serializers.UploadForOwnerSerializer permission_classes = [ - permissions.IsAuthenticated, + oauth_permissions.ScopePermission, common_permissions.OwnerPermission, ] + required_scope = "libraries" + anonymous_policy = "setting" owner_field = "library.actor.user" owner_checks = ["read", "write"] filterset_class = filters.UploadFilter @@ -432,12 +444,16 @@ class UploadViewSet( class TagViewSet(viewsets.ReadOnlyModelViewSet): queryset = Tag.objects.all().order_by("name") serializer_class = serializers.TagSerializer - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" class Search(views.APIView): max_results = 3 - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" def get(self, request, *args, **kwargs): query = request.GET["query"] @@ -502,7 +518,9 @@ class Search(views.APIView): class LicenseViewSet(viewsets.ReadOnlyModelViewSet): - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" serializer_class = serializers.LicenseSerializer queryset = models.License.objects.all().order_by("code") lookup_value_regex = ".*" @@ -527,7 +545,9 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet): class OembedView(views.APIView): - permission_classes = [common_permissions.ConditionalAuthentication] + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" def get(self, request, *args, **kwargs): serializer = serializers.OembedSerializer(data=request.GET) diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 2f536d7a712f47515dccc55a01d7ddc9fba55a47..ee9b9c4e3a16910583a58fa755639560cda68be0 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -2,11 +2,12 @@ from django.db import transaction from django.db.models import Count from rest_framework import exceptions, mixins, viewsets from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response from funkwhale_api.common import fields, permissions from funkwhale_api.music import utils as music_utils +from funkwhale_api.users.oauth import permissions as oauth_permissions + from . import filters, models, serializers @@ -28,10 +29,11 @@ class PlaylistViewSet( .with_duration() ) permission_classes = [ - permissions.ConditionalAuthentication, + oauth_permissions.ScopePermission, permissions.OwnerPermission, - IsAuthenticatedOrReadOnly, ] + required_scope = "playlists" + anonymous_policy = "setting" owner_checks = ["write"] filterset_class = filters.PlaylistFilter ordering_fields = ("id", "name", "creation_date", "modification_date") @@ -101,10 +103,11 @@ class PlaylistTrackViewSet( serializer_class = serializers.PlaylistTrackSerializer queryset = models.PlaylistTrack.objects.all() permission_classes = [ - permissions.ConditionalAuthentication, + oauth_permissions.ScopePermission, permissions.OwnerPermission, - IsAuthenticatedOrReadOnly, ] + required_scope = "playlists" + anonymous_policy = "setting" owner_field = "playlist.user" owner_checks = ["write"] diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py index 5df0fe287a28f69513f97640f3790bb0cc031018..fa0a6fe4a6037aa59a6fb0800f63760e82ba78f0 100644 --- a/api/funkwhale_api/radios/views.py +++ b/api/funkwhale_api/radios/views.py @@ -5,6 +5,7 @@ from rest_framework.response import Response from funkwhale_api.common import permissions as common_permissions from funkwhale_api.music.serializers import TrackSerializer +from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters, filtersets, models, serializers @@ -20,10 +21,11 @@ class RadioViewSet( serializer_class = serializers.RadioSerializer permission_classes = [ - permissions.IsAuthenticated, + oauth_permissions.ScopePermission, common_permissions.OwnerPermission, ] filterset_class = filtersets.RadioFilter + required_scope = "radios" owner_field = "user" owner_checks = ["write"] diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index c0b26bf4634c0cdf4cbd0418f763c71d494543f3..c40ed8ceda3c6fa4999b058721b6b1988a9f27f6 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -92,7 +92,7 @@ def get_playlist_qs(request): class SubsonicViewSet(viewsets.GenericViewSet): content_negotiation_class = negotiation.SubsonicContentNegociation authentication_classes = [authentication.SubsonicAuthentication] - permissions_classes = [rest_permissions.IsAuthenticated] + permission_classes = [rest_permissions.IsAuthenticated] def dispatch(self, request, *args, **kwargs): if not preferences.get("subsonic__enabled"): @@ -128,7 +128,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): detail=False, methods=["get", "post"], url_name="get_license", - permissions_classes=[], + permission_classes=[], url_path="getLicense", ) def get_license(self, request, *args, **kwargs): diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index a5b113cf4506f0a8c80074f38f5cf98c98669ce6..e7f046ef3fa21f786038d302355b59bbab494ef9 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,7 +1,7 @@ +import pytz import factory from django.contrib.auth.models import Permission from django.utils import timezone - from funkwhale_api.factories import ManyToManyFromList, registry, NoUpdateOnCreate from . import models @@ -87,3 +87,49 @@ class UserFactory(factory.django.DjangoModelFactory): class SuperUserFactory(UserFactory): is_staff = True is_superuser = True + + +@registry.register +class ApplicationFactory(factory.django.DjangoModelFactory): + name = factory.Faker("name") + redirect_uris = factory.Faker("url") + client_type = models.Application.CLIENT_CONFIDENTIAL + authorization_grant_type = models.Application.GRANT_AUTHORIZATION_CODE + scope = "read" + + class Meta: + model = "users.Application" + + +@registry.register +class GrantFactory(factory.django.DjangoModelFactory): + application = factory.SubFactory(ApplicationFactory) + scope = factory.SelfAttribute(".application.scope") + redirect_uri = factory.SelfAttribute(".application.redirect_uris") + user = factory.SubFactory(UserFactory) + expires = factory.Faker("future_datetime", end_date="+15m") + code = factory.Faker("uuid4") + + class Meta: + model = "users.Grant" + + +@registry.register +class AccessTokenFactory(factory.django.DjangoModelFactory): + application = factory.SubFactory(ApplicationFactory) + user = factory.SubFactory(UserFactory) + expires = factory.Faker("future_datetime", tzinfo=pytz.UTC) + token = factory.Faker("uuid4") + + class Meta: + model = "users.AccessToken" + + +@registry.register +class RefreshTokenFactory(factory.django.DjangoModelFactory): + application = factory.SubFactory(ApplicationFactory) + user = factory.SubFactory(UserFactory) + token = factory.Faker("uuid4") + + class Meta: + model = "users.RefreshToken" diff --git a/api/funkwhale_api/users/migrations/0014_oauth.py b/api/funkwhale_api/users/migrations/0014_oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..696867f68340129c66712450c6dfe36d5c405464 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0014_oauth.py @@ -0,0 +1,195 @@ +# Generated by Django 2.0.9 on 2018-12-06 10:08 + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import oauth2_provider.generators + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0013_auto_20181206_1008"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AccessToken", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("expires", models.DateTimeField()), + ("scope", models.TextField(blank=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("token", models.CharField(max_length=255, unique=True)), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Application", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "client_id", + models.CharField( + db_index=True, + default=oauth2_provider.generators.generate_client_id, + max_length=100, + unique=True, + ), + ), + ( + "redirect_uris", + models.TextField( + blank=True, help_text="Allowed URIs list, space separated" + ), + ), + ( + "client_type", + models.CharField( + choices=[ + ("confidential", "Confidential"), + ("public", "Public"), + ], + max_length=32, + ), + ), + ( + "authorization_grant_type", + models.CharField( + choices=[ + ("authorization-code", "Authorization code"), + ("implicit", "Implicit"), + ("password", "Resource owner password-based"), + ("client-credentials", "Client credentials"), + ], + max_length=32, + ), + ), + ( + "client_secret", + models.CharField( + blank=True, + db_index=True, + default=oauth2_provider.generators.generate_client_secret, + max_length=255, + ), + ), + ("name", models.CharField(blank=True, max_length=255)), + ("skip_authorization", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="users_application", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Grant", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("code", models.CharField(max_length=255, unique=True)), + ("expires", models.DateTimeField()), + ("redirect_uri", models.CharField(max_length=255)), + ("scope", models.TextField(blank=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="users.Application", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="users_grant", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="RefreshToken", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("token", models.CharField(max_length=255)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("revoked", models.DateTimeField(null=True)), + ( + "access_token", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="refresh_token", + to="users.AccessToken", + ), + ), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="users.Application", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="users_refreshtoken", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + migrations.AddField( + model_name="accesstoken", + name="application", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="users.Application", + ), + ), + migrations.AddField( + model_name="accesstoken", + name="source_refresh_token", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="refreshed_access_token", + to="users.RefreshToken", + ), + ), + migrations.AddField( + model_name="accesstoken", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="users_accesstoken", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterUniqueTogether( + name="refreshtoken", unique_together={("token", "revoked")} + ), + ] diff --git a/api/funkwhale_api/users/migrations/0015_application_scope.py b/api/funkwhale_api/users/migrations/0015_application_scope.py new file mode 100644 index 0000000000000000000000000000000000000000..5fa8e52d75dd66bfd1676c48a7688eac7255361b --- /dev/null +++ b/api/funkwhale_api/users/migrations/0015_application_scope.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-18 09:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_oauth'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='scope', + field=models.TextField(blank=True), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index b34693ed0b4089cba80780a1d69dba0de43a223a..d67dff4532e5dbab19bee06ed2704993c3af8e31 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -18,6 +18,8 @@ from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from django_auth_ldap.backend import populate_user as ldap_populate_user +from oauth2_provider import models as oauth2_models +from oauth2_provider import validators as oauth2_validators from versatileimagefield.fields import VersatileImageField from versatileimagefield.image_warmer import VersatileImageFieldWarmer @@ -37,12 +39,37 @@ PERMISSIONS_CONFIGURATION = { "moderation": { "label": "Moderation", "help_text": "Block/mute/remove domains, users and content", + "scopes": { + "read:instance:policies", + "write:instance:policies", + "read:instance:accounts", + "write:instance:accounts", + "read:instance:domains", + "write:instance:domains", + }, }, "library": { "label": "Manage library", "help_text": "Manage library, delete files, tracks, artists, albums...", + "scopes": { + "read:instance:edits", + "write:instance:edits", + "read:instance:libraries", + "write:instance:libraries", + }, + }, + "settings": { + "label": "Manage instance-level settings", + "help_text": "", + "scopes": { + "read:instance:settings", + "write:instance:settings", + "read:instance:users", + "write:instance:users", + "read:instance:invitations", + "write:instance:invitations", + }, }, - "settings": {"label": "Manage instance-level settings", "help_text": ""}, } PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) @@ -245,6 +272,45 @@ class Invitation(models.Model): return super().save(**kwargs) +class Application(oauth2_models.AbstractApplication): + scope = models.TextField(blank=True) + + @property + def normalized_scopes(self): + from .oauth import permissions + + raw_scopes = set(self.scope.split(" ") if self.scope else []) + return permissions.normalize(*raw_scopes) + + +# oob schemes are not supported yet in oauth toolkit +# (https://github.com/jazzband/django-oauth-toolkit/issues/235) +# so in the meantime, we override their validation to add support +OOB_SCHEMES = ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto"] + + +class CustomRedirectURIValidator(oauth2_validators.RedirectURIValidator): + def __call__(self, value): + if value in OOB_SCHEMES: + return value + return super().__call__(value) + + +oauth2_models.RedirectURIValidator = CustomRedirectURIValidator + + +class Grant(oauth2_models.AbstractGrant): + pass + + +class AccessToken(oauth2_models.AbstractAccessToken): + pass + + +class RefreshToken(oauth2_models.AbstractRefreshToken): + pass + + def get_actor_data(username): slugified_username = federation_utils.slugify_username(username) return { diff --git a/api/funkwhale_api/users/oauth/__init__.py b/api/funkwhale_api/users/oauth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/users/oauth/permissions.py b/api/funkwhale_api/users/oauth/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..ebd44a937a6dee21e462d3321dfdf1dfcc7149b8 --- /dev/null +++ b/api/funkwhale_api/users/oauth/permissions.py @@ -0,0 +1,123 @@ +from rest_framework import permissions +from django.core.exceptions import ImproperlyConfigured + +from funkwhale_api.common import preferences + +from .. import models +from . import scopes + + +def normalize(*scope_ids): + """ + Given an iterable containing scopes ids such as {read, write:playlists} + will return a set containing all the leaf scopes (and no parent scopes) + """ + final = set() + for scope_id in scope_ids: + try: + scope_obj = scopes.SCOPES_BY_ID[scope_id] + except KeyError: + continue + + if scope_obj.children: + final = final | {s.id for s in scope_obj.children} + else: + final.add(scope_obj.id) + return final + + +def should_allow(required_scope, request_scopes): + if not required_scope: + return True + + if not request_scopes: + return False + + return required_scope in normalize(*request_scopes) + + +METHOD_SCOPE_MAPPING = { + "get": "read", + "post": "write", + "patch": "write", + "put": "write", + "delete": "write", +} + + +class ScopePermission(permissions.BasePermission): + def has_permission(self, request, view): + + if request.method.lower() in ["options", "head"]: + return True + + try: + scope_config = getattr(view, "required_scope") + except AttributeError: + raise ImproperlyConfigured( + "ScopePermission requires the view to define the required_scope attribute" + ) + anonymous_policy = getattr(view, "anonymous_policy", False) + if anonymous_policy not in [True, False, "setting"]: + raise ImproperlyConfigured( + "{} is not a valid value for anonymous_policy".format(anonymous_policy) + ) + if isinstance(scope_config, str): + scope_config = { + "read": "read:{}".format(scope_config), + "write": "write:{}".format(scope_config), + } + action = METHOD_SCOPE_MAPPING[request.method.lower()] + required_scope = scope_config[action] + else: + # we have a dict with explicit viewset actions / scopes + required_scope = scope_config[view.action] + + token = request.auth + + if isinstance(token, models.AccessToken): + return self.has_permission_token(token, required_scope) + elif request.user.is_authenticated: + user_scopes = scopes.get_from_permissions(**request.user.get_permissions()) + return should_allow( + required_scope=required_scope, request_scopes=user_scopes + ) + elif hasattr(request, "actor") and request.actor: + # we use default anonymous scopes + user_scopes = scopes.FEDERATION_REQUEST_SCOPES + return should_allow( + required_scope=required_scope, request_scopes=user_scopes + ) + else: + if anonymous_policy is False: + return False + if anonymous_policy == "setting" and preferences.get( + "common__api_authentication_required" + ): + return False + + # we use default anonymous scopes + user_scopes = scopes.ANONYMOUS_SCOPES + return should_allow( + required_scope=required_scope, request_scopes=user_scopes + ) + + def has_permission_token(self, token, required_scope): + + if token.is_expired(): + return False + + if not token.user: + return False + + user = token.user + user_scopes = scopes.get_from_permissions(**user.get_permissions()) + token_scopes = set(token.scopes.keys()) + final_scopes = ( + user_scopes + & normalize(*token_scopes) + & token.application.normalized_scopes + & scopes.OAUTH_APP_SCOPES + ) + + return should_allow(required_scope=required_scope, request_scopes=final_scopes) diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py new file mode 100644 index 0000000000000000000000000000000000000000..61b07098383e832dcacd87f5da11fa843f821b29 --- /dev/null +++ b/api/funkwhale_api/users/oauth/scopes.py @@ -0,0 +1,93 @@ +class Scope: + def __init__(self, id, label="", children=None): + self.id = id + self.label = "" + self.children = children or [] + + def copy(self, prefix): + return Scope("{}:{}".format(prefix, self.id)) + + +BASE_SCOPES = [ + Scope( + "profile", "Access profile data (email, username, avatar, subsonic password…)" + ), + Scope("libraries", "Access uploads, libraries, and audio metadata"), + Scope("edits", "Browse and submit edits on audio metadata"), + Scope("follows", "Access library follows"), + Scope("favorites", "Access favorites"), + Scope("filters", "Access content filters"), + Scope("listenings", "Access listening history"), + Scope("radios", "Access radios"), + Scope("playlists", "Access playlists"), + Scope("notifications", "Access personal notifications"), + Scope("security", "Access security settings"), + # Privileged scopes that require specific user permissions + Scope("instance:settings", "Access instance settings"), + Scope("instance:users", "Access local user accounts"), + Scope("instance:invitations", "Access invitations"), + Scope("instance:edits", "Access instance metadata edits"), + Scope( + "instance:libraries", "Access instance uploads, libraries and audio metadata" + ), + Scope("instance:accounts", "Access instance federated accounts"), + Scope("instance:domains", "Access instance domains"), + Scope("instance:policies", "Access instance moderation policies"), +] +SCOPES = [ + Scope("read", children=[s.copy("read") for s in BASE_SCOPES]), + Scope("write", children=[s.copy("write") for s in BASE_SCOPES]), +] + + +def flatten(*scopes): + for scope in scopes: + yield scope + yield from flatten(*scope.children) + + +SCOPES_BY_ID = {s.id: s for s in flatten(*SCOPES)} + +FEDERATION_REQUEST_SCOPES = {"read:libraries"} +ANONYMOUS_SCOPES = { + "read:libraries", + "read:playlists", + "read:listenings", + "read:favorites", + "read:radios", + "read:edits", +} + +COMMON_SCOPES = ANONYMOUS_SCOPES | { + "read:profile", + "write:profile", + "write:libraries", + "write:playlists", + "read:follows", + "write:follows", + "write:favorites", + "read:notifications", + "write:notifications", + "write:radios", + "write:edits", + "read:filters", + "write:filters", + "write:listenings", +} + +LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"} + +# We don't allow admin access for oauth apps yet +OAUTH_APP_SCOPES = COMMON_SCOPES + + +def get_from_permissions(**permissions): + from funkwhale_api.users import models + + final = LOGGED_IN_SCOPES + for permission_name, value in permissions.items(): + if value is False: + continue + config = models.PERMISSIONS_CONFIGURATION[permission_name] + final = final | config["scopes"] + return final diff --git a/api/funkwhale_api/users/oauth/serializers.py b/api/funkwhale_api/users/oauth/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..4788ba220a8077d2fd3c84fd555216c446cd8581 --- /dev/null +++ b/api/funkwhale_api/users/oauth/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from .. import models + + +class ApplicationSerializer(serializers.ModelSerializer): + scopes = serializers.CharField(source="scope") + + class Meta: + model = models.Application + fields = ["client_id", "name", "scopes", "created", "updated"] + + +class CreateApplicationSerializer(serializers.ModelSerializer): + name = serializers.CharField(required=True, max_length=255) + scopes = serializers.CharField(source="scope", default="read") + + class Meta: + model = models.Application + fields = [ + "client_id", + "name", + "scopes", + "client_secret", + "created", + "updated", + "redirect_uris", + ] + read_only_fields = ["client_id", "client_secret", "created", "updated"] diff --git a/api/funkwhale_api/users/oauth/tasks.py b/api/funkwhale_api/users/oauth/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..2aaba353a793ccaa563e3ca18af8308f30cafaca --- /dev/null +++ b/api/funkwhale_api/users/oauth/tasks.py @@ -0,0 +1,8 @@ +from funkwhale_api.taskapp import celery + +from oauth2_provider import models as oauth2_models + + +@celery.app.task(name="oauth.clear_expired_tokens") +def clear_expired_tokens(): + oauth2_models.clear_expired() diff --git a/api/funkwhale_api/users/oauth/urls.py b/api/funkwhale_api/users/oauth/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..832f9ca1ba6cfd64a673213961ed35942aef3c98 --- /dev/null +++ b/api/funkwhale_api/users/oauth/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import url +from django.views.decorators.csrf import csrf_exempt + +from rest_framework import routers + +from . import views + +router = routers.SimpleRouter() +router.register(r"apps", views.ApplicationViewSet, "apps") +router.register(r"grants", views.GrantViewSet, "grants") + +urlpatterns = router.urls + [ + url("^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"), + url("^token/$", views.TokenView.as_view(), name="token"), + url("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"), +] diff --git a/api/funkwhale_api/users/oauth/views.py b/api/funkwhale_api/users/oauth/views.py new file mode 100644 index 0000000000000000000000000000000000000000..a8bbb239ca50a22be070904fc73b2a5536e51fa4 --- /dev/null +++ b/api/funkwhale_api/users/oauth/views.py @@ -0,0 +1,182 @@ +import json +import urllib.parse + +from django import http +from django.utils import timezone +from django.db.models import Q +from rest_framework import mixins, permissions, views, viewsets + +from oauth2_provider import exceptions as oauth2_exceptions +from oauth2_provider import views as oauth_views +from oauth2_provider.settings import oauth2_settings + +from .. import models +from .permissions import ScopePermission +from . import serializers + + +class ApplicationViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + anonymous_policy = True + required_scope = { + "retrieve": None, + "create": None, + "destroy": "write:security", + "update": "write:security", + "partial_update": "write:security", + "list": "read:security", + } + lookup_field = "client_id" + queryset = models.Application.objects.all().order_by("-created") + serializer_class = serializers.ApplicationSerializer + + def get_serializer_class(self): + if self.request.method.lower() == "post": + return serializers.CreateApplicationSerializer + return super().get_serializer_class() + + def perform_create(self, serializer): + return serializer.save( + client_type=models.Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE, + user=self.request.user if self.request.user.is_authenticated else None, + ) + + def get_serializer(self, *args, **kwargs): + serializer_class = self.get_serializer_class() + try: + owned = args[0].user == self.request.user + except (IndexError, AttributeError): + owned = False + if owned: + serializer_class = serializers.CreateApplicationSerializer + + kwargs["context"] = self.get_serializer_context() + return serializer_class(*args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset() + if self.action in ["list", "destroy", "update", "partial_update"]: + qs = qs.filter(user=self.request.user) + return qs + + +class GrantViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """ + This is a viewset that list applications that have access to the request user + account, to allow revoking tokens easily. + """ + + permission_classes = [permissions.IsAuthenticated, ScopePermission] + required_scope = "security" + lookup_field = "client_id" + queryset = models.Application.objects.all().order_by("-created") + serializer_class = serializers.ApplicationSerializer + pagination_class = None + + def get_queryset(self): + now = timezone.now() + queryset = super().get_queryset() + grants = models.Grant.objects.filter(user=self.request.user, expires__gt=now) + access_tokens = models.AccessToken.objects.filter(user=self.request.user) + refresh_tokens = models.RefreshToken.objects.filter( + user=self.request.user, revoked=None + ) + + return queryset.filter( + Q(pk__in=access_tokens.values("application")) + | Q(pk__in=refresh_tokens.values("application")) + | Q(pk__in=grants.values("application")) + ).distinct() + + def perform_create(self, serializer): + return serializer.save( + client_type=models.Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE, + ) + + def perform_destroy(self, instance): + application = instance + + access_tokens = application.accesstoken_set.filter(user=self.request.user) + for token in access_tokens: + token.revoke() + + refresh_tokens = application.refreshtoken_set.filter(user=self.request.user) + for token in refresh_tokens: + try: + token.revoke() + except models.AccessToken.DoesNotExist: + token.access_token = None + token.revoked = timezone.now() + token.save(update_fields=["access_token", "revoked"]) + grants = application.grant_set.filter(user=self.request.user) + grants.delete() + + +class AuthorizeView(views.APIView, oauth_views.AuthorizationView): + permission_classes = [permissions.IsAuthenticated] + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS + skip_authorization_completely = False + oauth2_data = {} + + def form_invalid(self, form): + """ + Return a JSON response instead of a template one + """ + errors = form.errors + + return self.json_payload(errors, status_code=400) + + def form_valid(self, form): + try: + response = super().form_valid(form) + + except models.Application.DoesNotExist: + return self.json_payload({"non_field_errors": ["Invalid application"]}, 400) + + if self.request.is_ajax() and response.status_code == 302: + # Web client need this to be able to redirect the user + query = urllib.parse.urlparse(response["Location"]).query + code = urllib.parse.parse_qs(query)["code"][0] + return self.json_payload( + {"redirect_uri": response["Location"], "code": code}, status_code=200 + ) + + return response + + def error_response(self, error, application): + if isinstance(error, oauth2_exceptions.FatalClientError): + return self.json_payload({"detail": error.oauthlib_error.description}, 400) + return super().error_response(error, application) + + def json_payload(self, payload, status_code): + return http.HttpResponse( + json.dumps(payload), status=status_code, content_type="application/json" + ) + + def handle_no_permission(self): + return self.json_payload( + {"detail": "Authentication credentials were not provided."}, 401 + ) + + +class TokenView(oauth_views.TokenView): + pass + + +class RevokeTokenView(oauth_views.RevokeTokenView): + pass diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py deleted file mode 100644 index 02c1198e8cb208a201bd257e910bce3c2d53174b..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/users/permissions.py +++ /dev/null @@ -1,23 +0,0 @@ -from rest_framework.permissions import BasePermission - - -class HasUserPermission(BasePermission): - """ - Ensure the request user has the proper permissions. - - Usage: - - class MyView(APIView): - permission_classes = [HasUserPermission] - required_permissions = ['federation'] - """ - - def has_permission(self, request, view): - if not hasattr(request, "user") or not request.user: - return False - if request.user.is_anonymous: - return False - operator = getattr(view, "permission_operator", "and") - return request.user.has_permissions( - *view.required_permissions, operator=operator - ) diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 2393882e725ada35c6d1890c82e04a685f60221f..85103afc69e1ce37a88b3c3b6d7b151d6bbc34de 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -11,6 +11,7 @@ from . import models, serializers class RegisterView(BaseRegisterView): serializer_class = serializers.RegisterSerializer + permission_classes = [] def create(self, request, *args, **kwargs): invitation_code = request.data.get("invitation") @@ -27,6 +28,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): queryset = models.User.objects.all() serializer_class = serializers.UserWriteSerializer lookup_field = "username" + required_scope = "profile" @action(methods=["get"], detail=False) def me(self, request, *args, **kwargs): @@ -34,7 +36,12 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): serializer = serializers.MeSerializer(request.user) return Response(serializer.data) - @action(methods=["get", "post", "delete"], url_path="subsonic-token", detail=True) + @action( + methods=["get", "post", "delete"], + required_scope="security", + url_path="subsonic-token", + detail=True, + ) def subsonic_token(self, request, *args, **kwargs): if not self.request.user.username == kwargs.get("username"): return Response(status=403) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 8b4639fee6a4a1a0925df7a15baa9c40f2b4153e..1a935cd8e7bf06376cbc84368aac29d5f592a9ad 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -68,3 +68,5 @@ pydub==0.23.0 pyld==1.0.4 aiohttp==3.5.4 autobahn>=19.3.2 + +django-oauth-toolkit==1.2 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index c4f65ea183b822ca10d3dffcdd302aea5b2c1a72..47b3e417f1208ebe7703063cae11c000c633eab7 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -29,7 +29,6 @@ from rest_framework.test import APIClient, APIRequestFactory from funkwhale_api.activity import record from funkwhale_api.federation import actors -from funkwhale_api.users.permissions import HasUserPermission pytest_plugins = "aiohttp.pytest_plugin" @@ -317,16 +316,6 @@ def authenticated_actor(factories, mocker): yield actor -@pytest.fixture -def assert_user_permission(): - def inner(view, permissions, operator="and"): - assert HasUserPermission in view.permission_classes - assert getattr(view, "permission_operator", "and") == operator - assert set(view.required_permissions) == set(permissions) - - return inner - - @pytest.fixture def to_api_date(): def inner(value): diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 7e8d1d3fdd14f5e082329873bfe25a6c7c550bfc..190c7918439f4acd73dce5b4e9d599ad3d979673 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -17,12 +17,14 @@ def test_user_can_add_favorite(factories): assert f.user == user -def test_user_can_get_his_favorites(api_request, factories, logged_in_client, client): +def test_user_can_get_his_favorites( + api_request, factories, logged_in_api_client, client +): r = api_request.get("/") - favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) + favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) factories["favorites.TrackFavorite"]() url = reverse("api:v1:favorites:tracks-list") - response = logged_in_client.get(url, {"user": logged_in_client.user.pk}) + response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk}) expected = [ { "user": users_serializers.UserBasicSerializer( @@ -40,21 +42,21 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl def test_user_can_retrieve_all_favorites_at_once( - api_request, factories, logged_in_client, client + api_request, factories, logged_in_api_client, client ): - favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) + favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) factories["favorites.TrackFavorite"]() url = reverse("api:v1:favorites:tracks-all") - response = logged_in_client.get(url, {"user": logged_in_client.user.pk}) + response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk}) expected = [{"track": favorite.track.id, "id": favorite.id}] assert response.status_code == 200 assert response.data["results"] == expected -def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted): +def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity_muted): track = factories["music.Track"]() url = reverse("api:v1:favorites:tracks-list") - response = logged_in_client.post(url, {"track": track.pk}) + response = logged_in_api_client.post(url, {"track": track.pk}) favorite = TrackFavorite.objects.latest("id") expected = { @@ -66,15 +68,15 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_mut assert expected == parsed_json assert favorite.track == track - assert favorite.user == logged_in_client.user + assert favorite.user == logged_in_api_client.user def test_adding_favorites_calls_activity_record( - factories, logged_in_client, activity_muted + factories, logged_in_api_client, activity_muted ): track = factories["music.Track"]() url = reverse("api:v1:favorites:tracks-list") - response = logged_in_client.post(url, {"track": track.pk}) + response = logged_in_api_client.post(url, {"track": track.pk}) favorite = TrackFavorite.objects.latest("id") expected = { @@ -86,27 +88,27 @@ def test_adding_favorites_calls_activity_record( assert expected == parsed_json assert favorite.track == track - assert favorite.user == logged_in_client.user + assert favorite.user == logged_in_api_client.user activity_muted.assert_called_once_with(favorite) -def test_user_can_remove_favorite_via_api(logged_in_client, factories, client): - favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) +def test_user_can_remove_favorite_via_api(logged_in_api_client, factories): + favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) url = reverse("api:v1:favorites:tracks-detail", kwargs={"pk": favorite.pk}) - response = client.delete(url, {"track": favorite.track.pk}) + response = logged_in_api_client.delete(url, {"track": favorite.track.pk}) assert response.status_code == 204 assert TrackFavorite.objects.count() == 0 @pytest.mark.parametrize("method", ["delete", "post"]) def test_user_can_remove_favorite_via_api_using_track_id( - method, factories, logged_in_client + method, factories, logged_in_api_client ): - favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) + favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) url = reverse("api:v1:favorites:tracks-remove") - response = getattr(logged_in_client, method)( + response = getattr(logged_in_api_client, method)( url, json.dumps({"track": favorite.track.pk}), content_type="application/json" ) @@ -122,11 +124,11 @@ def test_url_require_auth(url, method, db, preferences, client): assert response.status_code == 401 -def test_can_filter_tracks_by_favorites(factories, logged_in_client): - favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) +def test_can_filter_tracks_by_favorites(factories, logged_in_api_client): + favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) url = reverse("api:v1:tracks-list") - response = logged_in_client.get(url, data={"favorites": True}) + response = logged_in_api_client.get(url, data={"favorites": True}) parsed_json = json.loads(response.content.decode("utf-8")) assert parsed_json["count"] == 1 diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py index 6dd4f6345afebaf91dc001d0b408fb31ae83bbda..0b3b7b79ad2e57716159a5fd12396068ef04bdd8 100644 --- a/api/tests/instance/test_views.py +++ b/api/tests/instance/test_views.py @@ -1,13 +1,5 @@ -import pytest from django.urls import reverse -from funkwhale_api.instance import views - - -@pytest.mark.parametrize("view,permissions", [(views.AdminSettings, ["settings"])]) -def test_permissions(assert_user_permission, view, permissions): - assert_user_permission(view, permissions) - def test_nodeinfo_endpoint(db, api_client, mocker): payload = {"test": "test"} diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 6402fb6505cdd1ff66d833631a55903ab4924687..10db666256d9c781effb1bc38cae35c96c5d1f2a 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -3,22 +3,7 @@ from django.urls import reverse from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks -from funkwhale_api.manage import serializers, views - - -@pytest.mark.parametrize( - "view,permissions,operator", - [ - (views.ManageUploadViewSet, ["library"], "and"), - (views.ManageUserViewSet, ["settings"], "and"), - (views.ManageInvitationViewSet, ["settings"], "and"), - (views.ManageDomainViewSet, ["moderation"], "and"), - (views.ManageActorViewSet, ["moderation"], "and"), - (views.ManageInstancePolicyViewSet, ["moderation"], "and"), - ], -) -def test_permissions(assert_user_permission, view, permissions, operator): - assert_user_permission(view, permissions, operator) +from funkwhale_api.manage import serializers @pytest.mark.skip(reason="Refactoring in progress") diff --git a/api/tests/users/oauth/__init__.py b/api/tests/users/oauth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/users/oauth/test_api_permissions.py b/api/tests/users/oauth/test_api_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..aaac8430b5ae704e2de4d945fe07537188c14b8c --- /dev/null +++ b/api/tests/users/oauth/test_api_permissions.py @@ -0,0 +1,79 @@ +import pytest +import uuid + +from django.urls import reverse + +from funkwhale_api.users.oauth import scopes + +# mutations + + +@pytest.mark.parametrize( + "name, url_kwargs, scope, method", + [ + ("api:v1:search", {}, "read:libraries", "get"), + ("api:v1:artists-list", {}, "read:libraries", "get"), + ("api:v1:albums-list", {}, "read:libraries", "get"), + ("api:v1:tracks-list", {}, "read:libraries", "get"), + ("api:v1:tracks-mutations", {"pk": 42}, "read:edits", "get"), + ("api:v1:tags-list", {}, "read:libraries", "get"), + ("api:v1:licenses-list", {}, "read:libraries", "get"), + ("api:v1:moderation:content-filters-list", {}, "read:filters", "get"), + ("api:v1:listen-detail", {"uuid": uuid.uuid4()}, "read:libraries", "get"), + ("api:v1:uploads-list", {}, "read:libraries", "get"), + ("api:v1:playlists-list", {}, "read:playlists", "get"), + ("api:v1:playlist-tracks-list", {}, "read:playlists", "get"), + ("api:v1:favorites:tracks-list", {}, "read:favorites", "get"), + ("api:v1:history:listenings-list", {}, "read:listenings", "get"), + ("api:v1:radios:radios-list", {}, "read:radios", "get"), + ("api:v1:oauth:grants-list", {}, "read:security", "get"), + ("api:v1:federation:inbox-list", {}, "read:notifications", "get"), + ( + "api:v1:federation:libraries-detail", + {"uuid": uuid.uuid4()}, + "read:libraries", + "get", + ), + ("api:v1:federation:library-follows-list", {}, "read:follows", "get"), + # admin / privileged stuff + ("api:v1:instance:admin-settings-list", {}, "read:instance:settings", "get"), + ( + "api:v1:manage:users:invitations-list", + {}, + "read:instance:invitations", + "get", + ), + ("api:v1:manage:users:users-list", {}, "read:instance:users", "get"), + ("api:v1:manage:library:uploads-list", {}, "read:instance:libraries", "get"), + ("api:v1:manage:accounts-list", {}, "read:instance:accounts", "get"), + ("api:v1:manage:federation:domains-list", {}, "read:instance:domains", "get"), + ( + "api:v1:manage:moderation:instance-policies-list", + {}, + "read:instance:policies", + "get", + ), + ], +) +def test_views_permissions( + name, url_kwargs, scope, method, mocker, logged_in_api_client +): + """ + Smoke tests to ensure viewsets are correctly protected + """ + url = reverse(name, kwargs=url_kwargs) + user_scopes = scopes.get_from_permissions( + **logged_in_api_client.user.get_permissions() + ) + + should_allow = mocker.patch( + "funkwhale_api.users.oauth.permissions.should_allow", return_value=False + ) + handler = getattr(logged_in_api_client, method) + response = handler(url) + should_allow.assert_called_once_with( + required_scope=scope, request_scopes=user_scopes + ) + assert response.status_code == 403, "{} on {} is not protected correctly!".format( + method, url + ) diff --git a/api/tests/users/oauth/test_models.py b/api/tests/users/oauth/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..1b27900ee825568961f4d139128a09ce14d558a2 --- /dev/null +++ b/api/tests/users/oauth/test_models.py @@ -0,0 +1,21 @@ +import pytest + +from django import forms + +from funkwhale_api.users import models + + +@pytest.mark.parametrize( + "uri", + ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto", "http://test.com"], +) +def test_redirect_uris_oob(uri, db): + app = models.Application(redirect_uris=uri) + assert app.clean() is None + + +@pytest.mark.parametrize("uri", ["urn:ietf:wg:oauth:2.0:invalid", "noop"]) +def test_redirect_uris_invalid(uri, db): + app = models.Application(redirect_uris=uri) + with pytest.raises(forms.ValidationError): + app.clean() diff --git a/api/tests/users/oauth/test_permissions.py b/api/tests/users/oauth/test_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..65974fbf645f92af478457fadd7138f7067a3a01 --- /dev/null +++ b/api/tests/users/oauth/test_permissions.py @@ -0,0 +1,241 @@ +import pytest + +from funkwhale_api.users.oauth import scopes +from funkwhale_api.users.oauth import permissions + + +@pytest.mark.parametrize( + "required_scope, request_scopes, expected", + [ + (None, {}, True), + ("write:profile", {"write"}, True), + ("write:profile", {"read"}, False), + ("write:profile", {"read:profile"}, False), + ("write:profile", {"write:profile"}, True), + ("read:profile", {"read"}, True), + ("read:profile", {"write"}, False), + ("read:profile", {"read:profile"}, True), + ("read:profile", {"write:profile"}, False), + ("write:profile", {"write"}, True), + ("write:profile", {"read:profile"}, False), + ("write:profile", {"write:profile"}, True), + ("write:profile", {"write"}, True), + ("write:profile", {"read:profile"}, False), + ("write:profile", {"write:profile"}, True), + ("write:profile", {"write"}, True), + ("write:profile", {"read:profile"}, False), + ("write:profile", {"write:profile"}, True), + ], +) +def test_should_allow(required_scope, request_scopes, expected): + assert ( + permissions.should_allow( + required_scope=required_scope, request_scopes=request_scopes + ) + is expected + ) + + +@pytest.mark.parametrize("method", ["OPTIONS", "HEAD"]) +def test_scope_permission_safe_methods(method, mocker, factories): + view = mocker.Mock(required_scope="write:profile", anonymous_policy=False) + request = mocker.Mock(method=method) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is True + + +@pytest.mark.parametrize( + "policy, preference, expected", + [ + (True, False, True), + (False, False, False), + ("setting", True, False), + ("setting", False, True), + ], +) +def test_scope_permission_anonymous_policy( + policy, preference, expected, preferences, mocker, anonymous_user +): + preferences["common__api_authentication_required"] = preference + view = mocker.Mock(required_scope="libraries", anonymous_policy=policy) + request = mocker.Mock(method="GET", user=anonymous_user, actor=None) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is expected + + +def test_scope_permission_dict_no_required(mocker, anonymous_user): + view = mocker.Mock( + required_scope={"read": None, "write": "write:profile"}, + anonymous_policy=True, + action="read", + ) + request = mocker.Mock(method="GET", user=anonymous_user, actor=None) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is True + + +@pytest.mark.parametrize( + "required_scope, method, action, expected_scope", + [ + ("profile", "GET", "read", "read:profile"), + ("profile", "POST", "write", "write:profile"), + ({"read": "read:profile"}, "GET", "read", "read:profile"), + ({"write": "write:profile"}, "POST", "write", "write:profile"), + ], +) +def test_scope_permission_user( + required_scope, method, action, expected_scope, mocker, factories +): + user = factories["users.User"]() + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method=method, user=user, actor=None) + view = mocker.Mock( + required_scope=required_scope, anonymous_policy=False, action=action + ) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope=expected_scope, + request_scopes=scopes.get_from_permissions(**user.get_permissions()), + ) + + +def test_scope_permission_token(mocker, factories): + token = factories["users.AccessToken"]( + scope="write:profile read:playlists", + application__scope="write:profile read:playlists", + ) + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", auth=token) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope="write:profile", + request_scopes={"write:profile", "read:playlists"}, + ) + + +def test_scope_permission_actor(mocker, factories, anonymous_user): + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock( + method="POST", actor=factories["federation.Actor"](), user=anonymous_user + ) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope="write:profile", request_scopes=scopes.FEDERATION_REQUEST_SCOPES + ) + + +def test_scope_permission_token_anonymous_user_auth_required( + mocker, factories, anonymous_user, preferences +): + preferences["common__api_authentication_required"] = True + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", user=anonymous_user, actor=None) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is False + + should_allow.assert_not_called() + + +def test_scope_permission_token_anonymous_user_auth_not_required( + mocker, factories, anonymous_user, preferences +): + preferences["common__api_authentication_required"] = False + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", user=anonymous_user, actor=None) + view = mocker.Mock(required_scope="profile", anonymous_policy="setting") + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope="write:profile", request_scopes=scopes.ANONYMOUS_SCOPES + ) + + +def test_scope_permission_token_expired(mocker, factories, now): + token = factories["users.AccessToken"]( + scope="profile:write playlists:read", expires=now + ) + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", auth=token) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is False + + should_allow.assert_not_called() + + +def test_scope_permission_token_no_user(mocker, factories, now): + token = factories["users.AccessToken"]( + scope="profile:write playlists:read", user=None + ) + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", auth=token) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) is False + + should_allow.assert_not_called() + + +def test_scope_permission_token_honor_app_scopes(mocker, factories, now): + # token contains read access, but app scope only allows profile:write + token = factories["users.AccessToken"]( + scope="write:profile read", application__scope="write:profile" + ) + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", auth=token) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope="write:profile", request_scopes={"write:profile"} + ) + + +def test_scope_permission_token_honor_allowed_app_scopes(mocker, factories, now): + mocker.patch.object(scopes, "OAUTH_APP_SCOPES", {"read:profile"}) + token = factories["users.AccessToken"]( + scope="write:profile read:profile read", + application__scope="write:profile read:profile read", + ) + should_allow = mocker.patch.object(permissions, "should_allow") + request = mocker.Mock(method="POST", auth=token) + view = mocker.Mock(required_scope="profile", anonymous_policy=False) + p = permissions.ScopePermission() + + assert p.has_permission(request, view) == should_allow.return_value + + should_allow.assert_called_once_with( + required_scope="write:profile", request_scopes={"read:profile"} + ) diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py new file mode 100644 index 0000000000000000000000000000000000000000..3d12cb664e7dadcdc078706b0e79fae1764fe221 --- /dev/null +++ b/api/tests/users/oauth/test_scopes.py @@ -0,0 +1,156 @@ +import pytest + +from funkwhale_api.users.oauth import scopes + + +@pytest.mark.parametrize( + "user_perms, expected", + [ + ( + # All permissions, so all scopes + {"moderation": True, "library": True, "settings": True}, + { + "read:profile", + "write:profile", + "read:libraries", + "write:libraries", + "read:playlists", + "write:playlists", + "read:favorites", + "write:favorites", + "read:notifications", + "write:notifications", + "read:radios", + "write:radios", + "read:follows", + "write:follows", + "read:edits", + "write:edits", + "read:filters", + "write:filters", + "read:listenings", + "write:listenings", + "read:security", + "write:security", + "read:instance:policies", + "write:instance:policies", + "read:instance:accounts", + "write:instance:accounts", + "read:instance:domains", + "write:instance:domains", + "read:instance:settings", + "write:instance:settings", + "read:instance:users", + "write:instance:users", + "read:instance:invitations", + "write:instance:invitations", + "read:instance:edits", + "write:instance:edits", + "read:instance:libraries", + "write:instance:libraries", + }, + ), + ( + {"moderation": True, "library": False, "settings": True}, + { + "read:profile", + "write:profile", + "read:libraries", + "write:libraries", + "read:playlists", + "write:playlists", + "read:favorites", + "write:favorites", + "read:notifications", + "write:notifications", + "read:radios", + "write:radios", + "read:follows", + "write:follows", + "read:edits", + "write:edits", + "read:filters", + "write:filters", + "read:listenings", + "write:listenings", + "read:security", + "write:security", + "read:instance:policies", + "write:instance:policies", + "read:instance:accounts", + "write:instance:accounts", + "read:instance:domains", + "write:instance:domains", + "read:instance:settings", + "write:instance:settings", + "read:instance:users", + "write:instance:users", + "read:instance:invitations", + "write:instance:invitations", + }, + ), + ( + {"moderation": True, "library": False, "settings": False}, + { + "read:profile", + "write:profile", + "read:libraries", + "write:libraries", + "read:playlists", + "write:playlists", + "read:favorites", + "write:favorites", + "read:notifications", + "write:notifications", + "read:radios", + "write:radios", + "read:follows", + "write:follows", + "read:edits", + "write:edits", + "read:filters", + "write:filters", + "read:listenings", + "write:listenings", + "read:security", + "write:security", + "read:instance:policies", + "write:instance:policies", + "read:instance:accounts", + "write:instance:accounts", + "read:instance:domains", + "write:instance:domains", + }, + ), + ( + {"moderation": False, "library": False, "settings": False}, + { + "read:profile", + "write:profile", + "read:libraries", + "write:libraries", + "read:playlists", + "write:playlists", + "read:favorites", + "write:favorites", + "read:notifications", + "write:notifications", + "read:radios", + "write:radios", + "read:follows", + "write:follows", + "read:edits", + "write:edits", + "read:filters", + "write:filters", + "read:listenings", + "write:listenings", + "read:security", + "write:security", + }, + ), + ], +) +def test_get_scopes_from_user_permissions(user_perms, expected): + + assert scopes.get_from_permissions(**user_perms) == expected diff --git a/api/tests/users/oauth/test_tasks.py b/api/tests/users/oauth/test_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..c30f2488371f1c38d8525ddee47b8c4dc417185a --- /dev/null +++ b/api/tests/users/oauth/test_tasks.py @@ -0,0 +1,10 @@ +from oauth2_provider import models +from funkwhale_api.users.oauth import tasks + + +def test_clear_expired_tokens(mocker, db): + clear_expired = mocker.spy(models, "clear_expired") + + tasks.clear_expired_tokens() + + clear_expired.assert_called_once() diff --git a/api/tests/users/oauth/test_views.py b/api/tests/users/oauth/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..19d25870974fd0ee756fab765a20db06fd459186 --- /dev/null +++ b/api/tests/users/oauth/test_views.py @@ -0,0 +1,363 @@ +import json +import pytest + +from django.urls import reverse + +from funkwhale_api.users import models +from funkwhale_api.users.oauth import serializers + + +def test_apps_post(api_client, db): + url = reverse("api:v1:oauth:apps-list") + data = { + "name": "Test app", + "redirect_uris": "http://test.app", + "scopes": "read write:profile", + } + response = api_client.post(url, data) + + assert response.status_code == 201 + + app = models.Application.objects.get(name=data["name"]) + + assert app.client_type == models.Application.CLIENT_CONFIDENTIAL + assert app.authorization_grant_type == models.Application.GRANT_AUTHORIZATION_CODE + assert app.redirect_uris == data["redirect_uris"] + assert response.data == serializers.CreateApplicationSerializer(app).data + assert app.scope == "read write:profile" + assert app.user is None + + +def test_apps_post_logged_in_user(logged_in_api_client, db): + url = reverse("api:v1:oauth:apps-list") + data = { + "name": "Test app", + "redirect_uris": "http://test.app", + "scopes": "read write:profile", + } + response = logged_in_api_client.post(url, data) + + assert response.status_code == 201 + + app = models.Application.objects.get(name=data["name"]) + + assert app.client_type == models.Application.CLIENT_CONFIDENTIAL + assert app.authorization_grant_type == models.Application.GRANT_AUTHORIZATION_CODE + assert app.redirect_uris == data["redirect_uris"] + assert response.data == serializers.CreateApplicationSerializer(app).data + assert app.scope == "read write:profile" + assert app.user == logged_in_api_client.user + + +def test_apps_list_anonymous(api_client, db): + url = reverse("api:v1:oauth:apps-list") + response = api_client.get(url) + + assert response.status_code == 401 + + +def test_apps_list_logged_in(factories, logged_in_api_client, db): + app = factories["users.Application"](user=logged_in_api_client.user) + factories["users.Application"]() + url = reverse("api:v1:oauth:apps-list") + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data["results"] == [serializers.ApplicationSerializer(app).data] + + +def test_apps_delete_not_owner(factories, logged_in_api_client, db): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 404 + + +def test_apps_delete_owner(factories, logged_in_api_client, db): + app = factories["users.Application"](user=logged_in_api_client.user) + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + with pytest.raises(app.DoesNotExist): + app.refresh_from_db() + + +def test_apps_update_not_owner(factories, logged_in_api_client, db): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.patch(url, {"name": "Hello"}) + + assert response.status_code == 404 + + +def test_apps_update_owner(factories, logged_in_api_client, db): + app = factories["users.Application"](user=logged_in_api_client.user) + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.patch(url, {"name": "Hello"}) + + assert response.status_code == 200 + app.refresh_from_db() + + assert app.name == "Hello" + + +def test_apps_get(preferences, logged_in_api_client, factories): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == serializers.ApplicationSerializer(app).data + + +def test_apps_get_owner(preferences, logged_in_api_client, factories): + app = factories["users.Application"](user=logged_in_api_client.user) + url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id}) + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == serializers.CreateApplicationSerializer(app).data + + +def test_authorize_view_post(logged_in_client, factories): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": app.redirect_uris, + "client_id": app.client_id, + "state": "hello", + "response_type": "code", + "scope": "read", + }, + ) + grant = models.Grant.objects.get(application=app) + assert response.status_code == 302 + assert response["Location"] == "{}?code={}&state={}".format( + app.redirect_uris, grant.code, "hello" + ) + + +def test_authorize_view_post_ajax_no_redirect(logged_in_client, factories): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": app.redirect_uris, + "client_id": app.client_id, + "state": "hello", + "response_type": "code", + "scope": "read", + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.status_code == 200 + grant = models.Grant.objects.get(application=app) + assert json.loads(response.content.decode()) == { + "redirect_uri": "{}?code={}&state={}".format( + app.redirect_uris, grant.code, "hello" + ), + "code": grant.code, + } + + +def test_authorize_view_post_ajax_oob(logged_in_client, factories): + app = factories["users.Application"](redirect_uris="urn:ietf:wg:oauth:2.0:oob") + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": app.redirect_uris, + "client_id": app.client_id, + "state": "hello", + "response_type": "code", + "scope": "read", + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.status_code == 200 + grant = models.Grant.objects.get(application=app) + assert json.loads(response.content.decode()) == { + "redirect_uri": "{}?code={}&state={}".format( + app.redirect_uris, grant.code, "hello" + ), + "code": grant.code, + } + + +def test_authorize_view_invalid_form(logged_in_client, factories): + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": "", + "client_id": "Noop", + "state": "hello", + "response_type": "code", + "scope": "read", + }, + ) + + assert response.status_code == 400 + assert json.loads(response.content.decode()) == { + "redirect_uri": ["This field is required."] + } + + +def test_authorize_view_invalid_redirect_url(logged_in_client, factories): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": "http://wrong.url", + "client_id": app.client_id, + "state": "hello", + "response_type": "code", + "scope": "read", + }, + ) + + assert response.status_code == 400 + assert json.loads(response.content.decode()) == { + "detail": "Mismatching redirect URI." + } + + +def test_authorize_view_invalid_oauth(logged_in_client, factories): + app = factories["users.Application"]() + url = reverse("api:v1:oauth:authorize") + response = logged_in_client.post( + url, + { + "allow": True, + "redirect_uri": app.redirect_uris, + "client_id": "wrong_id", + "state": "hello", + "response_type": "code", + "scope": "read", + }, + ) + + assert response.status_code == 400 + assert json.loads(response.content.decode()) == { + "non_field_errors": ["Invalid application"] + } + + +def test_authorize_view_anonymous(client, factories): + url = reverse("api:v1:oauth:authorize") + response = client.post(url, {}) + + assert response.status_code == 401 + + +def test_token_view_post(api_client, factories): + grant = factories["users.Grant"]() + app = grant.application + url = reverse("api:v1:oauth:token") + + response = api_client.post( + url, + { + "redirect_uri": app.redirect_uris, + "client_id": app.client_id, + "client_secret": app.client_secret, + "grant_type": "authorization_code", + "code": grant.code, + }, + ) + payload = json.loads(response.content.decode()) + + assert "access_token" in payload + assert "refresh_token" in payload + assert payload["expires_in"] == 36000 + assert payload["scope"] == grant.scope + assert payload["token_type"] == "Bearer" + assert response.status_code == 200 + + with pytest.raises(grant.DoesNotExist): + grant.refresh_from_db() + + +def test_revoke_view_post(logged_in_client, factories): + token = factories["users.AccessToken"]() + url = reverse("api:v1:oauth:revoke") + + response = logged_in_client.post( + url, + { + "token": token.token, + "client_id": token.application.client_id, + "client_secret": token.application.client_secret, + }, + ) + assert response.status_code == 200 + + with pytest.raises(token.DoesNotExist): + token.refresh_from_db() + + +def test_grants_list(factories, logged_in_api_client): + token = factories["users.AccessToken"](user=logged_in_api_client.user) + refresh_token = factories["users.RefreshToken"](user=logged_in_api_client.user) + factories["users.AccessToken"]() + url = reverse("api:v1:oauth:grants-list") + expected = [ + serializers.ApplicationSerializer(refresh_token.application).data, + serializers.ApplicationSerializer(token.application).data, + ] + + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +def test_grant_delete(factories, logged_in_api_client, mocker, now): + token = factories["users.AccessToken"](user=logged_in_api_client.user) + refresh_token = factories["users.RefreshToken"]( + user=logged_in_api_client.user, application=token.application + ) + grant = factories["users.Grant"]( + user=logged_in_api_client.user, application=token.application + ) + revoke_token = mocker.spy(token.__class__, "revoke") + revoke_refresh = mocker.spy(refresh_token.__class__, "revoke") + to_keep = [ + factories["users.AccessToken"](application=token.application), + factories["users.RefreshToken"](application=token.application), + factories["users.Grant"](application=token.application), + ] + url = reverse( + "api:v1:oauth:grants-detail", kwargs={"client_id": token.application.client_id} + ) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + revoke_token.assert_called_once() + revoke_refresh.assert_called_once() + + with pytest.raises(token.DoesNotExist): + token.refresh_from_db() + + with pytest.raises(grant.DoesNotExist): + grant.refresh_from_db() + + refresh_token.refresh_from_db() + assert refresh_token.revoked == now + + for t in to_keep: + t.refresh_from_db() diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py deleted file mode 100644 index 0b92f74a58ab7d914a0b15ceeb48b17e5abf95ae..0000000000000000000000000000000000000000 --- a/api/tests/users/test_permissions.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest -from rest_framework.views import APIView - -from funkwhale_api.users import permissions - - -def test_has_user_permission_no_user(api_request): - view = APIView.as_view() - permission = permissions.HasUserPermission() - request = api_request.get("/") - assert permission.has_permission(request, view) is False - - -def test_has_user_permission_anonymous(anonymous_user, api_request): - view = APIView.as_view() - permission = permissions.HasUserPermission() - request = api_request.get("/") - setattr(request, "user", anonymous_user) - assert permission.has_permission(request, view) is False - - -@pytest.mark.parametrize("value", [True, False]) -def test_has_user_permission_logged_in_single(value, factories, api_request): - user = factories["users.User"](permission_moderation=value) - - class View(APIView): - required_permissions = ["moderation"] - - view = View() - permission = permissions.HasUserPermission() - request = api_request.get("/") - setattr(request, "user", user) - result = permission.has_permission(request, view) - assert result == user.has_permissions("moderation") == value - - -@pytest.mark.parametrize( - "moderation,library,expected", - [ - (True, False, False), - (False, True, False), - (False, False, False), - (True, True, True), - ], -) -def test_has_user_permission_logged_in_multiple_and( - moderation, library, expected, factories, api_request -): - user = factories["users.User"]( - permission_moderation=moderation, permission_library=library - ) - - class View(APIView): - required_permissions = ["moderation", "library"] - permission_operator = "and" - - view = View() - permission = permissions.HasUserPermission() - request = api_request.get("/") - setattr(request, "user", user) - result = permission.has_permission(request, view) - assert result == user.has_permissions("moderation", "library") == expected - - -@pytest.mark.parametrize( - "moderation,library,expected", - [ - (True, False, True), - (False, True, True), - (False, False, False), - (True, True, True), - ], -) -def test_has_user_permission_logged_in_multiple_or( - moderation, library, expected, factories, api_request -): - user = factories["users.User"]( - permission_moderation=moderation, permission_library=library - ) - - class View(APIView): - required_permissions = ["moderation", "library"] - permission_operator = "or" - - view = View() - permission = permissions.HasUserPermission() - request = api_request.get("/") - setattr(request, "user", user) - result = permission.has_permission(request, view) - has_permission_result = user.has_permissions("moderation", "library", operator="or") - - assert result == has_permission_result == expected diff --git a/changes/changelog.d/752.feature b/changes/changelog.d/752.feature new file mode 100644 index 0000000000000000000000000000000000000000..6d33f6faa629eacdf695111e2fa26c5a4929596f --- /dev/null +++ b/changes/changelog.d/752.feature @@ -0,0 +1 @@ +Support OAuth2 authorization for better integration with third-party apps (#752) diff --git a/changes/notes.rst b/changes/notes.rst index 19aa5a077c5e7425bb4b732470c8340f87e8b72c..22e626d961659e0cdca026ce7ec06c147d60af6b 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -20,3 +20,17 @@ Content linked to hidden artists will not show up in the interface anymore. Espe - Hidden artists won't appear in Subsonic apps Results linked to hidden artists will continue to show up in search results and their profile page remains accessible. + +OAuth2 authorization for better integration with third-party apps +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Funkwhale now support the OAuth2 authorization and authentication protocol which will allow +third-party apps to interact with Funkwhale on behalf of users. + +This feature makes it possible to build third-party apps that have the same capabilities +as Funkwhale's Web UI. The only exception at the moment is for actions that requires +special permissions, such as modifying instance settings or moderation (but this will be +enabled in a future release). + +If you want to start building an app on top of Funkwhale's API, please check-out +`https://docs.funkwhale.audio/api.html`_ and `https://docs.funkwhale.audio/developers/authentication.html`_. diff --git a/dev.yml b/dev.yml index d0ffc62e433b3106cc08e91245c0f0d3b75b0f07..73c67e386fc1f7100745e0afe9c704e492e13985 100644 --- a/dev.yml +++ b/dev.yml @@ -138,7 +138,7 @@ services: - "8001:8001" api-docs: - image: swaggerapi/swagger-ui + image: swaggerapi/swagger-ui:v3.21.0 environment: - "API_URL=/swagger.yml" ports: diff --git a/docs/developers/authentication.rst b/docs/developers/authentication.rst new file mode 100644 index 0000000000000000000000000000000000000000..0d32139c0ed3eb80b99a7c2ec5a81aee700d1cbb --- /dev/null +++ b/docs/developers/authentication.rst @@ -0,0 +1,97 @@ +API Authentication +================== + +Each Funkwhale API endpoint supports access from: + +- Anonymous users (if the endpoint is configured to do so, for exemple via the ``API Authentication Required`` setting) +- Logged-in users +- Third-party apps (via OAuth2) + +To seamlessly support this range of access modes, we internally use oauth scopes +to describes what permissions are required to perform any given operation. + +OAuth +----- + +Create an app +::::::::::::: + +To connect to Funkwhale API via OAuth, you need to create an application. There are +two ways to do that: + +1. By visiting ``/settings/applications/new`` when logged in on your Funkwhale instance. +2. By sending a ``POST`` request to ``/api/v1/oauth/apps/``, as described in `our API documentation <https://docs.funkwhale.audio/swagger/>`_. + +Both method will give you a client ID and secret. + +Getting an access token +::::::::::::::::::::::: + +Once you have a client ID and secret, you can request access tokens +using the `authorization code grant flow <https://tools.ietf.org/html/rfc6749#section-4.1>`_. + +We support the ``urn:ietf:wg:oauth:2.0:oob`` redirect URI for non-web applications, as well +as traditionnal redirection-based flow. + +Our authorization endpoint is located at ``/authorize``, and our token endpoint at ``/api/v1/oauth/token/``. + +Refreshing tokens +::::::::::::::::: + +When your access token is expired, you can `request a new one as described in the OAuth specification <https://tools.ietf.org/html/rfc6749#section-6>`_. + +Security considerations +::::::::::::::::::::::: + +- Grant codes are valid for a 5 minutes after authorization request is approved by the end user. +- Access codes are valid for 10 hours. When expired, you will need to request a new one using your refresh token. +- We return a new refresh token everytime an access token is requested, and invalidate the old one. Ensure you store the new refresh token in your app. + + +Scopes +:::::: + +Scopes are defined in :file:`funkwhale_api/users/oauth/scopes.py:BASE_SCOPES`, and generally are mapped to a business-logic resources (follows, favorites, etc.). All those base scopes come in two flawours: + +- `read:<base_scope>`: get read-only access to the resource +- `write:<base_scope>`: get write-only access to the ressource + +For example, ``playlists`` is a base scope, and ``write:playlists`` is the actual scope needed to perform write +operations on playlists (via a ``POST``, ``PATCH``, ``PUT`` or ``DELETE``. ``read:playlists`` is used +to perform read operations on playlists such as fetching a given playlist via ``GET``. + +Having the generic ``read`` or ``write`` scope give you the corresponding access on *all* resources. + +This is the list of OAuth scopes that third-party applications can request: + + ++-------------------------------------------+---------------------------------------------------+ +| Scope | Description | ++===========================================+===================================================+ +| ``read`` | Read-only access to all data | +| | (equivalent to all ``read:*`` scopes) | ++-------------------------------------------+---------------------------------------------------+ +| ``write`` | Write-only access to all data | +| | (equivalent to all ``write:*`` scopes) | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:profile`` | Access to profile data (email, username, etc.) | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:libraries`` | Access to library data (uploads, libraries | +| | tracks, albums, artists...) | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:favorites`` | Access to favorites | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:listenings`` | Access to history | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:follows`` | Access to followers | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:playlists`` | Access to playlists | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:radios`` | Access to radios | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:filters`` | Access to content filters | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:notifications`` | Access to notifications | ++-------------------------------------------+---------------------------------------------------+ +| ``<read/write>:edits`` | Access to metadata edits | ++-------------------------------------------+---------------------------------------------------+ diff --git a/docs/developers/index.rst b/docs/developers/index.rst index 69f22f2c6e2417b992da646064b95bb8e76c38ed..966cac3afd0d2867c7899bb34160e2a893ad8f9a 100644 --- a/docs/developers/index.rst +++ b/docs/developers/index.rst @@ -12,5 +12,6 @@ Reference architecture ../api + ./authentication ../federation/index subsonic diff --git a/docs/swagger.yml b/docs/swagger.yml index de809242d3eb0815e177598357116d2cb15a37e0..a60b233df732e90fc0632e5cea6db3a311fa46e8 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -1,13 +1,13 @@ -openapi: "3.0.0" +openapi: "3.0.2" info: description: "Documentation for [Funkwhale](https://funkwhale.audio) API. The API is **not** stable yet." version: "1.0.0" title: "Funkwhale API" servers: - - url: https://demo.funkwhale.audio/api/v1 + - url: https://demo.funkwhale.audio description: Demo server - - url: https://{domain}/api/v1 + - url: https://{domain} description: Custom server variables: domain: @@ -21,6 +21,38 @@ servers: components: securitySchemes: + oauth2: + type: oauth2 + description: This API uses OAuth 2 with the Authorization Code flow. You can register an app using the /oauth/apps/ endpoint. + flows: + authorizationCode: + # Swagger doesn't support relative URLs yet (cf https://github.com/swagger-api/swagger-ui/pull/5244) + authorizationUrl: /authorize + tokenUrl: /api/v1/oauth/token/ + refreshUrl: /api/v1/oauth/token/ + scopes: + "read": "Read-only access to all user data" + "write": "Write-only access on all user data" + "read:profile": "Read-only access to profile data" + "read:libraries": "Read-only access to library and uploads" + "read:playlists": "Read-only access to playlists" + "read:listenings": "Read-only access to listening history" + "read:favorites": "Read-only access to favorites" + "read:radios": "Read-only access to radios" + "read:edits": "Read-only access to edits" + "read:notifications": "Read-only access to notifications" + "read:follows": "Read-only to follows" + "read:filters": "Read-only to to content filters" + "write:profile": "Write-only access to profile data" + "write:libraries": "Write-only access to libraries" + "write:playlists": "Write-only access to playlists" + "write:follows": "Write-only access to follows" + "write:favorites": "Write-only access to favorits" + "write:notifications": "Write-only access to notifications" + "write:radios": "Write-only access to radios" + "write:edits": "Write-only access to edits" + "write:filters": "Write-only access to content-filters" + "write:listenings": "Write-only access to listening history" jwt: type: http scheme: bearer @@ -29,9 +61,44 @@ components: security: - jwt: [] + - oauth2: [] paths: - /token/: + /api/v1/oauth/apps/: + post: + tags: + - "auth" + description: + Register an OAuth application + security: [] + responses: + 201: + content: + application/json: + schema: + allOf: + - $ref: "#/definitions/OAuthApplication" + - $ref: "#/definitions/OAuthApplicationCreation" + requestBody: + required: true + content: + application/json: + schema: + type: "object" + properties: + name: + type: "string" + example: "My Awesome Funkwhale Client" + summary: "A human readable name for your app" + redirect_uris: + type: "string" + example: "https://myapp/oauth2/funkwhale" + summary: "A list of redirect uris, separated by spaces" + scopes: + type: "string" + summary: "A list of scopes requested by your app, separated by spaces" + example: "read write:playlists write:favorites" + /api/v1/token/: post: tags: - "auth" @@ -57,11 +124,14 @@ paths: type: "string" example: "demo" - /artists/: + /api/v1/artists/: get: summary: List artists tags: - "artists" + security: + - oauth2: + - "read:libraries" parameters: - name: "q" in: "query" @@ -99,12 +169,14 @@ paths: type: "array" items: $ref: "#/definitions/Artist" - /artists/{id}/: + /api/v1/artists/{id}/: get: summary: Retrieve a single artist parameters: - $ref: "#/parameters/ObjectId" - + security: + - oauth2: + - "read:libraries" tags: - "artists" responses: @@ -118,9 +190,12 @@ paths: application/json: schema: $ref: "#/definitions/ResourceNotFound" - /artists/{id}/libraries/: + /api/v1/artists/{id}/libraries/: get: summary: List available user libraries containing work from this artist + security: + - oauth2: + - "read:libraries" parameters: - $ref: "#/parameters/ObjectId" - $ref: "#/parameters/PageNumber" @@ -141,11 +216,15 @@ paths: schema: $ref: "#/definitions/ResourceNotFound" - /albums/: + /api/v1/albums/: get: summary: List albums tags: - "albums" + + security: + - oauth2: + - "read:libraries" parameters: - name: "q" in: "query" @@ -191,12 +270,15 @@ paths: type: "array" items: $ref: "#/definitions/Album" - /albums/{id}/: + /api/v1/albums/{id}/: get: summary: Retrieve a single album parameters: - $ref: "#/parameters/ObjectId" + security: + - oauth2: + - "read:libraries" tags: - "albums" responses: @@ -211,7 +293,7 @@ paths: schema: $ref: "#/definitions/ResourceNotFound" - /albums/{id}/libraries/: + /api/v1/albums/{id}/libraries/: get: summary: List available user libraries containing tracks from this album parameters: @@ -219,6 +301,9 @@ paths: - $ref: "#/parameters/PageNumber" - $ref: "#/parameters/PageSize" + security: + - oauth2: + - "read:libraries" tags: - "albums" - "libraries" @@ -234,11 +319,15 @@ paths: schema: $ref: "#/definitions/ResourceNotFound" - /tracks/: + /api/v1/tracks/: get: summary: List tracks tags: - "tracks" + + security: + - oauth2: + - "read:libraries" parameters: - name: "q" in: "query" @@ -300,12 +389,15 @@ paths: type: "array" items: $ref: "#/definitions/Track" - /tracks/{id}/: + /api/v1/tracks/{id}/: get: summary: Retrieve a single track parameters: - $ref: "#/parameters/ObjectId" + security: + - oauth2: + - "read:libraries" tags: - "tracks" responses: @@ -320,14 +412,16 @@ paths: schema: $ref: "#/definitions/ResourceNotFound" - /tracks/{id}/libraries/: + /api/v1/tracks/{id}/libraries/: get: summary: List available user libraries containing given track parameters: - $ref: "#/parameters/ObjectId" - $ref: "#/parameters/PageNumber" - $ref: "#/parameters/PageSize" - + security: + - oauth2: + - "read:libraries" tags: - "tracks" - "libraries" @@ -343,9 +437,12 @@ paths: schema: $ref: "#/definitions/ResourceNotFound" - /licenses/: + /api/v1/licenses/: get: summary: List licenses + security: + - oauth2: + - "read:libraries" tags: - "licenses" parameters: @@ -365,9 +462,12 @@ paths: items: $ref: "#/definitions/License" - /licenses/{code}/: + /api/v1/licenses/{code}/: get: summary: Retrieve a single license + security: + - oauth2: + - "read:libraries" parameters: - name: code in: path @@ -441,6 +541,34 @@ properties: description: "A musicbrainz ID" definitions: + OAuthApplication: + type: "object" + properties: + client_id: + type: "string" + example: "VKIZWv7FwBq56UMfUtbCSIgSxzUTv1b6nMyOkJvP" + created: + type: "string" + format: "date-time" + updated: + type: "string" + format: "date-time" + scopes: + type: "string" + description: "Coma-separated list of scopes requested by the app" + + OAuthApplicationCreation: + type: "object" + properties: + client_secret: + type: "string" + example: "qnKDX8zjIfC0BG4tUreKlqk3tNtuCfJdGsaEt5MIWrTv0YLLhGI6SGqCjs9kn12gyXtIg4FWfZqWMEckJmolCi7a6qew4LawPWMfnLDii4mQlY1eQG4BJbwPANOrDiTZ" + redirect_uris: + type: "string" + format: "uri" + description: "Coma-separated list of redirect uris allowed for the app" + + ResultPage: type: "object" properties: diff --git a/front/src/components/auth/ApplicationEdit.vue b/front/src/components/auth/ApplicationEdit.vue new file mode 100644 index 0000000000000000000000000000000000000000..b22ade7af6c00c4882a5a590525f7faec58aec25 --- /dev/null +++ b/front/src/components/auth/ApplicationEdit.vue @@ -0,0 +1,80 @@ +<template> + <main class="main pusher" v-title="labels.title"> + <div class="ui vertical stripe segment"> + <section class="ui text container"> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <template v-else> + <router-link :to="{name: 'settings'}"> + <translate translate-context="Content/Applications/Link">Back to settings</translate> + </router-link> + <h2 class="ui header"> + <translate translate-context="Content/Applications/Title">Application details</translate> + </h2> + <div class="ui form"> + <p> + <translate translate-context="Content/Application/Paragraph/"> + Application ID and secret are really sensitive values and must be treated like passwords. Do not share those with anyone else. + </translate> + </p> + <div class="field"> + <label><translate translate-context="Content/Applications/Label">Application ID</translate></label> + <copy-input :value="application.client_id" /> + </div> + <div class="field"> + <label><translate translate-context="Content/Applications/Label">Application secret</translate></label> + <copy-input :value="application.client_secret" /> + </div> + </div> + <h2 class="ui header"> + <translate translate-context="Content/Applications/Title">Edit application</translate> + </h2> + <application-form @updated="application = $event" :app="application" /> + </template> + </section> + </div> + </main> +</template> + +<script> +import axios from "axios" + +import ApplicationForm from "@/components/auth/ApplicationForm" + +export default { + props: ['id'], + components: { + ApplicationForm + }, + data() { + return { + application: null, + isLoading: false, + } + }, + created () { + this.fetchApplication() + }, + methods: { + fetchApplication () { + this.isLoading = true + let self = this + axios.get(`oauth/apps/${this.id}/`).then((response) => { + self.isLoading = false + self.application = response.data + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + }, + computed: { + labels() { + return { + title: this.$pgettext('Content/Applications/Title', "Edit application") + } + }, + } +} +</script> diff --git a/front/src/components/auth/ApplicationForm.vue b/front/src/components/auth/ApplicationForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..f89d0693a1f93c961617e9fb0d8912db54fd8c11 --- /dev/null +++ b/front/src/components/auth/ApplicationForm.vue @@ -0,0 +1,183 @@ +<template> + + <form class="ui form" @submit.prevent="submit()"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Content/*/Error message.Title">We cannot save your changes</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="ui field"> + <label><translate translate-context="Content/Applications/Input.Label/Noun">Name</translate></label> + <input name="name" required type="text" v-model="fields.name" /> + </div> + <div class="ui field"> + <label><translate translate-context="Content/Applications/Input.Label/Noun">Redirect URI</translate></label> + <input name="redirect_uris" type="text" v-model="fields.redirect_uris" /> + <p class="help"> + <translate translate-context="Content/Applications/Help Text"> + Use "urn:ietf:wg:oauth:2.0:oob" as a redirect URI if your application is not served on the web. + </translate> + </p> + </div> + <div class="ui field"> + <label><translate translate-context="Content/Applications/Input.Label/Noun">Scopes</translate></label> + <p> + <translate translate-context="Content/Applications/Paragraph/"> + Checking the parent "Read" or "Write" scopes implies access to all the corresponding children scopes. + </translate> + </p> + <div class="ui stackable two column grid"> + <div v-for="parent in allScopes" class="column"> + <div class="ui parent checkbox"> + <input + v-model="scopeArray" + :value="parent.id" + :id="parent.id" + type="checkbox"> + <label :for="parent.id"> + {{ parent.label }} + <p class="help"> + {{ parent.description }} + </p> + </label> + </div> + + <div v-for="child in parent.children"> + <div class="ui child checkbox"> + <input + v-model="scopeArray" + :value="child.id" + :id="child.id" + type="checkbox"> + <label :for="child.id"> + {{ child.id }} + <p class="help"> + {{ child.description }} + </p> + </label> + </div> + </div> + </div> + </div> + + </div> + <button :class="['ui', {'loading': isLoading}, 'green', 'button']" type="submit"> + <translate v-if="updating" key="2" translate-context="Content/Applications/Button.Label/Verb">Update application</translate> + <translate v-else key="2" translate-context="Content/Applications/Button.Label/Verb">Create application</translate> + </button> + </form> +</template> + +<script> +import lodash from "@/lodash" +import axios from "axios" +import TranslationsMixin from "@/components/mixins/Translations" + +export default { + mixins: [TranslationsMixin], + props: { + app: {type: Object, required: false} + }, + data() { + let app = this.app || {} + return { + isLoading: false, + errors: [], + fields: { + name: app.name || '', + redirect_uris: app.redirect_uris || 'urn:ietf:wg:oauth:2.0:oob', + scopes: app.scopes || 'read' + }, + scopes: [ + {id: "profile", icon: 'user'}, + {id: "libraries", icon: 'book'}, + {id: "favorites", icon: 'heart'}, + {id: "listenings", icon: 'music'}, + {id: "follows", icon: 'users'}, + {id: "playlists", icon: 'list'}, + {id: "radios", icon: 'rss'}, + {id: "filters", icon: 'eye slash'}, + {id: "notifications", icon: 'bell'}, + {id: "edits", icon: 'pencil alternate'}, + ] + } + }, + methods: { + submit () { + this.errors = [] + let self = this + self.isLoading = true + let payload = this.fields + let event, promise, message + if (this.updating) { + event = 'updated' + promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload) + } else { + event = 'created' + promise = axios.post(`oauth/apps/`, payload) + } + return promise.then( + response => { + self.isLoading = false + self.$emit(event, response.data) + }, + error => { + self.isLoading = false + self.errors = error.backendErrors + } + ) + }, + }, + computed: { + updating () { + return this.app + }, + scopeArray: { + get () { + return this.fields.scopes.split(' ') + }, + set (v) { + this.fields.scopes = _.uniq(v).join(' ') + } + }, + allScopes () { + let self = this + let parents = [ + { + id: 'read', + label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Read'), + description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'), + value: this.scopeArray.indexOf('read') > -1 + }, + { + id: 'write', + label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Write'), + description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'), + value: this.scopeArray.indexOf('write') > -1 + }, + ] + parents.forEach((p) => { + p.children = self.scopes.map(s => { + let id = `${p.id}:${s.id}` + return { + id, + value: this.scopeArray.indexOf(id) > -1, + } + }) + }) + return parents + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +.parent.checkbox { + margin: 1em 0; +} +.child.checkbox { + margin-left: 1em; +} +</style> diff --git a/front/src/components/auth/ApplicationNew.vue b/front/src/components/auth/ApplicationNew.vue new file mode 100644 index 0000000000000000000000000000000000000000..8bb36826ce9bef3931f0091b50e5213cbe108124 --- /dev/null +++ b/front/src/components/auth/ApplicationNew.vue @@ -0,0 +1,39 @@ +<template> + <main class="main pusher" v-title="labels.title"> + <div class="ui vertical stripe segment"> + <section class="ui text container"> + <router-link :to="{name: 'settings'}"> + <translate translate-context="Content/Applications/Link">Back to settings</translate> + </router-link> + <h2 class="ui header"> + <translate translate-context="Content/Applications/Title">Create a new application</translate> + </h2> + <application-form + @created="$router.push({name: 'settings.applications.edit', params: {id: $event.client_id}})" /> + </section> + </div> + </main> +</template> + +<script> +import ApplicationForm from "@/components/auth/ApplicationForm" + +export default { + components: { + ApplicationForm + }, + data() { + return { + application: null, + isLoading: false, + } + }, + computed: { + labels() { + return { + title: this.$pgettext('Content/Applications/Title', "Create a new application") + } + }, + } +} +</script> diff --git a/front/src/components/auth/Authorize.vue b/front/src/components/auth/Authorize.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd9bf6eebfddc2cc993401fdd31d94cce2892e40 --- /dev/null +++ b/front/src/components/auth/Authorize.vue @@ -0,0 +1,201 @@ +<template> + <main class="main pusher" v-title="labels.title"> + <section class="ui vertical stripe segment"> + <div class="ui small text container"> + <h2><i class="lock open icon"></i><translate translate-context="Content/Auth/Title/Verb">Authorize third-party app</translate></h2> + <div v-if="errors.length > 0" class="ui negative message"> + <div v-if="application" class="header"><translate translate-context="Popup/Moderation/Error message">Error while authorizing application</translate></div> + <div v-else class="header"><translate translate-context="Popup/Moderation/Error message">Error while fetching application data</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <form v-else-if="application && !code" :class="['ui', {loading: isLoading}, 'form']" @submit.prevent="submit"> + <h3><translate translate-context="Content/Auth/Title" :translate-params="{app: application.name}">%{ app } wants to access your Funkwhale account</translate></h3> + + <h4 v-for="topic in topicScopes" class="ui header"> + <span v-if="topic.write && !topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']"> + <i class="pencil icon"></i> + <translate translate-context="Content/Auth/Label/Noun">Write-only</translate> + </span> + <span v-else-if="!topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']"> + <translate translate-context="Content/Auth/Label/Noun">Read-only</translate> + </span> + <span v-else-if="topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']"> + <i class="pencil icon"></i> + <translate translate-context="Content/Auth/Label/Noun">Full access</translate> + </span> + <i :class="[topic.icon, 'icon']"></i> + <div class="content"> + {{ topic.label }} + <div class="sub header"> + {{ topic.description }} + </div> + </div> + </h4> + <div v-if="unknownRequestedScopes.length > 0"> + <p><strong><translate translate-context="Content/Auth/Paragraph">The application is also requesting the following unknown permissions:</translate></strong></p> + <ul v-for="scope in unknownRequestedScopes"> + <li>{{ scope }}</li> + </ul> + + </div> + <button class="ui green labeled icon button" type="submit"> + <i class="lock open icon"></i> + <translate translate-context="Content/Signup/Button.Label/Verb" :translate-params="{app: application.name}">Authorize %{ app }</translate> + </button> + <p v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'" key="1" v-translate translate-context="Content/Auth/Paragraph"> + You will be shown a code to copy-paste in the application.</p> + <p v-else key="2" v-translate="{url: redirectUri}" translate-context="Content/Auth/Paragraph" :translate-params="{url: redirectUri}">You will be redirected to <strong>%{ url }</strong></p> + + </form> + <div v-else-if="code"> + <p><strong><translate translate-context="Content/Auth/Paragraph">Copy-paste the following code in the application:</translate></strong></p> + <copy-input :value="code"></copy-input> + </div> + </div> + </section> + </main> +</template> + +<script> +import TranslationsMixin from "@/components/mixins/Translations" + +import axios from 'axios' + +export default { + mixins: [TranslationsMixin], + props: [ + 'clientId', + 'redirectUri', + 'scope', + 'responseType', + 'nonce', + 'state', + ], + data() { + return { + application: null, + isLoading: false, + errors: [], + code: null, + knownScopes: [ + {id: "profile", icon: 'user'}, + {id: "libraries", icon: 'book'}, + {id: "favorites", icon: 'heart'}, + {id: "listenings", icon: 'music'}, + {id: "follows", icon: 'users'}, + {id: "playlists", icon: 'list'}, + {id: "radios", icon: 'rss'}, + {id: "filters", icon: 'eye slash'}, + {id: "notifications", icon: 'bell'}, + {id: "edits", icon: 'pencil alternate'}, + ] + } + }, + created () { + if (this.clientId) { + this.fetchApplication() + } + }, + computed: { + labels () { + return { + title: this.$pgettext('Head/Authorize/Title', "Allow application") + } + }, + requestedScopes () { + return (this.scope || '').split(' ') + }, + supportedScopes () { + let supported = ['read', 'write'] + this.knownScopes.forEach(s => { + supported.push(`read:${s.id}`) + supported.push(`write:${s.id}`) + }) + return supported + }, + unknownRequestedScopes () { + let self = this + return this.requestedScopes.filter(s => { + return self.supportedScopes.indexOf(s) < 0 + }) + }, + topicScopes () { + let self = this + let requested = this.requestedScopes + let write = false + let read = false + if (requested.indexOf('read') > -1) { + read = true + } + if (requested.indexOf('write') > -1) { + write = true + } + + return this.knownScopes.map(s => { + let id = s.id + return { + id: id, + icon: s.icon, + label: self.sharedLabels.scopes[s.id].label, + description: self.sharedLabels.scopes[s.id].description, + read: read || requested.indexOf(`read:${id}`) > -1, + write: write || requested.indexOf(`write:${id}`) > -1, + } + }).filter(c => { + return c.read || c.write + }) + } + }, + methods: { + fetchApplication () { + this.isLoading = true + let self = this + axios.get(`oauth/apps/${this.clientId}/`).then((response) => { + self.isLoading = false + self.application = response.data + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + submit () { + this.isLoading = true + let self = this + let data = new FormData(); + data.set('redirect_uri', this.redirectUri) + data.set('scope', this.scope) + data.set('allow', true) + data.set('client_id', this.clientId) + data.set('response_type', this.responseType) + data.set('state', this.state) + data.set('nonce', this.nonce) + axios.post(`oauth/authorize/`, data, {headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'}}).then((response) => { + if (self.redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { + self.isLoading = false + self.code = response.data.code + } else { + window.location.href = response.data.redirect_uri + } + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +.ui.header .content { + text-align: left; +} +.ui.header > .ui.label { + margin-top: 0.3em; +} +</style> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 8f62655d35cb9778b4a596183b2b8a1bc390c362..6e2516ed87156da3c31b360d6358d027ef3009cb 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -1,7 +1,7 @@ <template> <main class="main pusher" v-title="labels.title"> <div class="ui vertical stripe segment"> - <section class="ui small text container"> + <section class="ui text container"> <h2 class="ui header"> <translate translate-context="Content/Settings/Title">Account settings</translate> </h2> @@ -29,7 +29,7 @@ </button> </form> </section> - <section class="ui small text container"> + <section class="ui text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> <translate translate-context="Content/Settings/Title">Avatar</translate> @@ -63,7 +63,7 @@ </div> </section> - <section class="ui small text container"> + <section class="ui text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> <translate translate-context="Content/Settings/Title/Verb">Change my password</translate> @@ -109,7 +109,7 @@ <subsonic-token-form /> </section> - <section class="ui small text container" id="content-filters"> + <section class="ui text container" id="content-filters"> <div class="ui hidden divider"></div> <h2 class="ui header"> <i class="eye slash outline icon"></i> @@ -155,6 +155,118 @@ </tbody> </table> </section> + <section class="ui text container" id="grants"> + <div class="ui hidden divider"></div> + <h2 class="ui header"> + <i class="open lock icon"></i> + <div class="content"> + <translate translate-context="Content/Settings/Title/Noun">Authorized apps</translate> + </div> + </h2> + <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that have access to your account data.</translate></p> + <button + @click="fetchApps()" + class="ui basic icon button"> + <i class="refresh icon"></i> + <translate translate-context="Content/*/Button.Label/Short, Verb">Refresh</translate> + </button> + <table v-if="apps.length > 0" class="ui compact very basic unstackable table"> + <thead> + <tr> + <th><translate translate-context="*/*/*/Noun">Application</translate></th> + <th><translate translate-context="Content/*/*/Noun">Permissions</translate></th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="app in apps" :key='app.client_id'> + <td> + {{ app.name }} + </td> + <td> + {{ app.scopes }} + </td> + <td> + <dangerous-button + class="ui tiny basic button" + @confirm="revokeApp(app.client_id)"> + <translate translate-context="*/*/*/Verb">Revoke</translate> + <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p> + <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will prevent this application from accessing the service on your behalf.</translate></p> + <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Revoke access</translate></div> + </dangerous-button> + </td> + </tr> + </tbody> + </table> + <empty-state v-else> + <translate slot="title" translate-context="Content/Applications/Paragraph"> + You don't have any application connected with your account. + </translate> + <translate translate-context="Content/Applications/Paragraph"> + If you authorize third-party applications to access your data, those applications will be listed here. + </translate> + </empty-state> + </section> + <section class="ui text container" id="apps"> + <div class="ui hidden divider"></div> + <h2 class="ui header"> + <i class="code icon"></i> + <div class="content"> + <translate translate-context="Content/Settings/Title/Noun">Your applications</translate> + </div> + </h2> + <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that you have created.</translate></p> + <router-link class="ui basic green button" :to="{name: 'settings.applications.new'}"> + <translate translate-context="Content/Settings/Button.Label">Create a new application</translate> + </router-link> + <table v-if="ownedApps.length > 0" class="ui compact very basic unstackable table"> + <thead> + <tr> + <th><translate translate-context="*/*/*/Noun">Application</translate></th> + <th><translate translate-context="Content/*/*/Noun">Scopes</translate></th> + <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="app in ownedApps" :key='app.client_id'> + <td> + <router-link :to="{name: 'settings.applications.edit', params: {id: app.client_id}}"> + {{ app.name }} + </router-link> + </td> + <td> + {{ app.scopes }} + </td> + <td> + <human-date :date="app.created" /> + </td> + <td> + <router-link class="ui basic tiny green button" :to="{name: 'settings.applications.edit', params: {id: app.client_id}}"> + <translate translate-context="Content/Settings/Button.Label">Edit</translate> + </router-link> + <dangerous-button + class="ui tiny basic button" + @confirm="deleteApp(app.client_id)"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p> + <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will permanently delete the application and all the associated tokens.</translate></p> + <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Delete application</translate></div> + </dangerous-button> + </td> + </tr> + </tbody> + </table> + <empty-state v-else> + <translate slot="title" translate-context="Content/Applications/Paragraph"> + You don't have any configured application yet. + </translate> + <translate translate-context="Content/Applications/Paragraph"> + Create one to integrate Funkwhale with third-party applications. + </translate> + </empty-state> + </section> </div> </main> </template> @@ -185,6 +297,8 @@ export default { isLoadingAvatar: false, avatarErrors: [], avatar: null, + apps: [], + ownedApps: [], settings: { success: false, errors: [], @@ -204,6 +318,10 @@ export default { }) return d }, + created () { + this.fetchApps() + this.fetchOwnedApps() + }, mounted() { $("select.dropdown").dropdown() }, @@ -229,6 +347,56 @@ export default { } ) }, + fetchApps() { + this.apps = [] + let self = this + let url = `oauth/grants/` + return axios.get(url).then( + response => { + self.apps = response.data + }, + error => { + } + ) + }, + fetchOwnedApps() { + this.ownedApps = [] + let self = this + let url = `oauth/apps/` + return axios.get(url).then( + response => { + self.ownedApps = response.data.results + }, + error => { + } + ) + }, + revokeApp (id) { + let self = this + let url = `oauth/grants/${id}/` + return axios.delete(url).then( + response => { + self.apps = self.apps.filter(a => { + return a.client_id != id + }) + }, + error => { + } + ) + }, + deleteApp (id) { + let self = this + let url = `oauth/apps/${id}/` + return axios.delete(url).then( + response => { + self.ownedApps = self.ownedApps.filter(a => { + return a.client_id != id + }) + }, + error => { + } + ) + }, submitAvatar() { this.isLoadingAvatar = true this.avatarErrors = [] diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 75bbaef205677bb9a9f796ff8a07290d3b070a96..2bbc64c83f05314018ce9e0ea101920a228cfc28 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -35,6 +35,48 @@ export default { received_messages: this.$pgettext('Content/Moderation/*/Noun', 'Received messages'), uploads: this.$pgettext('Content/Moderation/Table.Label/Noun', 'Uploads'), followers: this.$pgettext('Content/Federation/*/Noun', 'Followers'), + }, + scopes: { + profile: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Profile'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to email, username, and profile information'), + }, + libraries: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Libraries and uploads'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to audio files, libraries, artists, albums and tracks'), + }, + favorites: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Favorites'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to favorites'), + }, + listenings: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Listenings'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to listening history'), + }, + follows: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Follows'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to follows'), + }, + playlists: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Playlists'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to playlists'), + }, + radios: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Radios'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to radios'), + }, + filters: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Content filters'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to content filters'), + }, + notifications: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Notifications'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to notifications'), + }, + edits: { + label: this.$pgettext('Content/OAuth Scopes/Label', 'Edits'), + description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to edits'), + } } } } diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index fec8fdd0595ffd8866d93e54bd2cffaedd2f52d5..75cc97a8888713e49b3e28d9e28810fbc0985fc6 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -21,7 +21,7 @@ export default { }, beforeDestroy () { if (this.control) { - this.control.remove() + $(this.$el).modal('hide') } }, methods: { @@ -61,5 +61,4 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> - </style> diff --git a/front/src/router/index.js b/front/src/router/index.js index 9320b74b81d4b69cb844b09326995b68cf6f401c..8703eedead7693332d242ea6a52ef65c06aa358a 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -3,10 +3,13 @@ import Router from 'vue-router' import PageNotFound from '@/components/PageNotFound' import About from '@/components/About' import Home from '@/components/Home' +import Authorize from '@/components/auth/Authorize' import Login from '@/components/auth/Login' import Signup from '@/components/auth/Signup' import Profile from '@/components/auth/Profile' import Settings from '@/components/auth/Settings' +import ApplicationNew from '@/components/auth/ApplicationNew' +import ApplicationEdit from '@/components/auth/ApplicationEdit' import Logout from '@/components/auth/Logout' import PasswordReset from '@/views/auth/PasswordReset' import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm' @@ -104,6 +107,19 @@ export default new Router({ defaultToken: route.query.token }) }, + { + path: '/authorize', + name: 'authorize', + component: Authorize, + props: (route) => ({ + clientId: route.query.client_id, + redirectUri: route.query.redirect_uri, + scope: route.query.scope, + responseType: route.query.response_type, + nonce: route.query.nonce, + state: route.query.state, + }) + }, { path: '/signup', name: 'signup', @@ -122,6 +138,17 @@ export default new Router({ name: 'settings', component: Settings }, + { + path: '/settings/applications/new', + name: 'settings.applications.new', + component: ApplicationNew + }, + { + path: '/settings/applications/:id/edit', + name: 'settings.applications.edit', + component: ApplicationEdit, + props: true + }, { path: '/@:username', name: 'profile', diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index e436dc33735a1867222635a5b42bd7b74bfe7253..b5f1ac4a45d6eb75e3ff96b6d410b4dc0d6521ec 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -330,3 +330,11 @@ td.align.right { .card .description { word-wrap: break-word; } + +.ui.checkbox label { + cursor: pointer; +} + +input + .help { + margin-top: 0.5em; +}