From 2dfb832a8f1e54fc649ea047e2b49e1b98faf2c7 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 1/7] Introduce OIDC token for API. This commit hide the pagure api token settings if OIDC token are used. It also introduce flask-oidc method to validate a token to the api code. --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 73dc342..7708eb6 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -162,7 +162,7 @@ def check_api_acls(acls, optional=False): return jsonout -def api_login_required(acls=None): +def pagure_api_token_required(acls=None): ''' Decorator used to indicate that authentication is required for some API endpoint. ''' @@ -184,6 +184,31 @@ def api_login_required(acls=None): return decorator +def api_login_required(acls=None): + '''Decorator used to indicate that authentication is required for some + API endpoint. + This decorator returns a different decorator in function of the token + mechanism configured OIDC or pagure's token. + ''' + + def decorator(*args, **kwargs): + ''' The decorator of the function + Returns flask_oidc accept_token decorator if we use OIDC as token + provider, else use default pagure's token. + ''' + if pagure_config.get('OIDC_API_TOKEN', False): + from pagure.ui.oidc_login import oidc + scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl + for acl in acls] + func = oidc.accept_token(require_token=True, scopes_required=scopes) + return func(*args, **kwargs) + else: + func = pagure_api_token_required(acls) + return func(*args, **kwargs) + + return decorator + + def api_login_optional(acls=None): ''' Decorator used to indicate that authentication is optional for some API endpoint. diff --git a/pagure/default_config.py b/pagure/default_config.py index 43a12fe..ae2f524 100644 --- a/pagure/default_config.py +++ b/pagure/default_config.py @@ -212,6 +212,12 @@ PAGURE_AUTH = 'fas' # (IdP-specific user id, can be a nickname, email or a numeric ID # depending on IdP). # OIDC_PAGURE_USERNAME_FALLBACK = 'email' +# When this is set to True, pagure is using OIDC token to authenticate and +# authorize the API usage. When set to False it is using pagure's generated +# token +# OIDC_API_TOKEN = False +# Base namespace used for the OIDC API token scopes. +# OIDC_SCOPES_BASENAME = 'https://pagure.io/oidc/' # When this is set to True, the session cookie will only be returned to the # server via ssl (https). If you connect to the server via plain http, the diff --git a/pagure/templates/settings.html b/pagure/templates/settings.html index e1c8c44..9521ad4 100644 --- a/pagure/templates/settings.html +++ b/pagure/templates/settings.html @@ -119,6 +119,7 @@ {% endif %} + {% if not config.get('OIDC_API_TOKEN', False) %}
@@ -224,6 +225,7 @@
+ {% endif %}
diff --git a/pagure/templates/user_settings.html b/pagure/templates/user_settings.html index bde2cf9..09f81ec 100644 --- a/pagure/templates/user_settings.html +++ b/pagure/templates/user_settings.html @@ -160,6 +160,7 @@ {% endif %}
+ {% if not config.get('OIDC_API_TOKEN', False) %}
@@ -250,7 +251,7 @@
- + {% endif %}
{% endblock %} From 3387fae1754ef2a01eae355acd357e9bbcba41c0 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 2/7] Support specific project scopes This commit refactor the decorator used to check the apis token so that we can get the repo name, namespace and username from the api call. Signed-off-by: Clement Verna --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 7708eb6..a840eae 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -162,7 +162,7 @@ def check_api_acls(acls, optional=False): return jsonout -def pagure_api_token_required(acls=None): +def api_login_required(acls=None): ''' Decorator used to indicate that authentication is required for some API endpoint. ''' @@ -174,9 +174,40 @@ def pagure_api_token_required(acls=None): def decorated_function(*args, **kwargs): ''' Actually does the job with the arguments provided. ''' - response = check_api_acls(acls) - if response: - return response + if pagure_config.get('OIDC_API_TOKEN', False): + from pagure.ui.oidc_login import oidc + # Convert default acls to OIDC scopes + # ie acl: create_issue becomes + # https://pagure.io/oidc/create_issue + scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl + for acl in acls] + + # Build the name of the scope in function of repo, username + # or namespace specific api call. + # https://pagure.io/oidc/create_issue(username/namespace/repo) + if kwargs.get('repo'): + repo = kwargs.get('repo') + if kwargs.get('namespace'): + repo = '{}/{}'.format(kwargs.get('namespace'), repo) + if kwargs.get('username'): + repo = '{}/{}'.format(kwargs.get('username'), repo) + copy_scopes = list(scopes) + for scope in copy_scopes: + scopes.append(scope + '({})'.format(repo)) + + # Returns the oidc.accept_token decorator + decorator_function = oidc.accept_token(require_token=True, + scopes_required=scopes) + + # Decorates the original function with oidc decorator + func = decorator_function(function) + + # Returns the decorated original function with original args. + return func(*args, **kwargs) + else: + response = check_api_acls(acls) + if response: + return response return function(*args, **kwargs) return decorated_function @@ -184,31 +215,6 @@ def pagure_api_token_required(acls=None): return decorator -def api_login_required(acls=None): - '''Decorator used to indicate that authentication is required for some - API endpoint. - This decorator returns a different decorator in function of the token - mechanism configured OIDC or pagure's token. - ''' - - def decorator(*args, **kwargs): - ''' The decorator of the function - Returns flask_oidc accept_token decorator if we use OIDC as token - provider, else use default pagure's token. - ''' - if pagure_config.get('OIDC_API_TOKEN', False): - from pagure.ui.oidc_login import oidc - scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl - for acl in acls] - func = oidc.accept_token(require_token=True, scopes_required=scopes) - return func(*args, **kwargs) - else: - func = pagure_api_token_required(acls) - return func(*args, **kwargs) - - return decorator - - def api_login_optional(acls=None): ''' Decorator used to indicate that authentication is optional for some API endpoint. From 0f7cb6a49b3d722825ef347069b2365366c832e2 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 3/7] Rework how we validate the token. We need to support optional token for issues, groups and projects. We also need to setup the username and other info for the api call to work properly. Signed-off-by: Clement Verna --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index a840eae..82c2aa9 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -162,7 +162,45 @@ def check_api_acls(acls, optional=False): return jsonout -def api_login_required(acls=None): +def check_oidc_token(scopes, optional=False): + ''' Check if the provided oidc token has the required scopes + and allow the user to access the desired enpoint. + ''' + from pagure.ui.oidc_login import oidc + valid = False + if 'Authorization' in flask.request.headers: + token = flask.request.headers.get("Authorization").strip() + if token.startswith('token '): + token = token[len('token '):].strip() + valid = oidc.validate_token(token=token, scopes_required=scopes) + + if valid is True: + try: + flask.g.fas_user = pagure.lib.get_user( + flask.g.session, flask.g.oidc_token_info['sub']) + flask.g.authenticated = True + flask.g.fas_user.cla_done = True + except pagure.exceptions.PagureException: + output = { + 'error_code': APIERROR.EDBERROR.name, + 'error': APIERROR.EDBERROR.value} + jsonout = flask.jsonify(output) + jsonout.status_code = 404 + return jsonout + + elif optional is True: + return + + else: + output = { + 'error_code': APIERROR.EINVALIDTOK.name, + 'error': APIERROR.EINVALIDTOK.value} + jsonout = flask.jsonify(output) + jsonout.status_code = 401 + return jsonout + + +def api_login_required(acls=None, optional=False): ''' Decorator used to indicate that authentication is required for some API endpoint. ''' @@ -175,35 +213,31 @@ def api_login_required(acls=None): ''' Actually does the job with the arguments provided. ''' if pagure_config.get('OIDC_API_TOKEN', False): - from pagure.ui.oidc_login import oidc # Convert default acls to OIDC scopes # ie acl: create_issue becomes # https://pagure.io/oidc/create_issue - scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl - for acl in acls] - - # Build the name of the scope in function of repo, username - # or namespace specific api call. - # https://pagure.io/oidc/create_issue(username/namespace/repo) - if kwargs.get('repo'): - repo = kwargs.get('repo') + scopes = [] + if acls is not None: + scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl + for acl in acls] + + # Build the name of the scope in function of repo, username + # or namespace specific api call. + # https://pagure.io/oidc/create_issue(username/namespace/repo) + if kwargs.get('repo'): + repo = kwargs.get('repo') if kwargs.get('namespace'): repo = '{}/{}'.format(kwargs.get('namespace'), repo) if kwargs.get('username'): repo = '{}/{}'.format(kwargs.get('username'), repo) + copy_scopes = list(scopes) for scope in copy_scopes: scopes.append(scope + '({})'.format(repo)) - # Returns the oidc.accept_token decorator - decorator_function = oidc.accept_token(require_token=True, - scopes_required=scopes) - - # Decorates the original function with oidc decorator - func = decorator_function(function) - - # Returns the decorated original function with original args. - return func(*args, **kwargs) + response = check_oidc_token(scopes) + if response is not None: + return response else: response = check_api_acls(acls) if response: @@ -226,10 +260,15 @@ def api_login_optional(acls=None): @functools.wraps(function) def decorated_function(*args, **kwargs): ''' Actually does the job with the arguments provided. ''' - - response = check_api_acls(acls, optional=True) - if response: - return response + if pagure_config.get('OIDC_API_TOKEN', False): + scopes = ['openid'] + response = check_oidc_token(scopes, optional=True) + if response is not None: + return response + else: + response = check_api_acls(acls, optional=True) + if response: + return response return function(*args, **kwargs) return decorated_function From 9107c738cc6733047529437d7ebac5cb436835ff Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 4/7] We need a specific OIDC token error message Signed-off-by: Clement Verna --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 82c2aa9..5d6c825 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -70,6 +70,7 @@ class APIERROR(enum.Enum): EINVALIDTOK = 'Invalid or expired token. Please visit %s to get or '\ 'renew your API token.'\ % urljoin(pagure_config['APP_URL'], 'settings#api-keys') + EINVALIDOIDCTOK = 'Invalid or expired OIDC token' ENOISSUE = 'Issue not found' EISSUENOTALLOWED = 'You are not allowed to view this issue' EPULLREQUESTSDISABLED = 'Pull-Request have been deactivated for this '\ @@ -193,8 +194,8 @@ def check_oidc_token(scopes, optional=False): else: output = { - 'error_code': APIERROR.EINVALIDTOK.name, - 'error': APIERROR.EINVALIDTOK.value} + 'error_code': APIERROR.EINVALIDOIDCTOK.name, + 'error': APIERROR.EINVALIDOIDCTOK.value} jsonout = flask.jsonify(output) jsonout.status_code = 401 return jsonout From 33e4040f222b7e476f952a5d35de2b590ddce0f4 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 5/7] Update the documentation Signed-off-by: Clement Verna --- diff --git a/doc/configuration.rst b/doc/configuration.rst index 7847ec2..9eed5ca 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -751,6 +751,21 @@ is empty - can be ``email`` (to use the part before ``@``) or ``sub`` (IdP-specific user id, can be a nickname, email or a numeric ID depending on identity provider). +OIDC_API_TOKEN +^^^^^^^^^^^^^^ + +When set to ``True``, pagure REST APIs are using OIDC tokens to validate ACLs. +it requires ``PAGURE_AUTH`` to be set to ``OIDC`` to work. +Default to ``False``. + +OIDC_SCOPES_BASENAME +^^^^^^^^^^^^^^^^^^^^ + +Configurable part of the OIDC scopes. For example ``https://pagure.io/oidc`` is the +base name of ``https://pagure.io/oidc/create_issue`` scope. +By changing the value of this field you can register different instance of pagure to +the same identity provider. + IP_ALLOWED_INTERNAL ~~~~~~~~~~~~~~~~~~~ diff --git a/pagure/doc/api.rst b/pagure/doc/api.rst index f4fe072..8e5c83d 100644 --- a/pagure/doc/api.rst +++ b/pagure/doc/api.rst @@ -4,9 +4,12 @@ Authentication To access some endpoints, you need to login to Pagure using API token. You can generate one in the project setting page. +In the case where you are using OIDC tokens. You need to obtain a token form +your identity provider. + When sending HTTP request, include an ``Authorization`` field in the header with value ``token $your-api-token``, where ``$your-api-token`` is the -API token generated in the project setting page. +API token generated in the project setting page or an OIDC token. So the result should look like: @@ -14,7 +17,7 @@ So the result should look like: Authorization: token abcdefghijklmnop -Where ``abcdefghijklmnop`` is the API token provided by pagure. +Where ``abcdefghijklmnop`` is the API token. Anyone with the token can access the APIs on your behalf, so please be sure to keep it private and safe. From 6699b67a8cd57627bdad3ad3119504a9fce462e1 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 09:12:16 +0000 Subject: [PATCH 6/7] Move the scope creation to its own function Signed-off-by: Clement Verna --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 5d6c825..edafa7c 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -201,6 +201,35 @@ def check_oidc_token(scopes, optional=False): return jsonout +def create_oidc_scopes(acls, kwargs): + ''' Create the oidc scopes from the acls ''' + # Convert default acls to OIDC scopes + # ie acl: create_issue becomes + # https://pagure.io/oidc/create_issue + scopes = [] + if acls is not None: + scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl + for acl in acls] + + # Build the name of the scope in function of repo, username + # or namespace specific api call. + # https://pagure.io/oidc/create_issue(username/namespace/repo) + repo = None + if kwargs.get('repo'): + repo = kwargs.get('repo') + if kwargs.get('namespace'): + repo = '{}/{}'.format(kwargs.get('namespace'), repo) + if kwargs.get('username'): + repo = '{}/{}'.format(kwargs.get('username'), repo) + + if repo is not None: + copy_scopes = list(scopes) + for scope in copy_scopes: + scopes.append(scope + '({})'.format(repo)) + + return scopes + + def api_login_required(acls=None, optional=False): ''' Decorator used to indicate that authentication is required for some API endpoint. @@ -214,29 +243,10 @@ def api_login_required(acls=None, optional=False): ''' Actually does the job with the arguments provided. ''' if pagure_config.get('OIDC_API_TOKEN', False): - # Convert default acls to OIDC scopes - # ie acl: create_issue becomes - # https://pagure.io/oidc/create_issue - scopes = [] - if acls is not None: - scopes = [pagure_config.get('OIDC_SCOPES_BASENAME') + acl - for acl in acls] - - # Build the name of the scope in function of repo, username - # or namespace specific api call. - # https://pagure.io/oidc/create_issue(username/namespace/repo) - if kwargs.get('repo'): - repo = kwargs.get('repo') - if kwargs.get('namespace'): - repo = '{}/{}'.format(kwargs.get('namespace'), repo) - if kwargs.get('username'): - repo = '{}/{}'.format(kwargs.get('username'), repo) - - copy_scopes = list(scopes) - for scope in copy_scopes: - scopes.append(scope + '({})'.format(repo)) + scopes = create_oidc_scopes(acls, kwargs) response = check_oidc_token(scopes) + if response is not None: return response else: From 4758165129c42722b8981e73d424d30189ca8700 Mon Sep 17 00:00:00 2001 From: Clement Verna Date: Mar 30 2018 11:10:14 +0000 Subject: [PATCH 7/7] Allow use of pagure token and OIDC token in the same time Signed-off-by: Clement Verna --- diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index edafa7c..9c45012 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -171,8 +171,8 @@ def check_oidc_token(scopes, optional=False): valid = False if 'Authorization' in flask.request.headers: token = flask.request.headers.get("Authorization").strip() - if token.startswith('token '): - token = token[len('token '):].strip() + if token.startswith('Bearer '): + token = token[len('Bearer '):].strip() valid = oidc.validate_token(token=token, scopes_required=scopes) if valid is True: @@ -242,17 +242,20 @@ def api_login_required(acls=None, optional=False): def decorated_function(*args, **kwargs): ''' Actually does the job with the arguments provided. ''' - if pagure_config.get('OIDC_API_TOKEN', False): + # First verify pagure's API token, if the token is not valid + # then check if this is an OIDC token. + response = check_api_acls(acls) + if response is not None: + if pagure_config.get('OIDC_API_TOKEN', False): - scopes = create_oidc_scopes(acls, kwargs) - response = check_oidc_token(scopes) + scopes = create_oidc_scopes(acls, kwargs) + oidc_response = check_oidc_token(scopes) - if response is not None: - return response - else: - response = check_api_acls(acls) - if response: + if oidc_response is not None: + return oidc_response + else: return response + return function(*args, **kwargs) return decorated_function @@ -271,15 +274,19 @@ def api_login_optional(acls=None): @functools.wraps(function) def decorated_function(*args, **kwargs): ''' Actually does the job with the arguments provided. ''' - if pagure_config.get('OIDC_API_TOKEN', False): - scopes = ['openid'] - response = check_oidc_token(scopes, optional=True) - if response is not None: - return response - else: - response = check_api_acls(acls, optional=True) - if response: + + # First verify pagure's API token, if the token is not valid + # then check if this is an OIDC token. + response = check_api_acls(acls, optional=True) + if response is not None: + if pagure_config.get('OIDC_API_TOKEN', False): + scopes = ['openid'] + oidc_response = check_oidc_token(scopes, optional=True) + if oidc_response is not None: + return oidc_response + else: return response + return function(*args, **kwargs) return decorated_function