adapted to new authentication procedure
This commit is contained in:
		
							parent
							
								
									ef9b875465
								
							
						
					
					
						commit
						aaed3f44bb
					
				@ -79,19 +79,18 @@ if __name__ == "__main__":
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        with GarminClient(args.username, args.password) as client:
 | 
					        with GarminClient(args.username, args.password) as client:
 | 
				
			||||||
            # get all activity ids and timestamps from Garmin account
 | 
					            # get all activity ids and timestamps from Garmin account
 | 
				
			||||||
            log.info("retrieving activities for {} ...".format(args.username))
 | 
					            log.info("scanning activities for %s ...", args.username)
 | 
				
			||||||
            activities = set(client.list_activities())
 | 
					            activities = set(client.list_activities())
 | 
				
			||||||
            log.info("account has a total of {} activities.".format(
 | 
					            log.info("account has a total of %d activities", len(activities))
 | 
				
			||||||
                len(activities)))
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            missing_activities = garminexport.backup.need_backup(
 | 
					            missing_activities = garminexport.backup.need_backup(
 | 
				
			||||||
                activities, args.backup_dir, args.format)
 | 
					                activities, args.backup_dir, args.format)
 | 
				
			||||||
            backed_up = activities - missing_activities
 | 
					            backed_up = activities - missing_activities
 | 
				
			||||||
            log.info("{} contains {} backed up activities.".format(
 | 
					            log.info("%s contains %d backed up activities",
 | 
				
			||||||
                args.backup_dir, len(backed_up)))
 | 
					                args.backup_dir, len(backed_up))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            log.info("activities that aren't backed up: {}".format(
 | 
					            log.info("activities that aren't backed up: %d",
 | 
				
			||||||
                len(missing_activities)))
 | 
					                     len(missing_activities))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for index, activity in enumerate(missing_activities):
 | 
					            for index, activity in enumerate(missing_activities):
 | 
				
			||||||
                id, start = activity
 | 
					                id, start = activity
 | 
				
			||||||
@ -106,5 +105,4 @@ if __name__ == "__main__":
 | 
				
			|||||||
                        raise
 | 
					                        raise
 | 
				
			||||||
    except Exception as e:
 | 
					    except Exception as e:
 | 
				
			||||||
        exc_type, exc_value, exc_traceback = sys.exc_info()
 | 
					        exc_type, exc_value, exc_traceback = sys.exc_info()
 | 
				
			||||||
        log.error(u"failed with exception: %s", e)
 | 
					        log.error(u"failed with exception: %s", str(e))
 | 
				
			||||||
        raise
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -101,103 +101,54 @@ class GarminClient(object):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _authenticate(self):
 | 
					    def _authenticate(self):
 | 
				
			||||||
        log.info("authenticating user ...")
 | 
					        log.info("authenticating user ...")
 | 
				
			||||||
        params = {
 | 
					        form_data = {
 | 
				
			||||||
            "service": "http://connect.garmin.com/post-auth/login",
 | 
					            "username": self.username,
 | 
				
			||||||
            "clientId": "GarminConnect",
 | 
					            "password": self.password,
 | 
				
			||||||
            "consumeServiceTicket": "false",
 | 
					            "embed": "false"
 | 
				
			||||||
            "gauthHost": "https://sso.garmin.com/sso"
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        flow_execution_key = self._get_flow_execution_key(params)
 | 
					        request_params = {
 | 
				
			||||||
        log.debug("flow execution key: '{}'".format(flow_execution_key))
 | 
					            "service": "https://connect.garmin.com/modern"
 | 
				
			||||||
        validation_url = self._get_auth_ticket(flow_execution_key, params)
 | 
					        }
 | 
				
			||||||
        # recently, the validation url has started to escape slash characters
 | 
					        auth_response = self.session.post(
 | 
				
			||||||
        # (with a backslash). remove any such occurences.
 | 
					            SSO_LOGIN_URL, params=request_params, data=form_data)
 | 
				
			||||||
        validation_url = validation_url.replace("\/", "/")
 | 
					        log.debug("got auth response: %s", auth_response.text)
 | 
				
			||||||
        log.debug("auth ticket validation url: {}".format(validation_url))
 | 
					        if auth_response.status_code != 200:
 | 
				
			||||||
        self._validate_auth_ticket(validation_url)
 | 
					            raise ValueError(
 | 
				
			||||||
 | 
					                "authentication failure: did you enter valid credentials?")
 | 
				
			||||||
 | 
					        auth_ticket_url = self._extract_auth_ticket_url(
 | 
				
			||||||
 | 
					            auth_response.text)
 | 
				
			||||||
 | 
					        log.debug("auth ticket url: '%s'", auth_ticket_url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Referer seems to be a header that is required by the REST API
 | 
					        log.info("claiming auth ticket ...")
 | 
				
			||||||
        self.session.headers.update({'Referer': "https://some.random.site"})
 | 
					        response = self.session.get(auth_ticket_url)
 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
    def _get_flow_execution_key(self, request_params):
 | 
					 | 
				
			||||||
        # The flowExecutionKey is embedded in the
 | 
					 | 
				
			||||||
        # https://sso.garmin.com/sso/login response page. For example:
 | 
					 | 
				
			||||||
        #   <!-- flowExecutionKey: [e3s1] -->
 | 
					 | 
				
			||||||
        log.debug("get flow execution key ...")
 | 
					 | 
				
			||||||
        response = self.session.get(SSO_LOGIN_URL, params=request_params)
 | 
					 | 
				
			||||||
        if response.status_code != 200:
 | 
					        if response.status_code != 200:
 | 
				
			||||||
            raise RuntimeError(
 | 
					            raise RuntimeError(
 | 
				
			||||||
                "auth failure: %s: code %d: %s" %
 | 
					                "auth failure: failed to claim auth ticket: %s: %d\n%s" %
 | 
				
			||||||
                (SSO_LOGIN_URL, response.status_code, response.text))
 | 
					                (auth_ticket_url, response.status_code, response.text))
 | 
				
			||||||
        # extract flowExecutionKey
 | 
					 | 
				
			||||||
        match = re.search(r'<!-- flowExecutionKey: \[(\w+)\]', response.text)
 | 
					 | 
				
			||||||
        if not match:
 | 
					 | 
				
			||||||
            raise RuntimeError(
 | 
					 | 
				
			||||||
                "auth failure: unable to extract flowExecutionKey: %s:\n%s" %
 | 
					 | 
				
			||||||
                (SSO_LOGIN_URL, response.text))
 | 
					 | 
				
			||||||
        flow_execution_key = match.group(1)
 | 
					 | 
				
			||||||
        return flow_execution_key
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    
 | 
					        # appears like we need to touch base with the old API to initiate
 | 
				
			||||||
    def _get_auth_ticket(self, flow_execution_key, request_params):
 | 
					        # some form of legacy session. otherwise certain downloads will fail.
 | 
				
			||||||
        data = {
 | 
					 | 
				
			||||||
            "username": self.username, "password": self.password,
 | 
					 | 
				
			||||||
            "_eventId": "submit", "embed": "true", "lt": flow_execution_key
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        log.debug("single sign-on ...")
 | 
					 | 
				
			||||||
        sso_response = self.session.post(
 | 
					 | 
				
			||||||
            SSO_LOGIN_URL, params=request_params,
 | 
					 | 
				
			||||||
            data=data, allow_redirects=False)
 | 
					 | 
				
			||||||
        # response must contain an SSO ticket
 | 
					 | 
				
			||||||
        ticket_match = re.search("ticket=([^']+)'", sso_response.text)
 | 
					 | 
				
			||||||
        if not ticket_match:
 | 
					 | 
				
			||||||
            raise ValueError("auth failure: unable to get auth ticket: "
 | 
					 | 
				
			||||||
                             "did you enter valid credentials?")
 | 
					 | 
				
			||||||
        ticket = ticket_match.group(1)
 | 
					 | 
				
			||||||
        log.debug("SSO ticket: {}".format(ticket))
 | 
					 | 
				
			||||||
        # response should contain a URL where auth ticket can be validated
 | 
					 | 
				
			||||||
        validation_url = re.search(
 | 
					 | 
				
			||||||
            r"response_url\s+=\s+'([^']+)'", sso_response.text)
 | 
					 | 
				
			||||||
        validation_url = validation_url.group(1)
 | 
					 | 
				
			||||||
        return validation_url
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    def _validate_auth_ticket(self, validation_url):
 | 
					 | 
				
			||||||
        log.debug("validating auth ticket at %s ...", validation_url)
 | 
					 | 
				
			||||||
        response = self.session.get(validation_url, allow_redirects=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # It appears as if from this point on, the User-Agent header needs to
 | 
					 | 
				
			||||||
        # be set to something similar to the value below for authentication
 | 
					 | 
				
			||||||
        # to succeed and for downloads of .fit files to work properly.
 | 
					 | 
				
			||||||
        self.session.headers.update({
 | 
					 | 
				
			||||||
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36'
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # we will be redirected several times. just follow through ..
 | 
					 | 
				
			||||||
        while response.status_code == 302:
 | 
					 | 
				
			||||||
            redirect_url = response.headers['Location']
 | 
					 | 
				
			||||||
            log.debug("redirected to: '%s'", redirect_url)
 | 
					 | 
				
			||||||
            response = self.session.get(redirect_url, allow_redirects=False)
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if response.status_code != 200:
 | 
					 | 
				
			||||||
            raise Exception(
 | 
					 | 
				
			||||||
                u"auth failure: unable to validate auth ticket: {}:\n{}".format(
 | 
					 | 
				
			||||||
                    response.status_code, response.text))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # auth ticket successfully validated.
 | 
					 | 
				
			||||||
        # our client should now have all necessary cookies set.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # as a final step in the "Garmin login rain dance", it appears
 | 
					 | 
				
			||||||
        # as though we need to touch on their legacy session page before
 | 
					 | 
				
			||||||
        # being granted access to some api services (such as the
 | 
					 | 
				
			||||||
        # activity-search-service).
 | 
					 | 
				
			||||||
        self.session.get('https://connect.garmin.com/legacy/session')
 | 
					        self.session.get('https://connect.garmin.com/legacy/session')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _extract_auth_ticket_url(self, auth_response):
 | 
				
			||||||
 | 
					        """Extracts an authentication ticket URL from the response of an
 | 
				
			||||||
 | 
					        authentication form submission. The auth ticket URL is typically
 | 
				
			||||||
 | 
					        of form:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          https://connect.garmin.com/modern?ticket=ST-0123456-aBCDefgh1iJkLmN5opQ9R-cas
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param auth_response: HTML response from an auth form submission.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        match = re.search(
 | 
				
			||||||
 | 
					            r'response_url\s*=\s*"(https:[^"]+)"', auth_response)
 | 
				
			||||||
 | 
					        if not match:
 | 
				
			||||||
 | 
					            raise RuntimeError(
 | 
				
			||||||
 | 
					                "auth failure: unable to extract auth ticket URL. did you provide a correct username/password?")
 | 
				
			||||||
 | 
					        auth_ticket_url = match.group(1).replace("\\", "")
 | 
				
			||||||
 | 
					        return auth_ticket_url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @require_session
 | 
					    @require_session
 | 
				
			||||||
    def list_activities(self):
 | 
					    def list_activities(self):
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user