Skip to content

Commit

Permalink
[#7272] Update docs and wiki-copy script for OAuth2
Browse files Browse the repository at this point in the history
  • Loading branch information
Carlos Cruz authored and brondsem committed May 17, 2024
1 parent 85ab892 commit 6dcad14
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 38 deletions.
2 changes: 1 addition & 1 deletion Allura/allura/controllers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('.')
Expand Down
22 changes: 16 additions & 6 deletions Allura/allura/controllers/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -331,19 +338,23 @@ 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,
scopes=token.get('scope', []),
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()
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion Allura/docs/api-rest/api.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
173 changes: 146 additions & 27 deletions Allura/docs/api-rest/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -60,9 +60,9 @@ Python code example to create a new ticket:

import requests
from pprint import pprint

BEARER_TOKEN = '<bearer token from oauth page>'

r = requests.post('https://forge-allura.apache.org/rest/p/test-project/tickets/new', params={
'access_token': BEARER_TOKEN,
'ticket_form.summary': 'Test ticket',
Expand Down Expand Up @@ -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=<your callback URL>.

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 key from app registration>'
CONSUMER_SECRET='<consumer secret from app registration>'

ACCESS_KEY='<access key from previous script>'
ACCESS_SECRET='<access secret from previous script>'

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.


Expand Down
37 changes: 37 additions & 0 deletions Allura/docs/api-rest/securitySchemes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
-
Loading

0 comments on commit 6dcad14

Please sign in to comment.