launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06269
[Merge] lp:~rvb/maas/maas-oauth into lp:maas
Raphaël Badin has proposed merging lp:~rvb/maas/maas-oauth into lp:maas.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~rvb/maas/maas-oauth/+merge/91863
This branch makes the API use OAuth for authentication.
It also:
- introduces a UserProfile class (see https://docs.djangoproject.com/en/dev/topics/auth/#storing-additional-information-about-users) that we will use for our own user related methods (and fields if any).
- uses Django's signal mechanism to have the UserProfile and the Token/Consumer objects created when a User object gets created.
- adds the token/consumer objects related to admin and test in the sampledata. This way, the users's keys won't change.
- fixes a few tests so that they don't rely on data created in setUp.
--
https://code.launchpad.net/~rvb/maas/maas-oauth/+merge/91863
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-oauth into lp:maas.
=== modified file 'buildout.cfg'
--- buildout.cfg 2012-02-06 16:22:27 +0000
+++ buildout.cfg 2012-02-07 15:54:01 +0000
@@ -38,6 +38,7 @@
django-nose
django-piston
docutils
+ oauth2
oops
oops-datedir-repo
oops-wsgi
=== modified file 'docs/models.rst'
--- docs/models.rst 2012-02-04 22:20:08 +0000
+++ docs/models.rst 2012-02-07 15:54:01 +0000
@@ -27,3 +27,8 @@
:show-inheritance:
:members:
+.. autoclass:: UserProfile
+ :show-inheritance:
+ :members:
+
+
=== modified file 'src/maas/development.py'
--- src/maas/development.py 2012-01-25 14:24:52 +0000
+++ src/maas/development.py 2012-02-07 15:54:01 +0000
@@ -166,6 +166,7 @@
'maastesting',
'debug_toolbar',
'django_nose',
+ 'piston',
)
# A sample logging configuration. The only tangible logging
=== modified file 'src/maas/settings.py'
--- src/maas/settings.py 2012-01-27 14:30:12 +0000
+++ src/maas/settings.py 2012-02-07 15:54:01 +0000
@@ -41,6 +41,8 @@
YUI_VERSION = '3.4.1'
STATIC_LOCAL_SERVE = DEBUG
+AUTH_PROFILE_MODULE = 'maasserver.UserProfile'
+
AUTHENTICATION_BACKENDS = (
'maasserver.models.MaaSAuthorizationBackend',
)
@@ -171,6 +173,7 @@
'django.contrib.admin',
'django_nose',
'maasserver',
+ 'piston',
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
)
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py 2012-02-06 17:17:47 +0000
+++ src/maasserver/api.py 2012-02-07 15:54:01 +0000
@@ -36,15 +36,15 @@
MACAddress,
Node,
)
-from piston.authentication import HttpBasicAuthentication
+from piston.authentication import OAuthAuthentication
from piston.doc import generate_doc
from piston.handler import BaseHandler
from piston.utils import rc
-class DjangoHttpBasicAuthentication(HttpBasicAuthentication):
+class MaasAPIAuthentication(OAuthAuthentication):
"""A piston authentication class that uses the currently logged-in user
- if there is one, and defaults to piston's HttpBasicAuthentication if not.
+ if there is one, and defaults to piston's OAuthAuthentication if not.
"""
@@ -53,7 +53,10 @@
return request.user
else:
return super(
- DjangoHttpBasicAuthentication, self).is_authenticated(request)
+ MaasAPIAuthentication, self).is_authenticated(request)
+
+ def challenge(self):
+ return rc.FORBIDDEN
def validate_and_save(obj):
=== modified file 'src/maasserver/fixtures/dev_fixture.yaml'
--- src/maasserver/fixtures/dev_fixture.yaml 2012-01-24 17:21:45 +0000
+++ src/maasserver/fixtures/dev_fixture.yaml 2012-02-07 15:54:01 +0000
@@ -132,3 +132,21 @@
username: test
model: auth.user
pk: 6
+- fields: {description: '', key: RfGASHsLT6XwuRunC6, name: Generic consumer, secret: 99cpqUcPxJ8GP4RZdfgRHBnTr7uTcQm5, status: accepted,
+ user: 1}
+ model: piston.consumer
+ pk: 1
+- fields: {description: '', key: JcaW54bSSN66wj5M3d, name: Generic consumer, secret: wVbGPSzZ4YyNrCpST7WYR7Av9yUhZHJF, status: accepted,
+ user: 5}
+ model: piston.consumer
+ pk: 2
+- fields: {callback: null, callback_confirmed: false, consumer: 1, is_approved: true,
+ key: EswkkXrcungmh4wjgN, secret: Z7xkk6LBjfsEDnXpL93ksTRWn9jMBXDg, timestamp: 1328622205,
+ token_type: 2, user: 1, verifier: ''}
+ model: piston.token
+ pk: 1
+- fields: {callback: null, callback_confirmed: false, consumer: 1, is_approved: true,
+ key: e6jDkAJzAP7SZnpkQq, secret: mkWmXM62Xv6p73sF8uZNdu6kpLcdktpp, timestamp: 1328622494,
+ token_type: 2, user: 5, verifier: ''}
+ model: piston.token
+ pk: 2
=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py 2012-02-04 22:24:18 +0000
+++ src/maasserver/models.py 2012-02-07 15:54:01 +0000
@@ -25,8 +25,13 @@
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db import models
+from django.db.models.signals import post_save
from django.shortcuts import get_object_or_404
from maasserver.macaddress import MACAddressField
+from piston.models import (
+ Consumer,
+ Token,
+ )
class CommonInfo(models.Model):
@@ -265,9 +270,80 @@
return self.mac_address
+GENERIC_CONSUMER = 'Generic consumer'
+
+
+class UserProfile(models.Model):
+ """A custom UserProfile_
+
+ :ivar user: The related User_.
+
+ .. _UserProfile: https://docs.djangoproject.com/
+ en/dev/topics/auth/
+ #storing-additional-information-about-users
+
+ """
+
+ user = models.OneToOneField(User)
+
+ def reset_authorisation_token(self):
+ """Reset the key and the secret key of the OAuth authorisation token
+ attached to the related User_.
+
+ :return: The resetted Token.
+ :rtype: piston.models.Token
+
+ """
+ consumer, _ = Consumer.objects.get_or_create(
+ user=self.user, name=GENERIC_CONSUMER,
+ defaults={'status': 'accepted'})
+ consumer.generate_random_codes()
+ token, _ = Token.objects.get_or_create(
+ user=self.user, token_type=Token.ACCESS, consumer=consumer,
+ defaults={'is_approved': True})
+ token.generate_random_codes()
+ return 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
+
+
+# When a user is created: create the related profile and the default
+# consumer/token.
+def create_user(sender, instance, created, **kwargs):
+ if created:
+ # Create related UserProfile.
+ profile = UserProfile.objects.create(user=instance)
+
+ # Create initial authorisation token.
+ profile.reset_authorisation_token()
+
+
+post_save.connect(create_user, sender=User)
+
+
# Register the models in the admin site.
admin.site.register(Node)
admin.site.register(MACAddress)
+admin.site.register(Consumer)
class MaaSAuthorizationBackend(ModelBackend):
=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py 2012-02-06 11:13:25 +0000
+++ src/maasserver/tests/test_api.py 2012-02-07 15:54:01 +0000
@@ -11,9 +11,9 @@
__metaclass__ = type
__all__ = []
-import base64
import httplib
import json
+import time
from django.test.client import Client
from maasserver.models import (
@@ -26,6 +26,7 @@
TestCase,
)
from maasserver.testing.factory import factory
+import oauth2 as oauth
class NodeAnonAPITest(TestCase):
@@ -43,29 +44,35 @@
self.assertEqual(httplib.OK, response.status_code)
-class AuthenticatedClient(Client):
+class OAuthAuthenticatedClient(Client):
+ def __init__(self, user):
+ super(OAuthAuthenticatedClient, self).__init__()
+ self.consumer = user.get_profile().get_authorisation_consumer()
+ self.token = user.get_profile().get_authorisation_token()
- def __init__(self, auth):
- super(AuthenticatedClient, self).__init__()
- self.extra = {
- 'HTTP_AUTHORIZATION': auth,
+ def get_extra(self, path):
+ params = {
+ 'oauth_version': "1.0",
+ 'oauth_nonce': oauth.generate_nonce(),
+ 'oauth_timestamp': int(time.time()),
+ 'oauth_token': self.token.key,
+ 'oauth_consumer_key': self.consumer.key,
}
-
- def get(self, *args, **kwargs):
- kwargs.update(self.extra)
- return super(AuthenticatedClient, self).get(*args, **kwargs)
-
- def put(self, *args, **kwargs):
- kwargs.update(self.extra)
- return super(AuthenticatedClient, self).put(*args, **kwargs)
-
- def post(self, *args, **kwargs):
- kwargs.update(self.extra)
- return super(AuthenticatedClient, self).post(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- kwargs.update(self.extra)
- return super(AuthenticatedClient, self).delete(*args, **kwargs)
+ req = oauth.Request(method="GET", url=path, parameters=params)
+ signature_method = oauth.SignatureMethod_PLAINTEXT()
+ req.sign_request(signature_method, self.consumer, self.token)
+ return req.to_header()
+
+ def request(self, **kwargs):
+ # Get test url.
+ environ = self._base_environ()
+ url = '%s://%s' % (environ['wsgi.url_scheme'], kwargs['PATH_INFO'])
+ # Addi OAuth authorization information to the request.
+ extra = self.get_extra(url)
+ extra['HTTP_AUTHORIZATION'] = extra['Authorization']
+ kwargs.update(extra)
+
+ return super(OAuthAuthenticatedClient, self).request(**kwargs)
class APITestMixin(TestCase):
@@ -74,20 +81,7 @@
super(APITestMixin, self).setUp()
self.logged_in_user = factory.make_user(
username='test', password='test')
- auth = '%s:%s' % ('test', 'test')
- auth = 'Basic %s' % base64.encodestring(auth)
- auth = auth.strip()
- self.extra = {
- 'HTTP_AUTHORIZATION': auth,
- }
- self.client = AuthenticatedClient(auth)
- self.other_user = factory.make_user()
- self.other_node = factory.make_node(
- status=NODE_STATUS.DEPLOYED, owner=self.other_user)
-
- self.other_user = factory.make_user()
- self.other_node = factory.make_node(
- status=NODE_STATUS.DEPLOYED, owner=self.other_user)
+ self.client = OAuthAuthenticatedClient(self.logged_in_user)
class NodeAPILoggedInTest(LoggedInTestCase):
@@ -132,8 +126,10 @@
def test_node_GET_non_visible_node(self):
# The request to fetch a single node is denied if the node isn't
# visible by the user.
- response = self.client.get(
- '/api/nodes/%s/' % self.other_node.system_id)
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
+
+ response = self.client.get('/api/nodes/%s/' % other_node.system_id)
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
@@ -256,8 +252,10 @@
def test_node_PUT_non_visible_node(self):
# The request to update a single node is denied if the node isn't
# visible by the user.
- response = self.client.put(
- '/api/nodes/%s/' % self.other_node.system_id)
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
+
+ response = self.client.put('/api/nodes/%s/' % other_node.system_id)
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
@@ -271,8 +269,10 @@
def test_node_DELETE_non_visible_node(self):
# The request to delete a single node is denied if the node isn't
# visible by the user.
- response = self.client.delete(
- '/api/nodes/%s/' % self.other_node.system_id)
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
+
+ response = self.client.delete('/api/nodes/%s/' % other_node.system_id)
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
@@ -317,8 +317,10 @@
def test_macs_GET_forbidden(self):
# When fetching MAC Addresses, the api returns a 'Unauthorized' (401)
# error if the node is not visible to the logged-in user.
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
response = self.client.get(
- '/api/nodes/%s/macs/' % self.other_node.system_id)
+ '/api/nodes/%s/macs/' % other_node.system_id)
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
@@ -340,8 +342,10 @@
def test_macs_GET_node_forbidden(self):
# When fetching a MAC Address, the api returns a 'Unauthorized' (401)
# error if the node is not visible to the logged-in user.
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
response = self.client.get(
- '/api/nodes/%s/macs/0-aa-22-cc-44-dd/' % self.other_node.system_id)
+ '/api/nodes/%s/macs/0-aa-22-cc-44-dd/' % other_node.system_id)
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
@@ -396,9 +400,11 @@
def test_macs_DELETE_mac_forbidden(self):
# When deleting a MAC Address, the api returns a 'Unauthorized' (401)
# error if the node is not visible to the logged-in user.
+ other_node = factory.make_node(
+ status=NODE_STATUS.DEPLOYED, owner=factory.make_user())
response = self.client.delete(
'/api/nodes/%s/macs/%s/' % (
- self.other_node.system_id, self.mac1.mac_address))
+ other_node.system_id, self.mac1.mac_address))
self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
=== modified file 'src/maasserver/tests/test_models.py'
--- src/maasserver/tests/test_models.py 2012-01-25 14:16:04 +0000
+++ src/maasserver/tests/test_models.py 2012-02-07 15:54:01 +0000
@@ -16,12 +16,19 @@
ValidationError,
)
from maasserver.models import (
+ GENERIC_CONSUMER,
MACAddress,
Node,
NODE_STATUS,
+ UserProfile,
)
from maasserver.testing.factory import factory
from maastesting import TestCase
+from piston.models import (
+ Consumer,
+ KEY_SIZE,
+ Token,
+ )
class NodeTest(TestCase):
@@ -120,5 +127,32 @@
self.assertEqual([mac], list(MACAddress.objects.all()))
def test_invalid_address_raises_validation_error(self):
- mac = self.make_MAC('AA:BB:CCXDD:EE:FF')
+ mac = self.make_MAC('aa:bb:ccxdd:ee:ff')
self.assertRaises(ValidationError, mac.full_clean)
+
+
+class UserProfileTest(TestCase):
+
+ def test_profile_creation(self):
+ # A profile is created each time a user is created.
+ user = factory.make_user()
+ self.assertTrue(isinstance(user.get_profile(), UserProfile))
+ self.assertEqual(user, user.get_profile().user)
+
+ def test_consumer_creation(self):
+ # A generic consumer is created each time a user is created.
+ user = factory.make_user()
+ consumers = Consumer.objects.filter(user=user, name=GENERIC_CONSUMER)
+ self.assertEqual([user], [consumer.user for consumer in consumers])
+ self.assertEqual(GENERIC_CONSUMER, consumers[0].name)
+ self.assertTrue(isinstance(consumers[0].key, unicode))
+ self.assertEqual(KEY_SIZE, len(consumers[0].key))
+
+ 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.assertTrue(isinstance(tokens[0].key, unicode))
+ self.assertEqual(KEY_SIZE, len(tokens[0].key))
+ self.assertEqual(Token.ACCESS, tokens[0].token_type)
=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py 2012-02-05 14:56:29 +0000
+++ src/maasserver/urls.py 2012-02-07 15:54:01 +0000
@@ -22,7 +22,7 @@
)
from maasserver.api import (
api_doc,
- DjangoHttpBasicAuthentication,
+ MaasAPIAuthentication,
NodeHandler,
NodeMacHandler,
NodeMacsHandler,
@@ -61,7 +61,7 @@
)
# API.
-auth = DjangoHttpBasicAuthentication(realm="MaaS API")
+auth = MaasAPIAuthentication(realm="MaaS API")
node_handler = Resource(NodeHandler, authentication=auth)
nodes_handler = Resource(NodesHandler, authentication=auth)
=== modified file 'versions.cfg'
--- versions.cfg 2012-02-06 17:37:55 +0000
+++ versions.cfg 2012-02-07 15:54:01 +0000
@@ -10,6 +10,7 @@
ipython =
nose =
nose-subunit =
+oauth2 =
oops =
oops-datedir-repo =
oops-twisted =
Follow ups