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 <carlos.c...@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.