Merge branch '752-funkwhale-oauth-provider' into 'develop'
Resolve "Implement a Oauth provider in Funkwhale" Closes #752 See merge request funkwhale/funkwhale!672
This commit is contained in:
commit
86ea3cf4f3
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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",)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")}
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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"]
|
|
@ -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()
|
|
@ -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"),
|
||||
]
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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()
|
|
@ -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"}
|
||||
)
|
|
@ -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
|
|
@ -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()
|
|
@ -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()
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
Support OAuth2 authorization for better integration with third-party apps (#752)
|
|
@ -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`_.
|
||||
|
|
2
dev.yml
2
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:
|
||||
|
|
|
@ -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 |
|
||||
+-------------------------------------------+---------------------------------------------------+
|
|
@ -12,5 +12,6 @@ Reference
|
|||
|
||||
architecture
|
||||
../api
|
||||
./authentication
|
||||
../federation/index
|
||||
subsonic
|
||||
|
|
162
docs/swagger.yml
162
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:
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 = []
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -330,3 +330,11 @@ td.align.right {
|
|||
.card .description {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ui.checkbox label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input + .help {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue