Skip to content
Snippets Groups Projects
Commit 86ea3cf4 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '752-funkwhale-oauth-provider' into 'develop'

Resolve "Implement a Oauth provider in Funkwhale"

Closes #752

See merge request funkwhale/funkwhale!672
parents 1dc7304b 4c13d473
No related branches found
No related tags found
No related merge requests found
Showing
with 660 additions and 53 deletions
......@@ -75,6 +75,10 @@ v1_patterns += [
r"^users/",
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
),
url(
r"^oauth/",
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
),
url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
]
......
......@@ -121,6 +121,7 @@ THIRD_PARTY_APPS = (
"allauth.account", # registration
"allauth.socialaccount", # registration
"corsheaders",
"oauth2_provider",
"rest_framework",
"rest_framework.authtoken",
"taggit",
......@@ -152,6 +153,7 @@ LOCAL_APPS = (
"funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app
"funkwhale_api.users.oauth",
# Your stuff: custom apps go here
"funkwhale_api.instance",
"funkwhale_api.music",
......@@ -222,6 +224,14 @@ DATABASES = {
"default": env.db("DATABASE_URL")
}
DATABASES["default"]["ATOMIC_REQUESTS"] = True
MIGRATION_MODULES = {
# see https://github.com/jazzband/django-oauth-toolkit/issues/634
# swappable models are badly designed in oauth2_provider
# ignore migrations and provide our own models.
"oauth2_provider": None
}
#
# DATABASES = {
# 'default': {
......@@ -343,6 +353,22 @@ AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = "users:redirect"
LOGIN_URL = "account_login"
# OAuth configuration
from funkwhale_api.users.oauth import scopes # noqa
OAUTH2_PROVIDER = {
"SCOPES": {s.id: s.label for s in scopes.SCOPES_BY_ID.values()},
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https", "urn"],
# we keep expired tokens for 15 days, for tracability
"REFRESH_TOKEN_EXPIRE_SECONDS": 3600 * 24 * 15,
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 5 * 60,
"ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60 * 10,
}
OAUTH2_PROVIDER_APPLICATION_MODEL = "users.Application"
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken"
OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken"
# LDAP AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------
AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
......@@ -450,14 +476,19 @@ CELERY_TASK_TIME_LIMIT = 300
CELERY_BEAT_SCHEDULE = {
"federation.clean_music_cache": {
"task": "federation.clean_music_cache",
"schedule": crontab(hour="*/2"),
"schedule": crontab(minute="0", hour="*/2"),
"options": {"expires": 60 * 2},
},
"music.clean_transcoding_cache": {
"task": "music.clean_transcoding_cache",
"schedule": crontab(hour="*"),
"schedule": crontab(minute="0", hour="*"),
"options": {"expires": 60 * 2},
},
"oauth.clear_expired_tokens": {
"task": "oauth.clear_expired_tokens",
"schedule": crontab(minute="0", hour="0"),
"options": {"expires": 60 * 60 * 24},
},
}
JWT_AUTH = {
......@@ -477,7 +508,6 @@ CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination",
"PAGE_SIZE": 25,
"DEFAULT_PARSER_CLASSES": (
......@@ -487,12 +517,16 @@ REST_FRAMEWORK = {
"funkwhale_api.federation.parsers.ActivityParser",
),
"DEFAULT_AUTHENTICATION_CLASSES": (
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"rest_framework.authentication.SessionAuthentication",
"funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
"funkwhale_api.common.authentication.BearerTokenHeaderAuth",
"funkwhale_api.common.authentication.JSONWebTokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"funkwhale_api.users.oauth.permissions.ScopePermission",
),
"DEFAULT_FILTER_BACKENDS": (
"rest_framework.filters.OrderingFilter",
"django_filters.rest_framework.DjangoFilterBackend",
......
......@@ -87,4 +87,6 @@ def mutations_route(types):
)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action(methods=["get", "post"], detail=True)(mutations)
return decorators.action(
methods=["get", "post"], detail=True, required_scope="edits"
)(mutations)
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from django.db.models import Prefetch
......@@ -9,6 +8,7 @@ from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
......@@ -24,10 +24,11 @@ class TrackFavoriteViewSet(
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user")
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"]
def get_serializer_class(self):
......
......@@ -5,11 +5,11 @@ from django.db.models import Count
from rest_framework import decorators
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import viewsets
from funkwhale_api.music import models as music_models
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity
from . import api_serializers
......@@ -43,7 +43,8 @@ class LibraryFollowViewSet(
.select_related("actor", "target__actor")
)
serializer_class = api_serializers.LibraryFollowSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
filterset_class = filters.LibraryFollowFilter
ordering_fields = ("creation_date",)
......@@ -100,7 +101,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
.annotate(_uploads_count=Count("uploads"))
)
serializer_class = api_serializers.LibrarySerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
def get_queryset(self):
qs = super().get_queryset()
......@@ -169,7 +171,8 @@ class InboxItemViewSet(
.order_by("-activity__creation_date")
)
serializer_class = api_serializers.InboxItemSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "notifications"
filterset_class = filters.InboxItemFilter
ordering_fields = ("activity__creation_date",)
......
from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from django.db.models import Prefetch
......@@ -9,6 +8,8 @@ from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from . import filters, models, serializers
from funkwhale_api.users.oauth import permissions as oauth_permissions
class ListeningViewSet(
mixins.CreateModelMixin,
......@@ -19,11 +20,13 @@ class ListeningViewSet(
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user")
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
filterset_class = filters.ListeningFilter
......
......@@ -5,7 +5,7 @@ from rest_framework import views
from rest_framework.response import Response
from funkwhale_api.common import preferences
from funkwhale_api.users.permissions import HasUserPermission
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import nodeinfo
......@@ -14,8 +14,8 @@ NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.so
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "instance:settings"
class InstanceSettings(views.APIView):
......
......@@ -8,7 +8,7 @@ from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.music import models as music_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission
from . import filters, serializers
......@@ -23,8 +23,7 @@ class ManageUploadViewSet(
)
serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["library"]
required_scope = "instance:libraries"
ordering_fields = [
"accessed_date",
"modification_date",
......@@ -55,8 +54,7 @@ class ManageUserViewSet(
queryset = users_models.User.objects.all().order_by("-id")
serializer_class = serializers.ManageUserSerializer
filterset_class = filters.ManageUserFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
required_scope = "instance:users"
ordering_fields = ["date_joined", "last_activity", "username"]
def get_serializer_context(self):
......@@ -80,8 +78,7 @@ class ManageInvitationViewSet(
)
serializer_class = serializers.ManageInvitationSerializer
filterset_class = filters.ManageInvitationFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
required_scope = "instance:invitations"
ordering_fields = ["creation_date", "expiration_date"]
def perform_create(self, serializer):
......@@ -114,8 +111,7 @@ class ManageDomainViewSet(
)
serializer_class = serializers.ManageDomainSerializer
filterset_class = filters.ManageDomainFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["moderation"]
required_scope = "instance:domains"
ordering_fields = [
"name",
"creation_date",
......@@ -153,7 +149,7 @@ class ManageActorViewSet(
)
serializer_class = serializers.ManageActorSerializer
filterset_class = filters.ManageActorFilterSet
permission_classes = (HasUserPermission,)
required_scope = "instance:accounts"
required_permissions = ["moderation"]
ordering_fields = [
"name",
......@@ -199,8 +195,7 @@ class ManageInstancePolicyViewSet(
)
serializer_class = serializers.ManageInstancePolicySerializer
filterset_class = filters.ManageInstancePolicyFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["moderation"]
required_scope = "instance:policies"
ordering_fields = ["id", "creation_date"]
def perform_create(self, serializer):
......
from django.db import IntegrityError
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import status
from rest_framework import viewsets
......@@ -24,7 +23,7 @@ class UserFilterViewSet(
.select_related("target_artist")
)
serializer_class = serializers.UserFilterSerializer
permission_classes = [permissions.IsAuthenticated]
required_scope = "filters"
ordering_fields = ("creation_date",)
def create(self, request, *args, **kwargs):
......
......@@ -8,7 +8,6 @@ from django.db.models.functions import Length
from django.utils import timezone
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import settings as rest_settings
from rest_framework import views, viewsets
from rest_framework.decorators import action
......@@ -24,6 +23,7 @@ from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import actors
from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, licenses, models, serializers, tasks, utils
......@@ -64,7 +64,9 @@ class TagViewSetMixin(object):
class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
filterset_class = filters.ArtistFilter
ordering_fields = ("id", "name", "creation_date")
......@@ -90,7 +92,9 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
models.Album.objects.all().order_by("artist", "release_date").select_related()
)
serializer_class = serializers.AlbumSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
ordering_fields = ("creation_date", "release_date", "title")
filterset_class = filters.AlbumFilter
......@@ -126,9 +130,11 @@ class LibraryViewSet(
)
serializer_class = serializers.LibraryForOwnerSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
required_scope = "libraries"
anonymous_policy = "setting"
owner_field = "actor.user"
owner_checks = ["read", "write"]
......@@ -178,7 +184,9 @@ class TrackViewSet(
queryset = models.Track.objects.all().for_nested_serialization()
serializer_class = serializers.TrackSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
filterset_class = filters.TrackFilter
ordering_fields = (
"creation_date",
......@@ -350,7 +358,9 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
+ [SignatureAuthentication]
)
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
......@@ -385,9 +395,11 @@ class UploadViewSet(
)
serializer_class = serializers.UploadForOwnerSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
required_scope = "libraries"
anonymous_policy = "setting"
owner_field = "library.actor.user"
owner_checks = ["read", "write"]
filterset_class = filters.UploadFilter
......@@ -432,12 +444,16 @@ class UploadViewSet(
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all().order_by("name")
serializer_class = serializers.TagSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
class Search(views.APIView):
max_results = 3
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
def get(self, request, *args, **kwargs):
query = request.GET["query"]
......@@ -502,7 +518,9 @@ class Search(views.APIView):
class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
serializer_class = serializers.LicenseSerializer
queryset = models.License.objects.all().order_by("code")
lookup_value_regex = ".*"
......@@ -527,7 +545,9 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
class OembedView(views.APIView):
permission_classes = [common_permissions.ConditionalAuthentication]
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
anonymous_policy = "setting"
def get(self, request, *args, **kwargs):
serializer = serializers.OembedSerializer(data=request.GET)
......
......@@ -2,11 +2,12 @@ from django.db import transaction
from django.db.models import Count
from rest_framework import exceptions, mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from funkwhale_api.common import fields, permissions
from funkwhale_api.music import utils as music_utils
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
......@@ -28,10 +29,11 @@ class PlaylistViewSet(
.with_duration()
)
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "playlists"
anonymous_policy = "setting"
owner_checks = ["write"]
filterset_class = filters.PlaylistFilter
ordering_fields = ("id", "name", "creation_date", "modification_date")
......@@ -101,10 +103,11 @@ class PlaylistTrackViewSet(
serializer_class = serializers.PlaylistTrackSerializer
queryset = models.PlaylistTrack.objects.all()
permission_classes = [
permissions.ConditionalAuthentication,
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
required_scope = "playlists"
anonymous_policy = "setting"
owner_field = "playlist.user"
owner_checks = ["write"]
......
......@@ -5,6 +5,7 @@ from rest_framework.response import Response
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.music.serializers import TrackSerializer
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, filtersets, models, serializers
......@@ -20,10 +21,11 @@ class RadioViewSet(
serializer_class = serializers.RadioSerializer
permission_classes = [
permissions.IsAuthenticated,
oauth_permissions.ScopePermission,
common_permissions.OwnerPermission,
]
filterset_class = filtersets.RadioFilter
required_scope = "radios"
owner_field = "user"
owner_checks = ["write"]
......
......@@ -92,7 +92,7 @@ def get_playlist_qs(request):
class SubsonicViewSet(viewsets.GenericViewSet):
content_negotiation_class = negotiation.SubsonicContentNegociation
authentication_classes = [authentication.SubsonicAuthentication]
permissions_classes = [rest_permissions.IsAuthenticated]
permission_classes = [rest_permissions.IsAuthenticated]
def dispatch(self, request, *args, **kwargs):
if not preferences.get("subsonic__enabled"):
......@@ -128,7 +128,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
detail=False,
methods=["get", "post"],
url_name="get_license",
permissions_classes=[],
permission_classes=[],
url_path="getLicense",
)
def get_license(self, request, *args, **kwargs):
......
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"
# 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")}
),
]
# Generated by Django 2.1.7 on 2019-03-18 09:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0014_oauth'),
]
operations = [
migrations.AddField(
model_name='application',
name='scope',
field=models.TextField(blank=True),
),
]
......@@ -18,6 +18,8 @@ from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from django_auth_ldap.backend import populate_user as ldap_populate_user
from oauth2_provider import models as oauth2_models
from oauth2_provider import validators as oauth2_validators
from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
......@@ -37,12 +39,37 @@ PERMISSIONS_CONFIGURATION = {
"moderation": {
"label": "Moderation",
"help_text": "Block/mute/remove domains, users and content",
"scopes": {
"read:instance:policies",
"write:instance:policies",
"read:instance:accounts",
"write:instance:accounts",
"read:instance:domains",
"write:instance:domains",
},
},
"library": {
"label": "Manage library",
"help_text": "Manage library, delete files, tracks, artists, albums...",
"scopes": {
"read:instance:edits",
"write:instance:edits",
"read:instance:libraries",
"write:instance:libraries",
},
},
"settings": {
"label": "Manage instance-level settings",
"help_text": "",
"scopes": {
"read:instance:settings",
"write:instance:settings",
"read:instance:users",
"write:instance:users",
"read:instance:invitations",
"write:instance:invitations",
},
},
"settings": {"label": "Manage instance-level settings", "help_text": ""},
}
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
......@@ -245,6 +272,45 @@ class Invitation(models.Model):
return super().save(**kwargs)
class Application(oauth2_models.AbstractApplication):
scope = models.TextField(blank=True)
@property
def normalized_scopes(self):
from .oauth import permissions
raw_scopes = set(self.scope.split(" ") if self.scope else [])
return permissions.normalize(*raw_scopes)
# oob schemes are not supported yet in oauth toolkit
# (https://github.com/jazzband/django-oauth-toolkit/issues/235)
# so in the meantime, we override their validation to add support
OOB_SCHEMES = ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto"]
class CustomRedirectURIValidator(oauth2_validators.RedirectURIValidator):
def __call__(self, value):
if value in OOB_SCHEMES:
return value
return super().__call__(value)
oauth2_models.RedirectURIValidator = CustomRedirectURIValidator
class Grant(oauth2_models.AbstractGrant):
pass
class AccessToken(oauth2_models.AbstractAccessToken):
pass
class RefreshToken(oauth2_models.AbstractRefreshToken):
pass
def get_actor_data(username):
slugified_username = federation_utils.slugify_username(username)
return {
......
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)
class Scope:
def __init__(self, id, label="", children=None):
self.id = id
self.label = ""
self.children = children or []
def copy(self, prefix):
return Scope("{}:{}".format(prefix, self.id))
BASE_SCOPES = [
Scope(
"profile", "Access profile data (email, username, avatar, subsonic password…)"
),
Scope("libraries", "Access uploads, libraries, and audio metadata"),
Scope("edits", "Browse and submit edits on audio metadata"),
Scope("follows", "Access library follows"),
Scope("favorites", "Access favorites"),
Scope("filters", "Access content filters"),
Scope("listenings", "Access listening history"),
Scope("radios", "Access radios"),
Scope("playlists", "Access playlists"),
Scope("notifications", "Access personal notifications"),
Scope("security", "Access security settings"),
# Privileged scopes that require specific user permissions
Scope("instance:settings", "Access instance settings"),
Scope("instance:users", "Access local user accounts"),
Scope("instance:invitations", "Access invitations"),
Scope("instance:edits", "Access instance metadata edits"),
Scope(
"instance:libraries", "Access instance uploads, libraries and audio metadata"
),
Scope("instance:accounts", "Access instance federated accounts"),
Scope("instance:domains", "Access instance domains"),
Scope("instance:policies", "Access instance moderation policies"),
]
SCOPES = [
Scope("read", children=[s.copy("read") for s in BASE_SCOPES]),
Scope("write", children=[s.copy("write") for s in BASE_SCOPES]),
]
def flatten(*scopes):
for scope in scopes:
yield scope
yield from flatten(*scope.children)
SCOPES_BY_ID = {s.id: s for s in flatten(*SCOPES)}
FEDERATION_REQUEST_SCOPES = {"read:libraries"}
ANONYMOUS_SCOPES = {
"read:libraries",
"read:playlists",
"read:listenings",
"read:favorites",
"read:radios",
"read:edits",
}
COMMON_SCOPES = ANONYMOUS_SCOPES | {
"read:profile",
"write:profile",
"write:libraries",
"write:playlists",
"read:follows",
"write:follows",
"write:favorites",
"read:notifications",
"write:notifications",
"write:radios",
"write:edits",
"read:filters",
"write:filters",
"write:listenings",
}
LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"}
# We don't allow admin access for oauth apps yet
OAUTH_APP_SCOPES = COMMON_SCOPES
def get_from_permissions(**permissions):
from funkwhale_api.users import models
final = LOGGED_IN_SCOPES
for permission_name, value in permissions.items():
if value is False:
continue
config = models.PERMISSIONS_CONFIGURATION[permission_name]
final = final | config["scopes"]
return final
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment