Resolve "Implement a Oauth provider in Funkwhale"

merge-requests/757/head
Eliot Berriot 2019-03-25 17:02:51 +01:00
rodzic 1dc7304bd3
commit 4c13d47387
54 zmienionych plików z 2811 dodań i 249 usunięć

Wyświetl plik

@ -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"),
]

Wyświetl plik

@ -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",

Wyświetl plik

@ -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)

Wyświetl plik

@ -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):

Wyświetl plik

@ -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",)

Wyświetl plik

@ -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

Wyświetl plik

@ -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):

Wyświetl plik

@ -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):

Wyświetl plik

@ -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):

Wyświetl plik

@ -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)

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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):

Wyświetl plik

@ -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"

Wyświetl plik

@ -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")}
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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()

Wyświetl plik

@ -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"),
]

Wyświetl plik

@ -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

Wyświetl plik

@ -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
)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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"}

Wyświetl plik

@ -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")

Wyświetl plik

@ -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
)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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"}
)

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1 @@
Support OAuth2 authorization for better integration with third-party apps (#752)

Wyświetl plik

@ -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`_.

Wyświetl plik

@ -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:

Wyświetl plik

@ -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 |
+-------------------------------------------+---------------------------------------------------+

Wyświetl plik

@ -12,5 +12,6 @@ Reference
architecture
../api
./authentication
../federation/index
subsonic

Wyświetl plik

@ -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:

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>&nbsp;
<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 = []

Wyświetl plik

@ -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'),
}
}
}
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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',

Wyświetl plik

@ -330,3 +330,11 @@ td.align.right {
.card .description {
word-wrap: break-word;
}
.ui.checkbox label {
cursor: pointer;
}
input + .help {
margin-top: 0.5em;
}