Meteor apps en security

In mijn vorige tutorial heb ik een eerste keer met Meteor gewerkt. Alles ging vrij vlot en binnen enkele minuten kan je je eigen web applicatie schrijven met niets anders dan HTML, CSS en JavaScript. In die tutorial hebben we ook kennis gemaakt met de client side MongoDB API. In de todo app kon iedereen zomaar alle data bekijken, editteren en deleten. Dit kan misschien gemakkelijk zijn voor eenvoudige web applicaties, maar op een bepaald punt wil je toch bepaalde acties naar de database kunnen blokkeren of filteren.

In deze tutorial ga ik je tonen hoe je je MongoDB API kan beveiligen en hoe je authenticatie kan toevoegen. Het project dat we daarvoor gaan gebruiken is een chat applicatie waarvan je het resultaat hier kan terug vinden.

Heads up!

In deze tutorial wordt verwacht dat je de basis van Meteor kent. Het is daarom aangeraden mijn vorige tutorial over Meteor te lezen.

Project opzetten

Om security toe te passen ga ik een kleine chat applicatie schrijven en deze stap voor stap beveiligen. Het eerste wat je moet doen is een project aanmaken met het gekende meteor commando.

meteor create meteor-chat-app

Ziezo, we hebben nu een template gecreëerd, tijd om de HTML en JavaScript bestanden te wijzigen. De HTML template kan je hieronder vinden:

<head>
    <title>Chat applicatie</title>
    <link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet" />
    <script src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/js/bootstrap.min.js"></script>
</head>

<body>
    <div class="container">
        {{> messages}}
        {{> new_message}}
    </div>
</body>

<template name="messages">
    {{#each messages}}
        {{> message}}
    {{/each}}
</template>

<template name="message">
    <div class="row">
        <p><strong class="span2">{{name}}:</strong> <span class="span10">{{message}}</span></p>
    </div>
</template>

<template name="new_message">
    <hr />
    <div class="controls controls-row">
        <input type="text" class="span2" id="name" placeholder="Naam" />
        <input type="text" class="span8" id="message" placeholder="Bericht" />
        <button class="btn btn-primary span2"><i class="icon-envelope icon-white"></i> Chat!</button>
    </div>
</template>

Hier staat eigenlijk niets nieuws in ten opzichte van m’n vorige tutorial, vandaar dat ik hier ook vrij snel over ga. Ook in de JavaScript code staat weinig speciaals te vinden. De code hiervoor is:

Messages = new Meteor.Collection("messages");

if (Meteor.isClient) {
    Template.messages.messages = function() {
        return Messages.find({}, {
            sort: {
                timestamp: 1
            }
        });
    };

    Template.new_message.events = {
        'click button.btn-primary' : function() {
            var nameNode = document.getElementById('name');
            var messageNode = document.getElementById('message');
            Messages.insert({
                name: nameNode.value,
                message: messageNode.value,
                timestamp: new Date()
            });
            messageNode.value = "";
        }
    };
}

if (Meteor.isServer) {
    Meteor.startup(function () {
        // code to run on server at startup
    });
}

Security breach

We hebben nu een eenvoudige chat applicatie geschreven in nog geen 10 minuten tijd, de vraag is enkel, is dit wel een veilige/goede applicatie?
Als we de applicatie eenmaal lokaal deployen met de commando’s:

cd meteor-chat-app
meteor

Dan kunnen we navigeren naar http://localhost:3000 om de eerste applicatie al eens te testen. Alles lijkt mooi te werken totdat we onze console openen en het volgende invoeren:

Messages.find({})

client-mongodb-api

Hiermee kunnen we alle berichten ophalen en alle properties (zoals de

_id

) bekijken. Met deze properties kan je dan weer objecten gaan updaten en verwijderen zonder dat je ook maar iets van autorisatie nodig hebt. Als ik dan bijvoorbeeld onderstaand commando zou uitvoeren zou ik een bericht kunnen verwijderen.

Messages.remove({ _id: 'JfgSsTqZB72vdRMEx' });

Dit is uiteraard niet de bedoeling en vandaar gaan we enkele aanpassingen maken aan onze applicatie. De eerste aanpassing is dat we het niet langer meer gaan toelaten om zomaar objecten te schrijven naar MongoDB. Hiervoor moeten we even de huidige deploy beëindigen en het volgende commando uitvoeren:

meteor remove insecure

remove-insecure

Als we dan opnieuw de applicatie deployen en het <code>remove()</code> commando uitvoeren, krijgen we onderstaande foutmelding te zien.

insecure-access-denied

Zoals je kan zien heeft nu niemand nog toegang om objecten aan te maken, te updaten of te verwijderen. Dit levert uiteraard nog enkele problemen op want nu kunnen we ook geen chatberichten meer toevoegen! Als je nu een bericht zou toevoegen zou je het zien verschijnen maar snel daarna ook weer zien verdwijnen. Dit is het principe van latency compensation. De client update namelijk al z’n view, maar omdat de back-end het niet toelaat wordt de aanpassing weer ongedaan gemaakt en dat out of the box.

Authenticatie

Het eerste wat we moeten doen alvorens we rechten kunnen toekennen is een vorm van authenticatie aan te zetten. Meteor zorgt hiervoor via enkele modules die je zo kan installeren. Het eerste commando zorgt voor authenticatie support, namelijk:

meteor add accounts-password

De tweede module zorgt er dan weer voor een UI framework hier bovenop zodat de authenticatie via de UI kan gebeuren (standaard login/logout/change password/reset forms). Het commando hiervoor is:

meteor add accounts-ui

add-authentication-modules

Nu moeten we enkel nog voorzien in de template waar we het login menu willen. Pas daarom de

<body>

aan door de volgende code:

<body>
    <div class="container">
        <div class="header row">
            <div class="span7">
                <h3>Chat applicatie</h3>
            </div>
            <div class="span5">
                <div class="pull-right">
                    {{loginButtons align="right"}}
                </div>
            </div>
        </div>
        {{> messages}}
        {{> new_message}}
    </div>
</body>

Als we nu het project opnieuw deployen zal je zien dat er rechts bovenaan een login menu bij is gekomen met alle functionaliteit die je nodig hebt.

meteor-accounts-ui

Uiteraard is er voor de rest nog niets veranderd, er kan nog steeds niemand de MongoDB API gebruiken. Om hier aanpassingen aan te maken moeten we bovenaan het JavaScript enkele filters definiëren met

Meteor.allow()

. De code hiervoor is:

Messages = new Meteor.Collection("messages");
Messages.allow({
    'insert': function(userId, message) {
        return userId && message.owner && message.owner.userId == userId;
    },

    'update': function(userId, messages, fields, modifier) {
        return false;
    },

    'remove': function(userId, message) {
        return userId && message.owner && message.owner.userId == userId;
    }
});

Deze filters laten bepaalde calls naar de MongoDB API toe of niet afhankelijk van wat de filter-functie als resultaat geeft. We zullen eerst beginnen bij de update actie omdat deze het eenvoudigste is. We laten hier geen wijzigingen toe (dit kan uiteraard later altijd veranderen), dus moeten we als return value gewoon altijd

false

geven.
Bij de insert en remove actie gaan we wel een uitgebreidere filter toevoegen. Hier gaan we namelijk controleren of de gebruiker die de actie verricht dezelfde is als diegene die het bericht geplaatst heeft. Hiervoor gaan we een extra property

owner

introduceren die de ID van de gebruiker zal bevatten.

Gebruikersnaam koppelen

Omdat we nu met een vorm van authenticatie gaan werken is het wellicht ook verstandig om de naam van de gebruiker te koppelen aan deze user. In deze tutorial ga ik niet teveel aanpassen aan de authenticatie-modules dus daarom gaan we deze functionaliteit er bovenop maken.
Eerst moetne we een extra schema introduceren dat de gebruikersnaam kan koppelen aan een user ID. Dit schema noemen we

Usernames

.

Omdat we niet willen toelaten dat de gebruiker zomaar extra namen kan toevoegen gaan we alle acties gewoon tegenhouden en ervoor zorgen dat enkel via de server deze interactie kan verlopen. De code hiervoor is:

Usernames = new Meteor.Collection("usernames");
Usernames.allow({
    'insert': function(userId, user) {
        return false;
    },

    'update': function(userId, users, fields, modifier) {
        return false;
    },

    'remove': function(userId, user) {
        return true;
    }
});

Nu moeten we enkel nog een actie voorzien dat de client de server kan aanroepen en de user kan invoeren. Hiervoor gebruiken we de volgende code:

Meteor.methods({
    createUsername: function(userId, name, message) {
        if (userId && Usernames.find({ name: name }).count() == 0) {
            var id = Usernames.insert({
                userId: userId,
                name: name
            });
            Messages.insert({
                message: message,
                timestamp: new Date(),
                owner: Usernames.findOne({ userId: userId })
            });
            return id;
        } else if (!this.userId) {
            throw new Meteor.Error(403, "You must be logged in");
        } else {
            throw new Meteor.Error(400, "There is already someone with your username");
        }
    }
});

Als iemand een bericht post en hij nog geen gebruikersnaam heeft, zal dit eerst gevraagd worden (dit gaan we later nog implementeren). Dit wilt wel zeggen dat het eerste bericht van de gebruiker serverside gepost moet worden omdat we anders met een synchronisatieprobleem zitten omdat de client zal proberen het bericht toe te voegen terwijl de server nog bezig is met de user toe te voegen. Daarom doen we in deze functie twee zaken, we voegen een gebruikersnaam toe en we voegen het bericht toe op basis van deze gebruiker.

Hier zie je ook direct dat we in de

owner

property het Username object gaan plaatsen van deze gebruiker.
Alvorens we de username toevoegen moeten we echter wel valideren of de gebruikersnaam wel bestaat en of de gebruiker ingelogd is of niet. Indien dit niet het geval is gaan we een error terug geven in de vorm van een

Meteor.Error()

. Het mooie hieraan is dat we eigenlijk een soort van REST API beschikbaar stellen voor de client waar we zelf eigenlijk weinig rekening mee moeten houden.

Gebruikersnaam templates

Wat we nu gaan doen is de user interface updaten zodat de gebruikersnaam opgevraagd kan worden. Hiervoor moeten we eerst de

<body>

aanpassen door deze code:

<body>
    <div class="container">
        <div class="header row">
            <div class="span7">
                <h3>Chat applicatie</h3>
            </div>
            <div class="span5">
                <div class="pull-right">
                    {{loginButtons align="right"}}
                </div>
            </div>
        </div>
        {{> messages}}
        {{> new_message}}
        {{> username_modal}}
    </div>
</body>

Wat nieuw hieraan is is de template

username_modal

die een Twitter Bootstrap modal (dialog) zal aanmaken. De template code hiervoor is:

<template name="username_modal">
    <div class="modal hide" tabindex="-1" role="dialog" aria-hidden="true" id="gebruikersNaamModal">
        <div class="modal-header">
            <h3>Kies een gebruikersnaam</h3>
        </div>
        <div class="modal-body">
            <form class="form-horizontal">
                <div class="control-group">
                    <label class="control-label" for="myUsername">Gebruikersnaam</label>
                    <div class="controls">
                        <input type="text" id="myUsername" placeholder="Gebruikersnaam">
                    </div>
                </div>
            </form>
        </div>
        <div class="modal-footer">
            <a href="#" class="btn btn-primary" id="setUsername">OK</a>
            <input type="submit" class="hide" />
        </div>
    </div>
</template>

Omdat we de gebruikersnaam nu op een andere manier gaa opslaan moeten we uiteraard ook de message template aanpassen door deze code:

<template name="message">
    <div class="row">
        <p>
            <strong class="span2">{{owner.name}}:</strong> <span class="span9">{{message}}</span>
        </p>
    </div>
</template>

Ten slotte moeten we de new_message form ook aanpassen omdat we nu de gebruikersnaam niet meer opvragen.

<template name="new_message">
    <hr />
    <form>
        <div class="controls controls-row">
            <input type="text" class="span9" id="message" placeholder="Bericht" />
            <button class="btn btn-primary span3"><i class="icon-envelope icon-white"></i> Chat!</button>
            <input type="submit" class="hide" />
        </div>
    </form>
</template>

We hebben ook een submit button toegevoegd omdat sommige browsers het submit-event niet toelaten als er geen submit-knop te vinden is.

Event handlers

Bij deze nieuwe templates horen ook nieuwe event handlers die de gebruikersnaam zullen opvragen en de juiste code opvragen. Het eerste wat we doen is bij het versturen van een nieuw bericht is dat we gaan controleren of de gebruiker al een gebruikersnaam heeft of niet.
Hiervoor passen we de event handlers voor de

new_message

template aan naar:

Template.new_message.events = {
    'click button.btn-primary, submit' : function(evt) {
        if (Meteor.userId() && !Usernames.findOne({ userId: Meteor.userId() })) {
            $("#gebruikersNaamModal").show();
        } else {
            var messageNode = document.getElementById('message');
            Messages.insert({
                message: messageNode.value,
                timestamp: new Date(),
                owner: Usernames.findOne({ userId: Meteor.userId() })
            });
            messageNode.value = "";
        }
        evt.preventDefault();
    }
};

Zoals je kan zien gaa nwe eerst controleren of de gebruiker ingelogd is (en een user ID heeft) en of er in het Usernames schema een gebruikersnaam gevonden wordt. De user ID van de huidige gebruiker kunnen we opvragen met

Meteor.userId()

. Als de gebruiker nog geen naam heeft gaan we de modal tonen door middel van

$("#gebruikersNaamModal").show();

.
Indien de gebruiker al een naam gekozen heeft doen we gewoon hetzelfde als ervoor, behalve dat de naam van de gebruiker nu in de

owner

property terecht zal komen.

Onderaan hebben we ook nog

evt.preventDefault();

geplaatst omdat we niet willen dat de browser zelf nog acties onderneemt als de form gesubmit wordt (pagina vernieuwen ofzo).

Naast deze aangepaste event handler moeten we nu ook een event handler voorzien als de

username_modal

form gesubmit wordt (en er dus een gebruikersnaam ingevoerd wordt).

Template.username_modal.events = {
    'click a#setUsername, submit': function() {
        var usernameNode = document.getElementById('myUsername');
        var messageNode = document.getElementById('message');

        Meteor.call('createUsername', Meteor.userId(), usernameNode.value, messageNode.value);
        $("#gebruikersNaamModal").hide();
        messageNode.value = "";
        evt.preventDefault();
    }
};

Zoals je kan zien roepen we hier de

createUsername

methode aan op de server die we eerder geschreven hadden.

Verbeteringen aanbrengen

Als we nu het project uitvoeren zou de applicatie perfect werken, maar nu kunnen we ook nog enkele extra acties voorzien die de applicatie nog gebruiksvriendelijker maken. Zo kunnen we bijvoorbeeld een knop voorzien waarmee je je eigen berichten kan verwijderen. Hiervoor moeten we de

message

template vervangen door:

<template name="message">
    <div class="row">
        <p>
            <strong class="span2">{{owner.name}}:</strong> <span class="span9">{{message}}</span>
            {{#if isOwner owner}}
                <a class="close pull-right" href="#" data-id="{{_id}}">&times;</a>
            {{/if}}
        </p>
    </div>
</template>

Zoals je ziet controleren we nu of de gebruiker ook de owner is van het bericht, indien dat zo is gaan we een kruisje voorzien waar de gebruiker op kan klikken.
De

isOwner

functionaliteit moeten we uiteraard wel zelf nog schrijven en de code hiervoor is:

Template.message.isOwner = function(owner) {
    return (owner.userId == Meteor.userId());
};

Het argument

owner

geven we mee in de template waardoor de user IDs met elkaar vergeleken kunnen worden. Daarnaast moeten we uiteraard ook nog een event handler voorzien waarmee we het bericht kunnen verwijderen als de gebruiker op het kruisje klikt. Dit doen we met de volgende event handler:

Template.message.events = {
    'click .close': function(evt) {
        Messages.remove({
            _id: evt.target.getAttribute('data-id')
        });
        evt.preventDefault();
    }
};

We hebben nu de applicatie veel interessanter gemaakt en uiteindelijk hebben we 3 lijnen template code en 11 lijnen JavaScript code geschreven wat toch vrij weinig is.

Een tweede aanpassing die interesant is is dat we de berichten form disablen als de gebruiker niet ingelogd is. Hiervoor maken we de volgende aanpassingen aan de

new_message

template:

<template name="new_message">
    <hr />
    <form>
        <div class="controls controls-row">
            {{#if isLoggedIn}}
                <input type="text" class="span9" id="message" placeholder="Bericht" />
                <button class="btn btn-primary span3"><i class="icon-envelope icon-white"></i> Chat!</button>
            {{else}}
                <input type="text" class="span9 disabled" id="message" disabled placeholder="Bericht" />
                <button class="btn btn-primary span3 disabled" disabled><i class="icon-envelope icon-white"></i> Chat!</button>
            {{/if}}
            <input type="submit" class="hide" />
        </div>
    </form>
</template>

Zoals je kan zien hebben we een extra if voorzien met de code

{{#if isLoggedIn}}

die gaat controleren of de gebruiker al dan niet inelogd is. Als de gebruiker niet ingelogd is gaan we overal de disabled classes voorzien zodat de velden niet meer te gebruiken zijn zolang de gebruiker niet ingelogd is.

Uiteraard moeten we net zoals bij de

isOwner

functionaliteit ook wat logica schrijven voor

isLoggedIn

, de code hiervoor is:

Template.new_message.isLoggedIn = function() {
    return Meteor.userId();
};

Met dus 5 regels aan aanpassingen in de template en 3 lijnen JavaScript code hebben we nu validatie voorzien voor als de gebruiker al dan niet ingelogd is.

Project uitvoeren

De chat applicatie is nu echt wel af en nu kunnen we gaan beginnen met testen. Als je nu je browser opent en naar http://localhost:3000 gaat kijken zal je zien dat de applicatie licht gewijzigd is. Zo kan je nu namelijk niets meer invoeren in de chatform zolang je niet ingelogd bent.

not-logged-in

Als je dan eenmaal inlogt en een bericht post, zal je gevraagd worden wat je gebruikersnaam is.

gebruikersnaam-modal

Na het plaatsen van een bericht zal je ook merken dat bij je eigen bericht een “X” te zien is waarmee je het bericht kan verwijderen. Bij berichten van andere gebruikers staat dit niet.

logged-in-remove

Als je nu terug uitlogt zal je merken dat deze kruisjes ook niet meer bij de berichten staat die je zelf gepost hebt, je bent immers niet meer aangemeld.

authorisation-remove

Met een 100-tal regels JavaScript code en een 80-tal lijnen HTML hebben we nu een volwaardige applicatie geschreven met security, authenticatie, autorisatie, REST API, data synchronisatie, live weergave, latency compensation, hot code pushes, … .
Nog niet overtuigd? Wel laten we even Twitter autorisatie voorzien door middel van onderstaand commando:

meteor add accounts-twitter

Eerder een fan van Facebook? Ook dat kan geregeld worden met:

meteor add accounts-facebook

.

social-media-integration

Hiermee zijn we dan ook aan het einde van deze tweede tutorial rond Meteor.

Download project

Download – meteor-chat-app.tar.gz (2 kB)

Tagged , , , .

g00glen00b

Consultant at Cronos and Tech lead at Aquafin. Usually you can find me trying out new libraries and technologies. Loves both Java and JavaScript.