Welcome, guest | Sign In | My Account | Store | Cart

Notice! PyPM is being replaced with the ActiveState Platform, which enhances PyPM’s build and deploy capabilities. Create your free Platform account to download ActivePython or customize Python with the packages you require and get automatic updates.

Download
ActivePython
INSTALL>
pypm install pmr2.oauth

How to install pmr2.oauth

  1. Download and install ActivePython
  2. Open Command Prompt
  3. Type pypm install pmr2.oauth
 Python 2.7Python 3.2Python 3.3
Windows (32-bit)
0.1
0.4.4Never BuiltWhy not?
0.1 Available View build log
Windows (64-bit)
0.1
0.4.4Never BuiltWhy not?
0.1 Available View build log
Mac OS X (10.5+)
0.1
0.4.4Never BuiltWhy not?
0.1 Available View build log
Linux (32-bit)
0.2
0.4.4Never BuiltWhy not?
0.2 Available View build log
0.1 Available View build log
Linux (64-bit)
0.4.4 Available View build log
0.2 Available View build log
0.1 Available View build log
 
Author
License
GPL
Lastest release
version 0.4.4 on Jan 9th, 2014

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
>>> from urllib import quote_plus
>>> 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)
Token Authorization

Now the consumer can store this token, and redirect the resource owner to the authorization page. Instead of invoking the object directly, we use the testbrowser to demonstrate the functionality of the authentication surrounding this.

Before that though, see if the form itself will render the error message for an unknown token (we will log our local user back in first). Also, we will treat our page as a subform such that the rest of the Plone templates is not rendered:

>>> from Products.PloneTestCase.ptc import portal_owner
>>> from Products.PloneTestCase.ptc import default_user
>>> from Products.PloneTestCase.ptc import default_password
>>> self.login(default_user)
>>> request = TestRequest(form={
...     'oauth_token': 'nope',
... })
...
>>> rt = token.AuthorizeTokenForm(self.portal, request)
>>> result = rt()
>>> 'Invalid Token.' in result
True
>>> 'type="submit"' in result
False

Also that the form is rendered for an authorized token:

>>> request = TestRequest(form={
...     'oauth_token': atoken.key,
... })
>>> rt = token.AuthorizeTokenForm(self.portal, request)
>>> result = rt()
>>> 'Invalid Token.' in result
False
>>> 'type="submit"' in result
True

Now we do the test with the test browser class. First we see that the browser is currently not logged in:

>>> u_browser = Browser()
>>> u_browser.open(baseurl + '/test_current_user')
>>> print u_browser.contents
Anonymous User

Trying to view the token authorization page should result in redirection to login form in a vanilla site:

>>> u_browser.open(baseurl + '/OAuthAuthorizeToken?oauth_token=test')
>>> 'credentials_cookie_auth' in u_browser.url
True

So we log in, and try again. The page should render, but the token provided was invalid so we will receive a token invalid page:

>>> auth_baseurl = baseurl + '/OAuthAuthorizeToken'
>>> u_browser.open(baseurl + '/login')
>>> u_browser.getControl(name='__ac_name').value = default_user
>>> u_browser.getControl(name='__ac_password').value = default_password
>>> u_browser.getControl(name='submit').click()
>>> u_browser.open(baseurl + '/test_current_user')
>>> print u_browser.contents
test_user_1_
>>> u_browser.open(auth_baseurl + '?oauth_token=test')
>>> 'Invalid Token' in u_browser.contents
True
>>> 'Grant access' in u_browser.contents
False
>>> 'Deny access' in u_browser.contents
False

Now we use the token string returned by the token request initiated a bit earlier. Two confirmation button should be visible along with the name of the consumer, along with its identity:

>>> u_browser.open(auth_baseurl + '?oauth_token=' + atoken.key)
>>> 'Grant access' in u_browser.contents
True
>>> 'Deny access' in u_browser.contents
True
>>> '<strong>consumer1.example.com</strong>' in u_browser.contents
True

We can approve this token by selecting the 'Grant access' button. The browser should have been redirected to the callback URL with the token and verifier specified by the consumer, such that the consumer can request the access token with it:

>>> u_browser.getControl(name='form.buttons.approve').click()
>>> callback_baseurl = baseurl + '/test_oauth_callback?'
>>> url = u_browser.url
>>> url.startswith(callback_baseurl)
True
>>> qs = urlparse.parse_qs(urlparse.urlparse(url).query)
>>> atoken_verifier = qs['oauth_verifier'][0]
>>> atoken_key = qs['oauth_token'][0]
>>> atoken.key == atoken_key
True

Assuming the redirection was successful, the consumer will now know the verifier associated with this token, but since we control the consumer here, we can defer this till a bit later.

On the provider side, the request token should be updated to include the id of the user that performed the authorization:

>>> tokenManager.get(atoken_key).user
'test_user_1_'

Going to do the same to the second request token with an oob callback. The difference is, the user will be shown the verification code and will be asked to supply it to the consumer manually:

>>> u_browser.open(auth_baseurl + '?oauth_token=' + btoken.key)
>>> u_browser.getControl(name='form.buttons.approve').click()
>>> u_browser.url.startswith(baseurl)
True

We are going to extract the token verifier from the token manager and see that it's in the contents:

>>> tmpToken = tokenManager.get(btoken.key)
>>> btoken_verifier = tmpToken.verifier
>>> btoken_verifier in u_browser.contents
True

Of course the user should have the opportunity to deny the token. We can create tokens manually and let the user deny it. The token would then be purged, and user will be redirected back to the callback, which the consumer will then handle this denial:

>>> testtok = tokenManager.generateRequestToken(consumer1.key,
...     baseurl + '/test_oauth_callback?')
>>> scopeManager.requestScope(testtok.key, None)
True
>>> u_browser.open(auth_baseurl + '?oauth_token=' + testtok.key)
>>> u_browser.getControl(name='form.buttons.deny').click()
>>> u_browser.url == testtok.get_callback_url()
True
>>> tokenManager.get(testtok) is None
True
>>> scopeManager.getScope(testtok.key, None) is None
True

In the case of a rejected oob token, a message will be displayed:

>>> testtok = tokenManager.generateRequestToken(consumer1.key, 'oob')
>>> scopeManager.requestScope(testtok.key, None)
True
>>> u_browser.open(auth_baseurl + '?oauth_token=' + testtok.key)
>>> u_browser.getControl(name='form.buttons.deny').click()
>>> u_browser.url.startswith(baseurl)
True
>>> 'Token has been denied.' in u_browser.contents
True
>>> tokenManager.get(testtok) is None
True
>>> scopeManager.getScope(testtok.key, None) is None
True

After denial (or after completion of the exchange for an access token) the page should not fail when rejection is done again:

>>> request = TestRequest(form={
...     'oauth_token': testtok.key,
...     'form.buttons.deny': 1,
... })
>>> rt = token.AuthorizeTokenForm(self.portal, request)
>>> result = rt()

Or approved:

>>> request = TestRequest(form={
...     'oauth_token': testtok.key,
...     'form.buttons.approve': 1,
... })
>>> rt = token.AuthorizeTokenForm(self.portal, request)
>>> result = rt()
Request the Authorized Token

As the consumer had received the verifier from the resource owner in the previous step, construction of the final request to acquire the authorized token can proceed.

Trying to request an access token without a supplying a valid token will get you this (log back out first):

>>> self.logout()
>>> timestamp = str(int(time.time()))
>>> request = SignedTestRequest(
...     consumer=consumer1,
...     timestamp=timestamp,
... )
>>> rt = token.GetAccessTokenPage(self.portal, request)
>>> result = rt()
Traceback (most recent call last):
...
BadRequest...

Now for the token, but let's try to request an access token without the correct verifier assigned:

>>> timestamp = str(int(time.time()))
>>> request = SignedTestRequest(
...     consumer=consumer1,
...     token=atoken,
...     timestamp=timestamp,
... )
>>> rt = token.GetAccessTokenPage(self.portal, request)
>>> print rt()
Traceback (most recent call last):
...
BadRequest...

Okay, now do this properly with the verifier provided, as the consumer just accessed the callback URL of the consumer to supply it with the correct verifier:

>>> timestamp = str(int(time.time()))
>>> request = SignedTestRequest(
...     consumer=consumer1,
...     token=atoken,
...     verifier=atoken_verifier,
...     timestamp=timestamp,
... )
>>> rt = token.GetAccessTokenPage(self.portal, request)
>>> accesstokenstr = rt()
>>> print accesstokenstr
oauth_token_secret=...&oauth_token=...
>>> access_token = makeToken(accesstokenstr)

After verification, the old token should have been discarded and cannot be used again to request a new token:

>>> timestamp = str(int(time.time()))
>>> request = SignedTestRequest(
...     consumer=consumer1,
...     token=atoken,
...     verifier=atoken_verifier,
...     timestamp=timestamp,
... )
>>> rt = token.GetAccessTokenPage(self.portal, request)
>>> rt()
Traceback (most recent call last):
...
Forbidden...

Now try again using the browser:

>>> url = baseurl + '/OAuthGetAccessToken'
>>> request = SignedTestRequest(
...     url=url,
...     consumer=consumer1,
...     token=btoken,
...     verifier=btoken_verifier,
...     timestamp=timestamp,
... )
>>> auth = request._auth
>>> browser = Browser()
>>> browser.addHeader('Authorization', auth)
>>> browser.open(url)
>>> baccesstokenstr = browser.contents
>>> print baccesstokenstr
oauth_token_secret=...&oauth_token=...
>>> bacctoken = makeToken(baccesstokenstr)
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:

>>> scope = quote_plus('http://nohost/Plone/test_profile')
>>> url = '%s/OAuthRequestToken?scope=%s' % (baseurl, scope)
>>> 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
Adding new consumers

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

These new consumers should be able to generate request tokens:

>>> client2 = consumerManager.get(added_consumer_keys[0])
>>> scope = quote_plus('http://nohost/Plone/test_profile')
>>> url = '%s/OAuthRequestToken?scope=%s' % (baseurl, scope)
>>> request = SignedTestRequest(consumer=client2, url=url, callback='oob')
>>> auth = request._auth
>>> browser = Browser()
>>> browser.addHeader('Authorization', auth)
>>> browser.open(url)
>>> btoken2 = makeToken(browser.contents)

Authorization requests should happen without issues:

>>> u_browser.open(auth_baseurl + '?oauth_token=' + btoken2.key)
>>> u_browser.getControl(name='form.buttons.approve').click()
>>> tmpToken = tokenManager.get(btoken2.key)
>>> btoken2_verifier = tmpToken.verifier

Get the access token:

>>> url = baseurl + '/OAuthGetAccessToken'
>>> request = SignedTestRequest(url=url, consumer=client2, token=btoken2,
...     verifier=btoken2_verifier)
>>> auth = request._auth
>>> browser = Browser()
>>> browser.addHeader('Authorization', auth)
>>> browser.open(url)
>>> acctok2 = makeToken(browser.contents)

Test that it also works as intended:

>>> url = baseurl + '/test_current_roles'
>>> request = SignedTestRequest(consumer=client2, token=acctok2, url=url)
>>> auth = request._auth
>>> browser = Browser()
>>> browser.addHeader('Authorization', auth)
>>> browser.open(url)
>>> print browser.contents
Member
Authenticated

Users should be able to see the new access token from their list of issued tokens:

>>> u_browser.open(baseurl + '/issued_oauth_tokens')
>>> acctok2.key in u_browser.contents
True
>>> 'consumer2.example.com' in u_browser.contents
True
Removing consumers

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
Consequences of consumer removal

Naturally a design of OAuth is that once a client key is removed, any tokens authorized to work with that particular client will cease to function:

>>> url = baseurl + '/test_current_roles'
>>> request = SignedTestRequest(consumer=client2, token=acctok2, url=url)
>>> auth = request._auth
>>> browser = Browser()
>>> browser.addHeader('Authorization', auth)
>>> browser.open(url)
Traceback (most recent call last):
...
HTTPError: HTTP Error 403: Forbidden

Render the user's token interface:

>>> u_browser.open(baseurl + '/issued_oauth_tokens')
>>> acctok2.key in u_browser.contents
True
>>> 'consumer2.example.com' in u_browser.contents
False
>>> 'Deleted Client' in u_browser.contents
True

Also the details page should render:

>>> u_browser.open(baseurl + '/issued_oauth_tokens/view/' + acctok2.key)
>>> 'Deleted Client' in u_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.4 - 2013-04-04
  • URL encoding/decoding workaround to ensure the query parameters are being processed correctly.
  • Bug fix: Deleting a client (consumer) no longer prevents user from managing their list of issued tokens.
  • Depends on oauthlib 0.4.0
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.

Subscribe to package updates

Last updated Jan 9th, 2014

Download Stats

Last month:2

What does the lock icon mean?

Builds marked with a lock icon are only available via PyPM to users with a current ActivePython Business Edition subscription.

Need custom builds or support?

ActivePython Enterprise Edition guarantees priority access to technical support, indemnification, expert consulting and quality-assured language builds.

Plan on re-distributing ActivePython?

Get re-distribution rights and eliminate legal risks with ActivePython OEM Edition.