diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 1d73b29de..914ec9214 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -870,6 +870,7 @@ REST_FRAMEWORK = { ), "DEFAULT_AUTHENTICATION_CLASSES": ( "funkwhale_api.common.authentication.OAuth2Authentication", + "funkwhale_api.common.authentication.ApplicationTokenAuthentication", "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", "funkwhale_api.common.authentication.BearerTokenHeaderAuth", "funkwhale_api.common.authentication.JSONWebTokenAuthentication", diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py index f826b0c12..11447ce23 100644 --- a/api/funkwhale_api/common/authentication.py +++ b/api/funkwhale_api/common/authentication.py @@ -12,6 +12,8 @@ from rest_framework import exceptions from rest_framework_jwt import authentication from rest_framework_jwt.settings import api_settings +from funkwhale_api.users import models as users_models + def should_verify_email(user): if user.is_superuser: @@ -46,6 +48,36 @@ class OAuth2Authentication(BaseOAuth2Authentication): resend_confirmation_email(request, e.user) +class ApplicationTokenAuthentication(object): + def authenticate(self, request): + try: + header = request.headers["Authorization"] + except KeyError: + return + + if "Bearer" not in header: + return + + token = header.split()[-1].strip() + + try: + application = users_models.Application.objects.exclude(user=None).get( + token=token + ) + except users_models.Application.DoesNotExist: + return + user = users_models.User.objects.all().for_auth().get(id=application.user_id) + if not user.is_active: + msg = _("User account is disabled.") + raise exceptions.AuthenticationFailed(msg) + + if should_verify_email(user): + raise UnverifiedEmail(user) + + request.scopes = application.scope.split() + return user, None + + class BaseJsonWebTokenAuth(object): def authenticate(self, request): try: diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index ea079f47b..7b1504328 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -129,6 +129,7 @@ class SuperUserFactory(UserFactory): class ApplicationFactory(factory.django.DjangoModelFactory): name = factory.Faker("name") redirect_uris = factory.Faker("url") + token = factory.Faker("uuid4") client_type = models.Application.CLIENT_CONFIDENTIAL authorization_grant_type = models.Application.GRANT_AUTHORIZATION_CODE scope = "read" diff --git a/api/funkwhale_api/users/migrations/0020_application_token.py b/api/funkwhale_api/users/migrations/0020_application_token.py new file mode 100644 index 000000000..a8728eb56 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0020_application_token.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.8 on 2020-08-19 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0019_auto_20200718_0741'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='token', + field=models.CharField(blank=True, max_length=50, null=True, unique=True), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index dfb8a3f55..e4a26899b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -31,8 +31,8 @@ from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import utils as federation_utils -def get_token(): - return binascii.b2a_hex(os.urandom(15)).decode("utf-8") +def get_token(length=15): + return binascii.b2a_hex(os.urandom(length)).decode("utf-8") PERMISSIONS_CONFIGURATION = { @@ -350,6 +350,7 @@ class Invitation(models.Model): class Application(oauth2_models.AbstractApplication): scope = models.TextField(blank=True) + token = models.CharField(max_length=50, blank=True, null=True, unique=True) @property def normalized_scopes(self): diff --git a/api/funkwhale_api/users/oauth/serializers.py b/api/funkwhale_api/users/oauth/serializers.py index 4788ba220..b95d57624 100644 --- a/api/funkwhale_api/users/oauth/serializers.py +++ b/api/funkwhale_api/users/oauth/serializers.py @@ -10,6 +10,12 @@ class ApplicationSerializer(serializers.ModelSerializer): model = models.Application fields = ["client_id", "name", "scopes", "created", "updated"] + def to_representation(self, obj): + repr = super().to_representation(obj) + if obj.user_id: + repr["token"] = obj.token + return repr + class CreateApplicationSerializer(serializers.ModelSerializer): name = serializers.CharField(required=True, max_length=255) @@ -27,3 +33,9 @@ class CreateApplicationSerializer(serializers.ModelSerializer): "redirect_uris", ] read_only_fields = ["client_id", "client_secret", "created", "updated"] + + def to_representation(self, obj): + repr = super().to_representation(obj) + if obj.user_id: + repr["token"] = obj.token + return repr diff --git a/api/funkwhale_api/users/oauth/views.py b/api/funkwhale_api/users/oauth/views.py index 8fd88d908..3260dc031 100644 --- a/api/funkwhale_api/users/oauth/views.py +++ b/api/funkwhale_api/users/oauth/views.py @@ -4,7 +4,8 @@ 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 rest_framework import mixins, permissions, response, views, viewsets +from rest_framework.decorators import action from oauth2_provider import exceptions as oauth2_exceptions from oauth2_provider import views as oauth_views @@ -32,6 +33,7 @@ class ApplicationViewSet( "destroy": "write:security", "update": "write:security", "partial_update": "write:security", + "refresh_token": "write:security", "list": "read:security", } lookup_field = "client_id" @@ -54,6 +56,7 @@ class ApplicationViewSet( 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, + token=models.get_token(15) if self.request.user.is_authenticated else None, ) def get_serializer(self, *args, **kwargs): @@ -70,10 +73,31 @@ class ApplicationViewSet( def get_queryset(self): qs = super().get_queryset() - if self.action in ["list", "destroy", "update", "partial_update"]: + if self.action in [ + "list", + "destroy", + "update", + "partial_update", + "refresh_token", + ]: qs = qs.filter(user=self.request.user) return qs + @action( + detail=True, + methods=["post"], + url_name="refresh_token", + url_path="refresh-token", + ) + def refresh_token(self, request, *args, **kwargs): + app = self.get_object() + if not app.user_id or request.user != app.user: + return response.Response(status=404) + app.token = models.get_token(15) + app.save(update_fields=["token"]) + serializer = serializers.CreateApplicationSerializer(app) + return response.Response(serializer.data, status=200) + class GrantViewSet( mixins.RetrieveModelMixin, diff --git a/api/tests/common/test_authentication.py b/api/tests/common/test_authentication.py index e249d5260..5678abcf6 100644 --- a/api/tests/common/test_authentication.py +++ b/api/tests/common/test_authentication.py @@ -60,3 +60,13 @@ def test_json_webtoken_auth_verify_email_validity( auth.authenticate(request) should_verify.assert_called_once_with(user) + + +def test_app_token_authentication(factories, api_request): + user = factories["users.User"]() + app = factories["users.Application"](user=user, scope="read write") + request = api_request.get("/", HTTP_AUTHORIZATION="Bearer {}".format(app.token)) + + auth = authentication.ApplicationTokenAuthentication() + assert auth.authenticate(request)[0] == app.user + assert request.scopes == ["read", "write"] diff --git a/api/tests/users/oauth/test_views.py b/api/tests/users/oauth/test_views.py index bf78c83b4..96f594ec2 100644 --- a/api/tests/users/oauth/test_views.py +++ b/api/tests/users/oauth/test_views.py @@ -47,6 +47,8 @@ def test_apps_post_logged_in_user(logged_in_api_client, db): assert response.data == serializers.CreateApplicationSerializer(app).data assert app.scope == "read write:profile" assert app.user == logged_in_api_client.user + assert app.token is not None + assert response.data["token"] == app.token def test_apps_list_anonymous(api_client, db): @@ -120,6 +122,31 @@ def test_apps_get_owner(preferences, logged_in_api_client, factories): assert response.status_code == 200 assert response.data == serializers.CreateApplicationSerializer(app).data + assert response.data["token"] == app.token + + +def test_apps_refresh_token(preferences, logged_in_api_client, factories): + app = factories["users.Application"](user=logged_in_api_client.user) + old_token = app.token + url = reverse( + "api:v1:oauth:apps-refresh_token", kwargs={"client_id": app.client_id} + ) + response = logged_in_api_client.post(url) + + app.refresh_from_db() + assert response.status_code == 200 + assert response.data == serializers.CreateApplicationSerializer(app).data + assert app.token != old_token + + +def test_apps_refresh_token_not_owner(preferences, logged_in_api_client, factories): + app = factories["users.Application"]() + url = reverse( + "api:v1:oauth:apps-refresh_token", kwargs={"client_id": app.client_id} + ) + response = logged_in_api_client.post(url) + + assert response.status_code == 404 def test_authorize_view_post(logged_in_client, factories):