diff --git a/pushserver/applications/firebase.py b/pushserver/applications/firebase.py index 6f79b11..d5b9e66 100644 --- a/pushserver/applications/firebase.py +++ b/pushserver/applications/firebase.py @@ -1,93 +1,114 @@ import json from pushserver.resources import settings +from oauth2client.service_account import ServiceAccountCredentials __all__ = ['FirebaseHeaders', 'FirebasePayload'] class FirebaseHeaders(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type: str, silent: bool, reason: str): """ :param app_id: `str` id provided by the mobile application (bundle id) :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason - self.access_token = '' + + try: + self.auth_key = settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] + except KeyError: + self.auth_key = None + + try: + self.auth_file = settings.params.pns_register[(self.app_id, 'firebase')]['auth_file'] + except KeyError: + self.auth_file = None - self.auth_key = \ - settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] - if not self.auth_key: - pns_dict = settings.params.pns_register[(self.app_id, 'firebase')]['pns'].__dict__ - self.access_token = pns_dict['access_token'] + @property + def access_token(self) -> str: + #https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 + """ + Retrieve a valid access token that can be used to authorize requests. + :return: `str` Access token. + """ + scopes = ['https://www.googleapis.com/auth/firebase.messaging'] + try: + credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, scopes) + access_token_info = credentials.get_access_token() + return access_token_info.access_token + except Exception as e: + self.error = f"Error: cannot generated Firebase access token: {e}" + return '' + @property def headers(self): """ Generate Firebase headers structure for a push notification :return: a firebase push notification header. """ if self.auth_key: headers = {'Content-Type': 'application/json', 'Authorization': f"key={self.auth_key}"} else: headers = { 'Authorization': f"Bearer {self.access_token}", 'Content-Type': 'application/json; UTF-8', } return headers class FirebasePayload(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, sip_to: str, media_type: str, silent: bool, reason: str): """ :param token: `str` destination device token (required for sylk-apple) :param call_id: `str` unique SIP session id for each call :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param sip_from: `str` originator of the sip call. :param from_display_name: `str` :param sip_to: `str` destination uri """ self.app_id = app_id self.token = token self.call_id = call_id # corresponds to session_id in the output self.event = event self.media_type = media_type self.sip_from = sip_from self.from_display_name = from_display_name self.sip_to = sip_to self.silent = silent self.reason = reason @property def payload(self) -> dict: """ Generate a Firebase payload for a push notification :return a Firebase payload: """ payload = {} return payload diff --git a/pushserver/pns/firebase.py b/pushserver/pns/firebase.py index c8c4fa4..ebdc897 100644 --- a/pushserver/pns/firebase.py +++ b/pushserver/pns/firebase.py @@ -1,346 +1,291 @@ import json import os import time from datetime import datetime import oauth2client import requests -from oauth2client.service_account import ServiceAccountCredentials from pushserver.models.requests import WakeUpRequest from requests.adapters import HTTPAdapter from urllib3 import Retry from pushserver.pns.base import PNS, PushRequest, PlatformRegister from pushserver.resources.utils import log_event, fix_non_serializable_types class FirebasePNS(PNS): """ A Firebase Push Notification service """ def __init__(self, app_id: str, app_name: str, url_push: str, voip: bool, auth_key: str = None, auth_file: str = None): """ :param app_id `str`: Application ID. :param url_push `str`: URI to push a notification. :param voip `bool`: required for apple, `True` for voip push notification type. :param auth_key `str`: A Firebase credential for push notifications. :param auth_file `str`: A Firebase credential for push notifications. """ self.app_id = app_id self.app_name = app_name self.url_push = url_push self.voip = voip self.auth_key = auth_key self.auth_file = auth_file self.error = '' - self.last_update_token = None - self.access_token = self.set_access_token() - - def set_access_token(self) -> str: - """ - Retrieve a valid access token that can be used to authorize requests. - :return: `str` Access token. - """ - - #https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 - - if not self.auth_file or not os.path.exists(self.auth_file): - self.error = f"Cannot generate Firebase access token, " \ - f"auth file {self.auth_file} not found" - return '' - - scopes = ['https://www.googleapis.com/auth/firebase.messaging'] - try: - credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, scopes) - oauth2client.client.logger.setLevel('CRITICAL') - access_token_info = credentials.get_access_token() - - self.last_update_token = datetime.now() - return access_token_info.access_token - except Exception as e: - self.error = f"Error: cannot generated Firebase access token: {e}" - return '' - + class FirebaseRegister(PlatformRegister): def __init__(self, app_id: str, app_name: str, voip: bool, config_dict: dict, credentials_path: str, loggers: dict): self.app_id = app_id self.app_name = app_name self.voip = voip self.credentials_path = credentials_path self.config_dict = config_dict self.loggers = loggers self.error = '' self.auth_key, self.auth_file = self.set_auths() @property def url_push(self): try: return self.config_dict['firebase_push_url'] except KeyError: self.error = 'firebase_push_url not found in applications.ini' return None def set_auths(self): - auth_key = None auth_file = None try: auth_key = self.config_dict['firebase_authorization_key'] except KeyError: try: auth_file = self.config_dict['firebase_authorization_file'] if self.credentials_path: auth_file = f"{self.credentials_path}/" \ f"{auth_file}" else: pass if not os.path.exists(auth_file): self.error = f'{auth_file} - no such file' except KeyError: self.error = 'not firebase_authorization_key or ' \ 'firebase_authorization_file found in applications.ini' return auth_key, auth_file @property def pns(self) -> FirebasePNS: pns = None if self.auth_key: auth_file = '' pns = FirebasePNS(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, auth_key=self.auth_key, auth_file=auth_file) elif self.auth_file: pns = FirebasePNS(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, auth_file=self.auth_file) self.error = pns.error if pns.error else '' return pns @property def register_entries(self): if self.error: return {} return {'pns': self.pns, - 'access_token': self.pns.access_token, 'auth_key': self.auth_key, 'auth_file': self.auth_file} class FirebasePushRequest(PushRequest): """ Firebase push notification request """ def __init__(self, error: str, app_name: str, app_id: str, request_id: str, headers: str, payload: dict, loggers: dict, log_remote: dict, wp_request: WakeUpRequest, register: dict): """ :param error: `str` :param app_name: `str` 'linphone' or 'payload' :param app_id: `str` bundle id - :param headers: `AppleHeaders` Apple push notification headers - :param payload: `ApplePayload` Apple push notification payload + :param headers: `FirebaseHeaders` Firebase push notification headers + :param payload: `FirebasePayload`Firebase push notification payload :param wp_request: `WakeUpRequest` :param loggers: `dict` global logging instances to write messages (params.loggers) """ self.error = error self.app_name = app_name self.app_id = app_id self.platform = 'firebase' self.request_id = request_id self.headers = headers self.payload = payload self.token = wp_request.token self.wp_request = wp_request self.loggers = loggers self.log_remote = log_remote self.pns = register['pns'] self.path = self.pns.url_push self.results = self.send_notification() def requests_retry_session(self, counter=0): """ Define parameters to retry a push notification according to media_type. :param counter: `int` (optional) if retries was necessary because of connection fails Following rfc3261 specification, an exponential backoff factor is used. More specifically: backoff = 0.5 T1 = 500ms max_retries_call = 7 time_to_live_call = 64 seconds max_retries_sms = 11 time_to_live_sms ~ 2 hours """ retries = self.retries_params[self.media_type] - counter backoff_factor = self.retries_params['bo_factor'] * 0.5 * 2 ** counter status_forcelist = tuple([status for status in range(500, 600)]) session = None session = session or requests.Session() retry = Retry( total=retries, read=retries, connect=retries, backoff_factor=backoff_factor, status_forcelist=status_forcelist) adapter = HTTPAdapter(max_retries=retry) session.mount('http://', adapter) session.mount('https://', adapter) return session - def send_notification(self, got401=False) -> dict: + def send_notification(self) -> dict: """ Send a Firebase push notification """ if self.error: self.log_error() return {'code': 500, 'body': {}, 'reason': 'Internal server error'} n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) counter = 0 error = False code = 500 reason = "" body = None response = None - + while counter <= n_retries: - self.log_request(path=self.pns.url_push) - try: response = requests.post(self.pns.url_push, self.payload, headers=self.headers) break except requests.exceptions.RequestException as e: error = True reason = f'connection failed: {e}' counter += 1 timer = backoff_factor * (2 ** (counter - 1)) time.sleep(timer) if counter == n_retries: reason = "maximum retries reached" elif error: try: response = self.requests_retry_session(counter). \ post(self.pns.url_push, self.payload, headers=self.headers) except Exception as x: level = 'error' msg = f"outgoing {self.platform.title()} response for " \ f"{self.request_id}, push failed: " \ f"an error occurred in {x.__class__.__name__}" log_event(loggers=self.loggers, msg=msg, level=level) try: body = response.__dict__ except (TypeError, ValueError): code = 500 reason = 'cannot parse response body' body = {} else: reason = body.get('reason') code = response.status_code for k in ('raw', 'request', 'connection', 'cookies', 'elapsed'): try: del body[k] except KeyError: pass except TypeError: break body = json.dumps(fix_non_serializable_types(body)) if isinstance(body, str): body = json.loads(body) if code == 200: try: failure = body['_content']['failure'] except KeyError: pass else: if failure == 1: reason = body['_content']['results'][0]['error'] code = 410 elif code == 404: try: payload_code = body['_content']['error']['code'] except KeyError: pass else: if payload_code == 404: code = 410 keys = list(body.keys()) for key in keys: if not body[key]: del body[key] results = {'body': body, 'code': code, 'reason': reason, 'url': self.pns.url_push, 'call_id': self.wp_request.call_id, 'token': self.token } self.results = results self.log_results() - - # Request is missing required authentication credential. - # Expected OAuth 2 access token, login cookie or other valid authentication - # credential. UNAUTHENTICATED - code = results.get('code') - reason = results.get('reason') - if not got401 and code == 401 and reason == 'Unauthorized': - delta = datetime.now() - self.pns.last_update_token - level = 'warn' - msg = f"outgoing {self.platform.title()} response for request " \ - f"{self.request_id} need a new access token - " \ - f"server will refresh it and try again" - log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) - # retry with a new Fireplace access token - self.pns.access_token = self.pns.set_access_token() - level = 'warn' - msg = f"outgoing {self.platform.title()} response for request " \ - f"{self.request_id} a new access token {self.pns.access_token} was generated - " \ - f"trying again" - log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) - - self.results = self.send_notification(got401=True) - return results diff --git a/pushserver/resources/notification.py b/pushserver/resources/notification.py index 97910cb..d7a4ccb 100644 --- a/pushserver/resources/notification.py +++ b/pushserver/resources/notification.py @@ -1,94 +1,93 @@ import importlib import json from pushserver.models.requests import WakeUpRequest from pushserver.resources import settings def handle_request(wp_request, request_id: str) -> dict: """ Create a PushNotification object, and call methods to send the notification. :param wp_request: `WakeUpRequest', received from /push route. :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str`, request ID generated on request event. :return: a `dict` with push notification results """ - push_notification = PushNotification(wp_request=wp_request, - request_id=request_id) + push_notification = PushNotification(wp_request=wp_request, request_id=request_id) results = push_notification.send_notification() return results class PushNotification(object): """ Push Notification actions from wake up request """ def __init__(self, wp_request: WakeUpRequest, request_id: str): """ :param wp_request: `WakeUpRequest`, from http request :param request_id: `str`, request ID generated on request event. """ self.wp_request = wp_request self.app_id = self.wp_request.app_id self.platform = self.wp_request.platform self.pns_register = settings.params.pns_register self.request_id = request_id self.loggers = settings.params.loggers self.log_remote = self.pns_register[(self.app_id, self.platform)].get('log_remote') self.config_dict = self.pns_register[(self.app_id, self.platform)] self.app_name = self.pns_register[(self.app_id, self.platform)]['name'] self.args = [self.app_id, self.wp_request.event, self.wp_request.token, self.wp_request.call_id, self.wp_request.sip_from, self.wp_request.from_display_name, self.wp_request.sip_to, self.wp_request.media_type, self.wp_request.silent, self.wp_request.reason] @property def custom_apps(self): apps = [self.pns_register[key]['name'] for key in self.pns_register.keys()] custom_apps = set(app for app in apps if app not in ('sylk', 'linphone')) return custom_apps def send_notification(self) -> dict: """ Send a push notification according to wakeup request params. """ error = '' headers_class = self.pns_register[(self.app_id, self.platform)]['headers_class'] headers = headers_class(*self.args).headers payload_class = self.pns_register[(self.app_id, self.platform)]['payload_class'] payload = payload_class(*self.args).payload payload = json.dumps(payload) if not (headers and payload): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' if not isinstance(headers, dict) or not isinstance(payload, str): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' register = self.pns_register[(self.app_id, self.platform)] platform_module = importlib.import_module(f'pushserver.pns.{self.platform}') push_request_class = getattr(platform_module, f'{self.platform.capitalize()}PushRequest') push_request = push_request_class(error=error, app_name=self.app_name, app_id=self.app_id, request_id=self.request_id, headers=headers, payload=payload, loggers=self.loggers, log_remote=self.log_remote, wp_request=self.wp_request, register=register) return push_request.results