OAuth PAS Plugin
Project description
Introduction
This module provides basic OAuth support for Zope/Plone while aiming to be extensible through the Zope Component Architecture such that more demanding features can be slotted in. Basic features will be provided, such as simple management of consumers and their keys, and local users will be able to approve consumer requests and revoke the keys later.
While the test coverage is fairly complete and demonstrates that access restriction seem to function as intented, this package is still a proof of concept at this point in time. Production usage of this package should be avoided without a full understanding of how this package is constructed.
Installation
This package may require Plone 4 or later. Might work with Plone 3.3.x but it has not been tested on that yet.
Installing with buildout
You can install pmr2.oauth using buildout by adding an entry for this package in both eggs and zcml sections.
Example:
[buildout] ... [instance] ... eggs = ... pmr2.oauth zcml = ... pmr2.oauth
Usage
For further usage information, please refer to the tests and the associated README files (i.e. pmr2/oauth/README.rst)
OAuth PAS Plug-in
This module provides OAuth authentication for Plone via the Pluggable Authentication Service.
To run this, we first import all the modules we need.
>>> import time >>> import urlparse >>> import oauth2 as oauth >>> import zope.component >>> import zope.interface >>> from Testing.testbrowser import Browser >>> from plone.z3cform.interfaces import IWrappedForm >>> from pmr2.oauth.interfaces import * >>> from pmr2.oauth.browser.token import * >>> from pmr2.oauth.consumer import * >>> from pmr2.oauth.tests.base import TestRequest >>> from pmr2.oauth.tests.base import SignedTestRequest >>> request = TestRequest() >>> o_logged_view = zope.component.getMultiAdapter( ... (self.portal, request), name='test_current_user') >>> baseurl = self.portal.absolute_url()
Some of the forms need to be have the IWrappedForm interface applied to get the correct inner template adapted.
>>> from pmr2.oauth.browser import scope >>> zope.interface.classImplements(scope.ScopeEditForm, IWrappedForm)
The default OAuth utility should have been registered.
>>> utility = zope.component.getUtility(IOAuthUtility) >>> utility <pmr2.oauth.utility.OAuthUtility object at ...>
The request adapter should have been registered.
>>> request = TestRequest() >>> zope.component.getAdapter(request, IRequest) {}
The default consumer manager should also be available via adapter.
>>> request = TestRequest() >>> consumerManager = zope.component.getMultiAdapter( ... (self.portal, request), IConsumerManager) >>> consumerManager <pmr2.oauth.consumer.ConsumerManager object at ...>
Ditto for the token manager.
>>> request = TestRequest() >>> tokenManager = zope.component.getMultiAdapter( ... (self.portal, request), ITokenManager) >>> tokenManager <pmr2.oauth.token.TokenManager object at ...>
Lastly, the scope manager.
>>> request = TestRequest() >>> scopeManager = zope.component.getMultiAdapter( ... (self.portal, request), IScopeManager) >>> scopeManager <pmr2.oauth.scope.DefaultScopeManager object at ...>
Consumer Registration
In order for a client to use the site contents, it needs to register onto the site. For now we just add a consumer to the ConsumerManager.
>>> consumer1 = Consumer('consumer1.example.com', 'consumer1-secret') >>> consumerManager.add(consumer1) >>> consumer1 == consumerManager.get('consumer1.example.com') True
It will be possible to use the browser form to add one also.
Consumer Requests
Once the consumer is registered onto the site, it is now possible to use it to request token. We can try a standard request without any authorization, however we should log out here first.
>>> self.logout() >>> request = TestRequest() >>> rt = RequestTokenPage(self.portal, request) >>> rt() Traceback (most recent call last): ... BadRequest: missing oauth parameters
We can try to make up some random request, that should fail because it is not signed properly. ::
>>> timestamp = str(int(time.time())) >>> request = TestRequest(oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_consumer_key': "consumer1.example.com", ... 'oauth_nonce': "123123123123123123123123123", ... 'oauth_timestamp': timestamp, ... 'oauth_callback': "http://www.example.com/oauth/callback", ... 'oauth_signature_method': "HMAC-SHA1", ... 'oauth_signature': "ANT2FEjwDqxg383D", ... }) >>> rt = RequestTokenPage(self.portal, request) >>> rt() Traceback (most recent call last): ... BadRequest: Invalid signature...
Now we construct a request signed with the key, using python-oauth2. The desired request token string should be generated and returned. While the callback URL is still on the portal, this is for convenience sake.
>>> timestamp = str(int(time.time())) >>> request = SignedTestRequest(oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "4572616e48616d6d65724c61686176", ... 'oauth_timestamp': timestamp, ... 'oauth_callback': baseurl + '/test_oauth_callback', ... }, consumer=consumer1) >>> rt = RequestTokenPage(self.portal, request) >>> tokenstr = rt() >>> print tokenstr oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true >>> token = oauth.Token.from_string(tokenstr)
Try again using a browser, but try an oob callback.
>>> url = baseurl + '/OAuthRequestToken' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "4572616e48616d6d65724c61686176", ... 'oauth_timestamp': timestamp, ... 'oauth_callback': 'oob', ... }, ... consumer=consumer1, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', 'OAuth %s' % auth) >>> browser.open(url) >>> btokenstr = browser.contents >>> print btokenstr oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true >>> btoken = oauth.Token.from_string(btokenstr)
Using OAuth Tokens
This is basic auth, which we want to avoid since consumers would have to retain (thus know) the user/password combination.
>>> baseurl = self.portal.absolute_url() >>> browser = Browser() >>> auth = '%s:%s' % (default_user, default_password) >>> browser.addHeader('Authorization', 'Basic %s' % auth.encode('base64')) >>> browser.open(baseurl + '/test_current_user') >>> print browser.contents test_user_1_
For the OAuth testing request, we need to generate the authorization header proper, so we instantiate a signed request object and use it to build this string.
>>> url = baseurl + '/test_current_user' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "806052fe5585b22f63fe27cba8b78732", ... 'oauth_timestamp': timestamp, ... }, ... consumer=consumer1, ... token=access_token, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', 'OAuth %s' % auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
There is one more security consideration that needs to be satisified still - the scope. The default scope manager only permit GET requests, and they must match one of the permit rules that it contains. Add this URL and try again.
>>> scopeManager.permitted = 'test_current_user$\ntest_current_roles$\n' >>> browser.open(url) >>> print browser.contents test_user_1_
Try the roles view also, since it is also permitted.
>>> url = baseurl + '/test_current_roles' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "806052fe5585b22f63fe27cba8b78732", ... 'oauth_timestamp': timestamp, ... }, ... consumer=consumer1, ... token=access_token, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', 'OAuth %s' % auth) >>> browser.open(url) >>> print browser.contents Member Authenticated
Scope
While the current scope manager already place limits on what consumers can access, individual users should be able to place further restrictions on the amount of their resources a given consumer may access. Attaching a scope to a token is a method that enables this limitation. As the implementation is completely extensible, the specific parameters used/accepted by the scope is dependent on those details, likewise for the presentation of the scope to the end user (e.g. implementation of scope managers can translate raw scope into icons of a provided service more easily identifiable to end users).
For our demonstration, we continue our usage of the default scope manager by specifying a regular expression matching a URI. Here we have a consumer requesting a token like earlier, but with a scope parameter defined.
>>> url = baseurl + '/OAuthRequestToken?scope=test_current_user%24' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "109850980381481596938563", ... 'oauth_timestamp': timestamp, ... 'oauth_callback': baseurl + '/test_oauth_callback', ... }, ... consumer=consumer1, ... url=url, ... ) >>> auth = request._auth >>> consumer_browser = Browser() >>> consumer_browser.addHeader('Authorization', 'OAuth %s' % auth) >>> consumer_browser.open(url) >>> scoped_request_tokenstr = consumer_browser.contents >>> print scoped_request_tokenstr oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true >>> scoped_request_token = oauth.Token.from_string(scoped_request_tokenstr)
Verify that our scope value is stored in the request token.
>>> srt_key = scoped_request_token.key >>> raw_srt = tokenManager.get(srt_key) >>> print raw_srt.scope test_current_user$
Much like before, the user would be directed to the authorization page, this time the specific scope this consumer would like to access is visible. We also reuse the original current user’s browser (which should still be logged in). Also, since this token is limited in scope, the user should be informed.
>>> u_browser.open(auth_baseurl + '?oauth_token=' + srt_key) >>> 'The site <strong>' + consumer1.key + '</strong>' in u_browser.contents True >>> 'test_current_user$' in u_browser.contents True
User is nice once more and authorizes this second token.
>>> u_browser.getControl(name='form.buttons.approve').click() >>> url = u_browser.url >>> qs = urlparse.parse_qs(urlparse.urlparse(url).query) >>> token_verifier = qs['oauth_verifier'][0] >>> scoped_request_token.verifier = token_verifier
Complete the authorization by requesting the access token, and see that it retains the scope that was specified in the request token.
>>> url = baseurl + '/OAuthGetAccessToken' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "028516734893275926641849", ... 'oauth_timestamp': timestamp, ... }, ... consumer=consumer1, ... token=scoped_request_token, ... url=url, ... ) >>> auth = request._auth >>> consumer_browser = Browser() >>> consumer_browser.addHeader('Authorization', 'OAuth %s' % auth) >>> consumer_browser.open(url) >>> scoped_access_tokenstr = consumer_browser.contents >>> print scoped_access_tokenstr oauth_token_secret=...&oauth_token=... >>> scoped_access_token = oauth.Token.from_string(scoped_access_tokenstr) >>> sat_key = scoped_access_token.key >>> raw_sat = tokenManager.get(sat_key) >>> print raw_sat.scope test_current_user$
With this token, consumer only requested the current user and not the roles view, so this request should result in a forbidden error (even though it is publicly visible).
>>> url = baseurl + '/test_current_roles' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "028516734893275926641849", ... 'oauth_timestamp': timestamp, ... }, ... consumer=consumer1, ... token=scoped_access_token, ... url=url, ... ) >>> auth = request._auth >>> consumer_browser = Browser() >>> consumer_browser.addHeader('Authorization', 'OAuth %s' % auth) >>> consumer_browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
Now attempt to use it to access a permitted resource, in this case it would be the test_current_user view.
>>> url = baseurl + '/test_current_user' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... oauth_keys={ ... 'oauth_version': "1.0", ... 'oauth_nonce': "028516734893275926641849", ... 'oauth_timestamp': timestamp, ... }, ... consumer=consumer1, ... token=scoped_access_token, ... url=url, ... ) >>> auth = request._auth >>> consumer_browser = Browser() >>> consumer_browser.addHeader('Authorization', 'OAuth %s' % auth) >>> consumer_browser.open(url) >>> print consumer_browser.contents test_user_1_
However, if this view is no longer permitted by the default scope manager, it should no longer be accessible.
>>> scopeManager.permitted = 'test_current_roles$\n' >>> consumer_browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
Management Interfaces
Finally, the user (and site managers) would need to know what tokens are stored for who and also the ability to revoke tokens when they no longer wish to retain access for the consumer. This is where the management form comes in.
Do note that as of this release, the URIs to the following management interfaces are not linked. Site administrators may wish to add them manually if they wish to make these functions more visible.
As our test user have granted access to two tokens already, they both should show up if the listing page is viewed.
>>> from pmr2.oauth.browser import user >>> self.login(default_user) >>> request = TestRequest() >>> view = user.UserTokenForm(self.portal, request) >>> result = view() >>> access_token.key in result True >>> scoped_access_token.key in result True >>> 'consumer1.example.com' in result True
All the required data are present in the form. Let’s try to remove one of the tokens using the test browser.
>>> u_browser.open(baseurl + '/issued_oauth_tokens') >>> u_browser.getControl(name="form.widgets.key").controls[0].click() >>> u_browser.getControl(name='form.buttons.revoke').click() >>> len(tokenManager.getTokensForUser(default_user)) 2 >>> result = u_browser.contents >>> 'Access successfully removed' in result True
Same deal for consumers, we can open the consumer management form and we should see the single consumer that had been added earlier. Site managers can access this page at ${portal_url}/manage-oauth-consumers.
>>> from pmr2.oauth.browser import consumer >>> request = TestRequest() >>> view = consumer.ConsumerManageForm(self.portal, request) >>> result = view() >>> 'consumer1.example.com' in result True
We can try to add a few consumers using the form also.
>>> request = TestRequest(form={ ... 'form.widgets.key': 'consumer2.example.com', ... 'form.buttons.add': 1, ... }) >>> view = consumer.ConsumerAddForm(self.portal, request) >>> view.update() >>> request = TestRequest(form={ ... 'form.widgets.key': 'consumer3.example.com', ... 'form.buttons.add': 1, ... }) >>> view = consumer.ConsumerAddForm(self.portal, request) >>> view.update()
Now the management form should show these couple new consumers.
>>> request = TestRequest() >>> view = consumer.ConsumerManageForm(self.portal, request) >>> result = view() >>> 'consumer2.example.com' in result True >>> 'consumer3.example.com' in result True
Should have no problems removing them either.
>>> request = TestRequest(form={ ... 'form.widgets.key': [ ... 'consumer2.example.com', 'consumer3.example.com'], ... 'form.buttons.remove': 1, ... }) >>> view = consumer.ConsumerManageForm(self.portal, request) >>> result = view() >>> 'consumer2.example.com' in result False >>> 'consumer3.example.com' in result False
Lastly, scope manager also has a simple form that allow basic editing of global scope parameters for the current active scope manager. The URI to this is ${portal_url}/manage-oauth-consumers.
>>> request = TestRequest() >>> view = scope.ScopeEditForm(self.portal, request) >>> result = view() >>> 'test_current_roles$' in result True
That value can be edited.
>>> request = TestRequest(form={ ... 'form.widgets.permitted': 'test_current_user$', ... 'form.buttons.apply': 1, ... }) >>> view = scope.ScopeEditForm(self.portal, request) >>> result = view() >>> 'test_current_roles$' in result False >>> 'test_current_user$' in result True
Changelog
0.2 - 2012-10-16
Completing i18n coverage and added Italian support. [giacomos]
Added intermediate form class to eliminate the neeed to define wrapper classes for compatibility between Plone and z3c.form.
0.1 - 2011-10-20
Provide the core functionality of OAuth into Zope/Plone, through the use of custom forms and the Pluggable Authentication System.
Contain just the basic storage for all associated data types, but extensibility is allowed.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.