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 7c196450..0b0b990b 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_exportsettings.html b/rowers/templates/rower_exportsettings.html index a62ad6a2..4990ce37 100644 --- a/rowers/templates/rower_exportsettings.html +++ b/rowers/templates/rower_exportsettings.html @@ -7,37 +7,38 @@ {% block main %}

Import and Export Settings for {{ rower.user.first_name }} {{ rower.user.last_name }}

-

You are currently connected to: - {% if rower.c2token is not None and rower.c2token != '' %} - Concept2 Logbook, - {% endif %} - {% if rower.nktoken is not None and rower.nktoken != '' %} - NK Logbook, - {% endif %} - {% if rower.sporttrackstoken is not None and rower.sporttrackstoken != '' %} - SportTracks, - {% endif %} - {% if rower.tptoken is not None and rower.tptoken != '' %} - TrainingPeaks, - {% endif %} - {% if rower.polartoken is not None and rower.polartoken != '' %} - Polar, - {% endif %} - {% if rower.garmintoken is not None and rower.garmintoken != '' %} - Garmin Connect, - {% endif %} - {% if rower.stravatoken is not None and rower.stravatoken != '' %} - Strava, - {% endif %} - {% if rower.rp3token is not None and rower.rp3token != '' %} - RP3 - {% endif %} - {% if rower.rojabo_token is not None and rower.rojabo_token != '' %} - Rojabo - {% endif %} - -

- +
    +
  • +

    You are currently connected to: + {% if rower.c2token is not None and rower.c2token != '' %} + Concept2 Logbook, + {% endif %} + {% if rower.nktoken is not None and rower.nktoken != '' %} + NK Logbook, + {% endif %} + {% if rower.sporttrackstoken is not None and rower.sporttrackstoken != '' %} + SportTracks, + {% endif %} + {% if rower.tptoken is not None and rower.tptoken != '' %} + TrainingPeaks, + {% endif %} + {% if rower.polartoken is not None and rower.polartoken != '' %} + Polar, + {% endif %} + {% if rower.garmintoken is not None and rower.garmintoken != '' %} + Garmin Connect, + {% endif %} + {% if rower.stravatoken is not None and rower.stravatoken != '' %} + Strava, + {% endif %} + {% if rower.rp3token is not None and rower.rp3token != '' %} + RP3 + {% endif %} + {% if rower.rojabo_token is not None and rower.rojabo_token != '' %} + Rojabo + {% endif %} +

    + {% if form.errors %}

    Please correct the error{{ form.errors|pluralize }} below. @@ -71,24 +72,57 @@ a workout on Strava. If you want Deletions to propagate to Rowsandall, tick the Strava Auto Delete check box.

    -

    Click on one of the icons below to connect to the service of your - choice or to renew the authorization.

    -

    connect with strava

    -

    connect with Concept2

    -

    connect with NK Logbook

    -

    connect with SportTracks

    -

    +

  • + {% if grants %} +

    Applications

    + + + + + + + + + + {% for grant in grants %} + + + + + + {% endfor %} + +
    ApplicationScopeRevoke
    {{ grant.application }}{{ grant.scope }} + Revoke +
    + {% 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. +
  • + +
+

Click on one of the icons below to connect to the service of your + choice or to renew the authorization.

+

connect with strava

+

connect with Concept2

+

connect with NK Logbook

+

connect with SportTracks

+

connect with Polar

-

connect with Polar

- -

connect with Garmin

-

connect with RP3

-

connect with Rojabo

- +

connect with Polar

+ +

connect with Garmin

+

connect with RP3

+

connect with Rojabo

{% endblock %} 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 83b2ba82..c8608415 100644 --- a/rowers/tests/test_api.py +++ b/rowers/tests/test_api.py @@ -32,6 +32,8 @@ from bs4 import BeautifulSoup from rowers.ownapistuff import * from rowers.views.apiviews import * +from rowers.models import APIKey + from rowers.teams import add_member, add_coach from rowers.views.analysisviews import histodata @@ -476,9 +478,8 @@ class OwnApi(TestCase): self.password = faker.word() 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) @@ -642,6 +643,75 @@ class OwnApi(TestCase): response = strokedata_tcx(request) self.assertEqual(response.status_code,200) + def test_strokedataform_rowingdata(self): + + url = reverse('strokedata_rowingdata') + + filename = 'rowers/tests/testdata/testdata.csv' + f = open(filename, 'rb') + + # Use Basic Auth header (alternative if using a custom view) + credentials = f"{self.u.username}:{self.password}" + base64_credentials = base64.b64encode(credentials.encode()).decode() + + # Use Basic Auth header + headers = { + "HTTP_AUTHORIZATION": f"Basic {base64_credentials}" + } + + 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_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) self.assertTrue(login) diff --git a/rowers/urls.py b/rowers/urls.py index 18b28d66..1e4210dc 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -254,6 +254,10 @@ urlpatterns = [ name='strokedatajson_v3'), re_path(r'^api/TCX/workouts/$', views.strokedata_tcx, 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'), @@ -733,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/analysisviews.py b/rowers/views/analysisviews.py index 55a19d5a..4a6edcbb 100644 --- a/rowers/views/analysisviews.py +++ b/rowers/views/analysisviews.py @@ -2321,11 +2321,11 @@ def history_view_data(request, userid=0): try: ddict['hrmean'] = int(wavg(ddf, 'hr', 'deltat')) - except (KeyError, ValueError, AttributeError): # pragma: no cover + except (KeyError, ValueError, AttributeError, ColumnNotFoundError): # pragma: no cover ddict['hrmean'] = 0 try: ddict['hrmax'] = int(ddf['hr'].max()) - except (KeyError, ValueError, AttributeError): # pragma: no cover + except (KeyError, ValueError, AttributeError, ColumnNotFoundError): # pragma: no cover ddict['hrmax'] = 0 except ColumnNotFoundError: ddict['hrmax'] = 0 diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py index d383b3c5..b84dc6f5 100644 --- a/rowers/views/apiviews.py +++ b/rowers/views/apiviews.py @@ -22,6 +22,8 @@ import rowingdata.tcxtools as tcxtools from rowingdata import TCXParser, rowingdata import arrow +import base64 + class XMLParser(BaseParser): media_type = "application/xml" @@ -425,6 +427,110 @@ def get_crewnerd_liked(request): # Stroke data views +@csrf_exempt +@logged_in_or_basicauth() +#@api_view(["POST"]) +#@permission_required('rower.is_not_freecoach', fn=get_user_by_userid, raise_exception=True) +#@permission_classes([IsAuthenticated]) +def strokedata_rowingdata(request): + """ + Upload a .csv file (rowingdata standard) through API, using Basic Auth + """ + 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") # pragma: no cover + + 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 +@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 1ee903ee..7d759acf 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -95,7 +95,8 @@ from django.http import ( HttpResponse, HttpResponseRedirect, JsonResponse, HttpResponseForbidden, HttpResponseNotAllowed, - HttpResponseNotFound, Http404 + HttpResponseNotFound, Http404, + HttpResponseBadRequest, ) from django.contrib.auth import authenticate, login, logout from rowers.forms import ( @@ -165,7 +166,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, @@ -304,6 +305,142 @@ from rowers.weather import get_wind_data, get_airport_code, get_metar_data from oauth2_provider.models import Application, Grant, AccessToken +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): + """ + This is a helper function used by both 'logged_in_or_basicauth' and + 'has_perm_or_basicauth' that does the nitty of determining if they + are already logged in or if they have provided proper http-authorization + and returning the view if all goes well, otherwise responding with a 401. + """ + if test_func(request.user): + # Already logged in, just return the view. + # + return view(request, *args, **kwargs) + + # They are not logged in. See if they provided login credentials + # + if 'HTTP_AUTHORIZATION' in request.META: + auth = request.META['HTTP_AUTHORIZATION'].split() + if len(auth) == 2: + # NOTE: We are only support basic authentication for now. + # + if auth[0].lower() == "basic": + uname, passwd = base64.b64decode(auth[1]).decode("utf-8").split(':') + user = authenticate(username=uname, password=passwd) + if user is not None: + if user.is_active: + login(request, user) + request.user = user + return view(request, *args, **kwargs) + + # Either they did not provide an authorization header or + # something in the authorization attempt failed. Send a 401 + # back to them to ask them to authenticate. + # + response = HttpResponse() + response.status_code = 401 + response['WWW-Authenticate'] = 'Basic realm="%s"' % realm + return response + +############################################################################# +# +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 + logged in the request is examined for a 'authorization' header. + + If the header is present it is tested for basic authentication and + the user is logged in with the provided credentials. + + If the header is not present a http 401 is sent back to the + requestor to provide credentials. + + The purpose of this is that in several django projects I have needed + several specific views that need to support basic authentication, yet the + web site as a whole used django's provided authentication. + + The uses for this are for urls that are access programmatically such as + by rss feed readers, yet the view requires a user to be logged in. Many rss + readers support supplying the authentication credentials via http basic + auth (and they do NOT support a redirect to a form where they post a + username/password.) + + Use is simple: + + @logged_in_or_basicauth + def your_view: + ... + + You can provide the name of the realm to ask for authentication within. + """ + def view_decorator(func): + def wrapper(request, *args, **kwargs): + return view_or_basicauth(func, request, + lambda u: u.is_authenticated, + realm, *args, **kwargs) + return wrapper + return view_decorator + +############################################################################# +# +def has_perm_or_basicauth(perm, realm = ""): + """ + This is similar to the above decorator 'logged_in_or_basicauth' + except that it requires the logged in user to have a specific + permission. + + Use: + + @logged_in_or_basicauth('asforums.view_forumcollection') + def your_view: + ... + + """ + def view_decorator(func): + def wrapper(request, *args, **kwargs): + return view_or_basicauth(func, request, + lambda u: u.has_perm(perm), + realm, *args, **kwargs) + return wrapper + return view_decorator + + import django_rq queue = django_rq.get_queue('default') queuelow = django_rq.get_queue('low') diff --git a/rowers/views/userviews.py b/rowers/views/userviews.py index 0c1eed43..629be7a2 100644 --- a/rowers/views/userviews.py +++ b/rowers/views/userviews.py @@ -520,10 +520,19 @@ def rower_exportsettings_view(request, userid=0): } ] + 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_exportsettings.html', {'form': form, 'rower': r, 'breadcrumbs': breadcrumbs, + 'grants': grants, + 'apikey': apikey.key, }) @@ -632,6 +641,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 +655,24 @@ 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: + referer = request.META['HTTP_REFERER'] + except KeyError: + referer = '/rowers/me/edit/' + try: + apikey = APIKey.objects.get(user=request.user) + except APIKey.DoesNotExist: + apikey = APIKey.objects.create(user=request.user) + + apikey.regenerate_key() + + return HttpResponseRedirect(referer) + #simple initial settings page @login_required() @permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py index 4e649d2e..28111598 100644 --- a/rowers/views/workoutviews.py +++ b/rowers/views/workoutviews.py @@ -4932,7 +4932,6 @@ def workout_upload_api(request): post_data = {k: q.getlist(k) if len( q.getlist(k)) > 1 else v for k, v in q.items()} - # only allow local host hostt = request.get_host().split(':') if hostt[0] not in ['localhost', '127.0.0.1', 'dev.rowsandall.com', 'rowsandall.com','testserver']: diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 8c1d4255..5bf05b8b 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -481,10 +481,11 @@ REST_FRAMEWORK = { # 'rest_framework.permissions.DjangoModelPermissions' ], 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.BasicAuthentication', + #'rest_framework.authentication.BasicAuthentication', # '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',