[#7663] Initial work to support OAuth2 authorization
diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py
index ef4ae4c..855a383 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -18,9 +18,11 @@
"""REST Controller"""
import json
import logging
+from datetime import datetime, timedelta
from urllib.parse import unquote, urlparse, parse_qs
import oauthlib.oauth1
+import oauthlib.oauth2
import oauthlib.common
from paste.util.converters import asbool
from webob import exc
@@ -41,7 +43,7 @@
from allura.lib.project_create_helpers import make_newproject_schema, deserialize_project, create_project_with_attrs
from allura.lib.security import has_access
import six
-from datetime import datetime
+
log = logging.getLogger(__name__)
@@ -50,6 +52,7 @@
def __init__(self):
self.oauth = OAuthNegotiator()
+ self.oauth2 = Oauth2Negotiator()
self.auth = AuthRestController()
def _check_security(self):
@@ -234,6 +237,70 @@
return 'dummy-access-token-for-oauthlib'
+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
+
+ def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
+ return True
+
+ 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
+ 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
+
+ def get_default_scopes(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs):
+ return []
+
+ def get_default_redirect_uri(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs) -> str:
+ return request.uri
+
+ def invalidate_authorization_code(self, client_id: str, code: str, request: oauthlib.common.Request, *args, **kwargs) -> None:
+ return
+
+ 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
+
+ def validate_code(self, client_id: str, code: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
+ return True
+
+ 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:
+ auth_code = M.OAuth2AuthorizationCode(
+ client_id = client_id,
+ authorization_code = code['code'],
+ expires_at = datetime.utcnow() + timedelta(minutes=10)
+ )
+ request.client_id = client_id
+ session(auth_code).flush()
+ log.info(f'Saving new authorization code for client: {request.client_id}')
+
+ def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kwargs) -> object:
+ bearer_token = M.OAuth2Token(
+ 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'))
+ )
+
+ session(bearer_token).flush()
+ log.info(f'Saving new bearer token for client: {request.client_id}')
+
+
class AlluraOauth1Server(oauthlib.oauth1.WebApplicationServer):
def validate_request_token_request(self, request):
# this is NOT standard OAuth1 (spec requires the param)
@@ -370,6 +437,42 @@
return body
+class Oauth2Negotiator:
+ @property
+ def server(self):
+ return oauthlib.oauth2.WebApplicationServer(Oauth2Validator())
+
+ @expose('json:')
+ 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)
+
+ try:
+ scopes, credentials = self.server.validate_authorization_request(uri=request.url, http_method=request.method, headers=request.headers, body=json_body)
+ headers, body, status = self.server.create_authorization_response(
+ uri=request.url, http_method=request.method, body=json_body, headers=request.headers, scopes=[], credentials=credentials
+ )
+ except Exception as e:
+ log.exception(e)
+
+ @expose('json:')
+ @require_post()
+ def token(self, **kwargs):
+ try:
+ decoded_body = str(request.body, 'utf-8')
+ json_body = json.loads(decoded_body)
+ headers, body, status = self.server.create_token_response(uri=request.url, http_method=request.method, body=json_body, headers=request.headers)
+ return body
+ except Exception as e:
+ log.exception(e)
+
+
def rest_has_access(obj, user, perm):
"""
Helper function that encapsulates common functionality for has_access API
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index f015776..dd7511f 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -31,7 +31,7 @@
from .repository import Repository, RepositoryImplementation, CommitStatus
from .repository import MergeRequest, GitLikeTree
from .stats import Stats
-from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken
+from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken, OAuth2Client, OAuth2AuthorizationCode, OAuth2Token
from .monq_model import MonQTask
from .webhook import Webhook
from .multifactor import TotpKey
@@ -56,7 +56,8 @@
'DiscussionAttachment', 'BaseAttachment', 'AuthGlobals', 'User', 'ProjectRole', 'EmailAddress',
'AuditLog', 'AlluraUserProperty', 'File', 'Notification', 'Mailbox', 'Repository',
'RepositoryImplementation', 'CommitStatus', 'MergeRequest', 'GitLikeTree', 'Stats', 'OAuthToken', 'OAuthConsumerToken',
- 'OAuthRequestToken', 'OAuthAccessToken', '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', 'main_explicitflush_orm_session']
+ 'OAuthRequestToken', 'OAuthAccessToken', 'OAuth2Client', 'OAuth2AuthorizationCode', 'OAuth2Token', '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',
+ 'main_explicitflush_orm_session']
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index b7d539a..170718e 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -154,6 +154,56 @@
return False
+class OAuth2Client(MappedClass):
+ class __mongometa__:
+ session = main_orm_session
+ name = 'oauth2_client'
+
+ query: 'Query[OAuth2Client]'
+
+ _id = FieldProperty(S.ObjectId)
+ client_id = FieldProperty(str)
+ user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+ grant_type = FieldProperty(str)
+ response_type = FieldProperty(str)
+ scopes = FieldProperty([str])
+ redirect_uris = FieldProperty([str])
+
+class OAuth2AuthorizationCode(MappedClass):
+ class __mongometa__:
+ session = main_orm_session
+ name = 'oauth2_authorization_code'
+
+ query: 'Query[OAuth2AuthorizationCode]'
+
+ _id = FieldProperty(S.ObjectId)
+ client_id = FieldProperty(str)
+ user_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)
+
+
+class OAuth2Token(MappedClass):
+ class __mongometa__:
+ session = main_orm_session
+ name = 'oauth2_token'
+
+ query: 'Query[OAuth2Token]'
+
+ _id = FieldProperty(S.ObjectId)
+ client_id = FieldProperty(str)
+ user_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)
+
+
def dummy_oauths():
from allura.controllers.rest import Oauth1Validator
# oauthlib implementation NEEDS these "dummy" values. If a request comes in with an invalid param, it runs