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
The following commit(s) were added to refs/heads/master by this push: new 8a69cd0a2 [#7272] Add custom bearer token feature and other fixes 8a69cd0a2 is described below commit 8a69cd0a2a74094686ed44f2ec7ebf2831ddf75e Author: Carlos Cruz <carlos.c...@slashdotmedia.com> AuthorDate: Fri May 17 17:35:13 2024 +0000 [#7272] Add custom bearer token feature and other fixes --- Allura/allura/controllers/auth.py | 78 ++++++++- Allura/allura/controllers/rest.py | 57 +------ Allura/allura/lib/custom_middleware.py | 2 +- Allura/allura/model/oauth.py | 19 ++- Allura/allura/templates/oauth2_applications.html | 128 --------------- Allura/allura/templates/oauth2_authorize_ok.html | 35 ---- Allura/allura/templates/oauth_applications.html | 11 +- Allura/allura/tests/functional/test_auth.py | 198 ++++++++++++++++++++++- Allura/allura/tests/functional/test_rest.py | 47 ++++++ Allura/docs/api-rest/docs.md | 4 +- Allura/docs/api-rest/securitySchemes.yaml | 6 +- AlluraTest/alluratest/controller.py | 7 +- scripts/wiki-copy.py | 2 +- 13 files changed, 358 insertions(+), 236 deletions(-) diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py index 2ab770a69..1699f6447 100644 --- a/Allura/allura/controllers/auth.py +++ b/Allura/allura/controllers/auth.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import json import os from base64 import b32encode from datetime import datetime @@ -63,6 +64,8 @@ from allura.lib import utils from allura.controllers import BaseController from allura.tasks.mail_tasks import send_system_mail_to_user import six +import oauthlib.oauth2 + log = logging.getLogger(__name__) @@ -116,6 +119,9 @@ class AuthController(BaseController): self.subscriptions = SubscriptionsController() self.oauth = OAuthController() + if asbool(config.get('auth.oauth2.enabled', False)): + self.oauth2 = OAuth2AuthorizationController() + if asbool(config.get('auth.allow_user_to_disable_account', False)): self.disable = DisableAccountController() @@ -1346,12 +1352,12 @@ class OAuthController(BaseController): access_tokens.append({ 'type': 2, 'app': M.OAuth2ClientApp.query.get(client_id=oauth2_tok.client_id), - # TODO personal bearer tokens: - 'is_bearer': False, - 'api_key': None, + 'is_bearer': oauth2_tok.is_bearer, + 'api_key': oauth2_tok.access_token if oauth2_tok.is_bearer else None, 'last_access': oauth2_tok.last_access, '_id': oauth2_tok._id, }) + # include auth codes too, but only if they're not already listed via an access/refresh token for oauth2_auth in M.OAuth2AuthorizationCode.query.find({'user_id': c.user._id}): client_app = M.OAuth2ClientApp.query.get(client_id=oauth2_auth.client_id) @@ -1489,6 +1495,22 @@ class OAuthController(BaseController): flash('Invalid token ID', 'error') redirect('.') + @expose() + @require_post() + def generate_bearer_token(self, client_id): + """ + Manually generates an OAuth2 access token without needing to go through the OAuth2 flow. + """ + M.OAuth2AccessToken( + client_id=client_id, + user_id=c.user._id, + access_token=h.nonce(40), + is_bearer=True, + expires_at=datetime.max + ) + + redirect('.') + @expose() @require_post() def revoke_access_token(self, _id): @@ -1526,6 +1548,56 @@ class OAuthController(BaseController): flash('Authorization revoked') redirect('.') +class OAuth2AuthorizationController(BaseController): + def _check_security(self): + require_authenticated() + + @property + def server(self): + from allura.controllers.rest import Oauth2Validator + return oauthlib.oauth2.WebApplicationServer(Oauth2Validator()) + + @expose('jinja:allura:templates/oauth2_authorize.html') + @without_trailing_slash + def authorize(self, **kwargs): + 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. + decoded_body = str(request.body, 'utf-8') + json_body = json.loads(decoded_body) + + 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.OAuth2ClientApp.query.get(client_id=client_id) + + # The credentials object has a request object that it's too big to be serialized, + # so we remove it because we don't need it for the rest of the authorization workflow + del credentials['request'] + + return dict(client=client, credentials=json.dumps(credentials)) + + @expose() + @require_post() + def do_authorize(self, yes=None, no=None): + client_id = request.params['client_id'] + client = M.OAuth2ClientApp.query.get(client_id=client_id) + + if no: + flash(f'{client.name} NOT AUTHORIZED', 'error') + redirect('/auth/oauth/') + + credentials = json.loads(request.params['credentials']) + headers, body, status = self.server.create_authorization_response( + uri=request.url, http_method=request.method, body=request.body, headers=request.headers, scopes=[], credentials=credentials + ) + + response.status_int = status + response.headers.update(headers) + return body + + class DisableAccountController(BaseController): def _check_security(self): diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py index 0d4fbe60e..bfa95a4bf 100644 --- a/Allura/allura/controllers/rest.py +++ b/Allura/allura/controllers/rest.py @@ -255,7 +255,7 @@ class Oauth1Validator(oauthlib.oauth1.RequestValidator): class Oauth2Validator(oauthlib.oauth2.RequestValidator): - def validate_client_id(self, client_id: str, request: oauthlib.common.Request) -> bool: + def validate_client_id(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs) -> bool: return M.OAuth2ClientApp.query.get(client_id=client_id) is not None def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): @@ -270,7 +270,7 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): 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 grant_type in ['authorization_code', 'refresh_token', 'client_credentials'] + return grant_type in ['authorization_code', 'refresh_token'] def get_default_scopes(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs): return [] @@ -313,7 +313,7 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): return False def validate_refresh_token(self, refresh_token: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool: - return M.OAuth2AccessToken.query.get(refresh_token=refresh_token) is not None + return M.OAuth2AccessToken.query.get(refresh_token=refresh_token, client_id=client.client_id) is not None def confirm_redirect_uri(self, client_id: str, code: str, redirect_uri: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool: # This method is called when the client is exchanging the authorization code for an access token. @@ -322,11 +322,11 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): return authorization.redirect_uri == redirect_uri 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, user_id=c.user._id, authorization_code=code['code']) + authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id, user_id=c.user._id) # Remove the existing authorization code if it exists and create a new record if authorization: - M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 'user_id': c.user._id, 'authorization_code': code['code']}) + M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 'user_id': c.user._id}) log.info('Saving authorization code for client: %s', client_id) auth_code = M.OAuth2AuthorizationCode( @@ -347,10 +347,10 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator): elif request.grant_type == 'refresh_token': user_id = M.OAuth2AccessToken.query.get(client_id=request.client_id, refresh_token=request.refresh_token).user_id - current_token = M.OAuth2AccessToken.query.get(client_id=request.client_id, user_id=user_id) + current_token = M.OAuth2AccessToken.query.get(client_id=request.client_id, user_id=user_id, is_bearer=False) if current_token: - M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 'user_id': user_id}) + M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 'user_id': user_id, 'is_bearer': False}) bearer_token = M.OAuth2AccessToken( client_id=request.client_id, @@ -520,49 +520,6 @@ class Oauth2Negotiator: token.last_access = datetime.utcnow() return token - @expose('jinja:allura:templates/oauth2_authorize.html') - @without_trailing_slash - 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. - decoded_body = str(request.body, 'utf-8') - json_body = json.loads(decoded_body) - - 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.OAuth2ClientApp.query.get(client_id=client_id) - - # The credentials object has a request object that it's too big to be serialized, - # so we remove it because we don't need it for the rest of the authorization workflow - del credentials['request'] - - return dict(client=client, credentials=json.dumps(credentials)) - - @expose('jinja:allura:templates/oauth2_authorize_ok.html') - @require_post() - def do_authorize(self, yes=None, no=None): - security.require_authenticated() - - client_id = request.params['client_id'] - client = M.OAuth2ClientApp.query.get(client_id=client_id) - - if no: - flash(f'{client.name} NOT AUTHORIZED', 'error') - redirect('/auth/oauth/') - - credentials = json.loads(request.params['credentials']) - headers, body, status = self.server.create_authorization_response( - uri=request.url, http_method=request.method, body=request.body, headers=request.headers, scopes=[], credentials=credentials - ) - - response.status_int = status - response.headers.update(headers) - return body - @expose('json:') @require_post() def token(self, **kwargs): diff --git a/Allura/allura/lib/custom_middleware.py b/Allura/allura/lib/custom_middleware.py index 4a09325a5..e06f950c1 100644 --- a/Allura/allura/lib/custom_middleware.py +++ b/Allura/allura/lib/custom_middleware.py @@ -497,7 +497,7 @@ class ContentSecurityPolicyMiddleware: srcs += ' ' + ' '.join(environ['csp_form_actions']) oauth_endpoints = ( - '/rest/oauth2/authorize', '/rest/oauth2/do_authorize', '/rest/oauth/authorize', '/rest/oauth/do_authorize') + '/auth/oauth2/authorize', '/auth/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}") diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py index b8b21d8a3..69043635a 100644 --- a/Allura/allura/model/oauth.py +++ b/Allura/allura/model/oauth.py @@ -158,7 +158,10 @@ class OAuth2ClientApp(MappedClass): class __mongometa__: session = main_orm_session name = 'oauth2_client_app' - unique_indexes = [('client_id', 'user_id')] + unique_indexes = [ + ('client_id', 'user_id'), + ('client_id') + ] indexes = [ ('user_id'), ] @@ -194,7 +197,10 @@ class OAuth2AuthorizationCode(MappedClass): class __mongometa__: session = main_orm_session name = 'oauth2_authorization_code' - unique_indexes = [('authorization_code', 'client_id', 'user_id')] + unique_indexes = [ + ('authorization_code', 'client_id', 'user_id'), + ('authorization_code') + ] indexes = [ ('user_id'), ] @@ -219,7 +225,13 @@ class OAuth2AccessToken(MappedClass): class __mongometa__: session = main_orm_session name = 'oauth2_access_token' - unique_indexes = [('access_token', 'client_id', 'user_id')] + unique_indexes = [ + ('access_token', 'client_id', 'user_id'), + ('access_token') + ] + custom_indexes = [ + dict(fields=('refresh_token',), partialFilterExpression={'refresh_token': {'$gt': None}}, unique=True), + ] indexes = [ ('user_id'), ] @@ -232,6 +244,7 @@ class OAuth2AccessToken(MappedClass): scopes = FieldProperty([str]) access_token = FieldProperty(str) refresh_token = FieldProperty(str) + is_bearer = FieldProperty(bool, if_missing=False) expires_at = FieldProperty(S.DateTime) last_access = FieldProperty(datetime) diff --git a/Allura/allura/templates/oauth2_applications.html b/Allura/allura/templates/oauth2_applications.html deleted file mode 100644 index a0d5f01e0..000000000 --- a/Allura/allura/templates/oauth2_applications.html +++ /dev/null @@ -1,128 +0,0 @@ -{#- - Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. --#} -{% set hide_left_bar = True %} -{% extends "allura:templates/user_account_base.html" %} - -{% block title %}{{c.user.username}} / Applications {% endblock %} - -{% block header %}OAuth2 applications registered for {{c.user.username}}{% endblock %} - -{% block extra_css %} -<style type="text/css"> - table { - border: 1px solid #e5e5e5; - } - th { - text-align: left; - width: 10em; - padding: 5px; - border: 1px solid #e5e5e5; - } - tr.description p { - padding-left: 0; - } - tr.description p:last-child { - padding-bottom: 0; - } - tr.controls input[type="submit"] { - margin-bottom: 0; - } -</style> -{% endblock %} - -{% block extra_js %} -<script type="text/javascript"> - $(function() { - var btnClicked; - - // The click event will always trigger before the submit event, this will give us the chance to - // figure out which button was clicked in order to display the correct confirmation dialog - $('#deregister,#revoke').click(function(){ - btnClicked = this.name; - }); - - $('.client_action').submit(function(e) { - var confirmMsg; - - if(btnClicked === 'deregister') { - confirmMsg = 'Deregister client?. This action will also revoke authorization and access tokens.' - } - - if(btnClicked === 'revoke') { - confirmMsg = "Revoke client's authorization codes and access tokens?. This action will not delete the current client." - } - - var ok = confirm(confirmMsg) - if(!ok) { - e.preventDefault(); - return false; - } - }); - }) -</script> -{% endblock %} - -{% block content %} - {{ super() }} - - <h2>My Clients</h2> - <p> - These are the clients you have registered. They can request authorization - for a user by sending the client id and a response type. - Once you have an authorization code, you can generate an access token to give your client access - to your account and use a refresh token to generate a new one each time it expires. Note, however, - that you must be careful with access tokens, since anyone who has the token can - access your account as that client. - </p> - {% for app in model %} - <table class="registered_app"> - <tr><th>Name:</th><td>{{app.client.name}}</td></tr> - <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="client_secret"><th>Client Secret:</th><td>{{app.client.client_secret}}</td></tr> - <tr class="redirect_url"><th>Redirect URL:</th><td>{{app.client.redirect_uris[0] if app.client.redirect_uris else ''}}</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.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> - {% endif %} - - <tr class="controls"> - <td colspan="2"> - <form method="POST" action="do_client_action" class="client_action"> - <input type="hidden" name="_id" value="{{app.client.client_id}}"/> - <input id="deregister" type="submit" name="deregister" value="Deregister Client"/> - <input id="revoke" type="submit" name="revoke" value="Revoke Access"/> - {{lib.csrf_token()}} - </form> - </td> - </tr> - </table> - {% endfor %} - - <h2>Register New Application</h2> - {{ c.form.display() }} -{% endblock %} diff --git a/Allura/allura/templates/oauth2_authorize_ok.html b/Allura/allura/templates/oauth2_authorize_ok.html deleted file mode 100644 index 6334b35c9..000000000 --- a/Allura/allura/templates/oauth2_authorize_ok.html +++ /dev/null @@ -1,35 +0,0 @@ -{#- - Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. --#} -{% set hide_left_bar = True %} -{% extends g.theme.master %} - -{% block title %} Third-party client authorized. {% endblock %} - -{% block header %}Third-party client authorized.{% endblock %} - -{% block content %} -<p>You have authorized {{ client.name }} access to your account. If you wish - to revoke this access at any time, please visit - <a href="/auth/preferences">user preferences</a> - and click 'revoke access'.</p> -<p>You can use the following authorization code to request an access token from /rest/oauth2/token.</p> -<h2>Authorization Code: {{ authorization_code }}</h2> -<p>Please be aware that the authorization code will be valid for 10 minutes.</p> -<a href="/auth/preferences/">Return to preferences</a> -{% endblock %} diff --git a/Allura/allura/templates/oauth_applications.html b/Allura/allura/templates/oauth_applications.html index 9b22cfc68..d2e44c55c 100644 --- a/Allura/allura/templates/oauth_applications.html +++ b/Allura/allura/templates/oauth_applications.html @@ -78,6 +78,8 @@ {% block content %} {{ super() }} + <h5>For direct API use, <a href="#oauth_applications">create an application client</a> and generate a bearer token</h5> + <br/> <h2>Authorized Applications</h2> <p> These are applications you have authorized to act on your behalf. @@ -148,13 +150,12 @@ <input type="submit" value="Delete all access tokens"/> {{lib.csrf_token()}} </form> - {# - <form method="POST" action="generate_access_token2" class="generate_access_token"> - <input type="hidden" name="_id" value="{{consumer_token._id}}"/> + <form method="POST" action="generate_bearer_token" class="generate_bearer_token"> + <input type="hidden" name="client_id" value="{{client.client_id}}"/> <input type="submit" value="Generate Bearer Token"/> {{lib.csrf_token()}} </form> - #} + </td> </tr> </table> @@ -197,7 +198,7 @@ {% endif %} {% if h.asbool(config.get('auth.oauth2.enabled', False)) %} - <div class="grid-24" style="margin-left:0"> + <div id="oauth_applications" class="grid-24" style="margin-left:0"> <h2>Register New OAuth2 Application</h2> {{ c.form2.display() }} </div> diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py index 214caaee2..be33727bb 100644 --- a/Allura/allura/tests/functional/test_auth.py +++ b/Allura/allura/tests/functional/test_auth.py @@ -33,6 +33,7 @@ from tg import config, expose from mock import patch, Mock import mock import pytest +import webtest from tg import tmpl_context as c, app_globals as g from allura.tests import TestController @@ -2067,6 +2068,79 @@ class TestOAuth(TestController): class TestOAuth2(TestController): + @pytest.fixture + def mock_client(self): + user = M.User.by_username('test-admin') + M.OAuth2ClientApp( + client_id='client_12345', + client_secret='98765', + user_id=user._id, + name='testoauth2', + description='test client', + response_type='code', + redirect_uris=['https://localhost/'] + ) + ThreadLocalODMSession.flush_all() + + @pytest.fixture + def mock_credentials(self): + return dict(client_id='client_12345', redirect_uri='https://localhost/', response_type='code', state=None) + + @pytest.fixture + def mock_valid_token(self): + user = M.User.by_username('test-admin') + M.OAuth2AccessToken( + client_id='client_12345', + access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8', + refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP', + scopes=[], + expires_at=datetime.utcnow() + timedelta(hours=1), + user_id=user._id + ) + ThreadLocalODMSession.flush_all() + + @pytest.fixture + def mock_expired_token(self): + user = M.User.by_username('test-admin') + M.OAuth2AccessToken( + client_id='client_12345', + access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8', + refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP', + scopes=[], + expires_at=datetime.utcnow() - timedelta(days=1), + user_id=user._id + ) + ThreadLocalODMSession.flush_all() + + @pytest.fixture + def mock_valid_authorization_code(self): + user = M.User.by_username('test-admin') + M.OAuth2AuthorizationCode( + client_id='client_12345', + authorization_code='XToC3m9whoA304HMMKOOUF678n6s6r', + scopes=[], + redirect_uri='https://localhost/', + user_id=user._id, + expires_at=datetime.utcnow() + timedelta(minutes=10) + ) + ThreadLocalODMSession.flush_all() + + @pytest.fixture + def mock_expired_authorization_code(self): + user = M.User.by_username('test-admin') + M.OAuth2AuthorizationCode( + client_id='client_12345', + authorization_code='XToC3m9whoA304HMMKOOUF678n6s6r', + scopes=[], + redirect_uri='https://localhost/', + user_id=user._id, + expires_at=datetime.utcnow() - timedelta(minutes=10), + # Code verifier: iX7BOwXKgX2PH1diMIN_RQDiczkFTYAyzc-bhUVC3iCD3P2l_fYQYCORcLKbw2uGjGTX9UUBTAIqcjAdf3QRCQ + code_challenge='CqIDFfrLC7TTUzeCUdHryYMCUdiXjQsqX6qHgQtsFOQ', + code_challenge_method='S256' + ) + ThreadLocalODMSession.flush_all() + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) def test_register_deregister_client(self): # register @@ -2098,7 +2172,7 @@ class TestOAuth2(TestController): redirect_uris=['https://localhost/'] ) ThreadLocalODMSession.flush_all() - r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'}) + r = self.app.get('/auth/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'}) assert 'testoauth2' in r.text assert 'client_12345' in r.text @@ -2115,7 +2189,7 @@ class TestOAuth2(TestController): redirect_uris=['https://localhost/'] ) ThreadLocalODMSession.flush_all() - r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'}) + r = self.app.get('/auth/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'}) r = r.forms[0].submit('no') assert M.OAuth2AuthorizationCode.query.get(client_id='client_12345') is None @@ -2139,7 +2213,7 @@ class TestOAuth2(TestController): r.mustcontain(no='testoauth2') # 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/'}) + r = self.app.get('/auth/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.forms[0].submit('yes') @@ -2160,7 +2234,7 @@ class TestOAuth2(TestController): self.app.post_json('/rest/oauth2/token', oauth2_params, extra_environ={'username': '*anonymous'}) 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 + assert t.access_token is not None and t.refresh_token is not None and not t.is_bearer r = self.app.get('/auth/oauth/') r.mustcontain('testoauth2') @@ -2247,6 +2321,122 @@ class TestOAuth2(TestController): r.mustcontain(no='testoauth2') + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_pkce(self, mock_client, mock_credentials): + code_verifier = 'QkatVHgTq_cZj8tTKWPIe78fXpoeszhVq6kLIUxJj8g9tMmfi0XV4dfZHQBXwOiWsLihJotfrOGKR4nZSXA4mA' + code_challenge = 'BxGpJVKt_l6Srlq3uXPfpxge3TxtxetcWhGXq2958yU' + code_challenge_method = 'S256' # Must be uppercase + + # Authorize the app by sending the code challenge and code challenge method as qs param + params = dict(client_id='client_12345', response_type='code', redirect_uri='https://localhost/', code_challenge=code_challenge, + code_challenge_method=code_challenge_method) + r = self.app.get('/auth/oauth2/authorize', params=params) + + # Authorize app + r.forms[0].submit('yes') + + # Get the authorization code + ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345') + assert ac is not None + + # Exchange the authorization code for an access token. It should fail if you do not provide the code verifier + body = dict(client_id='client_12345', client_secret='98765', code=ac.authorization_code, grant_type='authorization_code', redirect_uri='https://localhost/') + + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'Code verifier required' in str(ex.value) + + # Now provide the code verifier and it should pass + body.update(code_verifier=code_verifier) + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + assert r.status_int == 200 + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_refresh_token(self, mock_client, mock_valid_token): + token = M.OAuth2AccessToken.query.get(client_id='client_12345') + + body = dict(client_id='client_12345', client_secret='98765', grant_type='refresh_token', refresh_token=token.refresh_token) + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + assert r.status_int == 200 + assert r.json['access_token'] != token.access_token + assert r.json['refresh_token'] != token.refresh_token + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_invalid_refresh_token(self, mock_client, mock_valid_token): + body = dict(client_id='client_12345', client_secret='98765', grant_type='refresh_token', refresh_token='invalid_token') + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'invalid_grant' in str(ex.value) + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_access_token_with_expired_code(self, mock_client, mock_expired_authorization_code): + c = M.OAuth2ClientApp.query.get(client_id='client_12345') + ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345') + + body = dict(client_id=c.client_id, client_secret=c.client_secret, grant_type='authorization_code', code=ac.authorization_code, + redirect_uri=c.redirect_uris[0]) + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'invalid_grant' in str(ex.value) + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_access_token_with_invalid_redirect_uri(self, mock_client, mock_valid_authorization_code): + c = M.OAuth2ClientApp.query.get(client_id='client_12345') + ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345') + + body = dict(client_id=c.client_id, client_secret=c.client_secret, grant_type='authorization_code', code=ac.authorization_code, + redirect_uri='https://invalid.com') + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'Mismatching redirect URI' in str(ex.value) + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_access_token_with_invalid_credentials(self, mock_client, mock_valid_authorization_code): + c = M.OAuth2ClientApp.query.get(client_id='client_12345') + ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345') + + # First test passing an invalid client id + body = dict(client_id='invalid_client_id', client_secret=c.client_secret, grant_type='authorization_code', code=ac.authorization_code, + redirect_uri=c.redirect_uris[0]) + + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'invalid_client' in str(ex.value) + + # Now test passing an invalid client secret + body = dict(client_id=c.client_id, client_secret='invalid_secret', grant_type='authorization_code', code=ac.authorization_code, + redirect_uri=c.redirect_uris[0]) + + with pytest.raises(webtest.app.AppError) as ex: + r = self.app.post_json('/rest/oauth2/token', body, extra_environ={'username': '*anonymous'}) + + assert 'invalid_client' in str(ex.value) + + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_authorization_code_no_duplicates(self, mock_client): + c = M.OAuth2ClientApp.query.get(client_id='client_12345') + + params = dict(client_id=c.client_id, response_type='code', redirect_uri=c.redirect_uris[0]) + r = self.app.get('/auth/oauth2/authorize', params=params) + r.forms[0].submit('yes') + + # Find all authorization codes and validate there's only one record + ac = M.OAuth2AuthorizationCode.query.find(dict(client_id=c.client_id)).all() + assert len(ac) == 1 + + # Now try to authorize again and validate there's still only one record + r = self.app.get('/auth/oauth2/authorize', params=params) + r.forms[0].submit('yes') + + ac = M.OAuth2AuthorizationCode.query.find(dict(client_id=c.client_id)).all() + assert len(ac) == 1 + + 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 5368b871b..d3b896b8e 100644 --- a/Allura/allura/tests/functional/test_rest.py +++ b/Allura/allura/tests/functional/test_rest.py @@ -17,11 +17,14 @@ import json +from datetime import datetime, timedelta import tg from bson import ObjectId from tg import app_globals as g import mock +import pytest +import webtest from ming.odm import ThreadLocalODMSession from tg import config @@ -419,6 +422,50 @@ class TestRestHome(TestRestApiBase): r = self.app.get('/rest/notification') assert r.json == {} + @td.with_wiki + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_oauth2_token_authentication(self): + # Create a valid access token + user = M.User.by_username('test-admin') + token = M.OAuth2AccessToken( + client_id='client_12345', + access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8', + refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP', + scopes=[], + expires_at=datetime.utcnow() + timedelta(days=1), + user_id=user._id + ) + ThreadLocalODMSession.flush_all() + + self.set_api_token(token) + r = self.api_get('/rest/p/test/wiki/Home') + params = dict(text=r.json['text']) + r = self.api_post('/rest/p/test/wiki/Home', params=params) + assert r.status_int == 200 + + @td.with_wiki + @mock.patch.dict(config, {'auth.oauth2.enabled': True}) + def test_oauth2_expired_token_authentication(self): + # Create an expired access token + user = M.User.by_username('test-admin') + token = M.OAuth2AccessToken( + client_id='client_12345', + access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8', + refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP', + scopes=[], + expires_at=datetime.utcnow() - timedelta(days=1), + user_id=user._id + ) + ThreadLocalODMSession.flush_all() + + self.set_api_token(token) + with pytest.raises(webtest.app.AppError) as ex: + r = self.api_get('/rest/p/test/wiki/Home') + params = dict(text=r.json['text']) + r = self.api_post('/rest/p/test/wiki/Home', params=params) + + assert '401 Unauthorized' in str(ex.value) + class TestRestNbhdAddProject(TestRestApiBase): diff --git a/Allura/docs/api-rest/docs.md b/Allura/docs/api-rest/docs.md index 324f9f421..35db1ae64 100755 --- a/Allura/docs/api-rest/docs.md +++ b/Allura/docs/api-rest/docs.md @@ -167,7 +167,7 @@ The following example demonstrates the authorization workflow and how to generat # Set up your client credentials client_id = 'YOUR_CLIENT_ID' client_secret = 'YOUR_CLIENT_SECRET' - authorization_base_url = 'https://forge-allura.apache.org/rest/oauth2/authorize' + authorization_base_url = 'https://forge-allura.apache.org/auth/oauth2/authorize' access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token' redirect_uri = 'https://forge-allura.apache.org/page' # Your registered redirect URI @@ -259,7 +259,7 @@ You can use the following example to generate a valid code verifier and code cha Having generated the codes, you would need to send the code challenge along with the challenge method (in this case S256) as part of the query string in the authorization url, for example: - https://forge-allura.apache.org/rest/oauth2/authorize?client_id=8dca182d3e6fe0cb76b8&response_type=code&code_challenge=G6wIRjEZlvhLsVS0exbID3o4ppUBsjxUBNtRVL8StXo&code_challenge_method=S256 + https://forge-allura.apache.org/auth/oauth2/authorize?client_id=8dca182d3e6fe0cb76b8&response_type=code&code_challenge=G6wIRjEZlvhLsVS0exbID3o4ppUBsjxUBNtRVL8StXo&code_challenge_method=S256 Afterwards, when you request an access token, you must provide the code verifier that derived the code challenge as part of the request's body, otherwise the token request validation will fail: diff --git a/Allura/docs/api-rest/securitySchemes.yaml b/Allura/docs/api-rest/securitySchemes.yaml index f52b052ad..60a56f4e2 100755 --- a/Allura/docs/api-rest/securitySchemes.yaml +++ b/Allura/docs/api-rest/securitySchemes.yaml @@ -41,7 +41,7 @@ description: | OAuth 2.0 may also be used to authenticate API requests. - First authorize your application at https://forge-allura.apache.org/rest/oauth2/authorize with following + First authorize your application at https://forge-allura.apache.org/auth/oauth2/authorize with following query string parameters: - response_type=code - client_id=YOUR_CLIENT_ID @@ -67,10 +67,10 @@ `Authorization: Bearer MY_BEARER_TOKEN`` type: OAuth 2.0 settings: - authorizationUri: https://forge-allura.apache.org/rest/oauth2/authorize + authorizationUri: https://forge-allura.apache.org/auth/oauth2/authorize accessTokenUri: https://forge-allura.apache.org/rest/oauth2/token authorizationGrants: - authorization_code - refresh_token scopes: - - + - diff --git a/AlluraTest/alluratest/controller.py b/AlluraTest/alluratest/controller.py index 782fcc44a..55ef5d94e 100644 --- a/AlluraTest/alluratest/controller.py +++ b/AlluraTest/alluratest/controller.py @@ -264,7 +264,12 @@ class TestRestApiBase(TestController): if not isinstance(params, str): params = variabledecode.variable_encode(params, add_repetitions=False) - token = self.token(user).api_key + bearer_token = self.token(user) + try: + token = bearer_token.api_key + except AttributeError: + token = bearer_token.access_token + headers = { 'Authorization': str(f'Bearer {token}') } diff --git a/scripts/wiki-copy.py b/scripts/wiki-copy.py index ee04011c6..bba2e5ad9 100644 --- a/scripts/wiki-copy.py +++ b/scripts/wiki-copy.py @@ -119,7 +119,7 @@ def make_oauth2_client(base_url) -> requests.Session: cp = ConfigParser() cp.read(config_file) - AUTHORIZE_URL = base_url + '/rest/oauth2/authorize' + AUTHORIZE_URL = base_url + '/auth/oauth2/authorize' ACCESS_TOKEN_URL = base_url + '/rest/oauth2/token' client_id = option(cp, base_url, 'oauth2_client_id',