From 54b33510ade39761419763644023cdca0ac8617f Mon Sep 17 00:00:00 2001 From: jrconlin Date: Fri, 2 Aug 2019 18:16:57 -0700 Subject: [PATCH] feat: allow multi app-ids and auths for fcm BREAKING CHANGE: This now supports multiple registrations for FCM. See the updated docs and sample configurations. Obsolete configuration options: * fcm_auth * fcm_senderid * fcm_projectid * fcm_service_cred_path New Configuration ```json fcm_creds={"_profile_": {"projectid": "_fcm_projectid_", "auth": "_fcm_service_cred_path_"}} ``` `v1` protocol is now the default. for existing FCM, _profile_ == _fcm_projectid_ Closes #1340 --- autopush/config.py | 43 ++++---- autopush/main_argparse.py | 28 +++-- autopush/router/fcm.py | 46 ++++---- autopush/router/fcm_v1.py | 53 +++++---- autopush/tests/test_integration.py | 12 ++- autopush/tests/test_router.py | 156 +++++++++++++++------------ autopush/tests/test_z_main.py | 18 ++-- autopush/web/webpush.py | 9 +- configs/autopush_endpoint.ini.sample | 20 ++-- docs/running.rst | 31 ++++-- 10 files changed, 238 insertions(+), 178 deletions(-) diff --git a/autopush/config.py b/autopush/config.py index 67049792..d16057d8 100644 --- a/autopush/config.py +++ b/autopush/config.py @@ -283,28 +283,29 @@ def from_argparse(cls, ns, **kwargs): "Invalid client_certs argument") client_certs[sig] = name - if ns.fcm_enabled: - fcm_core = { - "ttl": ns.fcm_ttl, - "dryrun": ns.fcm_dryrun, - "max_data": ns.max_data, - "collapsekey": ns.fcm_collapsekey} - if len(ns.fcm_auth) > 0: - if not ns.fcm_senderid: - raise InvalidConfig("No SenderID found for FCM") - fcm_core.update({ - "version": 0, - "auth": ns.fcm_auth, - "senderID": ns.fcm_senderid}) - if len(ns.fcm_service_cred_path) > 0: - fcm_core.update({ + if ns.fcm_enabled and ns.fcm_creds: + try: + router_conf["fcm"] = { + "version": ns.fcm_version, "ttl": ns.fcm_ttl, - "version": 1, - "service_cred_path": ns.fcm_service_cred_path, - "senderID": ns.fcm_project_id}) - if "version" not in fcm_core: - raise InvalidConfig("No credential info found for FCM") - router_conf["fcm"] = fcm_core + "dryrun": ns.fcm_dryrun, + "max_data": ns.max_data, + "collapsekey": ns.fcm_collapsekey, + "creds": json.loads(ns.fcm_creds) + } + if not router_conf["fcm"]["creds"]: + raise InvalidConfig( + "Empty credentials for FCM config options" + ) + for creds in router_conf["fcm"]["creds"].values(): + if "auth" not in creds: + raise InvalidConfig( + "Missing auth for FCM config options" + ) + except (ValueError, TypeError): + raise InvalidConfig( + "Invalid JSON specified for FCM config options" + ) if ns.adm_creds: # Create a common admclient diff --git a/autopush/main_argparse.py b/autopush/main_argparse.py index b63e9ed1..3a080c08 100644 --- a/autopush/main_argparse.py +++ b/autopush/main_argparse.py @@ -141,6 +141,11 @@ def _obsolete_args(parser): parser.add_argument('--storage_read_throughput', help="OBSOLETE") parser.add_argument('--storage_write_throughput', help="OBSOLETE") + parser.add_argument('--fcm_enabled', help="OBSOLETE") + parser.add_argument('--fcm_project_id', help="OBSOLETE") + parser.add_argument('--fcm_service_cred_path', help="OBSOLETE") + parser.add_argument('--fcm_senderid_list', help="OBSOLETE") + def _add_external_router_args(parser): """Parses out external router arguments""" @@ -165,10 +170,10 @@ def _add_external_router_args(parser): type=str, default="gcm-http.googleapis.com/gcm/send", env_var="GCM_ENDPOINT") # FCM - parser.add_argument('--fcm_enabled', help="Enable FCM Bridge", - action="store_true", default=False, - env_var="FCM_ENABLED") - label = "FCM Router:" + label = "FCM Router" + parser.add_argument('--fcm_creds', + help="JSON dictionary of {} settings".format(label), + type=str, default="", env_var="FCM_CREDS") parser.add_argument('--fcm_ttl', help="%s Time to Live" % label, type=int, default=60, env_var="FCM_TTL") parser.add_argument('--fcm_dryrun', @@ -179,17 +184,10 @@ def _add_external_router_args(parser): help="%s string to collapse messages" % label, type=str, default="simplepush", env_var="FCM_COLLAPSEKEY") - parser.add_argument('--fcm_auth', help='Auth Key for FCM', - type=str, default="", env_var="FCM_AUTH") - parser.add_argument('--fcm_senderid', help='SenderID for FCM', - type=str, default="", env_var="FCM_SENDERID") - # FCM v1 HTTP API - parser.add_argument('--fcm_project_id', help="FCM Project identifier", - type=str, default="", env_var="FCM_PROJECT_ID") - parser.add_argument('--fcm_service_cred_path', - help="Path to FCM Service Credentials", - type=str, default="", - env_var="FCM_SERVICE_CRED_PATH") + # Specify which FCM version you're using here. + parser.add_argument('--fcm_version', + help="{} version (0=legacy, 1=v1)".format(label), + type=int, default=1, env_var="FCM_VERSION") # Apple Push Notification system (APNs) for iOS # credentials consist of JSON struct containing a channel type diff --git a/autopush/router/fcm.py b/autopush/router/fcm.py index 09c08df9..c182cc1f 100644 --- a/autopush/router/fcm.py +++ b/autopush/router/fcm.py @@ -101,6 +101,14 @@ class FCMRouter(object): } } + def _setter(self, c): + (sid, creds) = c + try: + self.clients[sid] = pyfcm.FCMNotification(api_key=creds["auth"]) + except Exception as e: + self.log.error("Could not instantiate FCM {ex}", ex=e) + raise IOError("FCM Bridge not initiated in main") + def __init__(self, conf, router_conf, metrics): """Create a new FCM router and connect to FCM""" self.conf = conf @@ -109,42 +117,37 @@ def __init__(self, conf, router_conf, metrics): self.min_ttl = router_conf.get("ttl", 60) self.dryRun = router_conf.get("dryrun", False) self.collapseKey = router_conf.get("collapseKey", "webpush") - self.senderID = router_conf.get("senderID") - self.auth = router_conf.get("auth") - self._base_tags = ["platform:fcm"] + self.clients = {} try: - self.fcm = pyfcm.FCMNotification(api_key=self.auth) - except Exception as e: - self.log.error("Could not instantiate FCM {ex}", - ex=e) + map(self._setter, router_conf["creds"].items()) + except KeyError: + self.log.error("Could not instantiate FCM: missing credentials") raise IOError("FCM Bridge not initiated in main") + self._base_tags = ["platform:fcm"] self.log.debug("Starting FCM router...") def amend_endpoint_response(self, response, router_data): # type: (JSONDict, JSONDict) -> None - response["senderid"] = router_data.get('creds', {}).get('senderID') + response["senderid"] = router_data.get('app_id') def register(self, uaid, router_data, app_id, *args, **kwargs): # type: (str, JSONDict, str, *Any, **Any) -> None """Validate that the FCM Instance Token is in the ``router_data``""" - senderid = app_id - # "token" is the GCM registration id token generated by the client. + # "token" is the FCM registration id token generated by the client. if "token" not in router_data: raise self._error("connect info missing FCM Instance 'token'", status=401, uri=kwargs.get('uri'), - senderid=repr(senderid)) + senderid=repr(app_id)) # senderid is the remote client's senderID value. This value is # very difficult for the client to change, and there was a problem # where some clients had an older, invalid senderID. We need to # be able to match senderID to it's corresponding auth key. # If the client has an unexpected or invalid SenderID, # it is impossible for us to reach them. - if not (senderid == self.senderID): + if app_id not in self.clients: raise self._error("Invalid SenderID", status=410, errno=105) - # Assign a senderid - router_data["creds"] = {"senderID": self.senderID, - "auth": self.auth} + router_data["app_id"] = app_id def route_notification(self, notification, uaid_data): """Start the FCM notification routing, returns a deferred""" @@ -183,13 +186,19 @@ def _route(self, notification, router_data): data['cryptokey'] = notification.headers['crypto_key'] elif 'encryption_key' in notification.headers: data['enckey'] = notification.headers['encryption_key'] + try: + client = self.clients[router_data["app_id"]] + except KeyError: + self.log.critical("Missing FCM bridge credentials for {id}", + id=router_data['app_id']) + raise RouterException("Server error", status_code=500, error=901) # registration_ids are the FCM instance tokens (specified during # registration. router_ttl = min(self.MAX_TTL, max(self.min_ttl, notification.ttl or 0)) try: - result = self.fcm.notify_single_device( + result = client.notify_single_device( collapse_key=self.collapseKey, data_message=data, dry_run=self.dryRun or ('dryrun' in router_data), @@ -247,7 +256,7 @@ def _process_reply(self, reply, notification, router_data, ttl): err['msg'], nlen=notification.data_length, regid=router_data["token"], - senderid=self.senderID, + senderid=router_data.get('token'), ttl=notification.ttl, ) raise RouterException("FCM failure to deliver", @@ -255,10 +264,9 @@ def _process_reply(self, reply, notification, router_data, ttl): response_body="Please try request " "later.", log_exception=False) - creds = router_data["creds"] self.log.debug("{msg} : {info}", msg=err['msg'], - info={"senderid": creds.get('registration_id'), + info={"app_id": router_data["app_id"], "reason": reason}) return RouterResponse( status_code=err['err'], diff --git a/autopush/router/fcm_v1.py b/autopush/router/fcm_v1.py index 32e98767..8d072d6a 100644 --- a/autopush/router/fcm_v1.py +++ b/autopush/router/fcm_v1.py @@ -18,6 +18,18 @@ class FCMv1Router(FCMRouter): Note: FCM v1 is a newer version of the FCM HTTP API. """ + def _setter(self, c): + (sid, creds) = c + try: + self.clients[sid] = FCMv1( + project_id=creds["projectid"], + service_cred_path=creds["auth"], + logger=self.log, + metrics=self.metrics) + except Exception as e: + self.log.error("Could not instantiate FCMv1 {ex}", ex=e) + raise IOError("FCMv1 Bridge not initiated in main") + def __init__(self, conf, router_conf, metrics): """Create a new FCM router and connect to FCM""" self.conf = conf @@ -26,34 +38,33 @@ def __init__(self, conf, router_conf, metrics): self.min_ttl = router_conf.get("ttl", 60) self.dryRun = router_conf.get("dryrun", False) self.collapseKey = router_conf.get("collapseKey", "webpush") - self.senderID = router_conf.get("senderID") self.version = router_conf["version"] self.log = Logger() - self.fcm = FCMv1(project_id=self.senderID, - service_cred_path=router_conf['service_cred_path'], - logger=self.log, - metrics=self.metrics) + self.clients = {} + try: + map(self._setter, router_conf["creds"].items()) + except KeyError: + self.log.error("Could not instantiate FCMv1: missing credentials") + raise IOError("FCMv1 Bridge not initiated in main") self._base_tags = ["platform:fcmv1"] self.log.debug("Starting FCMv1 router...") def amend_endpoint_response(self, response, router_data): # type: (JSONDict, JSONDict) -> None - response["senderid"] = self.senderID + response["senderid"] = router_data["app_id"] def register(self, uaid, router_data, app_id, *args, **kwargs): # type: (str, JSONDict, str, *Any, **Any) -> None """Validate that the FCM Instance Token is in the ``router_data``""" - senderid = app_id # "token" is the FCM token generated by the client. if "token" not in router_data: raise self._error("connect info missing FCM Instance 'token'", status=401, uri=kwargs.get('uri'), - senderid=repr(senderid)) - if senderid != self.senderID: + senderid=repr(app_id)) + if app_id not in self.clients: raise self._error("Invalid SenderID", status=410, errno=105) - # Assign a senderid - router_data["creds"] = {"senderID": self.senderID} + router_data["app_id"] = app_id def route_notification(self, notification, uaid_data): """Start the FCM notification routing, returns a deferred""" @@ -95,14 +106,18 @@ def _route(self, notification, router_data): # registration. router_ttl = min(self.MAX_TTL, max(self.min_ttl, notification.ttl or 0)) - d = self.fcm.send( - token=router_data.get("token"), - payload={ - "collapse_key": self.collapseKey, - "data_message": data, - "dry_run": self.dryRun or ('dryrun' in router_data), - "ttl": router_ttl - }) + try: + d = self.clients[router_data["app_id"]].send( + token=router_data.get("token"), + payload={ + "collapse_key": self.collapseKey, + "data_message": data, + "dry_run": self.dryRun or ('dryrun' in router_data), + "ttl": router_ttl + }) + except KeyError: + raise self._error("Invalid Application ID specified", + 404, errno=106, log_exception=False) d.addCallback( self._process_reply, notification, router_data, router_ttl ) diff --git a/autopush/tests/test_integration.py b/autopush/tests/test_integration.py index 77c5ae45..fda339df 100644 --- a/autopush/tests/test_integration.py +++ b/autopush/tests/test_integration.py @@ -1907,12 +1907,13 @@ def _add_router(self): "dryrun": True, "max_data": 4096, "collapsekey": "test", - "senderID": self.senderID, - "auth": "AIzaSyCx9PRtH8ByaJR3CfJamz0D2N0uaCgRGiI", + "creds": { + self.senderID: { + "auth": "AIzaSyCx9PRtH8ByaJR3CfJamz0D2N0uaCgRGiI"} + }, }, self.ep.db.metrics ) - self.ep.routers["fcm"] = fcm # Set up the mock call to avoid calling the live system. # The problem with calling the live system (even sandboxed) is that # you need a valid credential set from a mobile device, which can be @@ -1923,8 +1924,9 @@ def _add_router(self): results=[{}], ) self._mock_send = Mock() - fcm.fcm.send_request = self._mock_send - fcm.fcm.parse_responses = Mock(return_value=reply) + fcm.clients[self.senderID].send_request = self._mock_send + fcm.clients[self.senderID].parse_responses = Mock(return_value=reply) + self.ep.routers["fcm"] = fcm @inlineCallbacks def test_registration(self): diff --git a/autopush/tests/test_router.py b/autopush/tests/test_router.py index b55e6bf6..05200c3d 100644 --- a/autopush/tests/test_router.py +++ b/autopush/tests/test_router.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import copy import decimal import json import socket @@ -311,7 +312,7 @@ def test_too_many_connections(self): assert str(ex.value) == "Too many APNS requests, increase pool from 2" assert ex.value.response_body == "APNS busy, please retry" - def test_amend(self): + def test_apns_amend(self): resp = {"key": "value"} expected = resp.copy() self.router.amend_endpoint_response(resp, {}) @@ -735,7 +736,7 @@ def check_results(fail): d.addBoth(check_results) return d - def test_amend(self): + def test_gcm_amend(self): router_data = {"token": "test123"} self.router.register("uaid", router_data=router_data, app_id="test123") @@ -761,12 +762,18 @@ def setUp(self): self.fcm_config = {'max_data': 32, 'ttl': 60, 'version': 1, - # We specify 'None' here because we're going to - # mock out the actual service credential service. - # This should be a path to a valid service - # credential JSON file. - 'service_cred_path': None, - 'senderID': 'fir-bridgetest'} + 'dryrun': False, + 'collapsekey': 'simplepush', + 'creds': { + 'fir-bridgetest': { + 'projectid': 'fir-bridgetest', + # We specify 'None' here because we're + # going to mock out the actual service + # credential service. + # This should be a path to a valid service + # credential JSON file. + 'auth': None + }}} self._m_request = Deferred() self.response = Mock(spec=treq.response._Response) self.response.code = 200 @@ -776,12 +783,12 @@ def setUp(self): self.response.content = json.dumps( {u'name': (u'projects/fir-bridgetest/messages/' u'0:1510011451922224%7a0e7efbaab8b7cc')}) - self.client = fcmv1client.FCMv1(project_id="SomeKey") + self.client = fcmv1client.FCMv1(project_id="fir-bridgetest") self.client._sender = Mock() self.client.svc_cred = Mock(spec=ServiceAccountCredentials) self.client._sender.return_value = self._m_request self.router = FCMv1Router(conf, self.fcm_config, SinkMetrics()) - self.router.fcm = self.client + self.router.clients = {"fir-bridgetest": self.client} self.headers = {"content-encoding": "aesgcm", "encryption": "test", "encryption-key": "test"} @@ -798,7 +805,8 @@ def setUp(self): self.router_data = dict( router_data=dict( token="connect_data", - creds=dict(senderID="fir-bridgetest"))) + app_id="fir-bridgetest") + ) def _set_content(self, content=None): if content is None: @@ -817,27 +825,26 @@ def _check_error_call(self, exc, code, response=None, errno=None): self.flushLoggedErrors() @patch("autopush.router.fcmv1client.ServiceAccountCredentials") - def test_init(self, m_sac): + def test_bad_init(self, m_sac): conf = AutopushConfig( hostname="localhost", statsd_host=None, ) m_sac.from_json_keyfile_name.side_effect = IOError with pytest.raises(IOError): + bad_router_conf = copy.deepcopy(self.fcm_config) + bad_router_conf["creds"]["fir-bridgetest"]["auth"] = "invalid_path" FCMv1Router(conf, - {"service_cred_path": "invalid_path", - "senderID": "fir-bridgetest", - "version": 1}, + bad_router_conf, SinkMetrics()) def test_register(self): - router_data = {"token": "fir-bridgetest"} + router_data = {"token": "registration_data"} self.router.register( "uaid", router_data=router_data, app_id="fir-bridgetest") # Check the information that will be recorded for this user - assert router_data == { - "token": "fir-bridgetest", - "creds": {"senderID": "fir-bridgetest"}} + assert "fir-bridgetest" in self.router.clients + assert router_data["app_id"] == "fir-bridgetest" def test_register_bad(self): with pytest.raises(RouterException): @@ -1069,8 +1076,8 @@ def check_results(fail): d.addBoth(check_results) return d - def test_amend(self): - router_data = {"token": "fir-bridgetest"} + def test_fcmv1_amend(self): + router_data = {"token": "connection_data"} self.router.register("uaid", router_data=router_data, app_id="fir-bridgetest") resp = {"key": "value"} @@ -1085,6 +1092,23 @@ def test_register_invalid_token(self): router_data={"token": "invalid"}, app_id="invalid") + def test_bad_credentials(self): + del(self.fcm_config['creds']) + with pytest.raises(IOError): + FCMv1Router( + AutopushConfig( + hostname="localhost", + statsd_host=None, + ), + self.fcm_config, + SinkMetrics() + ) + + def test_unknown_appid(self): + self.router_data["router_data"]["app_id"] = "invalid" + with pytest.raises(RouterException): + self.router.route_notification(self.notif, self.router_data) + class FCMRouterTestCase(unittest.TestCase): @@ -1096,10 +1120,31 @@ def setUp(self, ffcm): ) self.fcm_config = {'max_data': 32, 'ttl': 60, - 'senderID': 'test123', - "auth": "12345678abcdefg"} - self.fcm = ffcm + 'version': 1, + 'dryrun': False, + 'collapsekey': 'simplepush', + 'creds': { + 'test123': { + 'app_id': 'test123', + 'auth': "12345678abcdefg" + }}} + self.router_data = dict( + router_data=dict( + token="connect_data", + app_id="test123")) + mock_result = dict( + multicast_id="", + success=0, + failure=0, + canonical_ids=0, + results=[dict()], + ) + self.mock_result = mock_result self.router = FCMRouter(conf, self.fcm_config, SinkMetrics()) + self.fcm = self.router.clients[ + self.router_data['router_data']['app_id'] + ] + self.fcm.notify_single_device.return_value = mock_result self.headers = {"content-encoding": "aesgcm", "encryption": "test", "encryption-key": "test"} @@ -1112,37 +1157,23 @@ def setUp(self, ffcm): ttl=200 ) self.notif.cleanup_headers() - self.router_data = dict( - router_data=dict( - token="connect_data", - creds=dict(senderID="test123", auth="12345678abcdefg"))) - mock_result = dict( - multicast_id="", - success=0, - failure=0, - canonical_ids=0, - results=[dict()], - ) - self.mock_result = mock_result - ffcm.notify_single_device.return_value = mock_result def _check_error_call(self, exc, code): assert isinstance(exc, RouterException) assert exc.status_code == code - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called self.flushLoggedErrors() @patch("pyfcm.FCMNotification", spec=pyfcm.FCMNotification) def test_init(self, ffcm): - conf = AutopushConfig( - hostname="localhost", - statsd_host=None, - ) - def throw_auth(*args, **kwargs): raise Exception("oopsy") ffcm.side_effect = throw_auth + conf = AutopushConfig( + hostname="localhost", + statsd_host=None, + ) with pytest.raises(IOError): FCMRouter(conf, {}, SinkMetrics()) @@ -1151,17 +1182,13 @@ def test_register(self): self.router.register("uaid", router_data=router_data, app_id="test123") # Check the information that will be recorded for this user - assert router_data == { - "token": "test123", - "creds": {"senderID": "test123", - "auth": "12345678abcdefg"}} + assert router_data == {"token": "test123", "app_id": "test123"} def test_register_bad(self): with pytest.raises(RouterException): self.router.register("uaid", router_data={}, app_id="invalid123") def test_route_notification(self): - self.router.fcm = self.fcm d = self.router.route_notification(self.notif, self.router_data) def check_results(result): @@ -1169,9 +1196,9 @@ def check_results(result): assert result.status_code == 201 assert result.logged_status == 200 assert "TTL" in result.headers - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called # Make sure the data was encoded as base64 - args = self.router.fcm.notify_single_device.call_args[1] + args = self.fcm.notify_single_device.call_args[1] data = args['data_message'] assert data['body'] == 'q60d6g' assert data['chid'] == dummy_chid @@ -1182,7 +1209,6 @@ def check_results(result): return d def test_ttl_none(self): - self.router.fcm = self.fcm self.notif = WebPushNotification( uaid=uuid.UUID(dummy_uaid), channel_id=uuid.UUID(dummy_chid), @@ -1195,9 +1221,9 @@ def test_ttl_none(self): def check_results(result): assert isinstance(result, RouterResponse) - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called # Make sure the data was encoded as base64 - args = self.router.fcm.notify_single_device.call_args[1] + args = self.fcm.notify_single_device.call_args[1] data = args['data_message'] assert data['body'] == 'q60d6g' assert data['chid'] == dummy_chid @@ -1210,7 +1236,6 @@ def check_results(result): return d def test_ttl_high(self): - self.router.fcm = self.fcm self.notif = WebPushNotification( uaid=uuid.UUID(dummy_uaid), channel_id=uuid.UUID(dummy_chid), @@ -1223,9 +1248,9 @@ def test_ttl_high(self): def check_results(result): assert isinstance(result, RouterResponse) - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called # Make sure the data was encoded as base64 - args = self.router.fcm.notify_single_device.call_args[1] + args = self.fcm.notify_single_device.call_args[1] data = args['data_message'] assert data['body'] == 'q60d6g' assert data['chid'] == dummy_chid @@ -1238,7 +1263,6 @@ def check_results(result): return d def test_long_data(self): - self.router.fcm = self.fcm bad_notif = WebPushNotification( uaid=uuid.UUID(dummy_uaid), channel_id=uuid.UUID(dummy_chid), @@ -1259,14 +1283,13 @@ def check_results(result): return d def test_route_crypto_notification(self): - self.router.fcm = self.fcm del(self.notif.headers['encryption_key']) self.notif.headers['crypto_key'] = 'crypto' d = self.router.route_notification(self.notif, self.router_data) def check_results(result): assert isinstance(result, RouterResponse) - assert self.router.fcm.notify_single_device.called + assert self.router.clients['test123'].notify_single_device.called d.addCallback(check_results) return d @@ -1274,7 +1297,6 @@ def test_router_notification_fcm_auth_error(self): def throw_auth(*args, **kwargs): raise pyfcm.errors.AuthenticationError() self.fcm.notify_single_device.side_effect = throw_auth - self.router.fcm = self.fcm d = self.router.route_notification(self.notif, self.router_data) def check_results(fail): @@ -1286,7 +1308,6 @@ def test_router_notification_fcm_other_error(self): def throw_other(*args, **kwargs): raise Exception("oh my!") self.fcm.notify_single_device.side_effect = throw_other - self.router.fcm = self.fcm d = self.router.route_notification(self.notif, self.router_data) def check_results(fail): @@ -1301,7 +1322,6 @@ def throw_other(*args, **kwargs): raise ConnectionError("oh my!") self.fcm.notify_single_device.side_effect = throw_other - self.router.fcm = self.fcm d = self.router.route_notification(self.notif, self.router_data) def check_results(fail): @@ -1312,26 +1332,26 @@ def check_results(fail): def test_router_notification_fcm_id_change(self): self.mock_result['canonical_ids'] = 1 self.mock_result['results'][0] = {'registration_id': "new"} - self.router.fcm = self.fcm + self.fcm.notify_single_device.return_value = self.mock_result d = self.router.route_notification(self.notif, self.router_data) def check_results(result): assert isinstance(result, RouterResponse) assert result.router_data == dict(token="new") - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called d.addCallback(check_results) return d def test_router_notification_fcm_not_regged(self): self.mock_result['failure'] = 1 self.mock_result['results'][0] = {'error': 'NotRegistered'} - self.router.fcm = self.fcm + self.fcm.notify_single_device.return_value = self.mock_result d = self.router.route_notification(self.notif, self.router_data) def check_results(result): assert isinstance(result, RouterResponse) assert result.router_data == dict() - assert self.router.fcm.notify_single_device.called + assert self.fcm.notify_single_device.called d.addCallback(check_results) return d @@ -1339,7 +1359,7 @@ def test_router_notification_fcm_failed_items(self): self.mock_result['failure'] = 1 self.mock_result['results'][0] = {'error': 'TopicsMessageRateExceeded'} - self.router.fcm = self.fcm + self.fcm.notify_single_device.return_value = self.mock_result d = self.router.route_notification(self.notif, self.router_data) def check_results(fail): @@ -1356,7 +1376,7 @@ def check_results(fail): d.addBoth(check_results) return d - def test_amend(self): + def test_fcm_amend(self): self.router.register(uaid="uaid", router_data={"token": "test123"}, app_id="test123") diff --git a/autopush/tests/test_z_main.py b/autopush/tests/test_z_main.py index e193893c..9bdd1081 100644 --- a/autopush/tests/test_z_main.py +++ b/autopush/tests/test_z_main.py @@ -290,9 +290,8 @@ class TestArg(AutopushConfig): fcm_ttl = 999 fcm_dryrun = False fcm_collapsekey = "collapse" - fcm_senderid = '12345' - fcm_auth = 'abcde' - fcm_service_cred_path = '' + fcm_creds = json.dumps({"12345": {"auth": "abcd"}}) + fcm_version = 0 ssl_key = "keys/server.crt" ssl_cert = "keys/server.key" ssl_dh_param = None @@ -385,6 +384,7 @@ def test_memusage(self): ], False, resource=autopush.tests.boto_resource) def test_client_certs_parse(self): + print(self.TestArg.fcm_creds) conf = AutopushConfig.from_argparse(self.TestArg) assert conf.client_certs["1A:"*31 + "F9"] == 'partner1' assert conf.client_certs["2B:"*31 + "E8"] == 'partner2' @@ -428,8 +428,6 @@ def test_conf(self, *args): assert app.routers["adm"].router_conf['dev']['client_secret'] == \ "deadbeef0000decafbad1111" - self.TestArg.fcm_service_cred_path = "" - self.TestArg.fcm_project_id = "" conf = AutopushConfig.from_argparse(self.TestArg) assert conf.router_conf['fcm']['version'] == 0 app = EndpointApplication(conf, @@ -444,16 +442,14 @@ def test_bad_senders(self): self.TestArg.senderid_list = old_list def test_bad_fcm_senders(self): - old_auth = self.TestArg.fcm_auth - old_senderid = self.TestArg.fcm_senderid - self.TestArg.fcm_auth = "" + old_list = self.TestArg.fcm_creds + self.TestArg.fcm_creds = json.dumps({"12345": {"foo": "abcd"}}) with pytest.raises(InvalidConfig): AutopushConfig.from_argparse(self.TestArg) - self.TestArg.fcm_auth = old_auth - self.TestArg.fcm_senderid = "" + self.TestArg.fcm_creds = "{}" with pytest.raises(InvalidConfig): AutopushConfig.from_argparse(self.TestArg) - self.TestArg.fcm_senderid = old_senderid + self.TestArg.fcm_creds = old_list def test_gcm_start(self): endpoint_main([ diff --git a/autopush/web/webpush.py b/autopush/web/webpush.py index fa6f2813..98a48c27 100644 --- a/autopush/web/webpush.py +++ b/autopush/web/webpush.py @@ -100,7 +100,7 @@ def validate_uaid_month_and_chid(self, d): raise InvalidRequest("No such subscription", status_code=410, errno=106) - if (router_type in ["gcm", "fcm"] + if (router_type == "gcm" and 'senderID' not in result.get('router_data', {}).get("creds", {})): # Make sure we note that this record is bad. @@ -108,6 +108,13 @@ def validate_uaid_month_and_chid(self, d): result.get('critical_failure', "Missing SenderID") db.router.register_user(result) + if (router_type == "fcm" + and 'app_id' not in result.get('router_data', {})): + # Make sure we note that this record is bad. + result['critical_failure'] = \ + result.get('critical_failure', "Missing SenderID") + db.router.register_user(result) + if result.get("critical_failure"): raise InvalidRequest("Critical Failure: %s" % result.get("critical_failure"), diff --git a/configs/autopush_endpoint.ini.sample b/configs/autopush_endpoint.ini.sample index e67e6eba..ba502693 100644 --- a/configs/autopush_endpoint.ini.sample +++ b/configs/autopush_endpoint.ini.sample @@ -46,16 +46,20 @@ port = 8082 #adm_creds={"profileId":{"client_id":"...","client_secret":"..."}} ; Google Firebase Cloud Messaging (FCM) -; There are two APIs available for FCM, the older Legacy and the newer HTTP v1. Both are functionally equivalent, -; but use different methods to authenticate. +; There are two APIs available for FCM, the older Legacy and the newer HTTP v1. Both are +; functionally equivalent, but use different methods to authenticate. ; Common: # fcm_enabled # fcm_ttl = 60 # fcm_dryrun # fcm_collapsekey=simplepush -; Legacy API -# fcm_auth = -# fcm_senderid = -; v1 HTTP API (this will override the Legacy API) -# fcm_project_id = -# fcm_service_cred_path = +; There are two forms for FCM Legacy (0) and v1 (1). Only one form is allowed at a time. +; Legacy uses "senderid" as the auth token, v1 uses a credential file. You are encouraged to use +; `v1`. +# fcm_version = 1 +; The following are credential definitions for `v1`. +; They're specified as +; `"profile_name": {"projectid": "project-id-nane", "auth": "path/to/auth.json"` +; See https://autopush.readthedocs.io/en/latest/http.html#push-service-bridge-http-interface for +; details. +# fcm_creds={"dev": {"projectid": "fir-bridgetest", "auth": "keys/fir-bridgetest-123abc.json"}} diff --git a/docs/running.rst b/docs/running.rst index ac9874fe..33191d14 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -176,25 +176,34 @@ images, that you ***always*** supply a `CRYPTO_KEY` as part of the run command. Notes on GCM/FCM support ------------------------ -Autopush is capable of routing messages over Google Cloud Messaging/Firebase -Cloud Messaging for android devices. You will need to set up a valid `GCM`_ / +*Note*: GCM is no longer supported by Google. Some legacy users can still use GCM, +but it is strongly recommended that applications use FCM. + +Autopush is capable of routing messages over Firebase +Cloud Messaging for android devices. You will need to set up a valid `FCM`_ account. Once you have an account open the Google Developer Console: * create a new project. Record the Project Number as "SENDER_ID". You will need this value for your android application. -* create a new Auth Credential Key for your project. This is available under - **APIs & Auth** >> **Credentials** of the Google Developer Console. Store - this value as ``gcm_apikey`` or ``fcm_apikey`` (as appropriate) in - ``.autopush_endpoint`` server configuration file. +* in the ``.autopush_endpoint`` server config file: + + * add ``fcm_enabled`` to enable FCM routing. + + * add ``fcm_creds``. This is a json block with the following format: + + {"**app id**": {"projectid": "**project id name**", "auth": "**path to Private Key File**"}, ...} + +where: + +**profile_name**: the URL identifier to be used when registering endpoints. (e.g. if "reference_test" is +chosen here, registration requests should go to `https://updates.push.services.mozilla.com/v1/fcm/reference_test/registration` -* add ``gcm_enabled`` to the ``.autopush_shared`` server configuration file to - enable GCM routing. +**project id name**: the name of the *Project ID* as specified on the https://console.firebase.google.com/ Project Settings > General page. -* add ``fcm_enabled`` to the ``.autopush_shared`` server configuration file to - enable FCM routing. +**path to Private Key File**: path to the Private Key file provided by the Settings > Service accounts > Firebase Admin SDK page. *NOTE*: This is ***NOT*** the "google-services.json" config file. -Additional notes on using the GCM/FCM bridge are available `on the wiki`_. +Additional notes on using the FCM bridge are available `on the wiki`_. .. _`docker`: https://www.docker.com/ .. _`docker-compose`: https://docs.docker.com/compose/