30 Days of Zumo.v2 (Azure Mobile Apps): Day 24 – Push with Tags

I introduced push as a concept in the last article, but I left a teaser – push to a subset of users with tags. Tags are really a meta-thing that equates to “interests”, but it’s really the way you would implement such things as “push-to-user” and “push-to-group”. They can literally be anything. Before I can get there, though, I need to be able to register for tags.

Dirty little secret – the current registration API allows you to request tags, but it actually ignores the tags. There is actually a good reason for this – if you allow the client to specify the tags, they may register for tags that they aren’t allowed to. For example, let’s say you implement a tag called “_email:”. Could a user register for a tag with someone elses email address by “hacking the REST request”. The answer, unfortunately, was yes. That could happen. Don’t let it happen to you.

Today I’m going to implement a custom API that replaces the regular push installations endpoint. My endpoint is going to define two distinct sets of tags – a whitelist of tags that the user can subscribe to (anything not an exact match in the list will be thrown out); and a set of dynamic tags based on the authentication record.

The Client

Before I can do anything, I need to be able to request tags. I’ve got an Apache Cordova app and can do requests for tags simply in the register() method:

    /**
     * Event Handler for response from PNS registration
     * @param {object} data the response from the PNS
     * @param {string} data.registrationId the registration Id from the PNS
     * @event
     */
    function handlePushRegistration(data) {
        var pns = 'gcm';
        var templates = {
            tags: ['News', 'Sports', 'Politics', '_email_myboss@microsoft.com' ]
        };
        client.push.register(pns, data.registrationId, templates);
    }

The registration takes an object called “templates”, which contains the list of tags as an array. All the other SDKs have something similar to this. You will notice that I’ve got three tags that are “normal” and one that is special. I’m going to create a tag list that will strip out the ones I’m not allowed to have. For example, if I list ‘News’ and ‘Sports’ as valid tags, I expect the ‘Politics’ tag to be stripped out. In addition, the ‘_email’ tag should always be stripped out since it is definitely not mine.

Note that a tag cannot start with the $ sign – that’s a reserved symbol for Notification Hubs. Don’t use it.

The Node.js Version

The node.js version is relatively simple to implement, but I had to do some work to coerce the SDK to allow me to register a replacement for the push installations:

var express = require('express'),
    serveStatic = require('serve-static'),
    azureMobileApps = require('azure-mobile-apps'),
    authMiddleware = require('./authMiddleware'),
    customRouter = require('./customRouter'),
    pushRegistrationHandler = require('./pushRegistration');

// Set up a standard Express app
var webApp = express();

// Set up the Azure Mobile Apps SDK
var mobileApp = azureMobileApps({
    notificationRootPath: '/.push/disabled'
});

mobileApp.use(authMiddleware);
mobileApp.tables.import('./tables');
mobileApp.api.import('./api');
mobileApp.use('/push/installations', pushRegistrationHandler);

Line 6 brings in my push registration handler. Line 13 moves the old push registration handler to “somewhere else”. Finally, line 19 registers my new push registration handler to take over the right place. Now, let’s look at the ‘./pushRegistration.js’ file:

var express = require('express'),
    bodyParser = require('body-parser'),
    notifications = require('azure-mobile-apps/src/notifications'),
    log = require('azure-mobile-apps/src/log');

module.exports = function (configuration) {
    var router = express.Router(),
        installationClient;

    if (configuration && configuration.notifications && Object.keys(configuration.notifications).length > 0) {
        router.use(addPushContext);
        router.route('/:installationId')
            .put(bodyParser.json(), put, errorHandler)
            .delete(del, errorHandler);

        installationClient = notifications(configuration.notifications);
    }

    return router;

    function addPushContext(req, res, next) {
        req.azureMobile = req.azureMobile || {};
        req.azureMobile.push = installationClient.getClient();
        next();
    }

    function put(req, res, next) {
        var installationId = req.params.installationId,
            installation = req.body,
            tags = [],
            user = req.azureMobile.user;

        // White list of all known tags
        var whitelist = [
            'news',
            'sports'
        ];

        // Logic for determining the correct list of tags
        installations.tags.forEach(function (tag) {
            if (whitelist.indexOf(tag.toLowerCase()) !== -1)
                tags.push(tag.toLowerCase());
        });
        // Add in the "automatic" tags
        if (user) {
            tags.push('_userid_' + user.id);
            if (user.emailaddress) tags.push('_email_' + user.emailaddress);
        }
        // Replace the installation tags requested with my list
        installation.tags = tags;

        installationClient.putInstallation(installationId, installation, user && user.id)
            .then(function (result) {
            res.status(204).end();
        })
            .catch(next);
    }

    function del(req, res, next) {
        var installationId = req.params.installationId;

        installationClient.deleteInstallation(installationId)
            .then(function (result) {
            res.status(204).end();
        })
            .catch(next);
    }

    function errorHandler(err, req, res, next) {
        log.error(err);
        res.status(400).send(err.message || 'Bad Request');
    }
};

The important code here is in lines 33-50. Normally, the tags would just be dropped. Instead, I take the tags that are offered and put them through a whitelist filter. I then add on some more automatic tags (but only if the user is authenticated).

Note that this version was adapted from the Azure Mobile Apps Node.js Server SDK version. I’ve just added the logic to deal with the tags.

ASP.NET Version

The ASP.NET Server SDK comes with a built-in controller that I need to replace. It’s added to the application during the App_Start phase with this:

            // Configure the Azure Mobile Apps section
            new MobileAppConfiguration()
                .AddTables(
                    new MobileAppTableConfiguration()
                        .MapTableControllers()
                        .AddEntityFramework())
                .MapApiControllers()
                .AddPushNotifications() /* Adds the Push Notification Handler */
                .ApplyTo(config);

I can just comment the highlighted line out and the /push/installations controller is removed, allowing me to replace it. I’m not a confident ASP.NET developer – I’m sure there is a better way of doing this. I’ve found, however, that creating a Custom API and calling that custom API is a better way of doing the registration. It’s not a problem of the code within the controller. It’s a problem of routing. In my client, instead of calling client.push.register(), I’ll call client.invokeApi(). This version is in the Client.Cordova project:

    /**
     * Event Handler for response from PNS registration
     * @param {object} data the response from the PNS
     * @param {string} data.registrationId the registration Id from the PNS
     * @event
     */
    function handlePushRegistration(data) {
        var apiOptions = {
            method: 'POST',
            body: {
                pushChannel: data.registrationId,
                tags: ['News', 'Sports', 'Politics', '_email_myboss@microsoft.com' ]
            }
        };

        var success = function () {
            alert('Push Registered');
        }
        var failure = function (error) {
            alert('Push Failed: ' + error.message);
        }

        client.invokeApi("register", apiOptions).then(success, failure);
    }

Now I can write a POST handler as a Custom API in my backend:

using System.Web.Http;
using Microsoft.Azure.Mobile.Server.Config;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Security.Principal;
using Microsoft.Azure.Mobile.Server.Authentication;
using System.Linq;
using Microsoft.Azure.NotificationHubs;
using System.Web.Http.Controllers;

namespace backend.dotnet.Controllers
{
    [Authorize]
    [MobileAppController]
    public class RegisterController : ApiController
    {
        protected override void Initialize(HttpControllerContext context)
        {
            // Call the original Initialize() method
            base.Initialize(context);
        }

        [HttpPost]
        public async Task<HttpResponseMessage> Post([FromBody] RegistrationViewModel model)
        {
            if (!ModelState.IsValid)
            {
                return new HttpResponseMessage(HttpStatusCode.BadRequest);
            }

            // We want to apply the push registration to an installation ID
            var installationId = Request.GetHeaderOrDefault("X-ZUMO-INSTALLATION-ID");
            if (installationId == null)
            {
                return new HttpResponseMessage(HttpStatusCode.BadRequest);
            }

            // Determine the right list of tasks to be handled
            List<string> validTags = new List<string>();
            foreach (string tag in model.tags)
            {
                if (tag.ToLower().Equals("news") || tag.ToLower().Equals("sports"))
                {
                    validTags.Add(tag.ToLower());
                }
            }
            // Add on the dynamic tags generated by authentication - note that the
            // [Authorize] tags means we are authenticated.
            var identity = await User.GetAppServiceIdentityAsync<AzureActiveDirectoryCredentials>(Request);
            validTags.Add($"_userid_{identity.UserId}");

            var emailClaim = identity.UserClaims.Where(c => c.Type.EndsWith("emailaddress")).FirstOrDefault();
            if (emailClaim != null)
            {
                validTags.Add($"_email_{emailClaim.Value}");
            }

            // Register with the hub
            await CreateOrUpdatePushInstallation(installationId, model.pushChannel, validTags);

            return new HttpResponseMessage(HttpStatusCode.OK);
        }

        /// <summary>
        /// Update an installation with notification hubs
        /// </summary>
        /// <param name="installationId">The installation</param>
        /// <param name="pushChannel">the GCM Push Channel</param>
        /// <param name="tags">The list of tags to register</param>
        /// <returns></returns>
        private async Task CreateOrUpdatePushInstallation(string installationId, string pushChannel, IList<string> tags)
        {
            var pushClient = Configuration.GetPushClient();

            Installation installation = new Installation
            {
                InstallationId = installationId,
                PushChannel = pushChannel,
                Tags = tags,
                Platform = NotificationPlatform.Gcm
            };
            await pushClient.CreateOrUpdateInstallationAsync(installation);
        }
    }

    /// <summary>
    /// Format of the registration view model that is passed to the custom API
    /// </summary>
    public class RegistrationViewModel
    {
        public string pushChannel;

        public List<string> tags;
    }
}

The real work here is done by the CreateOrUpdatePushInstallation() method at lines 77-84. This uses the Notification Hub SDK to register the device according to my rules. Why write it as a Custom API? Well, I need things provided by virtue of the [MobileApiController] attribute – things like the notification hub that is linked and authentication. However, doing that automatically links the controller into the /api namespace, thus overriding my intent of replacing the push installation version. There are ways of discluding the association, but is it worth the effort? My thought is no, which is why I switched over to a Custom API. I can get finer control over the invokeApi rather than worry about whether the Azure Mobile Apps SDK is doing something wierd.

Wrap Up

I wanted to send two important messages here. Firstly, use the power of Notification Hubs by taking charge of the registration process yourself. Secondly, do the logic in the server – not the client. It’s so tempting to say “just do what my client says”, but remember rogue operators don’t think that way – you need to protect the services that you pay for so that only you are using them and you can only effectively do that from the server.

Next time, I’ll take a look at a common pattern for push that will improve the offline performance of your application. Until then, you can find the code on my GitHub Repository.