OAuth PAS Plugin
Project description
Introduction
This module provides OAuth support for Zope/Plone, with the option to be extensible through the Zope Component Architecture to allow the addition of more feature-rich components. Features core to OAuth are fully supported, this includes the full OAuth authentication workflow, client (consumer) management for site managers and access token management for resource owners. Scope restriction is also supported; site managers can define content types and subpaths that are available for client usage using scope profiles, and clients can specify a list of them to better inform the resource owners of what will be accessed under their access rights.
While the test coverage is fairly complete and demonstrates that access permissions and scope restriction function as intended, the author does not currently endorse the usage of this package in a mission critical environment as there may be issues that can compromise the security of such sites, as no audits have been done on this package by security experts. For providing third-party access to casually private data that is not highly sensitive in nature, this package should be sufficiently adequate in addressing that need.
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
Run buildout, then restart the Zope/Plone instance. This package must also be activated using the Add-ons panel under Site Setup within the Plone instance where OAuth based authorization is to be used.
Further information and usage instructions
If the add-on is correctly installed and activated, an index of views made available as part of this add-on can be found at ${portal_url}/@@pmr2-oauth.
If you are upgrading from a previously installed version of this add-on, please refer to docs/UPGRADE.rst for some important information.
For more detailed information, please refer to the doctest file at 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 zope.component >>> import zope.interface >>> from Testing.testbrowser import Browser >>> from Products.statusmessages.interfaces import IStatusMessage >>> from pmr2.oauth.interfaces import * >>> from pmr2.oauth.consumer import * >>> from pmr2.oauth.tests.base import makeToken >>> 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()
The OAuth adapter should have been set up:
>>> request = TestRequest() >>> oauthAdapter = zope.component.getMultiAdapter( ... (self.portal, request), IOAuthAdapter) >>> oauthAdapter <pmr2.oauth.utility.SiteRequestOAuth1ServerAdapter object at ...>
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. Verify that this component is registered for both the base generic interface and its specific interface:
>>> request = TestRequest() >>> scopeManager = zope.component.getMultiAdapter( ... (self.portal, request), IScopeManager) >>> scopeManager <pmr2.oauth.scope.ContentTypeScopeManager object at ...> >>> IScopeManager.providedBy(scopeManager) True >>> result = zope.component.getMultiAdapter( ... (self.portal, request), IContentTypeScopeManager) >>> scopeManager == result True
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') >>> consumer1.title = u'consumer1.example.com' >>> 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:
>>> from pmr2.oauth.browser import token >>> self.logout() >>> request = TestRequest() >>> rt = token.RequestTokenPage(self.portal, request) >>> rt() Traceback (most recent call last): ... BadRequest...
Now we construct a request signed with the key. 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( ... timestamp=timestamp, ... consumer=consumer1, ... callback=baseurl + '/test_oauth_callback', ... ) >>> rt = token.RequestTokenPage(self.portal, request) >>> atokenstr = rt() >>> print atokenstr oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true >>> atoken = makeToken(atokenstr)
Try again using a browser, but try an oob callback:
>>> url = baseurl + '/OAuthRequestToken' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, ... url=url, ... callback='oob', ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> btokenstr = browser.contents >>> print btokenstr oauth_token_secret=...&oauth_token=...&oauth_callback_confirmed=true >>> btoken = makeToken(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' >>> request = SignedTestRequest( ... consumer=consumer1, ... token=access_token, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', 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.
For now we omit its restrictions by overriding some of the fields through unconventional injection of values:
>>> scopeManager._mappings[scopeManager.default_mapping_id] = { ... 'Plone Site': ['test_current_user', 'test_current_roles'], ... } >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> print browser.contents test_user_1_
Try the roles view also, since it is also permitted:
>>> url = baseurl + '/test_current_roles' >>> request = SignedTestRequest( ... consumer=consumer1, ... token=access_token, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> print browser.contents Member Authenticated
If a client were to access a content type object without specifying a view, typically the default view will be resolved. If this is included in the list of allowed names for the content type, the scope manager will permit access. Again a brute forced approach is taken to work around scope manager restrictions:
>>> scopeManager._mappings[scopeManager.default_mapping_id] = { ... 'Plone Site': ['test_current_user', 'test_current_roles'], ... 'Folder': ['folder_listing',], ... } >>> url = self.folder.absolute_url() >>> request = SignedTestRequest( ... consumer=consumer1, ... token=access_token, ... url=url, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> 'There are currently no items in this folder.' in browser.contents True
Callback Management
Callbacks need to be verified. If an attacker were to obtain the client key and secrets, it can be used illegitimately to trick resource owners into giving access. This can be trivially done using an out of band client key and associated secret extracted from a client application. For this reason, such client keys for oob usage MUST NOT provide a domain name. Alternatively, they could be unintentionally leaked, and if the key does not have any restrictions on where the callback can lead to, it can also be misused in the same manner.
Previously we had use a callback URL that is will redirect to the same Plone instance. By default this will not pose any security issues as no information leakage is possible under normal circumstances. In practice this will not happen, because a redirection to an external site must be done in order to propagate the value of the token verifier, and by default the response redirector will check whether this redirect is trusted, and it is not unless otherwise stated.
If a client makes a request for a request token with a callback outside of their domain name, the request will fail:
>>> url = baseurl + '/OAuthRequestToken' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, ... url=url, ... callback='http://www.example.com/plone/test_oauth_callback', ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 400: Bad Request
An appropriate domain name assigned to the client will then permit the above request:
>>> consumer1.domain = u'www.example.com' >>> browser.open(url) >>> print browser.contents oauth_token_secret=...&oauth_token=... >>> wwwtoken = makeToken(browser.contents)
If for whatever reason a request token with a callback that no longer matches the current domain belonging to the client, the redirect would then fail to work for the resource owner.:
>>> servtoken = tokenManager.generateRequestToken(consumer1.key, ... 'http://service.example.com/plone/test_oauth_callback') >>> scopeManager.requestScope(servtoken.key, None) True >>> u_browser.open(auth_baseurl + '?oauth_token=' + servtoken.key) >>> u_browser.getControl(name='form.buttons.approve').click() >>> 'Callback is not approved for the client.' in u_browser.contents True
On the other hand, the user should encounter no issues when accepting a token with an approved callback url:
>>> u_browser.open(auth_baseurl + '?oauth_token=' + wwwtoken.key) >>> u_browser.getControl(name='form.buttons.approve').click()
With the user successfully redirected to the client’s host, who then will receive the token id with its associated verifier:
>>> print u_browser.url http://www.example.com/plone/test_oauth_callback?... >>> print u_browser.contents Verifier: ... Token: ...
Now if the client’s domain is updated to accept wildcard sub-domains, the manually generated token will be abled to be processed and also be redirected:
>>> consumer1.domain = u'*.example.com' >>> u_browser.open(auth_baseurl + '?oauth_token=' + servtoken.key) >>> u_browser.getControl(name='form.buttons.approve').click() >>> print u_browser.url http://service.example.com/plone/test_oauth_callback?...
Lastly, check that the request token will fail when the oauth_callback parameter is missing:
>>> url = baseurl + '/OAuthRequestToken' >>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, ... url=url, ... callback='', ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 400: Bad Request
One last test, with just the raw class this time:
>>> timestamp = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, ... callback='', ... ) >>> rt = token.RequestTokenPage(self.portal, request) >>> rt.update() Traceback (most recent call last): ... BadRequest
Scope Profile and Management
To properly restrict what resources can be accessed by consumers, access granted by an access token is limited by scope managers, which was demostrated earlier. However, the adminstrators must have a way to customize them. To do that views and forms are provided:
>>> from pmr2.oauth.browser import scope >>> context = self.portal >>> request = TestRequest() >>> view = scope.ContentTypeScopeManagerView(context, request) >>> print view() <BLANKLINE> ... <h2> List of Scope Profiles. </h2> <ul> </ul> <p> <a href=".../add" id="ctsm_add_scope_profile">Add Scope Profile</a> </p> ...
Selecting that link will bring up the Add Scope Profile form:
>>> request = TestRequest(form={ ... 'form.widgets.name': 'test_profile', ... 'form.buttons.add': 1, ... }) >>> view = scope.ContentTypeScopeProfileAddForm(context, request) >>> view.update()
Once that profile is added it will be first added as an edit profile, which are work in progress profiles to separate them from active ones. This ensures that any existing access keys using the original scopes will not get retroactively updated by new scopes.
As stated, this can be retrieved and listed using the method provided by the scope manager:
>>> scopeManager.getEditProfile('test_profile') <pmr2.oauth.scope.ContentTypeScopeProfile object at ...> >>> scopeManager.getEditProfileNames()[0] 'test_profile'
The manager view will list this also:
>>> request = TestRequest() >>> view = scope.ContentTypeScopeManagerView(context, request) >>> print view() <BLANKLINE> ... <h2> List of Scope Profiles. </h2> <ul> <li> <a href=".../test_profile">test_profile</a> </li> </ul> <p> <a href=".../add" id="ctsm_add_scope_profile">Add Scope Profile</a> </p> ...
The link leads to the view form. There should be some actions with corresponding buttons:
>>> request = TestRequest() >>> view = scope.ContentTypeScopeProfileDisplayForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update() >>> result = view.render() >>> 'Commit Update' in result True >>> 'Edit' in result True >>> 'Revert' in result True
Now instantiate the edit view for that profile:
>>> request = TestRequest() >>> view = scope.ContentTypeScopeProfileEditForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update() >>> result = view.render() >>> 'Document' in result True >>> 'Folder' in result True
Apply the value and see that the profile is updated:
>>> request = TestRequest(form={ ... 'form.widgets.title': u'Test current user', ... 'form.widgets.description': u'See current user information.', ... 'form.widgets.methods': 'GET HEAD OPTIONS', ... 'form.widgets.mapping.widgets.Plone Site': u'test_current_user', ... 'form.widgets.mapping.widgets.Document': u'document_view', ... 'form.widgets.mapping-empty-marker': 1, ... 'form.buttons.apply': 1, ... }) >>> view = scope.ContentTypeScopeProfileEditForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update() >>> result = view.render() >>> profile = scopeManager.getEditProfile('test_profile') >>> profile.mapping['Document'] ['document_view']
However, as currently implemented, views that were permitted for another type that may have been installed previously will not be saved if the profile is updated with the form. Here we add a dummy mapping and then update the form again and see that the views enabled for the Dummy type is not preserved:
>>> request.environ['pmr2.traverse_subpath'] = [] >>> new_mapping = {} >>> new_mapping.update(profile.mapping) >>> new_mapping['Dummy'] = ['dummy_view'] >>> profile.mapping = new_mapping >>> profile.mapping.get('Dummy', False) ['dummy_view'] >>> view = scope.ContentTypeScopeProfileEditForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update() >>> profile = scopeManager.getEditProfile('test_profile') >>> profile.mapping.get('Dummy', False) False
Back onto the edit form. See that the profile can be committed for use:
>>> request = TestRequest(form={ ... 'form.buttons.setdefault': 1, ... }) >>> view = scope.ContentTypeScopeProfileDisplayForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update()
Wait, the profile has not been committed. There will be an error rendered, along with the notification that it has been modified.:
>>> status = IStatusMessage(request) >>> messages = status.show() >>> messages[0].message u'This profile has not been committed yet.' >>> messages[1].message u'This profile has been modified. ...
Try this again after committing it:
>>> request = TestRequest(form={ ... 'form.buttons.commit': 1, ... }) >>> view = scope.ContentTypeScopeProfileDisplayForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update()
Use the newly created mapping as the default mapping:
>>> request = TestRequest(form={ ... 'form.buttons.setdefault': 1, ... }) >>> view = scope.ContentTypeScopeProfileDisplayForm(context, request) >>> view = view.publishTraverse(request, 'test_profile') >>> view.update() >>> mapping_id = scopeManager.default_mapping_id >>> mapping_id 1
Verify that the mapping and associated metadata is saved:
>>> mapping = scopeManager.getMapping(mapping_id) >>> mapping['Document'] ['document_view'] >>> mapping['Folder'] >>> scopeManager.getMappingMetadata(mapping_id) == { ... 'title': u'Test current user', ... 'description': u'See current user information.', ... 'methods': 'GET HEAD OPTIONS', ... } True
Error Handling
Traversing to profiles using edit form will get NotFound:
>>> request = TestRequest() >>> view = scope.ContentTypeScopeProfileEditForm(context, request) >>> view.update() Traceback (most recent call last): ... NotFound... >>> request = TestRequest() >>> view = scope.ContentTypeScopeProfileEditForm(context, request) >>> view = view.publishTraverse(request, 'no_profile') >>> view.update() Traceback (most recent call last): ... NotFound... >>> request = TestRequest() >>> view = scope.ContentTypeScopeProfileDisplayForm(context, request) >>> view = view.publishTraverse(request, 'no_profile') >>> view.update() Traceback (most recent call last): ... NotFound...
Through a web browser.
To set up the scope management interface in a more natural manner, the views use the base scope management view as the context. This can result in some unintended consequences and here these will be tested.
First log in as portal owner:
>>> o_browser = Browser() >>> o_browser.open(baseurl + '/login') >>> o_browser.getControl(name='__ac_name').value = portal_owner >>> o_browser.getControl(name='__ac_password').value = default_password >>> o_browser.getControl(name='submit').click()
Now traverse to the content type scope profile management page. The created profile will be available for selection:
>>> o_browser.open(baseurl + '/manage-ctsp') >>> contents = o_browser.contents >>> o_browser.getLink('test_profile').click()
A brief summary of the permitted views will be shown:
>>> contents = o_browser.contents >>> 'document_view' in contents True
The edit button should be available. Select that to open the edit form, and see that the fields are populated with previously assigned values:
>>> o_browser.getControl(name='form.buttons.edit').click() >>> ct = o_browser.getControl(name="form.widgets.mapping.widgets.Document") >>> ct.value 'document_view'
Permit the viewing of folder contents and the two test views definied for this test that are for the site root:
>>> o_browser.getControl(name="form.widgets.mapping.widgets.Folder" ... ).value = 'folder_listing' >>> o_browser.getControl(name="form.widgets.mapping.widgets.Plone Site" ... ).value = 'test_current_roles' >>> o_browser.getControl(name='form.buttons.apply').click() >>> profile = scopeManager.getEditProfile('test_profile') >>> profile.mapping.get('Plone Site', False) ['test_current_roles']
Return to the main view and see that the profile is applied:
>>> o_browser.getControl(name='form.buttons.cancel').click() >>> contents = o_browser.contents >>> 'test_current_roles' in contents True >>> 'This profile has been modified.' in contents True
Now commit the changes, and see if this profile is activated. Note the status message about the modified state is now visible again:
>>> o_browser.getControl(name='form.buttons.commit').click() >>> contents = o_browser.contents >>> mapping = scopeManager.getMappingByName('test_profile') >>> mapping.get('Plone Site', False) ['test_current_roles'] >>> mapping.get('Document', False) ['document_view'] >>> 'This profile has been modified.' in contents False
Test for the functionality of the revert button also:
>>> o_browser.getControl(name='form.buttons.edit').click() >>> o_browser.getControl(name="form.widgets.mapping.widgets.Plone Site" ... ).value = 'test_current_user\r\ntest_current_roles' >>> o_browser.getControl(name='form.buttons.apply').click() >>> profile = scopeManager.getEditProfile('test_profile') >>> profile.mapping.get('Plone Site', False) ['test_current_user', 'test_current_roles'] >>> o_browser.getControl(name='form.buttons.cancel').click() >>> 'This profile has been modified.' in o_browser.contents True >>> o_browser.getControl(name='form.buttons.revert').click() >>> 'This profile has been modified.' in o_browser.contents False >>> profile = scopeManager.getEditProfile('test_profile') >>> profile.mapping.get('Plone Site', False) ['test_current_roles']
Back to the main page, and try to add a new profile:
>>> o_browser.open(baseurl + '/manage-ctsp') >>> contents = o_browser.contents >>> o_browser.getLink(id='ctsm_add_scope_profile').click() >>> o_browser.getControl(name="form.widgets.name").value = 'another' >>> o_browser.getControl(name="form.buttons.add").click() >>> o_browser.getControl(name="form.buttons.edit").click() >>> o_browser.getControl(name="form.widgets.title" ... ).value = 'Access document contents' >>> o_browser.getControl(name="form.widgets.description" ... ).value = 'Allow clients to view documents.' >>> o_browser.getControl(name="form.widgets.mapping.widgets.Document" ... ).value = 'document_view' >>> o_browser.getControl(name="form.widgets.mapping.widgets.Plone Site" ... ).value = 'test_current_user' >>> o_browser.getControl(name="form.buttons.apply").click() >>> o_browser.getControl(name="form.buttons.cancel").click() >>> o_browser.getControl(name="form.buttons.commit").click() >>> another_id = scopeManager.getMappingId('another') >>> another_mapping = scopeManager.getMapping(another_id) >>> another_mapping.get('Document') ['document_view'] >>> scopeManager.getMappingMetadata(another_id) == { ... 'title': u'Access document contents', ... 'description': u'Allow clients to view documents.', ... 'methods': 'GET HEAD OPTIONS', ... } True
One more, for allowing the POST method:
>>> o_browser.open(baseurl + '/manage-ctsp') >>> o_browser.getLink(id='ctsm_add_scope_profile').click() >>> o_browser.getControl(name="form.widgets.name").value = 'sharing' >>> o_browser.getControl(name="form.buttons.add").click() >>> o_browser.getControl(name="form.buttons.edit").click() >>> o_browser.getControl(name="form.widgets.title" ... ).value = 'Manages sharing permissions' >>> o_browser.getControl(name="form.widgets.description" ... ).value = 'Allows clients to edit sharing rights on Documents.' >>> o_browser.getControl(name="form.widgets.methods" ... ).value = 'GET HEAD OPTIONS POST' >>> o_browser.getControl(name="form.widgets.mapping.widgets.Document" ... ).value = 'sharing' >>> o_browser.getControl(name="form.widgets.mapping.widgets.Plone Site" ... ).value = 'test_current_user' >>> o_browser.getControl(name="form.buttons.apply").click() >>> o_browser.getControl(name="form.buttons.cancel").click() >>> o_browser.getControl(name="form.buttons.commit").click() >>> another_id = scopeManager.getMappingId('sharing') >>> another_mapping = scopeManager.getMapping(another_id) >>> another_mapping.get('Document') ['sharing'] >>> another_mapping.get('Plone Site') ['test_current_user'] >>> scopeManager.getMappingMetadata(another_id) == { ... 'title': u'Manages sharing permissions', ... 'description': ... u'Allows clients to edit sharing rights on Documents.', ... 'methods': 'GET HEAD OPTIONS POST', ... } True
Using OAuth with scope
To properly take advantage of OAuth, scope must be managed and used effectively to safeguard content owner’s data. Here we set up a new tokens using the default profile.:
>>> url = baseurl + '/OAuthRequestToken' >>> request = SignedTestRequest(consumer=consumer1, url=url, ... callback='oob', ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> toks1 = browser.contents >>> tok1 = makeToken(toks1) >>> tok1 = tokenManager.get(tok1.key) >>> tokenManager.claimRequestToken(tok1, default_user) >>> url = baseurl + '/OAuthGetAccessToken' >>> request = SignedTestRequest(url=url, consumer=consumer1, token=tok1, ... verifier=tok1.verifier, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> tok1 = browser.contents >>> tok1 = makeToken(tok1)
Test out some of the views:
>>> url = self.folder.absolute_url() >>> request = SignedTestRequest(consumer=consumer1, token=tok1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden >>> url = self.portal.absolute_url() + '/test_current_user' >>> request = SignedTestRequest(consumer=consumer1, token=tok1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> browser.contents 'test_user_1_'
The second token, however, will make use of the scope parameter to make use of the scope profile we have defined earlier:
>>> url = (baseurl + ... '/OAuthRequestToken?scope=http://nohost/Plone/test_profile') >>> request = SignedTestRequest(consumer=consumer1, url=url, ... callback='oob', ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> toks2 = browser.contents >>> tok2 = makeToken(toks2) >>> tok2 = tokenManager.get(tok2.key) >>> tokenManager.claimRequestToken(tok2, default_user) >>> url = baseurl + '/OAuthGetAccessToken' >>> request = SignedTestRequest(url=url, consumer=consumer1, token=tok2, ... verifier=tok2.verifier, ... ) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> tok2 = browser.contents >>> tok2 = makeToken(tok2)
Test out some of the views with the second token. There will be a different set of views available:
>>> url = self.folder.absolute_url() >>> request = SignedTestRequest(consumer=consumer1, token=tok2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> url = self.portal.absolute_url() + '/test_current_user' >>> request = SignedTestRequest(consumer=consumer1, token=tok2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden >>> url = self.portal.absolute_url() + '/test_current_roles' >>> request = SignedTestRequest(consumer=consumer1, token=tok2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> print browser.contents Member Authenticated
As mentioned before, even with an updated profile, the previously used scope for a given token is retained. The first token issued in this subsection had the outdated default scope which forbid access to folder contents, so test that this is the case by using the owner’s browser to set the current test_profile as the default profile, then demonstrate that the original permissions are still intact:
>>> scopeManager.default_mapping_id 1 >>> o_browser.getControl(name='form.buttons.setdefault').click() >>> scopeManager.default_mapping_id 4 >>> url = self.folder.absolute_url() >>> request = SignedTestRequest(consumer=consumer1, token=tok1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
Client specified scope
Clients can specify the scope profiles that will be checked against when accessing the contents of the resource owner. These scope profiles will be used instead of the default one.
If a specific scope was requested, the title, description and list of subpaths permitted per each view will be made visible to the resource owner:
>>> scopetok1 = tokenManager.generateRequestToken(consumer1.key, 'oob') >>> scopeManager.requestScope(scopetok1.key, ... 'http://nohost/Plone/scope/another') True >>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok1.key) >>> print u_browser.contents <... <dl> <dt>Access document contents</dt> <dd> <p>Allow clients to view documents.</p> </dd> </dl> ... <dd... ... <p> The following is a detailed listing of all subpaths available per content type for tokens using this set of scope profiles. </p> <dl> <dt>Document</dt> <dd> <ul> <li>document_view</li> </ul> </dd> </dl> <dl> <dt>Plone Site</dt> <dd> <ul> <li>test_current_user</li> </ul> </dd> </dl> </dd> ...
Multiple scopes can be specified. For the content type scope manager, the scope argument is a list of comma-separated urls with paths ending with a valid profile identifier. If multiple profiles are specified, the mappings will be merged together with the descriptions appropriately updated:
>>> scopetok2 = tokenManager.generateRequestToken(consumer1.key, 'oob') >>> scopeManager.requestScope(scopetok2.key, ... 'http://nohost/Plone/scope/another,' ... 'http://nohost/Plone/scope/test_profile') True >>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok2.key) >>> print u_browser.contents <... <dl> <dt>Access document contents</dt> <dd> <p>Allow clients to view documents.</p> </dd> <dt>Test current user</dt> <dd> <p>See current user information.</p> </dd> </dl> ... <dd... ... <p> The following is a detailed listing of all subpaths available per content type for tokens using this set of scope profiles. </p> <dl> <dt>Document</dt> <dd> <ul> <li>document_view</li> </ul> </dd> </dl> <dl> <dt>Folder</dt> <dd> <ul> <li>folder_listing</li> </ul> </dd> </dl> <dl> <dt>Plone Site</dt> <dd> <ul> <li>test_current_roles</li> <li>test_current_user</li> </ul> </dd> </dl> </dd> ...
Multiple scopes involving write privileges can be specified. The displayed scope for the profiles that involve write privileges will be additional. While the current iteration of the provided scope manager checks for the HTTP methods, this is not directly presented to resource owners who are end-users that may not concern themselves with such details. This also works on the assumption that no data is manipulated via requests by GET or others. With that, let’s render the form:
>>> scopetok3 = tokenManager.generateRequestToken(consumer1.key, 'oob') >>> scopeManager.requestScope(scopetok3.key, ... 'http://nohost/Plone/scope/another,' ... 'http://nohost/Plone/scope/sharing,' ... 'http://nohost/Plone/scope/test_profile') True >>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok3.key) >>> print u_browser.contents <... <dl> <dt>Access document contents</dt> <dd> <p>Allow clients to view documents.</p> </dd> <dt>Manages sharing permissions</dt> <dd> <p>Allows clients to edit sharing rights on Documents.</p> </dd> <dt>Test current user</dt> <dd> <p>See current user information.</p> </dd> </dl> ... <dd... ... <p> The following is a detailed listing of all subpaths available per content type for tokens using this set of scope profiles. </p> <dl> <dt>Document</dt> <dd> <ul> <li>document_view</li> <li>sharing</li> </ul> </dd> </dl> <dl> <dt>Folder</dt> <dd> <ul> <li>folder_listing</li> </ul> </dd> </dl> <dl> <dt>Plone Site</dt> <dd> <ul> <li>test_current_roles</li> <li>test_current_user</li> </ul> </dd> </dl> <p> Additionally, the following are subpaths within the content types that will be granted access to the client to manipulate your content with. </p> <dl> <dt>Document</dt> <dd> <ul> <li>sharing</li> </ul> </dd> </dl> <dl> <dt>Plone Site</dt> <dd> <ul> <li>test_current_user</li> </ul> </dd> </dl> </dd> ...
To test that the permissions function as they are, have the user approve both those tokens:
>>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok1.key) >>> u_browser.getControl(name='form.buttons.approve').click() >>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok2.key) >>> u_browser.getControl(name='form.buttons.approve').click() >>> u_browser.open(auth_baseurl + '?oauth_token=' + scopetok3.key) >>> u_browser.getControl(name='form.buttons.approve').click()
Then have the client request the access token for bot those tokens:
>>> v = tokenManager.get(scopetok1.key).verifier >>> t = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, token=scopetok1, verifier=v, timestamp=t) >>> atp = token.GetAccessTokenPage(self.portal, request) >>> asto1 = makeToken(atp()) >>> v = tokenManager.get(scopetok2.key).verifier >>> t = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, token=scopetok2, verifier=v, timestamp=t) >>> atp = token.GetAccessTokenPage(self.portal, request) >>> asto2 = makeToken(atp()) >>> v = tokenManager.get(scopetok3.key).verifier >>> t = str(int(time.time())) >>> request = SignedTestRequest( ... consumer=consumer1, token=scopetok3, verifier=v, timestamp=t) >>> atp = token.GetAccessTokenPage(self.portal, request) >>> asto3 = makeToken(atp())
Now test access using the first token. The test_current_roles page is not one of the approved links for the site root:
>>> url = baseurl + '/test_current_roles' >>> request = SignedTestRequest(consumer=consumer1, token=asto1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
Document view is, however:
>>> url = baseurl + '/front-page/document_view' >>> request = SignedTestRequest(consumer=consumer1, token=asto1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> 'Welcome to Plone' in browser.contents True
The “default” view should work too in this particular case as the object resolution will need to do this to give expected results:
>>> url = baseurl >>> request = SignedTestRequest(consumer=consumer1, token=asto1, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> 'Welcome to Plone' in browser.contents True
Now for the second token:
>>> url = baseurl + '/test_current_roles' >>> request = SignedTestRequest(consumer=consumer1, token=asto2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> print browser.contents Member Authenticated
Requests that worked with the first set of scopes should also work for the second:
>>> url = baseurl >>> request = SignedTestRequest(consumer=consumer1, token=asto2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) >>> 'Welcome to Plone' in browser.contents True
However, post requests should not work for this token as it does not have the method set as permitted:
>>> url = baseurl + '/test_current_user' >>> request = SignedTestRequest(consumer=consumer1, token=asto2, ... method='POST', url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.post(url, '') Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
On the other hand, the third token will, as it has post access to the two subpaths that have this enabled:
>>> url = baseurl + '/test_current_user' >>> request = SignedTestRequest(consumer=consumer1, token=asto3, ... method='POST', url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.post(url, '') >>> print browser.contents test_user_1_
Token Management Interfaces
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 made visible such as from the dashboard or the Site Setup interfaces. Site administrators may wish to add those links manually if they wish to make these functions more visible.
As our test user have granted access a few tokens already, they will all should show up if the listing page is accessed:
>>> 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 >>> '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:
>>> t_count = len(tokenManager.getTokensForUser(default_user)) >>> 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)) == t_count - 1 True >>> result = u_browser.contents >>> 'Access successfully removed' in result True
Users can also review the token details:
>>> u_browser.open(baseurl + '/issued_oauth_tokens') >>> u_browser.getLink('[details]').click() >>> print u_browser.contents <... The token was granted to <strong>consumer1.example.com</strong> with the following rights: ...
For tokens that have write privileges, the extra notice must also be shown:
>>> u_browser.open(baseurl + '/issued_oauth_tokens/view/' + asto3.key) >>> print u_browser.contents <... The token was granted to <strong>consumer1.example.com</strong> with the following rights: ... <p> The following is a detailed listing of all subpaths available per content type for tokens using this set of scope profiles. </p> ... <p> Additionally, the following are subpaths within the content types that will be granted access to the client to manipulate your content with. </p> ...
If user revokes the token, the example associated with that token will cease to work:
>>> u_browser.open(baseurl + '/issued_oauth_tokens') >>> u_browser.getControl(name="form.widgets.key").controls[-2].click() >>> u_browser.getControl(name="form.widgets.key").controls[-1].click() >>> u_browser.getControl(name='form.buttons.revoke').click() >>> url = baseurl >>> request = SignedTestRequest(consumer=consumer1, token=asto2, url=url) >>> auth = request._auth >>> browser = Browser() >>> browser.addHeader('Authorization', auth) >>> browser.open(url) Traceback (most recent call last): ... HTTPError: HTTP Error 403: Forbidden
With the scope associated with the token removed also:
>>> scopeManager.getAccessScope(asto2.key, None) is None True
Token access/removal rights
Users can only remove tokens they personally own using the user specific form; even if they are administrators - a management level view should be provided to manage tokens belonging to other users:
>>> u_browser.getLink('[details]').click() >>> o_browser.open(u_browser.url) Traceback (most recent call last): ... HTTPError: HTTP Error 404: Not Found
Naturally, logged out users should not be able to do anything (even if a security misconfiguration allow them access to this form):
>>> self.logout() >>> request = TestRequest(form={ ... 'form.widgets.key': [asto1.key], ... 'form.buttons.revoke': 1, ... }) >>> view = user.UserTokenForm(self.portal, request) >>> view.update() >>> tokenManager.getAccessToken(asto1.key).key == asto1.key True
A newly created user cannot revoke this either:
>>> self.portal.acl_users.userFolderAddUser('test_user_2_', ... default_password, ['Member'], []) >>> self.login('test_user_2_') >>> request = TestRequest(form={ ... 'form.widgets.key': [asto1.key], ... 'form.buttons.revoke': 1, ... }) >>> view = user.UserTokenForm(self.portal, request) >>> view.update() >>> tokenManager.getAccessToken(asto1.key).key == asto1.key True
Only the correct user can revoke this token:
>>> self.logout() >>> self.login(default_user) >>> request = TestRequest(form={ ... 'form.widgets.key': [asto1.key], ... 'form.buttons.revoke': 1, ... }) >>> view = user.UserTokenForm(self.portal, request) >>> view.update() >>> tokenManager.getAccessToken(asto1.key).key == asto1.key Traceback (most recent call last): ... TokenInvalidError: 'no such access token.'
Consumer Management Interfaces
For consumers, we can open the consumer management form and we should see the single consumer that had been added earlier. This page can be accessed via ${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. Since the client in this case should be a browser, we will use the authenticated test request class:
>>> added_consumer_keys = [] >>> request = TestRequest(form={ ... 'form.widgets.title': 'consumer2.example.com', ... 'form.buttons.add': 1, ... }) >>> view = consumer.ConsumerAddForm(self.portal, request) >>> view.update() >>> added_consumer_keys.append(view._data['key']) >>> request = TestRequest(form={ ... 'form.widgets.title': 'consumer3.example.com', ... 'form.buttons.add': 1, ... }) >>> view = consumer.ConsumerAddForm(self.portal, request) >>> view.update() >>> added_consumer_keys.append(view._data['key'])
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': added_consumer_keys, ... 'form.buttons.remove': 1, ... }) >>> view = consumer.ConsumerManageForm(self.portal, request) >>> result = view() >>> 'consumer2.example.com' in result False >>> 'consumer3.example.com' in result False
Users should not be able to access the page:
>>> u_browser.open(baseurl + '/manage-oauth-consumers') >>> 'Insufficient Privileges' in u_browser.contents True >>> 'consumer1.example.com' in u_browser.contents False
Owners or users with permissions can:
>>> o_browser.open(baseurl + '/manage-oauth-consumers') >>> 'Insufficient Privileges' in o_browser.contents False >>> 'consumer1.example.com' in o_browser.contents True
Upgrading pmr2.oauth
The following are required reading for users wishing to upgrade pmr2.oauth, as this add-on is not yet matured so many items are still rather unstable.
From 0.2 to 0.4
A word of caution before replacing 0.2 with 0.4: the changes made to the default scope manager is a complete rewrite, as the security it offered was merely a quick and dirty demonstration. Due to this change, all existing tokens and scopes will need to be purged, and clients (consumers) will need to request new access tokens from the resource owners.
If you wish to continue with this upgrade, please upgrade by going into the Zope Management Interface, portal_setup, upgrades, and select the pmr2.oauth:default profile. If no profiles appear, there should be an option to show old upgrades. Select that, select the pmr2.oauth upgrade to v0.4 step and upgrade. Running this step will also reinstall the product to ensure the activation of the accompanied pmr2.z3cform helper library.
Another note: consumers now have their keys randomly generated, with the human-friendly names moved to its own fields. The recommendation is to remove all previous keys and issue new ones, but this step is not done automatically. Old keys should still be usable, but they won’t be as friendly to the end users as they will lack the human-friendly component.
Changelog
0.4.3 - 2013-02-01
Pin the minimum supported oauthlib version to 0.3.5 to maintain consistency of the require_callback parameter.
0.4.2 - 2013-01-31
Security Fix: Correctly apply CSRF protection to all forms.
Denying a non-existent token will no longer show a stack trace.
Note: 0.4.1 was aborted due to incomplete fix.
0.4 - 2013-01-22
Major architectural changes
Removal of python-oauth2 and use oauthlib. Significant changes to the PAS OAuthPlugin, including the removal of all private methods, replacement of the OAuthUtility with an adapter, with nearly all authentication and verification functions moved into this adapter, which extends the oauthlib server class.
Scope manager completely redefined to accept any identifiers, which can be client (consumer), temporary or access keys. Specific implementations can then make use of this change.
Default scope manager no longer manages permitted URIs based directly on regex, but views and subpaths within specific content types.
Consumer keys now randomly generated. For identification purposes the title and domain fields are introduced. Domain field serves an additional purpose for verification of callbacks by the default callback manager.
New features
Introduction of callback manager. This manages permitted targets for callbacks, so that resource owners will not be redirected to untrusted hosts especially for oob clients.
Default scope manager provides the concept of scope profiles, which are concise representations of access that will be granted by the resource owner to clients.
Base classes for extending/replacing provided functionalities.
An index of all valid endpoints (views) made available by this add-on.
Bugs (and maybe fixes)
The missing permissions.zcml is now included. (noted by ngi644)
Translations are not included with this release as there were too many new and modified text.
0.3a - 2012-11-23
Scope manager now permit POST requests.
Corrected the scope verification to be based on the resolved internal script URL.
Corrected the signature verification method to use the actual URL, not the internal script URL.
Workaround the adherence to legacy part of the spec in python-oauth2.
Note: This is a special release for development of PMR2-0.7 (or Release 7), as this package now depends on some packages not yet released. This release is made regardless as it is needed for demonstration purposes.
0.2 - 2012-10-16
Completing i18n coverage and added Italian support. [giacomos]
Added intermediate form class to eliminate the need 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.