A RESTful password locker with Django and backbone.js part 5

29 May

In this penultimate part of this series, we’re going to add the ability to share passwords between users. The spec for sharing passwords is as follows:

  1. Users should be able to maintain a contact list of other users with whom they can share passwords. Users must be able to search for other users by user name, and be able to view their first and last name to confirm the user is who they think they are.
  2. If a user removes another user from their contact list, all passwords shared with that user should stop being shared with the removed contact.
  3. Passwords must be able to be shared with multiple users in the creator’s contact list.
  4. Only the creator of a password is allowed to modify data. Users with whom it’s shared have read-only access.
  5. Only the creator of a password may share a password (i.e. if User A has shared a password with User B, User B may not share that password with anyone else).

Since this series is primarily about Backbone.js, we’ll implement the above in a single paged web app.

The contact list

To allow users to share passwords, we’ll create a many-to-many relationship to a new model. Update `apps/passwords/models.py` to the following:

from django.db import models
from django.contrib.auth.models import User

class Password(models.Model):
    """
    Represents a username and password together with several other fields
    """
    created_by = models.ForeignKey(User, related_name='+', editable=False)
    title = models.CharField(max_length=200)
    username = models.CharField(max_length=200,
        blank=True)
    password = models.CharField(max_length=200)
    url = models.URLField(max_length=500,
        blank=True,
        verbose_name='Site URL')
    notes = models.CharField(
        max_length=500,
        blank=True)
    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)
    shares = models.ManyToManyField('PasswordContact',
        verbose_name='Share with', blank=True)

    def __unicode__(self):
        return self.title

class PasswordContact(models.Model):
    """
    Someone with whom a user can share a Password
    """
    from_user = models.ForeignKey(User, related_name="passwordcontactfrom", editable=False)
    to_user = models.ForeignKey(User, related_name="passwordcontactto")
    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    def __unicode__(self):
        return "%s %s (%s)" % (self.to_user.first_name, self.to_user.last_name, self.to_user.username)

This is standard Django. Migrate with south and apply it: `./manage.py schemamigration passwords –auto && ./manage.py migrate passwords`.

We’ll create 2 new APIs – one for the PasswordContact resource, and another for User objects which will allow members to search for other users.

Create `apps/users/resources.py` and enter the following:

from djangorestframework.resources import ModelResource

from django.contrib.auth.models import User

class UserResource(ModelResource):
    """
    Lets users search for other users by username.
    """
    model = User
    fields = ('id', 'first_name', 'last_name', 'username', 'url')

    def validate_request(self, data, files=None):
        """
        Backbone.js will submit all fields in the model back to us, but
        some fields are set as uneditable in our Django model. So we need
        to remove those extra fields before performing validation.
        """
        for key in self.ignore_fields:
            if key in data:
                del data[key]

        return super(UserResource, self).validate_request(data, files)

Update `apps/passwords/resources.py` to the following:

from djangorestframework.resources import ModelResource
from djangorestframework.serializer import Serializer
from django.core.urlresolvers import reverse

from apps.users.resources import UserResource
from models import Password, PasswordContact

class PasswordContactResource(ModelResource):
    model = PasswordContact
    ordering = ('to_user__first_name',)
    fields = ('id', 'url', ('to_user', 'UserResource'), ('from_user', 'UserResource'))
    ignore_fields = ('id',)

    def validate_request(self, data, files=None):
        """
        Backbone.js will submit all fields in the model back to us, but
        some fields are set as uneditable in our Django model. So we need
        to remove those extra fields before performing validation.
        """
        for key in self.ignore_fields:
            if key in data:
                del data[key]

        return super(PasswordContactResource, self).validate_request(data, files)

class CurrentUserSingleton(object):
    """
    Literally the only way I can find to give the PasswordResource access
    to the current user object.
    """
    user = None

    @classmethod
    def set_user(cls, user):
        cls.user = user

class PasswordResource(ModelResource):
    model = Password
    # by default, django rest framework won't return the ID - backbone.js
    # needs it though, so don't exclude it
    exclude = ('created_by',)
    ordering = ('-title',)
    # django rest framework will overwrite our 'url' attribute with its own
    # that points to the resource, so we need to provide an alternative.
    include = ('resource_url',)
    ignore_fields = ('created_at', 'updated_at', 'id', 'maskedPassword',
        'resource_url', 'is_owner')
    fields = ('id', 'title', 'username', 'password', 'url', 'notes',
        'resource_url', 'shares', 'is_owner')

    related_serializer = PasswordContactResource

    def is_owner(self, instance):
        """
        Returns True if this resource was created by the current user.
        """
        return instance.created_by == CurrentUserSingleton.user

    def url(self, instance):
        """
        Return the instance URL. If we don't specify this, django rest
        framework will return a generated URL to the resource
        """
        return instance.url

    def resource_url(self, instance):
        """
        An alternative to the 'url' attribute django rest framework will
        add to the model.
        """
        return reverse('passwords_api_instance',
                       kwargs={'id': instance.id})

    def validate_request(self, data, files=None):
        """
        Backbone.js will submit all fields in the model back to us, but
        some fields are set as uneditable in our Django model. So we need
        to remove those extra fields before performing validation.
        """
        for key in self.ignore_fields:
            if key in data:
                del data[key]

        return super(PasswordResource, self).validate_request(data, files)

Change `apps/api/urls.py` to the following to wire up URLs:

from django.conf.urls.defaults import patterns, url

from views import PasswordListView, PasswordInstanceView
from views import PasswordContactListView, PasswordContactReadOrDeleteInstanceView
from views import UserView

urlpatterns = patterns('',
    url(r'^passwords/$', PasswordListView.as_view(), name='passwords_api_root'),
    url(r'^passwords/(?P[0-9]+)$', PasswordInstanceView.as_view(), name='passwords_api_instance'),
    url(r'^passwordcontacts/$', PasswordContactListView.as_view(),
        name='password_contacts_api_root'),
    url(r'^passwordcontacts/(?P[0-9]+)$', PasswordContactReadOrDeleteInstanceView.as_view(),
        name='password_contacts_api_instance'),
    url(r'^user/(?P.+)$', UserView.as_view(), name='user_api'),
)

We also need to update the views in `apps/api/views.py`:

from django.db.models import Q
from djangorestframework.mixins import ModelMixin, InstanceMixin, \
ReadModelMixin, DeleteModelMixin
from djangorestframework.permissions import IsAuthenticated
from djangorestframework.response import ErrorResponse
from djangorestframework import status
from djangorestframework.views import ListOrCreateModelView, InstanceModelView, ModelView

from apps.passwords.models import PasswordContact
from apps.passwords.resources import PasswordResource, PasswordContactResource, \
CurrentUserSingleton
from apps.users.resources import UserResource

class RestrictPasswordToUserMixin(ModelMixin):
    """
    Mixin that restricts users to working with their own data
    """
    def get_queryset(self):
        """
        Only return objects created by, or shared with, the currently
        authenticated user.
        """
        return self.resource.model.objects.filter(Q(created_by=self.user) |
            Q(shares__to_user=self.user)).distinct()

    def get_instance_data(self, model, content, **kwargs):
        """
        Set the created_by field to the currently authenticated user.
        """
        content['created_by'] = self.user
        return super(RestrictPasswordToUserMixin, self).get_instance_data(model, content, **kwargs)

    def initial(self, request, *args, **kwargs):
        """
        Set the currently authenticated user on the resource
        """
        CurrentUserSingleton.set_user(request.user)
        return super(ModelMixin, self).initial(request, *args, **kwargs)

    def final(self, request, response, *args, **kargs):
        """
        Clear the current user singleton to make sure it doesn't leak
        """
        CurrentUserSingleton.set_user(None)
        return super(ModelMixin, self).final(request, response, *args, **kargs)

class PasswordListView(RestrictPasswordToUserMixin, ListOrCreateModelView):
    """
    List view for Password objects.
    """
    resource = PasswordResource
    permissions = (IsAuthenticated, )

class PasswordInstanceView(RestrictPasswordToUserMixin, InstanceModelView):
    """
    View for individual Password instances
    """
    resource = PasswordResource
    permissions = (IsAuthenticated, )

    def put(self, request, *args, **kwargs):
        """
        Only allow the creating user to modify an instance.
        """
        model = self.resource.model
        query_kwargs = self.get_query_kwargs(request, *args, **kwargs)

        try:
            self.model_instance = self.get_instance(**query_kwargs)

            if self.model_instance.created_by == self.user:
                return super(RestrictPasswordToUserMixin, self).put(request, *args, **kwargs)
        except model.DoesNotExist:
            pass

        raise ErrorResponse(status.HTTP_401_UNAUTHORIZED, None, {})

    def delete(self, request, *args, **kwargs):
        """
        Only the creator should be able to delete an instance.
        """
        model = self.resource.model
        query_kwargs = self.get_query_kwargs(request, *args, **kwargs)

        try:
            instance = self.get_instance(**query_kwargs)
        except model.DoesNotExist:
            raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {})

        if instance.created_by == self.user:
            instance.delete()
        else:
            raise ErrorResponse(status.HTTP_401_UNAUTHORIZED, None, {})

class PasswordContactListView(ListOrCreateModelView):
    """
    List view for PasswordContact objects.
    """
    resource = PasswordContactResource
    permissions = (IsAuthenticated, )

    def get_queryset(self):
        """
        Only return objects where the from_user is the currently authenticated user.
        """
        return self.resource.model.objects.filter(from_user=self.user)

    def get_instance_data(self, model, content, **kwargs):
        """
        Set the from_user field to the currently authenticated user.
        """
        content['from_user'] = self.user
        return super(PasswordContactListView, self).get_instance_data(model, content, **kwargs)

class ReadOnlyInstanceModelView(InstanceMixin, ReadModelMixin, ModelView):
    """
    A view which provides default operations for read/delete against a model instance
    but that prevents updates.
    """
    _suffix = 'Instance'

class PasswordContactReadOrDeleteInstanceView(ReadOnlyInstanceModelView):
    """
    View for individual PasswordContact instances
    """
    resource = PasswordContactResource
    permissions = (IsAuthenticated, )

    def delete(self, request, *args, **kwargs):
        """
        Deletes shares from Passwords when a PasswordContact is deleted
        """
        model = self.resource.model
        query_kwargs = self.get_query_kwargs(request, *args, **kwargs)

        try:
            instance = self.get_instance(**query_kwargs)
        except model.DoesNotExist:
            raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {})

        # remove any shares from any passwords shared with this contact
        password_contacts = PasswordContact.objects.filter(from_user=self.user,
            to_user=instance.to_user)

        for password_contact in password_contacts:
            password_contact.delete()

        instance.delete()
        return

class UserView(InstanceMixin, ReadModelMixin, ModelView):
    """
    View for individual Users lets users find other users by username
    """
    resource = UserResource
    permissions = (IsAuthenticated, )

    def get_queryset(self):
        """
        Filter the current user from search results to prevent them sharing
        with themselves.
        """
        return self.resource.model.objects.filter(~Q(id=self.user.id))

Finally,

There’s quite a lot going on in the above code:

  • We’re restricting users to only viewing those objects for which they’re the creator or a recipient of a share.
  • A singleton is used to enable the PasswordResource to determine whether the currently authenticated user created a resource or not, and this is returned as the `is_owner` property we added to the PasswordResource.
  • We restrict CRUD operations on password instances so they can only be performed by the creator of a password.
  • Users can only create or delete password contacts, they can’t update them. When a password contact is deleted, we remove all shares associated with that user. So when a user removes someone from their contact list, access to all shared passwords is automatically revoked.
  • Finally we prevent the UserView from returning the current user. This view only support GET preventing users from browsing all members.

One last thing we need before we can hook things up on the front-end is to update the PasswordForm so it allows users to share their passwords with users in their contact list. Update `apps/passwords/forms.py` with the following:

from django.forms import ModelForm
from django.forms import widgets
from django.forms.models import ModelMultipleChoiceField
from django.utils.translation import ugettext_lazy as _

from models import Password, PasswordContact

class PasswordForm(ModelForm):
    class Meta:
        model = Password
        widgets = {
            'shares': widgets.CheckboxSelectMultiple
        }

    def __init__(self, user, *args, **kwargs):
        super(PasswordForm, self).__init__(*args, **kwargs)
        remove_message = unicode(_('Hold down "Control", or "Command" on a Mac, to select more than one.'))

        for field in self.fields:
            if remove_message in self.fields[field].help_text:
                self.fields[field].help_text = self.fields[field].help_text.replace(remove_message, '').strip()

        # restrict the choice of users to share passwords with to a
        # user's PasswordContacts
        self.fields['shares'] = ModelMultipleChoiceField(
            queryset=PasswordContact.objects.filter(from_user=user) \
                .order_by('to_user__first_name'),
            widget=widgets.CheckboxSelectMultiple())

Backbone.js

Let’s create a separate application for handling contacts, although we’ll load it all on the same page.

First, update `templates/passwords/password_list.html` as follows:

{% extends "base.html" %}

{% load sekizai_tags %}
{% load bootstrap_toolkit %}

{% block content %}
    {% addtoblock "js" %}
<script type="text/javascript" src="{{ STATIC_URL }}bootstrap/js/bootstrap.min.js"></script>

    <!-- backbone --><script type="text/javascript" src="{{ STATIC_URL }}contrib/backbone/ICanHaz.min.js"></script>
<script type="text/javascript" src="{{ STATIC_URL }}contrib/backbone/json2.js"></script><script type="text/javascript" src="{{ STATIC_URL }}contrib/backbone/underscore-min.js"></script>
<script type="text/javascript" src="{{ STATIC_URL }}contrib/backbone/backbone-min.js"></script><script type="text/javascript" src="{{ STATIC_URL }}/js/passwords.js"></script>
<script type="text/javascript" src="{{ STATIC_URL }}/js/contacts.js"></script>
    {% endaddtoblock %}</pre>

&nbsp;

<ul class="nav nav-tabs">
<ul class="nav nav-tabs">
	<li class="active"><a href="#passwordPanel" data-toggle="tab">Passwords</a></li>
</ul>
</ul>

&nbsp;

<ul class="nav nav-tabs">
<ul class="nav nav-tabs">
	<li><a href="#contactPanel" data-toggle="tab">Contacts</a></li>
</ul>
</ul>

&nbsp;

<pre>

</pre>
<div class="tab-content">

<div id="passwordPanel" class="tab-pane active">

<h1 class="page-header">Passwords</h1>

Move your mouse over a password to reveal it. You can only edit your own passwords, not those that have been shared with you.

<table class="table table-striped">
<thead>
<tr>
<th>Title</th>
<th>User name</th>
<th>Password</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tfoot>
<tr>
<td colspan="5"><button class="btn btn-primary" data-toggle="modal">Add new password</button></td>
</tr>
</tfoot>
</table>

<div id="passwordModal" class="modal hide fade">
<form id="passwordForm" method="post">

<div class="modal-header"><button class="close" data-dismiss="modal">×</button>

<h3>Password Details</h3>
</div>

<div class="modal-body">{{ form|as_bootstrap }} {% csrf_token %}</div>

<div class="modal-footer"><a class="btn" href="#" data-dismiss="modal">Cancel</a> <input class="btn btn-primary" type="submit" value="Save" /></div>
</form></div>
</div>

<div id="contactPanel" class="tab-pane">

<h1 class="page-header">Manage contacts</h1>

<div class="well">

<h3>Add new contacts</h3>

To find other users to share passwords with, enter their username below:

<form class="form-search"><input id="userSearch" class="search-query" type="text" /> <input class="btn" type="submit" value="Add user" /></form></div>

<h3>My contacts</h3>

You can share passwords with the following users. If you want to stop sharing all passwords with a certain user, simply delete them from your list by clicking on the icon.

<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>User name</th>
<th>Actions</th>
</tr>
</thead>
</table>
</div>
</div>
<pre>
    {% load verbatim %}

    <!-- ICanHaz templates -->{% comment %}
    Mustache and django both use {{}} tags for templates, so we need to use
    a custom template tag to output the mustache template exactly as it is.
    {% endcomment %}
    {% verbatim %}<script id="passwordRowTpl" type="text/html">// <![CDATA[
<td>
            <a href="{{ url }}" target="_blank">
                {{ title }}
            </a></td>

<td>{{ username }}</td>

<td class="password">{{ maskedPassword }}</td>

<td>{{ notes }}</td>

<td>
            {{#is_owner}}
            <a href="#" class="edit" title="Edit this entry"><i class="icon-pencil"></i></a>
            <a href="#" class="destroy" title="Delete this entry"><i class="icon-remove"></i></a>
            {{/is_owner}}</td>

// ]]></script>

<script id="contactRowTpl" type="text/html">// <![CDATA[
<td>
            {{ to_user.first_name }} {{ to_user.last_name }}</td>

<td>{{ to_user.username }}</td>

<td>
            <a href="#" class="destroy" title="Delete this contact"><i class="icon-remove"></i></a></td>

// ]]></script>

<script id="shareOption" type="text/html">// <![CDATA[
        <label class="checkbox">
            <input type="checkbox" value="{{ id }}" name="shares">
            {{ to_user.first_name }} {{ to_user.last_name }} ({{ to_user.username }})
        </label>

// ]]></script>
    {% endverbatim %}
{% endblock %}

We’ve added a few new templates and have created a nice tabbed interface courtesy of Twitter Bootstrap.

So, to create our contact application, enter the following into a new file, `staticfiles/js/contacts.js`:

// load the following using JQuery's document ready function
$(function(){

    // Contact model
    var Contact = Backbone.Model.extend({
        remove: function(options) {
            mergedOptions = {wait: true};
            $.extend(mergedOptions, options);
            this.destroy(mergedOptions);
        }
    });

    // set up the view for a contact
    var ContactView = Backbone.View.extend({
        tagName: 'tr',

        events: {
            "click a.destroy" : "remove"
        },

        remove: function(event) {
            event.stopImmediatePropagation();
            event.preventDefault();
            if (confirm("Are you sure you want to delete this contact?"))
            {
                var that = this;

                this.model.remove({error: function(model, response) {
                        if (response.status == 403) {
                            alert("You don't have permission to delete that data");
                        }
                        else {
                            alert("Unable to delete that data");
                        }
                    },
                    success: function() {
                        // update the form options - a little hacky, but oh well
                        $('#passwordForm').find(':checkbox').remove();
                        $('#passwordForm').find('.checkbox').remove();

                        var shareOptions = new Array();

                        that.options.collection.each(function(data){
                            shareOptions.push(ich.shareOption(data.toJSON(), true));
                        });

                        $(shareOptions.join('')).insertAfter('#id_notes');
                    }
                });
            }
        },

        render: function () {
            // template with ICanHaz.js (ich)
            $(this.el).html(ich.contactRowTpl(this.model.toJSON()));
            return this;
        }
    });

    // define the collection of contacts
    var ContactCollection = Backbone.Collection.extend({
        model: Contact,
        url: '/api/1.0/passwordcontacts/',

        // maintain ordering by first_name
        comparator: function(obj1, obj2) {
            return obj1.get('to_user').first_name.localeCompare(obj2.get('to_user').first_name);
        }
    });

    /**
     * Manages the list of contacts.
     */
    var ContactListView = Backbone.View.extend({
        tagName: 'tbody',

        /**
         * Constructor. Takes a reference to the parent view so we can invoke
         * methods on it.
         */
        initialize: function(options) {
            // instantiate a password collection
            this.collection = new ContactCollection();

            this.collection.bind('all', this.render, this);
            this.collection.fetch();
        },

        addOne: function(contact) {
            this.$el.append(new ContactView({model: contact, collection: this.collection}).render().el);
            return this;
        },

        addNew: function(data, options) {
            mergedOptions = {wait: true};
            $.extend(mergedOptions, options);

            var contact = {
                to_user: data.id
            };

            this.collection.create(contact, mergedOptions);
            return this;
        },

        render: function() {
            this.$el.html('');
            this.collection.each(this.addOne, this);
            return this;
        }
    });

    /**
     * View for the overall application. We need this because backbone can only
     * bind events for children of 'el'.
     *
     * In our template our modal is inside #app, so this class handles
     * interaction at the application level rather than strictly with a
     * collection of Passwords (that's the job of the PasswordListView).
     */
    var ContactPanelView = Backbone.View.extend({
        el: '#contactPanel',
        events: {
            "click #contactPanel :submit": "handleSearch",
            "keydown #contactPanel :input[type=text]": "handleSearchOnEnter"
        },

        initialize: function() {
            this.dataList = new ContactListView({app: this});
        },

        displayError: function(model, response) {
            if (response.status == 403) {
                alert("You don't have permission to edit that data");
            }
            else {
                alert("Unable to create or edit that data. Please make sure you entered valid data.");
            }
        },

        render: function() {
            this.$el.find('table').append(this.dataList.render().el);
        },

        handleSearch: function(event) {
            event.preventDefault();
            event.stopImmediatePropagation();

            var username = $('#userSearch').val();

            var that = this;

            // perform a GET request to the userSearch service and if it
            // returns a user, create a new PasswordContact
            $.ajax({
                url: '/api/1.0/user/' + username,
                dataType: 'json',
                success: function(data, textStatus, jqXHR) {
                    that.dataList.addNew(data, {success: function() {
                        $('#userSearch').val('');

                        // update the form options
                        $('#passwordForm').find(':checkbox').remove();
                        $('#passwordForm').find('.checkbox').remove();

                        var shareOptions = new Array();

                        that.dataList.collection.each(function(data){
                            shareOptions.push(ich.shareOption(data.toJSON(), true));
                        });

                        $(shareOptions.join('')).insertAfter('#id_notes');
                    }});
                },
                error: function(jqXHR, textStatus, errorThrown) {
                    if (jqXHR.status) {
                        alert("Sorry, we couldn't find that user");
                    }
                    else {
                        alert("There was a problem searching for that user.");
                    }
                }
            });

            return this;
        },

        handleSearchOnEnter: function(event) {
            // process the modal if the user pressed the ENTER key
            if (event.keyCode == 13)
            {
                return this.handleSearch(event);
            }
        }
    });

    var contactPanel = new ContactPanelView();
    contactPanel.render();
});

The above is quite similar to passwords.js, but generally simpler. About the only complex code is to do with updating the list of checkboxes on the password form when users add or delete contacts. It’s not ideal having selectors in the view like that, but we’ve managed to keep it to a minimum, so I can live with it here.

The `handleSearch` method uses the `user` API – if it successfully receives a response it creates a new PasswordContact and updates the user’s contact list.

Try it out

This post is a bit like a shopping list, but hopefully it’ll help make the repository more accessible. If you open two different browsers you’ll be able to create two different users, add them to each others’ contact lists and share and revoke passwords between them.

We’ll finish off this series with a discussion of the current architecture and what could be done to make the application more secure.

One Response to “A RESTful password locker with Django and backbone.js part 5”

  1. Leonard 3 October, 2012 at 14:43 #

    I’ve resolved all the dependencies, even the ones not required on github repo but I keep on getting:

    TemplateSyntaxError at /
    Caught ImportError while rendering: cannot import name InstanceMixin

    Can’t find anything on the net about it..

Leave a comment