From 5968d2a0e2be7cd4156b4e7d0ee20b4fc94a681b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 26 Nov 2024 14:49:26 +0100 Subject: [PATCH] adding APIKey method --- rowers/authentication.py | 15 +++++++++ rowers/models.py | 13 +++++++- rowers/templates/rower_form.html | 8 +++++ rowers/tests/test_api.py | 35 ++++++++++++++++++++- rowers/urls.py | 3 ++ rowers/views/apiviews.py | 52 ++++++++++++++++++++++++++++++++ rowers/views/statements.py | 32 +++++++++++++++++++- rowers/views/userviews.py | 18 +++++++++++ rowsandall_app/settings.py | 1 + 9 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 rowers/authentication.py diff --git a/rowers/authentication.py b/rowers/authentication.py new file mode 100644 index 00000000..e5cf818e --- /dev/null +++ b/rowers/authentication.py @@ -0,0 +1,15 @@ +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from rowers.models import APIKey + +class APIKeyAuthentication(BaseAuthentication): + def authenticate(self, request): + api_key = request.META.get('HTTP_AUTHORIZATION') + if not api_key: + return None + try: + api_key = APIKey.objects.get(key=api_key, is_active=True) + except APIKey.DoesNotExist: + raise AuthenticationFailed('Invalid API key') + + return (api_key.user, None) diff --git a/rowers/models.py b/rowers/models.py index 5ddc51dc..310b93a4 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -27,7 +27,7 @@ from django.contrib.admin.widgets import AdminDateWidget, AdminTimeWidget, Admin import os import json import ssl - +import secrets import re import pytz @@ -923,7 +923,18 @@ class CoachingGroup(models.Model): return Rower.objects.filter(mycoachgroup=self) # Extension of User with rowing specific data +class APIKey(models.Model): + key = models.CharField(max_length=50, unique=True, default=secrets.token_urlsafe) + user = models.ForeignKey('auth.User', on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + def __str__(self): + return f"{self.user.username}:{self.key}" + + def regenerate_key(self): + self.key = secrets.token_urlsafe() + self.save() class Rower(models.Model): adaptivetypes = mytypes.adaptivetypes diff --git a/rowers/templates/rower_form.html b/rowers/templates/rower_form.html index 68096f27..4912dd0e 100644 --- a/rowers/templates/rower_form.html +++ b/rowers/templates/rower_form.html @@ -116,6 +116,14 @@ {% endif %} +
  • +

    API Key

    +

    {{ apikey }}

    +

    + Regenerate +

    + This API key can be used to access the Rowsandall API. It is used by some third party applications to access your data. Keep it secret. +
  • {% endif %} diff --git a/rowers/tests/test_api.py b/rowers/tests/test_api.py index 6f32ba8d..74dff25d 100644 --- a/rowers/tests/test_api.py +++ b/rowers/tests/test_api.py @@ -26,6 +26,7 @@ import json from rowers.ownapistuff import * from rowers.views.apiviews import * +from rowers.models import APIKey class OwnApi(TestCase): def setUp(self): @@ -45,7 +46,7 @@ class OwnApi(TestCase): self.u.set_password(self.password) self.u.save() self.factory = APIRequestFactory() - + self.apikey = APIKey.objects.create(user=self.u) def test_strokedataform(self): login = self.c.login(username=self.u.username, password=self.password) @@ -245,6 +246,38 @@ class OwnApi(TestCase): self.assertEqual(response.status_code, 201) + def test_strokedataform_rowingdata_apikey(self): + + url = reverse('strokedata_rowingdata_apikey') + + filename = 'rowers/tests/testdata/testdata.csv' + f = open(filename, 'rb') + + # Use API Key header + headers = { + "HTTP_AUTHORIZATION": self.apikey.key, + } + + form_data = { + "workouttype": "rower", + "boattype": "1x", + "notes": "A test file upload", + } + + # Send POST request + response = self.client.post( + url, + {"file": f, **form_data}, + format="multipart", # Ensure multipart/form-data is used + **headers, # Optional if login doesn't suffice + ) + + f.close() + + # Assertions + self.assertEqual(response.status_code, 201) + + def test_strokedataform_empty(self): login = self.c.login(username=self.u.username, password=self.password) diff --git a/rowers/urls.py b/rowers/urls.py index 8f82d46e..1e4210dc 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -256,6 +256,8 @@ urlpatterns = [ name='strokedata_tcx'), re_path(r'^api/rowingdata/workouts/$', views.strokedata_rowingdata, name='strokedata_rowingdata'), + re_path(r'^api/rowingdata/$', views.strokedata_rowingdata_apikey, + name='strokedata_rowingdata_apikey'), re_path(r'^api/courses/$', views.course_list, name='course_list'), re_path(r'^api/courses/(?P\d+)/$', views.get_crewnerd_kml, name='get_crewnerd_kml'), re_path(r'^api/courses/kml/liked/$', views.get_crewnerd_liked, name='get_crewnerd_liked'), @@ -735,6 +737,7 @@ urlpatterns = [ re_path(r'^me/request/$', views.manager_requests_view, name='manager_requests_view'), re_path(r'^me/edit/$', views.rower_edit_view, name='rower_edit_view'), + re_path(r'^me/regenerateapikey/$', views.rower_regenerate_apikey, name='rower_regenerate_apikey'), re_path(r'^me/edit/user/(?P\d+)/$', views.rower_edit_view, name='rower_edit_view'), re_path(r'^me/preferences/$', views.rower_prefs_view, diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py index 9008a763..828216e8 100644 --- a/rowers/views/apiviews.py +++ b/rowers/views/apiviews.py @@ -473,6 +473,58 @@ def strokedata_rowingdata(request): return response +@csrf_exempt +@api_view(["POST"]) +@permission_required('rower.is_not_freecoach', fn=get_user_by_userid, raise_exception=True) +@permission_classes([IsAuthenticated]) +def strokedata_rowingdata_apikey(request): + """ + Upload a .csv file (rowingdata standard) through API, using + """ + + r = getrower(request.user) + if r.rowerplan == 'freecoach': + return HttpResponseNotAllowed("This endpoint is for users, not for free coach accounts") + + if request.method != 'POST': + return HttpResponseNotAllowed("Method not supported") + + form = DocumentsForm(request.POST, request.FILES) + if not form.is_valid(): + return HttpResponseBadRequest(json.dumps(form.errors)) + + f = form.cleaned_data['file'] + if f is None: + return HttpResponseBadRequest("Missing file") + + filename, completefilename = handle_uploaded_file(f) + + uploadoptions = { + 'secret': settings.UPLOAD_SERVICE_SECRET, + 'user': r.user.id, + 'file': completefilename, + 'workouttype': form.cleaned_data['workouttype'], + 'boattype': form.cleaned_data['boattype'], + 'title': form.cleaned_data['title'], + 'rpe': form.cleaned_data['rpe'], + 'notes': form.cleaned_data['notes'] + } + + url = settings.UPLOAD_SERVICE_URL + + _ = myqueue(queuehigh, + handle_request_post, + url, + uploadoptions) + response = JsonResponse( + { + "status": "success", + } + ) + response.status_code = 201 + return response + + @csrf_exempt #@login_required() @api_view(["POST"]) diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 4807ebb5..01b768c5 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -164,7 +164,7 @@ from rowers.models import ( StandardCollection, CourseStandard, VirtualRaceFollower, TombStone, InstantPlan, PlannedSessionStep,InStrokeAnalysis, ForceCurveAnalysis, SyncRecord, - UserMessage, + UserMessage,APIKey, ) from rowers.models import ( RowerPowerForm, RowerHRZonesForm, SimpleRowerPowerForm, RowerForm, RowerCPForm, GraphImage, AdvancedWorkoutForm, @@ -307,6 +307,27 @@ import base64 from django.http import HttpResponse from django.contrib.auth import authenticate, login +def view_or_apikey(view, request, test_func, realm = "", *args, **kwargs): + if test_func(request.user): + return view(request, *args, **kwargs) + + if 'Authorization' in request.META: + api_key = request.META.get('Authorization') + if api_key: + try: + api_key = APIKey.objects.get(key=api_key, is_active=True) + except APIKey.DoesNotExist: + raise AuthenticationFailed('Invalid API key') + + login(request, api_key.user, backend='django.contrib.auth.backends.ModelBackend') + request.user = api_key.user + return view(request, *args, **kwargs) + + response = HttpResponse() + response.status_code = 401 + response['WWW-Authenticate'] = 'Basic realm="%s"' % realm + return response + ############################################################################# # def view_or_basicauth(view, request, test_func, realm = "", *args, **kwargs): @@ -348,6 +369,15 @@ def view_or_basicauth(view, request, test_func, realm = "", *args, **kwargs): ############################################################################# # +def logged_in_or_apikey(realm = ""): + def view_decorator(func): + def wrapper(request, *args, **kwargs): + return view_or_apikey(func, request, + lambda u: u.is_authenticated, + realm, *args, **kwargs) + return wrapper + return view_decorator + def logged_in_or_basicauth(realm = ""): """ A simple decorator that requires a user to be logged in. If they are not diff --git a/rowers/views/userviews.py b/rowers/views/userviews.py index 0c1eed43..250a5432 100644 --- a/rowers/views/userviews.py +++ b/rowers/views/userviews.py @@ -632,6 +632,12 @@ def rower_edit_view(request, rowerid=0, userid=0, message=""): userform = UserForm(instance=r.user) grants = AccessToken.objects.filter(user=request.user) + try: + apikey = APIKey.objects.get(user=request.user) + except APIKey.DoesNotExist: + apikey = APIKey.objects.create(user=request.user) + + return render(request, 'rower_form.html', { 'teams': get_my_teams(request.user), @@ -640,8 +646,20 @@ def rower_edit_view(request, rowerid=0, userid=0, message=""): 'userform': userform, 'accountform': accountform, 'rower': r, + 'apikey': apikey.key, }) +@login_required() +def rower_regenerate_apikey(request): + try: + apikey = APIKey.objects.get(user=request.user) + except APIKey.DoesNotExist: + apikey = APIKey.objects.create(user=request.user) + + apikey.regenerate_key() + + return HttpResponseRedirect(reverse('rower_edit_view')) + #simple initial settings page @login_required() @permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 8c1d4255..8d862009 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -485,6 +485,7 @@ REST_FRAMEWORK = { # 'rest_framework.authentication.SessionAuthentication', # 'rest_framework.authentication.TokenAuthentication', 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + 'rowers.authentication.APIKeyAuthentication', ), 'PAGE_SIZE': 20, 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',