← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/maas-multiple-keys2 into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/maas-multiple-keys2 into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/maas-multiple-keys2/+merge/92940

This branch changes the Account model, the API and the preferences page (http://localhost:8000/account/prefs/?tab=1) to allow a user to have multiple API tokens at his disposal.

Drive-by fixes:
- fix src/maasserver/api.py.__all__ and have the handlers included in the API documentation discovered by introspecting the module.
- fix the api display of the api doc when there is more than one operation to be described (broken display: http://91.189.93.91:8000/api/doc/)
- indentation in src/maasserver/static/js/node_add.js
-- 
https://code.launchpad.net/~rvb/maas/maas-multiple-keys2/+merge/92940
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-multiple-keys2 into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-02-13 17:07:22 +0000
+++ src/maasserver/api.py	2012-02-14 10:48:33 +0000
@@ -12,12 +12,16 @@
 __all__ = [
     "api_doc",
     "generate_api_doc",
+    "AccountHandler",
     "FilesHandler",
     "NodeHandler",
+    "NodesHandler",
+    "NodeMacHandler",
     "NodeMacsHandler",
     ]
 
 import httplib
+import sys
 import types
 
 from django.core.exceptions import (
@@ -48,7 +52,10 @@
     )
 from piston.authentication import OAuthAuthentication
 from piston.doc import generate_doc
-from piston.handler import BaseHandler
+from piston.handler import (
+    BaseHandler,
+    HandlerMetaClass,
+    )
 from piston.utils import rc
 
 
@@ -183,7 +190,7 @@
         dispatcher.__doc__ = (
             "The actual operation to execute depends on the value of the '%s' "
             "parameter:\n\n" % OP_PARAM)
-        dispatcher.__doc__ += "\n- ".join(
+        dispatcher.__doc__ += "\n".join(
             "- Operation '%s' (op=%s):\n\t%s" % (name, name, op.__doc__)
             for name, op in cls._available_api_methods[method].iteritems())
 
@@ -392,36 +399,66 @@
         return ('files_handler', [])
 
 
+@api_operations
 class AccountHandler(BaseHandler):
     """Manage the current logged-in user."""
     allowed_methods = ('POST',)
 
-    @api_exported('reset_authorisation_token')
-    def reset_authorisation_token(self, request):
-        """Regenerate the token and the secret of the OAuth Token.
+    @api_exported('create_authorisation_token', method='POST')
+    def create_authorisation_token(self, request):
+        """Create an authorisation OAuth token and OAuth consumer.
 
-        :return: A json dict with three keys: 'token_key',
+        :return: a json dict with three keys: 'token_key',
             'token_secret' and 'consumer_key' (e.g.
             {token_key: 's65244576fgqs', token_secret: 'qsdfdhv34',
-             consumer_key: '68543fhj854fg'}).
+            consumer_key: '68543fhj854fg'}).
+        :rtype: string (json)
 
         """
         profile = request.user.get_profile()
-        consumer, token = profile.reset_authorisation_token()
+        consumer, token = profile.create_authorisation_token()
         return {
             'token_key': token.key, 'token_secret': token.secret,
             'consumer_key': consumer.key,
             }
 
+    @api_exported('delete_authorisation_token', method='POST')
+    def delete_authorisation_token(self, request):
+        """Delete an authorisation OAuth token and the related OAuth consumer.
+
+        :param token_key: The key of the token to be deleted.
+        :type token_key: str
+
+        """
+        profile = request.user.get_profile()
+        token_key = request.data.get('token_key', None)
+        if token_key is None:
+            raise ValidationError('No provided token_key!')
+        profile.delete_authorisation_token(token_key)
+        return rc.DELETED
+
+    @classmethod
+    def resource_uri(cls, *args, **kwargs):
+        return ('account_handler', [])
+
 
 def generate_api_doc(add_title=False):
-    docs = (
-        generate_doc(FilesHandler),
-        generate_doc(NodesHandler),
-        generate_doc(NodeHandler),
-        generate_doc(NodeMacsHandler),
-        generate_doc(NodeMacHandler),
-        )
+    # Fetch all the API Handlers (objects with the class
+    # HandlerMetaClass).
+    modname = globals()['__name__']
+    module = sys.modules[modname]
+    handlers = [
+        getattr(module, itemname) for itemname in module.__all__
+        if getattr(module, itemname).__class__ == HandlerMetaClass
+        ]
+    # Make sure each handler defines a 'resource_uri' method (this is
+    # easily forgotten and essential to have a proper documentation).
+    for handler in handlers:
+        has_resource_uri = hasattr(handler, 'resource_uri')
+        assert has_resource_uri, "Missing resource_uri in %s" % (
+            handler.__name__)
+
+    docs = [generate_doc(handler) for handler in handlers]
 
     messages = []
     if add_title:

=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py	2012-02-13 13:07:48 +0000
+++ src/maasserver/models.py	2012-02-14 10:48:33 +0000
@@ -312,48 +312,53 @@
 
     user = models.OneToOneField(User)
 
-    def reset_authorisation_token(self):
-        """Create (if necessary) and regenerate the keys for the Consumer and
-        the related Token of the OAuth authorisation.
-
-        :return: A tuple containing the Consumer and the Token that were reset.
+    def get_authorisation_tokens(self):
+        """Fetches all the user's OAuth tokens.
+
+        :return: A QuerySet of the tokens.
+        :rtype: django.db.models.query.QuerySet_
+
+        .. _django.db.models.query.QuerySet: https://docs.djangoproject.com/
+           en/dev/ref/models/querysets/
+
+        """
+        return Token.objects.select_related().filter(
+            user=self.user, token_type=Token.ACCESS,
+            is_approved=True).order_by('id')
+
+    def create_authorisation_token(self):
+        """Create a new Token and its related  Consumer (OAuth authorisation).
+
+        :return: A tuple containing the Consumer and the Token that were
+            created.
         :rtype: tuple
 
         """
-        consumer, _ = Consumer.objects.get_or_create(
-            user=self.user, name=GENERIC_CONSUMER,
-            defaults={'status': 'accepted'})
+        consumer = Consumer.objects.create(
+            user=self.user, name=GENERIC_CONSUMER, status='accepted')
         consumer.generate_random_codes()
         # This is a 'generic' consumer aimed to service many clients, hence
         # we don't authenticate the consumer with key/secret key.
         consumer.secret = ''
         consumer.save()
-        token, _ = Token.objects.get_or_create(
+        token = Token.objects.create(
             user=self.user, token_type=Token.ACCESS, consumer=consumer,
-            defaults={'is_approved': True})
+            is_approved=True)
         token.generate_random_codes()
         return consumer, token
 
-    def get_authorisation_consumer(self):
-        """Returns the OAuth Consumer attached to the related User_.
-
-        :return: The attached Consumer.
-        :rtype: piston.models.Consumer
-
-        """
-        return Consumer.objects.get(user=self.user, name=GENERIC_CONSUMER)
-
-    def get_authorisation_token(self):
-        """Returns the OAuth authorisation token attached to the related User_.
-
-        :return: The attached Token.
-        :rtype: piston.models.Token
-
-        """
-        consumer = self.get_authorisation_consumer()
-        token = Token.objects.get(
-            user=self.user, token_type=Token.ACCESS, consumer=consumer)
-        return token
+    def delete_authorisation_token(self, token_key):
+        """Delete the user's OAuth token wich key token_key.
+
+        :param token_key: The key of the token to be deleted.
+        :type token_key: str
+        :raises: django.http.Http404_
+
+        """
+        token = get_object_or_404(
+            Token,  user=self.user, token_type=Token.ACCESS, key=token_key)
+        token.consumer.delete()
+        token.delete()
 
 
 # When a user is created: create the related profile and the default
@@ -364,7 +369,7 @@
         profile = UserProfile.objects.create(user=instance)
 
         # Create initial authorisation token.
-        profile.reset_authorisation_token()
+        profile.create_authorisation_token()
 
 # Connect the 'create_user' method to the post save signal of User.
 post_save.connect(create_user, sender=User)

=== added file 'src/maasserver/static/img/delete.png'
Binary files src/maasserver/static/img/delete.png	1970-01-01 00:00:00 +0000 and src/maasserver/static/img/delete.png	2012-02-14 10:48:33 +0000 differ
=== modified file 'src/maasserver/static/js/node_add.js'
--- src/maasserver/static/js/node_add.js	2012-02-10 09:12:00 +0000
+++ src/maasserver/static/js/node_add.js	2012-02-14 10:48:33 +0000
@@ -25,12 +25,12 @@
 
 AddNodeWidget.ATTRS = {
 
-    /**
-     * The number MAC Addresses fields on the form.
-     *
-     * @attribute nb_mac_fields
-     * @type integer
-     */
+   /**
+    * The number MAC Addresses fields on the form.
+    *
+    * @attribute nb_mac_fields
+    * @type integer
+    */
     nb_mac_fields: {
         getter: function() {
             return this.get(

=== modified file 'src/maasserver/static/js/prefs.js'
--- src/maasserver/static/js/prefs.js	2012-02-10 09:45:34 +0000
+++ src/maasserver/static/js/prefs.js	2012-02-14 10:48:33 +0000
@@ -20,6 +20,16 @@
 
 TokenWidget.NAME = 'profile-widget';
 
+TokenWidget.ATTRS = {
+    // The number of tokens displayed.
+    nb_tokens: {
+        readOnly: true,
+        getter: function() {
+            return this.get('srcNode').all('.bundle').size();
+        }
+    }
+};
+
 Y.extend(TokenWidget, Y.Widget, {
 
     displayError: function(message) {
@@ -27,25 +37,65 @@
     },
 
     initializer: function(cfg) {
-        this.regenerate_link = Y.Node.create('<a />')
+        this.create_link = Y.Node.create('<a />')
             .set('href', '#')
-            .set('id','regenerate_tokens')
-            .set('text', "Regenerate the tokens");
-        this.status_node = Y.Node.create('<span />')
-            .set('id','regenerate_error');
+            .set('id','create_token')
+            .set('text', "Create a new API token");
+        this.status_node = Y.Node.create('<div />')
+            .set('id','create_error');
         this.spinnerNode = Y.Node.create('<img />')
             .set('src', MAAS_config.uris.statics + 'img/spinner.gif');
-        this.get('srcNode').one('#tokens_regeneration_placeholder')
-            .append(this.regenerate_link)
+        this.get('srcNode').one('#token_creation_placeholder')
+            .append(this.create_link)
             .append(this.status_node);
     },
 
+    bindDeleteRow: function(row) {
+        var self = this;
+        var delete_link = row.one('a.delete-link');
+        delete_link.on('click', function(e) {
+            e.preventDefault();
+            self.deleteToken(row);
+        });
+    },
+
     bindUI: function() {
         var self = this;
-        this.regenerate_link.on('click', function(e) {
+        this.create_link.on('click', function(e) {
             e.preventDefault();
-            self.regenerateTokens();
-        });
+            self.requestKeys();
+        });
+        Y.each(this.get('srcNode').all('.bundle'), function(row) {
+            self.bindDeleteRow(row);
+        });
+    },
+
+   /**
+    * Delete the token contained in the provided row.
+    * Call the API to delete the token and then remove the table row.
+    *
+    * @method deleteToken
+    */
+    deleteToken: function(row) {
+        var token_key = row.one('td').get('id');
+        var self = this;
+        var cfg = {
+            method: 'POST',
+            data: 'op=delete_authorisation_token&token_key=' + token_key,
+            sync: false,
+            on: {
+                start: Y.bind(self.showSpinner, self),
+                end: Y.bind(self.hideSpinner, self),
+                success: function(id, out) {
+                    row.remove();
+                 },
+                failure: function(id, out) {
+                    self.displayError('Unable to delete the token.');
+                }
+            }
+        };
+        var request = module._io.io(
+            MAAS_config.uris.account_handler, cfg);
     },
 
     showSpinner: function() {
@@ -57,38 +107,75 @@
         this.spinnerNode.remove();
     },
 
-    regenerateTokens: function() {
+   /**
+    * Create a single string token from a key set.
+    *
+    * A key set is composed of 3 keys: consumer_key, token_key, token_secret.
+    * For an easy copy and paste experience, the string handed over to the
+    * user is a colon separated concatenation of these keys called 'token'.
+    *
+    * @method createTokenFromKeys
+    */
+    createTokenFromKeys: function(consumer_key, token_key, token_secret) {
+        return consumer_key + ':' + token_key + ':' + token_secret;
+    },
+
+   /**
+    * Add a token to the list of tokens.
+    *
+    * @method addToken
+    */
+    addToken: function(token, token_key) {
+        var tbody = this.get('srcNode').one('tbody');
+        var row = Y.Node.create('<tr />')
+            .addClass('bundle')
+            .append(Y.Node.create('<td />')
+                .set('id', token_key)
+                .set('text', token))
+            .append(Y.Node.create('<td />')
+                .append(Y.Node.create('<a />')
+                    .set('href', '#').addClass('delete-link')
+                    .append(Y.Node.create('<img />')
+                        .set('title', 'Delete token')
+                        .set(
+                            'src',
+                            MAAS_config.uris.statics + 'img/delete.png'))));
+       tbody.append(row);
+       this.bindDeleteRow(row);
+    },
+
+   /**
+    * Request a new OAuth key set from the API.
+    *
+    * @method requestKeys
+    */
+    requestKeys: function() {
         var self = this;
         var cfg = {
             method: 'POST',
-            data: 'op=reset_authorisation_token',
+            data: 'op=create_authorisation_token',
             sync: false,
             on: {
                 start: Y.bind(self.showSpinner, self),
                 end: Y.bind(self.hideSpinner, self),
                 success: function(id, out) {
-                    var token;
+                    var keys;
                     try {
-                        token = JSON.parse(out.response);
+                        keys = JSON.parse(out.response);
                     }
                     catch(e) {
                         // Parsing error.
-                        displayError('Unable to regenerate the tokens.');
+                        self.displayError('Unable to create a new token.');
                     }
-                    // Update the 3 tokens (consumer key, token key and
-                    // token secret).
-                    self.get(
-                        'srcNode').one(
-                            '#token_key').set('text', token.token_key);
-                    self.get(
-                        'srcNode').one(
-                            '#token_secret').set('text', token.token_secret);
-                    self.get(
-                        'srcNode').one(
-                            '#consumer_key').set('text', token.consumer_key);
+                    // Generate a token from the keys.
+                    var token = self.createTokenFromKeys(
+                        keys.consumer_key, keys.token_key,
+                        keys.token_secret);
+                    // Add the new token to the list of tokens.
+                    self.addToken(token, keys.token_key);
                  },
                 failure: function(id, out) {
-                    displayError('Unable to regenerate the tokens.');
+                    self.displayError('Unable to create a new token.');
                 }
             }
         };

=== modified file 'src/maasserver/static/js/tests/test_prefs.html'
--- src/maasserver/static/js/tests/test_prefs.html	2012-02-09 17:11:07 +0000
+++ src/maasserver/static/js/tests/test_prefs.html	2012-02-14 10:48:33 +0000
@@ -26,14 +26,20 @@
   <span id="suite">maas.prefs.tests</span>
   <script type="text/x-template" id="api-template">
     <div id="placeholder">
-      <div id="consumer_key">
-      </div>
-      <div id="token_key">
-      </div>
-      <div id="token_secret">
-      </div>
-      <div id="tokens_regeneration_placeholder">
-    </div>
+      <table>
+        <tbody>
+          <tr class="bundle">
+            <td id="tokenkey1">Sample token 1</td>
+            <td><a class="delete-link" href="#">Delete</a></td>
+          </tr>
+          <tr class="bundle">
+            <td id="tokenkey2">Sample token 2</td>
+            <td><a class="delete-link" href="#">Delete</a></td>
+          </tr>
+         </tbody>
+      </table>
+      <p id="token_creation_placeholder">
+      </p>
     </div>
   </script>
   </body>

=== modified file 'src/maasserver/static/js/tests/test_prefs.js'
--- src/maasserver/static/js/tests/test_prefs.js	2012-02-09 14:29:09 +0000
+++ src/maasserver/static/js/tests/test_prefs.js	2012-02-14 10:48:33 +0000
@@ -23,49 +23,161 @@
         var widget = new module.TokenWidget({srcNode: '#placeholder'});
         this.addCleanup(function() { widget.destroy(); });
         widget.render();
-        var regenerate_link = widget.get('srcNode').one('#regenerate_tokens');
-        Y.Assert.isNotNull(regenerate_link);
+        // The "create a new API token" has been created.
+        var create_link = widget.get('srcNode').one('#create_token');
+        Y.Assert.isNotNull(create_link);
         Y.Assert.areEqual(
-            "Regenerate the tokens", regenerate_link.get('text'));
-        var status_node = widget.get('srcNode').one('#regenerate_error');
+            "Create a new API token", create_link.get('text'));
+        // The placeholder node for errors has been created.
+        var status_node = widget.get('srcNode').one('#create_error');
         Y.Assert.isNotNull(status_node);
-    },
-
-    testRegenerateTokensCall: function() {
-        var mockXhr = Y.Mock();
-        Y.Mock.expect(mockXhr, {
-            method: 'io',
-            args: [MAAS_config.uris.account_handler, Y.Mock.Value.Any]
-        });
-        this.mockIO(mockXhr, module);
-        var widget = new module.TokenWidget({srcNode: '#placeholder'});
-        this.addCleanup(function() { widget.destroy(); });
-        widget.render();
-        var link = widget.get('srcNode').one('#regenerate_tokens');
-        link.simulate('click');
-        Y.Mock.verify(mockXhr);
-    },
-
-    testRegenerateTokensUpdatesTokens: function() {
-        var mockXhr = new Y.Base();
-        mockXhr.io = function(url, cfg) {
+        Y.Assert.areEqual(
+            '',
+            widget.get('srcNode').one('#create_error').get('text'));
+    },
+
+    test_nb_tokens: function() {
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        Y.Assert.areEqual(2, widget.get('nb_tokens'));
+     },
+
+    testDeleteTokenCall: function() {
+        // A click on the delete link calls the API to delete a token.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
+            Y.Assert.areEqual(MAAS_config.uris.account_handler, url);
+            Y.Assert.areEqual(
+                "op=delete_authorisation_token&token_key=tokenkey1",
+                cfg.data);
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var link = widget.get('srcNode').one('.delete-link');
+        link.simulate('click');
+        Y.Assert.isTrue(fired);
+    },
+
+    testDeleteTokenFail: function() {
+        // If the API call to delete a token fails, an error is displayed.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
+            cfg.on.failure(3);
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var link = widget.get('srcNode').one('.delete-link');
+        link.simulate('click');
+        Y.Assert.isTrue(fired);
+        Y.Assert.areEqual(
+            'Unable to delete the token.',
+            widget.get('srcNode').one('#create_error').get('text'));
+    },
+
+    testDeleteTokenDisplay: function() {
+        // When the token is successfully deleted by the API, the
+        // corresponding row is deleted.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
+            cfg.on.success(3);
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var link = widget.get('srcNode').one('.delete-link');
+        Y.Assert.isNotNull(Y.one('#tokenkey1'));
+        link.simulate('click');
+        Y.Assert.isTrue(fired);
+        Y.Assert.isNull(Y.one('#tokenkey1'));
+        Y.Assert.isNotNull(Y.one('#tokenkey2'));
+        Y.Assert.areEqual(1, widget.get('nb_tokens'));
+    },
+
+    test_createTokenFromKeys: function() {
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        var token = widget.createTokenFromKeys(
+            'consumer_key', 'token_key', 'token_secret');
+        Y.Assert.areEqual('consumer_key:token_key:token_secret', token);
+    },
+
+    testCreateTokenCall: function() {
+        // A click on the "create a new token" link calls the API to
+        // create a token.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
+            Y.Assert.areEqual(MAAS_config.uris.account_handler, url);
+            Y.Assert.areEqual(
+                "op=create_authorisation_token",
+                cfg.data);
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var create_link = widget.get('srcNode').one('#create_token');
+        create_link.simulate('click');
+        Y.Assert.isTrue(fired);
+    },
+
+    testCreateTokenFail: function() {
+        // If the API call to create a token fails, an error is displayed.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
+            cfg.on.failure(3);
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var create_link = widget.get('srcNode').one('#create_token');
+        create_link.simulate('click');
+        Y.Assert.isTrue(fired);
+        Y.Assert.areEqual(
+            'Unable to create a new token.',
+            widget.get('srcNode').one('#create_error').get('text'));
+    },
+
+    testCreateTokenDisplay: function() {
+        // When a new token is successfully created by the API, a new
+        // corresponding row is added.
+        var mockXhr = new Y.Base();
+        var fired = false;
+        mockXhr.io = function(url, cfg) {
+            fired = true;
             var response = {
-                token_key: 'token_key', token_secret: 'token_secret',
-                consumer_key: 'consumer_key'};
+                consumer_key: 'consumer_key',
+                token_key: 'token_key',
+                token_secret: 'token_secret'
+            };
             cfg.on.success(3, {response: Y.JSON.stringify(response)});
         };
         this.mockIO(mockXhr, module);
         var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
         widget.render();
-        var link = widget.get('srcNode').one('#regenerate_tokens');
-        link.simulate('click');
-        var src_node = widget.get('srcNode');
-        Y.Assert.areEqual('token_key', src_node.one('#token_key').get('text'));
-        Y.Assert.areEqual(
-            'token_secret', src_node.one('#token_secret').get('text'));
-        Y.Assert.areEqual(
-            'consumer_key', src_node.one('#consumer_key').get('text'));
-     }
+        var create_link = widget.get('srcNode').one('#create_token');
+        create_link.simulate('click');
+        Y.Assert.isTrue(fired);
+        Y.Assert.areEqual(3, widget.get('nb_tokens'));
+        Y.Assert.isNotNull(Y.one('#token_key'));
+    }
 
 }));
 

=== modified file 'src/maasserver/templates/maasserver/prefs.html'
--- src/maasserver/templates/maasserver/prefs.html	2012-02-10 09:34:47 +0000
+++ src/maasserver/templates/maasserver/prefs.html	2012-02-14 10:48:33 +0000
@@ -49,37 +49,36 @@
       </div>
       <div id="api">
           <p>
-            This is the required information you need to provide to a third
+            An API token is the information you need to provide to a third
             party application for it to be able to access the MaaS server
             <a href="{% url 'api-doc' %}">API</a> on your behalf.
           </p>
           <p>
-            <label>API consumer key:
-            <img src="{{ STATIC_URL }}img/maybe.png"
-                 title="Note that the API consumer secret is the empty string"
-             />
-            </label>
-            <div id="consumer_key">
-              {{ user.get_profile.get_authorisation_consumer.key }}
-            </div>
-          </p>
-          <p>
-            <label>API consumer secret:</label>
-            <div>'' (the empty string)</div>
-          </p>
-          <p>
-            <label>API token key:</label>
-            <div id="token_key">
-              {{ user.get_profile.get_authorisation_token.key }}
-            </div>
-          </p>
-          <p>
-            <label>API token secret:</label>
-            <div id="token_secret">
-              {{ user.get_profile.get_authorisation_token.secret }}
-            </div>
-          </p>
-          <p id="tokens_regeneration_placeholder" />
+            You can create as much API tokens as you require.
+          </p>
+          <table>
+            <thead>
+              <tr>
+                <th>API tokens</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              {% for token in user.get_profile.get_authorisation_tokens %}
+              <tr class="bundle">
+                <td id="{{ token.key }}">
+                  {{ token.consumer.key }}:{{ token.key }}:{{ token.secret }}</td>
+                <td>
+                  <a href="#" class="delete-link">
+                    <img title="Delete token"
+                         src="{{ STATIC_URL }}img/delete.png" />
+                  </a>
+                </td>
+              </tr>
+              {% endfor %}
+            </tbody>
+          </table>
+          <p id="token_creation_placeholder" />
       </div>
       <div id="password">
         <form action="." method="post">

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-02-14 08:20:40 +0000
+++ src/maasserver/tests/test_api.py	2012-02-14 10:48:33 +0000
@@ -54,10 +54,11 @@
 
 
 class OAuthAuthenticatedClient(Client):
+
     def __init__(self, user):
         super(OAuthAuthenticatedClient, self).__init__()
-        consumer = user.get_profile().get_authorisation_consumer()
-        token = user.get_profile().get_authorisation_token()
+        token = user.get_profile().get_authorisation_tokens()[0]
+        consumer = token.consumer
         self.consumer = OAuthConsumer(str(consumer.key), str(consumer.secret))
         self.token = OAuthToken(str(token.key), str(token.secret))
 
@@ -545,6 +546,41 @@
         self.assertEqual(httplib.BAD_REQUEST, response.status_code)
 
 
+class AccountAPITest(APITestCase):
+
+    def test_create_authorisation_token(self):
+        # The api operation create_authorisation_token returns a json dict
+        # with the consumer_key, the token_key and the token_secret in it.
+        response = self.client.post(
+            '/api/account/', {'op': 'create_authorisation_token'})
+        parsed_result = json.loads(response.content)
+
+        self.assertEqual(
+            ['consumer_key', 'token_key', 'token_secret'],
+            sorted(parsed_result.keys()))
+        self.assertIsInstance(parsed_result['consumer_key'], basestring)
+        self.assertIsInstance(parsed_result['token_key'], basestring)
+        self.assertIsInstance(parsed_result['token_secret'], basestring)
+
+    def test_delete_authorisation_token_not_found(self):
+        # If the provided token_key does not exist (for the currently
+        # logged-in user), the api returns a 'Not Found' (404) error.
+        response = self.client.post(
+            '/api/account/',
+            {'op': 'delete_authorisation_token', 'token_key': 'no-such-token'})
+
+        self.assertEqual(httplib.NOT_FOUND, response.status_code)
+
+    def test_delete_authorisation_token_bad_request_no_token(self):
+        # token_key is a mandatory parameter when calling
+        # delete_authorisation_token. It it is not present in the request's
+        # parameters, the api returns a 'Bad Request' (400) error.
+        response = self.client.post(
+            '/api/account/', {'op': 'delete_authorisation_token'})
+
+        self.assertEqual(httplib.BAD_REQUEST, response.status_code)
+
+
 class FileStorageAPITest(APITestCase):
 
     def setUp(self):

=== modified file 'src/maasserver/tests/test_models.py'
--- src/maasserver/tests/test_models.py	2012-02-13 16:32:31 +0000
+++ src/maasserver/tests/test_models.py	2012-02-14 10:48:33 +0000
@@ -31,6 +31,7 @@
 from piston.models import (
     Consumer,
     KEY_SIZE,
+    SECRET_SIZE,
     Token,
     )
 
@@ -194,6 +195,17 @@
 
 class UserProfileTest(TestCase):
 
+    def assertTokenValid(self, token):
+        self.assertIsInstance(token.key, basestring)
+        self.assertEqual(KEY_SIZE, len(token.key))
+        self.assertIsInstance(token.secret, basestring)
+        self.assertEqual(SECRET_SIZE, len(token.secret))
+
+    def assertConsumerValid(self, consumer):
+        self.assertIsInstance(consumer.key, basestring)
+        self.assertEqual(KEY_SIZE, len(consumer.key))
+        self.assertEqual('', consumer.secret)
+
     def test_profile_creation(self):
         # A profile is created each time a user is created.
         user = factory.make_user()
@@ -205,23 +217,37 @@
         user = factory.make_user()
         consumers = Consumer.objects.filter(user=user, name=GENERIC_CONSUMER)
         self.assertEqual([user], [consumer.user for consumer in consumers])
-        self.assertEqual(
-            consumers[0], user.get_profile().get_authorisation_consumer())
-        self.assertEqual(GENERIC_CONSUMER, consumers[0].name)
-        self.assertEqual(KEY_SIZE, len(consumers[0].key))
-        # The generic consumer has an empty secret.
-        self.assertEqual(0, len(consumers[0].secret))
+        self.assertConsumerValid(consumers[0])
 
     def test_token_creation(self):
         # A token is created each time a user is created.
         user = factory.make_user()
         tokens = Token.objects.filter(user=user)
         self.assertEqual([user], [token.user for token in tokens])
-        self.assertEqual(
-            tokens[0], user.get_profile().get_authorisation_token())
-        self.assertIsInstance(tokens[0].key, unicode)
-        self.assertEqual(KEY_SIZE, len(tokens[0].key))
-        self.assertEqual(Token.ACCESS, tokens[0].token_type)
+        self.assertTokenValid(tokens[0])
+
+    def test_create_authorisation_token(self):
+        user = factory.make_user()
+        profile = user.get_profile()
+        consumer, token = profile.create_authorisation_token()
+        self.assertEqual(consumer, token.consumer)
+        self.assertEqual(user, token.user)
+        self.assertEqual(user, consumer.user)
+        self.assertConsumerValid(consumer)
+        self.assertTokenValid(token)
+
+    def test_get_authorisation_tokens(self):
+        user = factory.make_user()
+        other_user = factory.make_user()
+        profile = user.get_profile()
+        other_profile = other_user.get_profile()
+        _, token = profile.create_authorisation_token()
+        other_profile.create_authorisation_token()
+        tokens = profile.get_authorisation_tokens()
+        # This user has 2 tokens: the one that was created automatically
+        # when the user was created plus the one we've created manually.
+        self.assertEqual(2, tokens.count())
+        self.assertEqual(token, list(tokens.order_by('id'))[1])
 
 
 class FileStorageTest(TestCase):

=== modified file 'src/maasserver/tests/test_views.py'
--- src/maasserver/tests/test_views.py	2012-02-10 12:59:12 +0000
+++ src/maasserver/tests/test_views.py	2012-02-14 10:48:33 +0000
@@ -41,23 +41,20 @@
     def test_prefs_GET_api(self):
         # The preferences page (api tab) displays the API access tokens.
         user = self.logged_in_user
+        # Create a few tokens.
+        for i in xrange(3):
+            user.get_profile().create_authorisation_token()
         response = self.client.get('/account/prefs/?tab=1')
         doc = fromstring(response.content)
-        # The consumer key and the token key/secret are displayed.
-        consumer = user.get_profile().get_authorisation_consumer()
-        token = user.get_profile().get_authorisation_token()
-        self.assertSequenceEqual(
-            [consumer.key],
-            [elem.text.strip() for elem in
-                doc.cssselect('div#consumer_key')])
-        self.assertSequenceEqual(
-            [token.key],
-            [elem.text.strip() for elem in
-                doc.cssselect('div#token_key')])
-        self.assertSequenceEqual(
-            [token.secret],
-            [elem.text.strip() for elem in
-                doc.cssselect('div#token_secret')])
+        # The OAuth tokens are displayed.
+        for token in user.get_profile().get_authorisation_tokens():
+            consumer = token.consumer
+            # The token string is a compact representation of the keys.
+            token_string = '%s:%s:%s' % (consumer.key, token.key, token.secret)
+            self.assertSequenceEqual(
+                [token_string],
+                [elem.text.strip() for elem in
+                    doc.cssselect('td#%s' % token.key)])
 
     def test_prefs_POST_profile(self):
         # The preferences page allows the user the update its profile