diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py index 61bfd7d75..06ac5183c 100644 --- a/Allura/allura/controllers/auth.py +++ b/Allura/allura/controllers/auth.py @@ -1448,7 +1448,7 @@ def index(self, **kw): def register(self, application_name=None, application_description=None, redirect_url=None, **kw): M.OAuth2ClientApp(name=application_name, description=application_description, - redirect_uris=[redirect_url], + redirect_uris=[redirect_url] if redirect_url else [], user_id=c.user._id) flash('Oauth2 Client registered') redirect('.') diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py index bb0496a8a..f0b8f8448 100644 --- a/Allura/allura/controllers/rest.py +++ b/Allura/allura/controllers/rest.py @@ -275,8 +275,12 @@ def validate_grant_type(self, client_id: str, grant_type: str, client: oauthlib. def get_default_scopes(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs): return [] + def get_original_scopes(self, refresh_token: str, request: oauthlib.common.Request, *args, **kwargs) -> list[str]: + return None + def get_default_redirect_uri(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs) -> str: - return request.uri + client = M.OAuth2ClientApp.query.get(client_id=client_id) + return client.redirect_uris[0] if client.redirect_uris else None def is_pkce_required(self, client_id: str, request: oauthlib.common.Request) -> bool: return False @@ -304,6 +308,9 @@ def validate_bearer_token(self, token: str, scopes: list[str], request: oauthlib access_token = M.OAuth2AccessToken.query.get(access_token=token) return access_token.expires_at >= datetime.utcnow() if access_token else 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 + 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. # If a redirect uri was provided when the authorization code was created, it must match the redirect uri provided here. @@ -331,11 +338,15 @@ def save_authorization_code(self, client_id: str, code, request: oauthlib.common log.info(f'Saving new authorization code for client: {client_id}') def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kwargs) -> object: - authorization_code = M.OAuth2AuthorizationCode.query.get(client_id=request.client_id, authorization_code=request.code) - current_token = M.OAuth2AccessToken.query.get(client_id=request.client_id, user_id=authorization_code.user_id) + if request.grant_type == 'authorization_code': + user_id = M.OAuth2AuthorizationCode.query.get(client_id=request.client_id, authorization_code=request.code).user_id + 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) if current_token: - M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 'user_id': c.user._id}) + M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 'user_id': user_id}) bearer_token = M.OAuth2AccessToken( client_id=request.client_id, @@ -343,7 +354,7 @@ def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kw access_token=token.get('access_token'), refresh_token=token.get('refresh_token'), expires_at=datetime.utcnow() + timedelta(seconds=token.get('expires_in')), - user_id=authorization_code.user_id + user_id=user_id ) session(bearer_token).flush() @@ -572,7 +583,6 @@ def token(self, **kwargs): headers, body, status = self.server.create_token_response(uri=request.url, http_method=request.method, body=request_body, headers=request.headers) return body - def rest_has_access(obj, user, perm): """ Helper function that encapsulates common functionality for has_access API diff --git a/Allura/docs/api-rest/api.raml b/Allura/docs/api-rest/api.raml index dd655cfd6..44072067c 100755 --- a/Allura/docs/api-rest/api.raml +++ b/Allura/docs/api-rest/api.raml @@ -24,7 +24,7 @@ title: Apache Allura version: 1 baseUri: https://{domain}/rest -securedBy: [null, oauth_1_0] +securedBy: [null, oauth_1_0, oauth_2_0] resourceTypes: !include resourceTypes.yaml traits: !include traits.yaml @@ -63,6 +63,10 @@ documentation: description: | See separate docs section for authenticating with the OAuth 1.0 APIs +/oauth2: + description: | + See separate docs section for authenticating with the OAuth 2.0 APIs + /{neighborhood}: description: | Neighborhoods are groups of logically related projects, which have the same default options. diff --git a/Allura/docs/api-rest/docs.md b/Allura/docs/api-rest/docs.md index 886514719..324f9f421 100755 --- a/Allura/docs/api-rest/docs.md +++ b/Allura/docs/api-rest/docs.md @@ -19,7 +19,7 @@ # Basic API architecture -All url endpoints are prefixed with /rest/ and the path to the project and tool. +All url endpoints are prefixed with /rest/ and the path to the project and tool. For example, in order to access a wiki installed in the 'test' project with the mount point 'docs' the API endpoint would be /rest/p/test/docs. @@ -60,9 +60,9 @@ Python code example to create a new ticket: import requests from pprint import pprint - + BEARER_TOKEN = '' - + r = requests.post('https://forge-allura.apache.org/rest/p/test-project/tickets/new', params={ 'access_token': BEARER_TOKEN, 'ticket_form.summary': 'Test ticket', @@ -92,76 +92,195 @@ If you want your application to be able to use the API on behalf of another user REQUEST_TOKEN_URL = 'https://forge-allura.apache.org/rest/oauth/request_token' AUTHORIZE_URL = 'https://forge-allura.apache.org/rest/oauth/authorize' ACCESS_TOKEN_URL = 'https://forge-allura.apache.org/rest/oauth/access_token' - + oauth = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET, callback_uri='oob') - - # Step 1: Get a request token. This is a temporary token that is used for - # having the user authorize an access token and to sign the request to obtain + + # Step 1: Get a request token. This is a temporary token that is used for + # having the user authorize an access token and to sign the request to obtain # said access token. - + request_token = oauth.fetch_request_token(REQUEST_TOKEN_URL) - + # these are intermediate tokens and not needed later # print("Request Token:") # print(" - oauth_token = %s" % request_token['oauth_token']) # print(" - oauth_token_secret = %s" % request_token['oauth_token_secret']) # print() - - # Step 2: Redirect to the provider. Since this is a CLI script we do not + + # Step 2: Redirect to the provider. Since this is a CLI script we do not # redirect. In a web application you would redirect the user to the URL # below, specifying the additional parameter oauth_callback=. - + webbrowser.open(oauth.authorization_url(AUTHORIZE_URL, request_token['oauth_token'])) - - # Since we didn't specify a callback, the user must now enter the PIN displayed in - # their browser. If you had specified a callback URL, it would have been called with + + # Since we didn't specify a callback, the user must now enter the PIN displayed in + # their browser. If you had specified a callback URL, it would have been called with # oauth_token and oauth_verifier parameters, used below in obtaining an access token. oauth_verifier = input('What is the PIN? ') - + # Step 3: Once the consumer has redirected the user back to the oauth_callback - # URL you can request the access token the user has approved. You use the + # URL you can request the access token the user has approved. You use the # request token to sign this request. After this is done you throw away the - # request token and use the access token returned. You should store this + # request token and use the access token returned. You should store this # access token somewhere safe, like a database, for future use. access_token = oauth.fetch_access_token(ACCESS_TOKEN_URL, oauth_verifier) - + print("Access Token:") print(" - oauth_token = %s" % access_token['oauth_token']) print(" - oauth_token_secret = %s" % access_token['oauth_token_secret']) print() - print("You may now access protected resources using the access tokens above.") + print("You may now access protected resources using the access tokens above.") print() You can then use your access token with the REST API. For instance script to create a wiki page might look like this: from requests_oauthlib import OAuth1Session - + PROJECT='test' - + CONSUMER_KEY='' CONSUMER_SECRET='' - + ACCESS_KEY='' ACCESS_SECRET='' - + URL_BASE='https://forge-allura.apache.org/rest/' - + oauth = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET, resource_owner_key=ACCESS_KEY, resource_owner_secret=ACCESS_SECRET) - + response = oauth.post(URL_BASE + 'p/' + PROJECT + '/wiki/TestPage', data=dict(text='This is a test page')) response.raise_for_status() print("Done. Response was:") print(response) +### OAuth2 Authorization + +Another option for authorizing your apps is to use the OAuth2 workflow. This is accomplished by authorizing the application which generates an `authorization_code` that can be later exchanged for an `access_token` + +The following example demonstrates the authorization workflow and how to generate an access token to authenticate your apps + + from requests_oauthlib import OAuth2Session + + # 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' + access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token' + redirect_uri = 'https://forge-allura.apache.org/page' # Your registered redirect URI + + # Create an OAuth2 session + oauth2 = OAuth2Session(client_id, redirect_uri=redirect_uri) + + # Step 1: Prompt the user to navigate to the authorization URL + authorization_url, state = oauth2.authorization_url(authorization_base_url) + + print('Please go to this URL to authorize the app:', authorization_url) + + # Step 2: Obtain the authorization code (you can find it in the 'code' URL parameter) + # In real use cases, you might implement a small web server to capture this + authorization_code = input('Paste authorization code here: ') + + # Step 3: Exchange the authorization code for an access token + token = oauth2.fetch_token(access_token_url, + code=authorization_code, + client_secret=client_secret, + include_client_id=True) + + # Print the access and refresh tokens for verification (or use it to request user data) + # If your access token expires, you can request a new one using the refresh token + print(f"Access Token: {token.get('access_token')}") + print(f"Refresh Token: {token.get('refresh_token')}") + + # Step 4: Use the access token to make authenticated requests + response = oauth2.get('https://forge-allura.apache.org/user') + print('User data:', response.json()) + +### Refreshing Access Tokens + +A new access token can be requested once it expires. The following example demonstrates how can the refresh token obtained in the previous code sample be used to generate a new access token: + + from requests_oauthlib import OAuth2Session + + # Set up your client credentials + client_id = 'YOUR_CLIENT_ID' + client_secret = 'YOUR_CLIENT_SECRET' + refresh_token = 'YOUR_REFRESH_TOKEN' + access_token = 'YOUR_ACCESS_TOKEN' + access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token' + + # Step 1: Create an OAuth2 session by also passing token information + token = dict(access_token=access_token, token_type='Bearer', refresh_token=refresh_token) + oauth2 = OAuth2Session(client_id=client_id, token=token) + + # Step 2: Request for a new token + extra = dict(client_id=client_id, client_secret=client_secret) + refreshed_token = oauth2.refresh_token(access_token_url, **extra) + + # You can inspect the response object to get the new access and refresh tokens + print(f"Access Token: {token.get('access_token')}") + print(f"Refresh Token: {token.get('refresh_token')}") + +### PKCE support + +PKCE (Proof Key for Code Exchange) is an extension to the authorization code flow to prevent CSRF and authorization code injection attacks. It mitigates the risk of the authorization code being intercepted by a malicious entity during the exchange from the authorization endpoint to the token endpoint. + +To make use of this security extension, you must generate a string known as a "code verifier", which is a random string using the characters A-Z, a-z, 0-9 and the special characters -._~ and it should be between 43 and 128 characters long. + +Once the string has been created, perform a SHA256 hash on it and encode the resulting value as a Base- + +You can use the following example to generate a valid code verifier and code challenge: + + import hashlib + import base64 + import os + + + # Generate a code verifier (random string) + def generate_code_verifier(length=64): + return base64.urlsafe_b64encode( + os.urandom(length)).decode('utf-8').rstrip('=') + + + # Generate a code challenge (SHA-256) + def generate_code_challenge(verifier): + digest = hashlib.sha256(verifier.encode('utf-8')).digest() + return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=') + + + code_verifier = generate_code_verifier() + code_challenge = generate_code_challenge(code_verifier) + + # The code challenge should be sent in the initial authorization request. + print("Code Verifier:", code_verifier) + print("Code Challenge:", code_challenge) + +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 + + +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: + + POST https://forge-allura.apache.org/rest/oauth2/token + + { + "client_id": "8dca182d3e6fe0cb76b8", + "client_secret": "1c6a2d99db80223590dd12cc32dfdb8a0cc2e9a38620e05c16076b2872110688b9c1b17db63bb7c3", + "code": "Gvw53xmSBFZYBy0xdawm0qSX0cqhHs", + "code_verifier": "aEyhTs4BfWjZ7g5HT0o7Hu24p6Qw6TxotdX8_G20NN9J1lXIfSnNr3b6jhOUZe5ZWkP5ADCEzlWABUHSPXslgQ", + "grant_type": "authorization_code" + } + + # Permission checks The `has_access` API can be used to run permission checks. It is available on a neighborhood, project and tool level. -It is only available to users that have 'admin' permission for corresponding neighborhood/project/tool. +It is only available to users that have 'admin' permission for corresponding neighborhood/project/tool. It requires `user` and `perm` parameters and will return JSON dict with `result` key, which contains boolean value, indicating if given `user` has `perm` permission to the neighborhood/project/tool. diff --git a/Allura/docs/api-rest/securitySchemes.yaml b/Allura/docs/api-rest/securitySchemes.yaml index 194b5f71a..f52b052ad 100755 --- a/Allura/docs/api-rest/securitySchemes.yaml +++ b/Allura/docs/api-rest/securitySchemes.yaml @@ -37,3 +37,40 @@ authorizationUri: https://forge-allura.apache.org/rest/oauth/authorize tokenCredentialsUri: https://forge-allura.apache.org/rest/oauth/access_token +- oauth_2_0: + 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 + query string parameters: + - response_type=code + - client_id=YOUR_CLIENT_ID + - redirect_uri=YOUR_REDIRECT_URI + + For PKCE support send these additional parameters + - code_challenge=YOUR_CODE_CHALLENGE + - code_challenge_method=S256 + + An authorization code will be generated which can be exchanged for an access token at https://forge-allura.apache.org/rest/oauth2/token + with the following parameters: + - grant_type=authorization_code + - code=YOUR_AUTHORIZATION_CODE + - client_id=YOUR_CLIENT_ID + - client_secret=YOUR_CLIENT_SECRET + - redirect_uri=YOUR_REDIRECT_URI + + For PKCE support send these additional parameters + - code_verifier=YOUR_CODE_VERIFIER + + Use the access token in an HTTP header like: + + `Authorization: Bearer MY_BEARER_TOKEN`` + type: OAuth 2.0 + settings: + authorizationUri: https://forge-allura.apache.org/rest/oauth2/authorize + accessTokenUri: https://forge-allura.apache.org/rest/oauth2/token + authorizationGrants: + - authorization_code + - refresh_token + scopes: + - diff --git a/scripts/wiki-copy.py b/scripts/wiki-copy.py index c023684b0..ee04011c6 100644 --- a/scripts/wiki-copy.py +++ b/scripts/wiki-copy.py @@ -19,12 +19,13 @@ import os import sys -from optparse import OptionParser +from optparse import OptionParser, OptionValueError from configparser import ConfigParser, NoOptionError +from datetime import datetime, timedelta import webbrowser import requests -from requests_oauthlib import OAuth1Session +from requests_oauthlib import OAuth1Session, OAuth2Session def main(): @@ -36,10 +37,13 @@ def main(): help='URL of wiki API to copy to like http://toserver.com/rest/p/test/wiki/') op.add_option('-D', '--debug', action='store_true', dest='debug', default=False) + op.add_option('-O', '--oauth', type='int', dest='oauth_version', default=1, + help='OAuth version to use for authentication. Defaults to OAuth v1.', + action='callback', callback=validate_oauth_version) (options, args) = op.parse_args(sys.argv[1:]) base_url = options.to_wiki.split('/rest/')[0] - oauth_client = make_oauth_client(base_url) + oauth_client = make_oauth2_client(base_url) if options.oauth_version == 2 else make_oauth_client(base_url) wiki_json = requests.get(options.from_wiki, timeout=30).json()['pages'] for p in wiki_json: @@ -107,6 +111,77 @@ def make_oauth_client(base_url) -> requests.Session: return oauthSess +def make_oauth2_client(base_url) -> requests.Session: + """ + Build an oauth2 client with which callers can query Allura. + """ + config_file = os.path.join(os.environ['HOME'], '.allurarc') + cp = ConfigParser() + cp.read(config_file) + + AUTHORIZE_URL = base_url + '/rest/oauth2/authorize' + ACCESS_TOKEN_URL = base_url + '/rest/oauth2/token' + + client_id = option(cp, base_url, 'oauth2_client_id', + 'Forge API OAuth2 Client App ID (%s/auth/oauth/): ' % base_url) + client_secret = option(cp, base_url, 'oauth2_client_secret', + 'Forge API Oauth2 Client App Secret: ') + + def token_saver(token): + token_expires = datetime.utcnow() + timedelta(seconds=token.get('expires_in')) + cp.set(base_url, 'oauth2_expires_in', str(int(token_expires.timestamp()))) + cp.set(base_url, 'oauth2_access_token', token['access_token']) + cp.set(base_url, 'oauth2_refresh_token', token.get('refresh_token', '')) + cp.write(open(config_file, 'w')) + print(f'Saving refreshed OAuth2 access token in {config_file} for later re-use') + + try: + access_token = cp.get(base_url, 'oauth2_access_token') + refresh_token = cp.get(base_url, 'oauth2_refresh_token') + expires_in = cp.get(base_url, 'oauth2_expires_in') + except NoOptionError: + oauth2_session = OAuth2Session(client_id) + authorization_url, state = oauth2_session.authorization_url(AUTHORIZE_URL) + if isinstance(webbrowser.get(), webbrowser.GenericBrowser): + print('Go to %s' % authorization_url) + else: + webbrowser.open(authorization_url) + + authorization_code = input("What is the authorization code? (You can copy it from the 'code' parameter in the URL you were redirected to) ") + response = oauth2_session.fetch_token( + ACCESS_TOKEN_URL, + code=authorization_code, + client_secret=client_secret, + include_client_id=True) + + # We get the expiration date from oauthlib in seconds so we need to determine the actual date + # and save its Unix timestamp representation + token_expires = datetime.utcnow() + timedelta(seconds=response.get('expires_in')) + cp.set(base_url, 'oauth2_expires_in', str(int(token_expires.timestamp()))) + cp.set(base_url, 'oauth2_access_token', response.get('access_token')) + cp.set(base_url, 'oauth2_refresh_token', response.get('refresh_token')) + + # save access token for later use + cp.write(open(config_file, 'w')) + print(f'Saving OAuth2 access token in {config_file} for later re-use') + print() + else: + print(f'Saved access token: {access_token}') + + # requests-oauthlib expects the expiration time as seconds so we use the saved timestamp to calculate + # the differece. If that difference is a negative number it means the token is already expired and a new one + # will be automatically generated. + date_diff = datetime.utcfromtimestamp(int(expires_in)) - datetime.utcnow() + token_expires = int(date_diff.total_seconds()) + oauth2_session = OAuth2Session(client_id=client_id, + token=dict(access_token=access_token, token_type='Bearer', refresh_token=refresh_token, expires_in=token_expires), # noqa: S106 + auto_refresh_url=ACCESS_TOKEN_URL, + auto_refresh_kwargs=dict(client_id=client_id, client_secret=client_secret), + token_updater=token_saver) + + return oauth2_session + + def option(cp, section, key, prompt=None): if not cp.has_section(section): cp.add_section(section) @@ -118,5 +193,12 @@ def option(cp, section, key, prompt=None): return value +def validate_oauth_version(option, opt_str, value, parser): + if value not in (1, 2): + raise OptionValueError(f'Option {opt_str} requires a value of 1 or 2') + + setattr(parser.values, option.dest, value) + + if __name__ == '__main__': main()