Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract basic OAuth2 functionality from SportTracks so it can be used elsewhere #103

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 17 additions & 53 deletions tapiriik/services/SportTracks/sporttracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
from tapiriik.services.service_base import ServiceAuthenticationType, ServiceBase
from tapiriik.services.interchange import UploadedActivity, ActivityType, ActivityStatistic, ActivityStatisticUnit, Waypoint, WaypointType, Location, LapIntensity, Lap
from tapiriik.services.api import APIException, UserException, UserExceptionType, APIExcludeActivity
from tapiriik.services.sessioncache import SessionCache
from tapiriik.services.oauth2 import OAuth2Client
from tapiriik.database import cachedb
from django.core.urlresolvers import reverse
import pytz
from datetime import timedelta
import dateutil.parser
from dateutil.tz import tzutc
import requests
import json
import re
import urllib.parse

import logging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -137,52 +135,18 @@ class SportTracksService(ServiceBase):

SupportedActivities = list(_reverseActivityMappings.keys())

_tokenCache = SessionCache(lifetime=timedelta(minutes=115), freshen_on_get=False)
_oaClient = OAuth2Client(SPORTTRACKS_CLIENT_ID, SPORTTRACKS_CLIENT_SECRET, "https://api.sporttracks.mobi/oauth2/token", tokenTimeoutMin=115)

def WebInit(self):
self.UserAuthorizationURL = "https://api.sporttracks.mobi/oauth2/authorize?response_type=code&client_id=%s&state=mobi_api" % SPORTTRACKS_CLIENT_ID

def _getAuthHeaders(self, serviceRecord=None):
token = self._tokenCache.Get(serviceRecord.ExternalID)
if not token:
if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization:
# When I convert the existing users, people who didn't check the remember-credentials box will be stuck in limbo
raise APIException("User not upgraded to OAuth", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

# Use refresh token to get access token
# Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff
params = {"grant_type": "refresh_token", "refresh_token": serviceRecord.Authorization["RefreshToken"], "client_id": SPORTTRACKS_CLIENT_ID, "client_secret": SPORTTRACKS_CLIENT_SECRET, "redirect_uri": "https://tapiriik.com/auth/return/sporttracks"}
response = requests.post("https://api.sporttracks.mobi/oauth2/token", data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"})
if response.status_code != 200:
if response.status_code >= 400 and response.status_code < 500:
raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text), block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text))
token = response.json()["access_token"]
self._tokenCache.Set(serviceRecord.ExternalID, token)

return {"Authorization": "Bearer %s" % token}

def RetrieveAuthorizationToken(self, req, level):
from tapiriik.services import Service
# might consider a real OAuth client
code = req.GET.get("code")
params = {"grant_type": "authorization_code", "code": code, "client_id": SPORTTRACKS_CLIENT_ID, "client_secret": SPORTTRACKS_CLIENT_SECRET, "redirect_uri": WEB_ROOT + reverse("oauth_return", kwargs={"service": "sporttracks"})}

response = requests.post("https://api.sporttracks.mobi/oauth2/token", data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"})
if response.status_code != 200:
print(response.text)
raise APIException("Invalid code")
access_token = response.json()["access_token"]
refresh_token = response.json()["refresh_token"]

existingRecord = Service.GetServiceRecordWithAuthDetails(self, {"Token": access_token})
if existingRecord is None:
uid_res = requests.post("https://api.sporttracks.mobi/api/v2/system/connect", headers={"Authorization": "Bearer %s" % access_token})
uid = uid_res.json()["user"]["uid"]
else:
uid = existingRecord.ExternalID
def fetchUid(tokenData):
access_token = tokenData["access_token"]
uid_res = self._oaClient.post(None, "https://api.sporttracks.mobi/api/v2/system/connect", access_token=access_token)
return uid_res.json()["user"]["uid"]

return (uid, {"RefreshToken": refresh_token})
return self._oaClient.retrieveAuthorizationToken(self, req, WEB_ROOT + reverse("oauth_return", kwargs={"service": "sporttracks"}), fetchUid)

def RevokeAuthorization(self, serviceRecord):
pass # Can't revoke these tokens :(
Expand All @@ -191,18 +155,19 @@ def DeleteCachedData(self, serviceRecord):
cachedb.sporttracks_meta_cache.remove({"ExternalID": serviceRecord.ExternalID})

def DownloadActivityList(self, serviceRecord, exhaustive=False):
headers = self._getAuthHeaders(serviceRecord)
activities = []
exclusions = []
pageUri = self.OpenFitEndpoint + "/fitnessActivities.json"

session = self._oaClient.session(serviceRecord)

activity_tz_cache_raw = cachedb.sporttracks_meta_cache.find_one({"ExternalID": serviceRecord.ExternalID})
activity_tz_cache_raw = activity_tz_cache_raw if activity_tz_cache_raw else {"Activities":[]}
activity_tz_cache = dict([(x["ActivityURI"], x["TZ"]) for x in activity_tz_cache_raw["Activities"]])

while True:
logger.debug("Req against " + pageUri)
res = requests.get(pageUri, headers=headers)
res = session.get(pageUri)
try:
res = res.json()
except ValueError:
Expand Down Expand Up @@ -232,7 +197,7 @@ def DownloadActivityList(self, serviceRecord, exhaustive=False):
else:
# So, we get the first location in the activity and calculate the TZ from that.
try:
firstLocation = self._downloadActivity(serviceRecord, activity, returnFirstLocation=True)
firstLocation = self._downloadActivity(session, activity, returnFirstLocation=True)
except APIExcludeActivity:
pass
else:
Expand Down Expand Up @@ -270,10 +235,9 @@ def DownloadActivityList(self, serviceRecord, exhaustive=False):
cachedb.sporttracks_meta_cache.update({"ExternalID": serviceRecord.ExternalID}, {"ExternalID": serviceRecord.ExternalID, "Activities": [{"ActivityURI": k, "TZ": v} for k, v in activity_tz_cache.items()]}, upsert=True)
return activities, exclusions

def _downloadActivity(self, serviceRecord, activity, returnFirstLocation=False):
def _downloadActivity(self, session, activity, returnFirstLocation=False):
activityURI = activity.ServiceData["ActivityURI"]
headers = self._getAuthHeaders(serviceRecord)
activityData = requests.get(activityURI, headers=headers)
activityData = session.get(activityURI)
activityData = activityData.json()

if "clock_duration" in activityData:
Expand Down Expand Up @@ -457,7 +421,8 @@ def hasStreamData(stream):
return activity

def DownloadActivity(self, serviceRecord, activity):
return self._downloadActivity(serviceRecord, activity)
session = self._oaClient.session(serviceRecord)
return self._downloadActivity(session, activity)

def UploadActivity(self, serviceRecord, activity):
activityData = {}
Expand Down Expand Up @@ -557,9 +522,8 @@ def stream_append(stream, wp, data):
activityData["location"] = location_stream
activityData["timer_stops"] = [[y.isoformat() for y in x] for x in timer_stops]

headers = self._getAuthHeaders(serviceRecord)
headers.update({"Content-Type": "application/json"})
upload_resp = requests.post(self.OpenFitEndpoint + "/fitnessActivities.json", data=json.dumps(activityData), headers=headers)
headers = {"Content-Type": "application/json"}
upload_resp = self._oaClient.post(serviceRecord, self.OpenFitEndpoint + "/fitnessActivities.json", data=json.dumps(activityData), headers=headers)
if upload_resp.status_code != 200:
if upload_resp.status_code == 401:
raise APIException("ST.mobi trial expired", block=True, user_exception=UserException(UserExceptionType.AccountExpired, intervention_required=True))
Expand Down
79 changes: 79 additions & 0 deletions tapiriik/services/oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from tapiriik.services.api import APIException, UserException, UserExceptionType
from tapiriik.services.sessioncache import SessionCache
from datetime import timedelta
import requests
import urllib.parse


class OAuth2Client():
"""
A simple helper you can add to a service to automatically refresh oauth2 tokens
"""

def __init__(self, clientID, clientSecret, tokenUrl, tokenTimeoutMin=60):
self._tokenCache = SessionCache(lifetime=timedelta(minutes=tokenTimeoutMin), freshen_on_get=False)
self._tokenUrl = tokenUrl
self._clientID = clientID
self._clientSecret = clientSecret

def _getAuthHeaders(self, serviceRec, token=None):
token = token or self._tokenCache.Get(serviceRec.ExternalID)
if not token:
if not serviceRec.Authorization or "RefreshToken" not in serviceRec.Authorization:
# When I convert the existing sportstracks users, people who didn't check the remember-credentials box will be stuck in limbo
raise APIException("User not upgraded to OAuth", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

# Use refresh token to get access token (no redirect url required)
params = {"grant_type": "refresh_token", "refresh_token": serviceRec.Authorization["RefreshToken"], "client_id": self._clientID, "client_secret": self._clientSecret}
response = requests.post(self._tokenUrl, data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"})
if response.status_code != 200:
if response.status_code >= 400 and response.status_code < 500:
raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text), block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text))
token = response.json()["access_token"]
self._tokenCache.Set(serviceRec.ExternalID, token)

return {"Authorization": "Bearer %s" % token}

def retrieveAuthorizationToken(self, service, req, redirectUri, getUidCallback):
"""
Implements most of the work for ServiceBase.RetrieveAuthorizationToken.
The getUidCallback is given the token data and must extract a usable
user ID from it - or make requests to get one.
"""
from tapiriik.services import Service
code = req.GET.get("code")
params = {"grant_type": "authorization_code", "code": code, "client_id": self._clientID, "client_secret": self._clientSecret, "redirect_uri": redirectUri}
response = requests.post(self._tokenUrl, data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"})
if response.status_code != 200:
print(response.text)
raise APIException("Invalid code")
data = response.json()
access_token = data["access_token"]
refresh_token = data["refresh_token"]

existingRecord = Service.GetServiceRecordWithAuthDetails(service, {"Token": access_token})
if existingRecord is None:
uid = getUidCallback(data)
else:
uid = existingRecord.ExternalID

return (uid, {"RefreshToken": refresh_token})

def get(self, serviceRec, url, params=None, headers=None, access_token=None):
auth_headers = self._getAuthHeaders(serviceRec, token=access_token)
if headers:
auth_headers.update(headers)

return requests.get(url, params=params, headers=auth_headers)

def post(self, serviceRec, url, params=None, data=None, headers=None, access_token=None):
auth_headers = self._getAuthHeaders(serviceRec, token=access_token)
if headers:
auth_headers.update(headers)
return requests.post(url, params=params, data=data, headers=auth_headers)

def session(self, serviceRec):
s = requests.Session()
s.headers.update(self._getAuthHeaders(serviceRec))
return s