You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2024/04/30 22:04:00 UTC
(allura) 02/03: [#7272] OAuth2 tests, renaming, improvements, config option
This is an automated email from the ASF dual-hosted git repository.
brondsem pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git
commit 13cfdd53523a4f7267e7c240456c2bc1abf581fc
Author: Carlos Cruz <ca...@slashdotmedia.com>
AuthorDate: Tue Apr 16 16:24:30 2024 +0000
[#7272] OAuth2 tests, renaming, improvements, config option
---
Allura/allura/controllers/auth.py | 17 ++-
Allura/allura/controllers/rest.py | 111 ++++++++++------
Allura/allura/lib/custom_middleware.py | 11 +-
Allura/allura/model/__init__.py | 4 +-
Allura/allura/model/oauth.py | 42 +++---
Allura/allura/templates/oauth2_applications.html | 4 +-
Allura/allura/tests/functional/test_auth.py | 156 +++++++++++++++++++++++
Allura/allura/tests/functional/test_rest.py | 6 +
Allura/development.ini | 3 +
9 files changed, 288 insertions(+), 66 deletions(-)
diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 8f462def0..3f3f82fdb 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -114,7 +114,10 @@ class AuthController(BaseController):
self.user_info = UserInfoController()
self.subscriptions = SubscriptionsController()
self.oauth = OAuthController()
- self.oauth2 = OAuth2Controller()
+
+ if asbool(config.get('auth.oauth2.enabled', False)):
+ self.oauth2 = OAuth2Controller()
+
if asbool(config.get('auth.allow_user_to_disable_account', False)):
self.disable = DisableAccountController()
@@ -1413,19 +1416,19 @@ class OAuth2Controller(BaseController):
def _revoke_all(self, client_id):
M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
- M.OAuth2Token.query.remove({'client_id': client_id})
+ M.OAuth2AccessToken.query.remove({'client_id': client_id})
@with_trailing_slash
@expose('jinja:allura:templates/oauth2_applications.html')
def index(self, **kw):
c.form = F.oauth2_application_form
provider = plugin.AuthenticationProvider.get(request)
- clients = M.OAuth2Client.for_user(c.user)
+ clients = M.OAuth2ClientApp.for_owner(c.user)
model = []
for client in clients:
authorization = M.OAuth2AuthorizationCode.query.get(client_id=client.client_id)
- token = M.OAuth2Token.query.get(client_id=client.client_id)
+ token = M.OAuth2AccessToken.query.get(client_id=client.client_id)
model.append(dict(client=client, authorization=authorization, token=token))
return dict(
@@ -1437,7 +1440,7 @@ class OAuth2Controller(BaseController):
@require_post()
@validate(F.oauth2_application_form, error_handler=index)
def register(self, application_name=None, application_description=None, redirect_url=None, **kw):
- M.OAuth2Client(name=application_name,
+ M.OAuth2ClientApp(name=application_name,
description=application_description,
redirect_uris=[redirect_url])
flash('Oauth2 Client registered')
@@ -1446,8 +1449,8 @@ class OAuth2Controller(BaseController):
@expose()
@require_post()
def do_client_action(self, _id=None, deregister=None, revoke=None):
- client = M.OAuth2Client.query.get(client_id=_id)
- if client is None or client.user_id != c.user._id:
+ client = M.OAuth2ClientApp.query.get(client_id=_id)
+ if client is None or client.owner_id != c.user._id:
flash('Invalid client ID', 'error')
redirect('.')
diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py
index e77c8bea9..b16ae0dec 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -16,6 +16,7 @@
# under the License.
"""REST Controller"""
+from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
@@ -52,11 +53,16 @@ class RestController:
def __init__(self):
self.oauth = OAuthNegotiator()
- self.oauth2 = Oauth2Negotiator()
self.auth = AuthRestController()
+ if self._is_oauth2_enabled():
+ self.oauth2 = Oauth2Negotiator()
+
+ def _is_oauth2_enabled(self):
+ return asbool(config.get('auth.oauth2.enabled', False))
+
def _check_security(self):
- if not request.path.startswith('/rest/oauth/'): # everything but OAuthNegotiator
+ if not request.path.startswith(('/rest/oauth/', '/rest/oauth2/')): # everything but OAuthNegotiators
c.api_token = self._authenticate_request()
if c.api_token:
c.user = c.api_token.user
@@ -67,7 +73,17 @@ class RestController:
params_auth = 'oauth_token' in request.params
params_auth = params_auth or 'access_token' in request.params
if headers_auth or params_auth:
- return self.oauth._authenticate()
+ try:
+ access_token = self.oauth._authenticate()
+ except exc.HTTPUnauthorized:
+ if not self._is_oauth2_enabled():
+ raise
+
+ access_token = self.oauth2._authenticate()
+ if not access_token:
+ raise
+
+ return access_token
else:
return None
@@ -239,20 +255,21 @@ class Oauth1Validator(oauthlib.oauth1.RequestValidator):
class Oauth2Validator(oauthlib.oauth2.RequestValidator):
def validate_client_id(self, client_id: str, request: oauthlib.common.Request) -> bool:
- return M.OAuth2Client.query.get(client_id=client_id) is not None
+ return M.OAuth2ClientApp.query.get(client_id=client_id) is not None
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
- return True
+ client = M.OAuth2ClientApp.query.get(client_id=client_id)
+ return redirect_uri in client.redirect_uris
def validate_response_type(self, client_id: str, response_type: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
- res_type = M.OAuth2Client.query.get(client_id=client_id).response_type
+ res_type = M.OAuth2ClientApp.query.get(client_id=client_id).response_type
return res_type == response_type
def validate_scopes(self, client_id: str, scopes, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
return True
def validate_grant_type(self, client_id: str, grant_type: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
- return True
+ return grant_type in ['authorization_code', 'refresh_token', 'client_credentials']
def get_default_scopes(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs):
return []
@@ -261,60 +278,63 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
return request.uri
def invalidate_authorization_code(self, client_id: str, code: str, request: oauthlib.common.Request, *args, **kwargs) -> None:
- M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
+ M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 'authorization_code': code})
def authenticate_client(self, request: oauthlib.common.Request, *args, **kwargs) -> bool:
client_id = request.body['client_id']
- client = M.OAuth2Client.query.get(client_id=client_id)
- if not client:
- return False
-
- request.client = client
- return True
+ request.client = M.OAuth2ClientApp.query.get(client_id=client_id)
+ return request.client is not None
def validate_code(self, client_id: str, code: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
- authorization = M.OAuth2AuthorizationCode.query.get({'client_id': client_id})
- return authorization.expires_at <= datetime.utcnow()
+ authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id, authorization_code=code)
+ return authorization.expires_at >= datetime.utcnow() if authorization else False
+
+ def validate_bearer_token(self, token: str, scopes: list[str], request: oauthlib.common.Request) -> bool:
+ access_token = M.OAuth2AccessToken.query.get(access_token=token)
+ return access_token.expires_at >= datetime.utcnow() if access_token else False
def confirm_redirect_uri(self, client_id: str, code: str, redirect_uri: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
return True
def save_authorization_code(self, client_id: str, code, request: oauthlib.common.Request, *args, **kwargs) -> None:
- authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id)
+ authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id, authorization_code=code['code'])
+ log.info('Saving authorization code for client: %s', client_id)
if not authorization:
auth_code = M.OAuth2AuthorizationCode(
- client_id = client_id,
- authorization_code = code['code'],
- expires_at = datetime.utcnow() + timedelta(minutes=10)
+ client_id=client_id,
+ authorization_code=code['code'],
+ expires_at=datetime.utcnow() + timedelta(minutes=10),
+ redirect_uri=request.redirect_uri,
+ owner_id=c.user._id
)
session(auth_code).flush()
log.info(f'Saving new authorization code for client: {client_id}')
else:
log.info(f'Updating authorization code for {client_id}')
- log.info(f'Current authorization code: {authorization.authorization_code}')
- log.info(f'New authorization code: {code["code"]}')
M.OAuth2AuthorizationCode.query.update(
{'client_id': client_id},
{'$set': {'authorization_code': code['code'], 'expires_at': datetime.utcnow() + timedelta(minutes=10)}})
log.info(f'Updating authorization code for client: {client_id}')
def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kwargs) -> object:
- current_token = M.OAuth2Token.query.get(client_id=request.client_id)
+ current_token = M.OAuth2AccessToken.query.get(client_id=request.client_id, token=token.get('access_token'))
+ client = M.OAuth2ClientApp.query.get(client_id=request.client_id)
if not current_token:
- bearer_token = M.OAuth2Token(
+ bearer_token = M.OAuth2AccessToken(
client_id = request.client_id,
scopes = token.get('scope', []),
access_token = token.get('access_token'),
refresh_token = token.get('refresh_token'),
- expires_at = datetime.utcfromtimestamp(token.get('expires_in'))
+ expires_at = datetime.utcfromtimestamp(token.get('expires_in')),
+ owner_id = client.owner_id
)
session(bearer_token).flush()
log.info(f'Saving new bearer token for client: {request.client_id}')
else:
- M.OAuth2Token.query.update(
+ M.OAuth2AccessToken.query.update(
{'client_id': request.client_id},
{'$set': {'access_token': token.get('access_token'), 'expires_at': datetime.utcfromtimestamp(token.get('expires_in')), 'refresh_token': token.get('refresh_token')}})
log.info(f'Updating bearer token for client: {request.client_id}')
@@ -461,11 +481,30 @@ class Oauth2Negotiator:
def server(self):
return oauthlib.oauth2.WebApplicationServer(Oauth2Validator())
+ def _authenticate(self):
+ bearer_token_prefix = 'Bearer ' # noqa: S105
+ auth_header = request.headers.get('Authorization')
+ if auth_header and auth_header.startswith(bearer_token_prefix):
+ access_token = auth_header[len(bearer_token_prefix):]
+
+ valid, req = self.server.verify_request(
+ request.url,
+ http_method=request.method,
+ body=request.body,
+ headers=request.headers)
+
+ if not valid:
+ raise exc.HTTPUnauthorized
+
+ access_token = M.OAuth2AccessToken.query.get(access_token=req.access_token)
+ access_token.last_access = datetime.utcnow()
+ return access_token
+
+
@expose('jinja:allura:templates/oauth2_authorize.html')
def authorize(self, **kwargs):
security.require_authenticated()
json_body = None
-
if request.body:
# We need to decode the request body and convert it to a dict because Turbogears creates it as bytes
# and oauthlib will treat it as x-www-form-urlencoded format.
@@ -475,18 +514,15 @@ class Oauth2Negotiator:
try:
scopes, credentials = self.server.validate_authorization_request(uri=request.url, http_method=request.method, headers=request.headers, body=json_body)
client_id = request.params.get('client_id')
- client = M.OAuth2Client.query.get(client_id=client_id)
+ client = M.OAuth2ClientApp.query.get(client_id=client_id)
# We need to save the credentials to the current session so we can use it later in the POST request.
# We also need to use __dict__ because the internal oauthlib request object cannot be directly serialized
# and saved to Ming
credentials['request'] = credentials['request'].__dict__
- M.OAuth2Client.set_credentials(client_id, credentials)
+ M.OAuth2ClientApp.set_credentials(client_id, credentials)
- return dict(
- credentials=credentials,
- client=client
- )
+ return dict(client=client)
except Exception as e:
log.exception(e)
@@ -496,7 +532,7 @@ class Oauth2Negotiator:
security.require_authenticated()
client_id = request.params['client_id']
- client = M.OAuth2Client.query.get(client_id=client_id)
+ client = M.OAuth2ClientApp.query.get(client_id=client_id)
if no:
flash(f'{client.name} NOT AUTHORIZED', 'error')
@@ -507,8 +543,11 @@ class Oauth2Negotiator:
uri=request.url, http_method=request.method, body=request.body, headers=request.headers, scopes=[], credentials=client.credentials
)
- qs_params = dict(parse_qsl(headers['Location']))
- return dict(client=client, authorization_code=qs_params.get('code', ''))
+ response.status_int = status
+ for k, v in headers.items():
+ response.headers[k] = v
+
+ return body
except Exception as ex:
log.exception(ex)
diff --git a/Allura/allura/lib/custom_middleware.py b/Allura/allura/lib/custom_middleware.py
index d9ca652fb..e5c64d497 100644
--- a/Allura/allura/lib/custom_middleware.py
+++ b/Allura/allura/lib/custom_middleware.py
@@ -495,10 +495,13 @@ class ContentSecurityPolicyMiddleware:
srcs = self.config['csp.form_action_urls']
if environ.get('csp_form_actions'):
srcs += ' ' + ' '.join(environ['csp_form_actions'])
- if asbool(self.config.get('csp.form_actions_enforce', False)):
- rules.add(f"form-action {srcs}")
- else:
- report_rules.add(f"form-action {srcs}")
+
+ oauth_endpoints = ('/rest/oauth2/authorize', '/rest/oauth2/do_authorize', '/rest/oauth/authorize', '/rest/oauth/do_authorize')
+ if not req.path.startswith(oauth_endpoints): # Do not enforce CSP for OAuth1 and OAuth2 authorization
+ if asbool(self.config.get('csp.form_actions_enforce', False)):
+ rules.add(f"form-action {srcs}")
+ else:
+ report_rules.add(f"form-action {srcs}")
if self.config.get('csp.script_src'):
script_srcs = self.config['csp.script_src']
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index dd7511f94..abbf624a4 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -31,7 +31,7 @@ from .notification import Notification, Mailbox, SiteNotification
from .repository import Repository, RepositoryImplementation, CommitStatus
from .repository import MergeRequest, GitLikeTree
from .stats import Stats
-from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken, OAuth2Client, OAuth2AuthorizationCode, OAuth2Token
+from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken, OAuth2ClientApp, OAuth2AuthorizationCode, OAuth2AccessToken
from .monq_model import MonQTask
from .webhook import Webhook
from .multifactor import TotpKey
@@ -56,7 +56,7 @@ __all__ = [
'DiscussionAttachment', 'BaseAttachment', 'AuthGlobals', 'User', 'ProjectRole', 'EmailAddress',
'AuditLog', 'AlluraUserProperty', 'File', 'Notification', 'Mailbox', 'Repository',
'RepositoryImplementation', 'CommitStatus', 'MergeRequest', 'GitLikeTree', 'Stats', 'OAuthToken', 'OAuthConsumerToken',
- 'OAuthRequestToken', 'OAuthAccessToken', 'OAuth2Client', 'OAuth2AuthorizationCode', 'OAuth2Token', 'MonQTask', 'Webhook',
+ 'OAuthRequestToken', 'OAuthAccessToken', 'OAuth2ClientApp', 'OAuth2AuthorizationCode', 'OAuth2AccessToken', 'MonQTask', 'Webhook',
'ACE', 'ACL', 'EVERYONE', 'ALL_PERMISSIONS', 'DENY_ALL', 'MarkdownCache', 'main_doc_session', 'main_orm_session',
'project_doc_session', 'project_orm_session', 'artifact_orm_session', 'repository_orm_session', 'task_orm_session',
'ArtifactSessionExtension', 'repository', 'repo_refresh', 'SiteNotification', 'TotpKey', 'UserLoginDetails',
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index 08791f76e..f6ddcadbc 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -154,29 +154,34 @@ class OAuthAccessToken(OAuthToken):
return False
-class OAuth2Client(MappedClass):
+class OAuth2ClientApp(MappedClass):
class __mongometa__:
session = main_orm_session
- name = 'oauth2_client'
+ name = 'oauth2_client_app'
+ unique_indexes = [('client_id', 'owner_id')]
- query: 'Query[OAuth2Client]'
+ query: 'Query[OAuth2ClientApp]'
_id = FieldProperty(S.ObjectId)
client_id = FieldProperty(str, if_missing=lambda: h.nonce(20))
credentials = FieldProperty(S.Anything)
name = FieldProperty(str)
description = FieldProperty(str, if_missing='')
- user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+ description_cache = FieldProperty(MarkdownCache)
+ owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
grant_type = FieldProperty(str, if_missing='authorization_code')
response_type = FieldProperty(str, if_missing='code')
scopes = FieldProperty([str])
redirect_uris = FieldProperty([str])
+ owner = RelationProperty('User')
+
+
@classmethod
- def for_user(cls, user=None):
- if user is None:
- user = c.user
- return cls.query.find(dict(user_id=user._id)).all()
+ def for_owner(cls, owner=None):
+ if owner is None:
+ owner = c.user
+ return cls.query.find(dict(owner_id=owner._id)).all()
@classmethod
def set_credentials(cls, client_id, credentials):
@@ -191,35 +196,42 @@ class OAuth2AuthorizationCode(MappedClass):
class __mongometa__:
session = main_orm_session
name = 'oauth2_authorization_code'
+ unique_indexes = [('authorization_code', 'client_id', 'owner_id')]
query: 'Query[OAuth2AuthorizationCode]'
_id = FieldProperty(S.ObjectId)
client_id = FieldProperty(str)
- user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+ owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
scopes = FieldProperty([str])
redirect_uri = FieldProperty(str)
authorization_code = FieldProperty(str)
expires_at = FieldProperty(S.DateTime)
# For PKCE support
- challenge = FieldProperty(str)
- challenge_method = FieldProperty(str)
+ code_challenge = FieldProperty(str)
+ code_challenge_method = FieldProperty(str)
+
+ owner = RelationProperty('User')
-class OAuth2Token(MappedClass):
+class OAuth2AccessToken(MappedClass):
class __mongometa__:
session = main_orm_session
- name = 'oauth2_token'
+ name = 'oauth2_access_token'
+ unique_indexes = [('access_token', 'client_id', 'owner_id')]
- query: 'Query[OAuth2Token]'
+ query: 'Query[OAuth2AccessToken]'
_id = FieldProperty(S.ObjectId)
client_id = FieldProperty(str)
- user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+ owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
scopes = FieldProperty([str])
access_token = FieldProperty(str)
refresh_token = FieldProperty(str)
expires_at = FieldProperty(S.DateTime)
+ last_access = FieldProperty(datetime)
+
+ owner = RelationProperty('User')
def dummy_oauths():
diff --git a/Allura/allura/templates/oauth2_applications.html b/Allura/allura/templates/oauth2_applications.html
index cb16be3db..510342edf 100644
--- a/Allura/allura/templates/oauth2_applications.html
+++ b/Allura/allura/templates/oauth2_applications.html
@@ -96,14 +96,14 @@
<tr class="description"><th>Description:</th><td>{{app.client.description }}</td></tr>
<tr class="client_id"><th>Client ID:</th><td>{{app.client.client_id}}</td></tr>
<tr class="redirect_url"><th>Redirect URL:</th><td>{{app.client.redirect_uris[0] if app.client.redirect_uris else ''}}</td></tr>
- <tr class="grant_type"><th>Grant Type:</th><td>{{app.client.grant_type}}</td></tr>
{% if app.authorization %}
+ <tr class="grant_type"><th>Grant Type:</th><td>{{app.client.grant_type}}</td></tr>
<tr class="authorization_code"><th>Authorization Code:</th><td>{{app.authorization.authorization_code}}</td></tr>
<tr class="authorization_code_expires"><th>Authorization Code Expires At:</th><td>{{app.authorization.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
{% endif %}
- {% if app.access_token %}
+ {% if app.token %}
<tr class="access_token"><th>Access Token:</th><td>{{app.token.access_token}}</td></tr>
<tr class="access_token_expires"><th>Access Token Expires At:</th><td>{{app.token.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
<tr class="refresh_token"><th>Refresh Token:</th><td>{{app.token.refresh_token}}</td></tr>
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index 10dbd380d..2ed7b06f4 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -2067,6 +2067,162 @@ class TestOAuth(TestController):
assert r.location.startswith(url)
+class TestOAuth2(TestController):
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_register_deregister_client(self):
+ #register
+ r = self.app.get('/auth/oauth2/')
+ r = self.app.post('/auth/oauth2/register',
+ params={'application_name': 'testoauth2', 'application_description': 'Oauth2 Test',
+ 'redirect_url': '', '_session_id': self.app.cookies['_session_id'],
+ }).follow()
+
+ assert 'testoauth2' in r
+
+ #deregister
+ assert r.forms[0].action == 'do_client_action'
+ r.forms[0].submit('deregister')
+ r = self.app.get('/auth/oauth2/')
+ assert 'testoauth2' not in r
+
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_authorize(self):
+ user = M.User.by_username('test-admin')
+ M.OAuth2ClientApp(
+ client_id='client_12345',
+ owner_id=user._id,
+ name='testoauth2',
+ description='test client',
+ response_type='code',
+ redirect_uris=['https://localhost/']
+ )
+ ThreadLocalODMSession.flush_all()
+ r = self.app.get('/rest/oauth2/authorize/', params={'client_id': 'client_12345', 'response_type': 'code'})
+ assert 'testoauth2' in r.text
+ assert 'client_12345' in r.text
+
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_do_authorize_no(self):
+ user = M.User.by_username('test-admin')
+ M.OAuth2ClientApp(
+ client_id='client_12345',
+ owner_id=user._id,
+ name='testoauth2',
+ description='test client',
+ response_type='code',
+ redirect_uris=['https://localhost/']
+ )
+ ThreadLocalODMSession.flush_all()
+ r = self.app.post('/rest/oauth2/do_authorize', params={'no': '1', 'client_id': 'client_12345', 'response_type': 'code'})
+ assert M.OAuth2AuthorizationCode.query.get(client_id='client_12345') is None
+
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_do_authorize(self):
+ user = M.User.by_username('test-admin')
+ M.OAuth2ClientApp(
+ client_id='client_12345',
+ owner_id=user._id,
+ name='testoauth2',
+ description='test client',
+ response_type='code',
+ redirect_uris=['https://localhost/']
+ )
+ ThreadLocalODMSession.flush_all()
+
+ # First navigate to the authorization page for the backend to validate the authorization request
+ r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+ # The submit authorization for the authorization code to be created
+ r = self.app.post('/rest/oauth2/do_authorize', params={'yes': '1', 'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+ q = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+ assert q is not None
+
+ r = self.app.get('/auth/oauth2/')
+ assert 'Authorization Code:' in r
+
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_create_access_token(self):
+ user = M.User.by_username('test-admin')
+ M.OAuth2ClientApp(
+ client_id='client_12345',
+ owner_id=user._id,
+ name='testoauth2',
+ description='test client',
+ response_type='code',
+ redirect_uris=['https://localhost/']
+ )
+ ThreadLocalODMSession.flush_all()
+
+ # First navigate to the authorization page for the backend to validate the authorization request
+ r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+ # The submit authorization for the authorization code to be created
+ r = self.app.post('/rest/oauth2/do_authorize', params={'yes': '1', 'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+ ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+ assert ac is not None
+
+ r = self.app.get('/auth/oauth2/')
+ assert 'Authorization Code:' in r
+
+ # Create the authorization token
+ oauth2_params = dict(
+ client_id='client_12345',
+ code=ac.authorization_code,
+ grant_type='authorization_code'
+ )
+ r = self.app.post_json('/rest/oauth2/token', oauth2_params)
+ t = M.OAuth2AccessToken.query.get(client_id='client_12345')
+ assert t is not None
+ assert t.access_token is not None and t.refresh_token is not None
+
+ r = self.app.get('/auth/oauth2/')
+ assert 'Access Token:' in r
+ assert 'Refresh Token:' in r
+
+ @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+ def test_revoke_tokens(self):
+ user = M.User.by_username('test-admin')
+ M.OAuth2ClientApp(
+ client_id='client_12345',
+ owner_id=user._id,
+ name='testoauth2',
+ description='test client',
+ response_type='code',
+ redirect_uris=['https://localhost/']
+ )
+
+ M.OAuth2AuthorizationCode(
+ client_id='client_12345',
+ authotization_code='authcode_12345',
+ expires_at=datetime.utcnow() + timedelta(minutes=10),
+ owner_id=user._id,
+ )
+
+ M.OAuth2AccessToken(
+ client_id='client_12345',
+ access_token='12345',
+ refresh_token='54321',
+ expires_at=datetime.utcnow() + timedelta(minutes=20),
+ owner_id=user._id,
+ )
+
+ ThreadLocalODMSession.flush_all()
+
+ r = self.app.get('/auth/oauth2/')
+ assert 'authorization code' in r
+ assert 'access token' in r
+ assert r.forms[0].action == 'do_client_action'
+
+ r.forms[0].submit('revoke')
+
+ r = self.app.get('/auth/oauth2/')
+ assert 'testoauth2' in r
+ assert 'Authorization Code:' not in r
+ assert 'Access Token:' not in r
+
+
class TestOAuthRequestToken(TestController):
oauth_params = dict(
diff --git a/Allura/allura/tests/functional/test_rest.py b/Allura/allura/tests/functional/test_rest.py
index 5ee8a45ff..3cac55f34 100644
--- a/Allura/allura/tests/functional/test_rest.py
+++ b/Allura/allura/tests/functional/test_rest.py
@@ -46,6 +46,7 @@ class TestRestHome(TestRestApiBase):
request.params = {'access_token': 'foo'}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
self._patch_token(OAuthAccessToken)
access_token = OAuthAccessToken.query.get.return_value
access_token.is_bearer = False
@@ -59,6 +60,7 @@ class TestRestHome(TestRestApiBase):
request.params = {'access_token': 'foo'}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
self._patch_token(OAuthAccessToken)
OAuthAccessToken.query.get.return_value = None
r = self.api_post('/rest/p/test/wiki', access_token='foo', status=401)
@@ -89,6 +91,7 @@ class TestRestHome(TestRestApiBase):
request.params = {'access_token': access_token.api_key}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
r = self.api_post('/rest/p/test/wiki', access_token='foo')
assert r.status_int == 200
@@ -100,6 +103,7 @@ class TestRestHome(TestRestApiBase):
}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
self._patch_token(OAuthAccessToken)
access_token = OAuthAccessToken.query.get.return_value
access_token.is_bearer = False
@@ -114,6 +118,7 @@ class TestRestHome(TestRestApiBase):
}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
self._patch_token(OAuthAccessToken)
OAuthAccessToken.query.get.return_value = None
r = self.api_post('/rest/p/test/wiki', access_token='foo', status=401)
@@ -147,6 +152,7 @@ class TestRestHome(TestRestApiBase):
}
request.scheme = 'https'
request.path = '/rest/p/test/wiki'
+ request.url = 'https://localhost/rest/p/test/wiki'
r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200)
# reverse proxy situation
request.scheme = 'http'
diff --git a/Allura/development.ini b/Allura/development.ini
index 4cad5509a..424538d3f 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -240,6 +240,9 @@ auth.hibp_failure_force_pwd_change = true
; HIBP-listed password without going through email
auth.auth.trust_ip_3_octets_match = true
+; Enable OAuth2 support
+auth.oauth2.enabled = true
+
user_prefs_storage.method = local
; user_prefs_storage.method = ldap
; If using ldap, you can specify which fields to use for a preference.