diff --git a/docs/index.rst b/docs/index.rst index fd1178f..d68f9f8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,12 +1,102 @@ .. _swh-web-client: .. include:: README.rst +Authentication +-------------- + +If you have a user account registered on `Software Heritage Identity Provider`_, +it is possible to authenticate requests made to the Web APIs through the use of +OpenID Connect bearer tokens. Sending authenticated requests can notably +allow to lift API rate limiting depending on your permissions. + +To get these tokens, a dedicated CLI tool is made available when installing +``swh-web-client``: + +.. code-block:: text + + $ swh auth + Usage: swh auth [OPTIONS] COMMAND [ARGS]... + + Authenticate Software Heritage users with OpenID Connect. + + This CLI tool eases the retrieval of bearer tokens to authenticate a user + querying the Software Heritage Web API. + + Options: + --oidc-server-url TEXT URL of OpenID Connect server (default to + "https://auth.softwareheritage.org/auth/") + --realm-name TEXT Name of the OpenID Connect authentication realm + (default to "SoftwareHeritage") + --client-id TEXT OpenID Connect client identifier in the realm + (default to "swh-web") + -h, --help Show this message and exit. + + Commands: + login Login and create new offline OpenID Connect session. + logout Logout from an offline OpenID Connect session. + refresh Refresh an offline OpenID Connect session. + +In order to get your tokens, you need to use the ``login`` subcommand of +that CLI tool by passing your username as argument. You will be prompted +for your password and if the authentication succeeds a new OpenID Connect +session will be created and tokens will be dumped in JSON format to standard +output. + +.. code-block:: text + + $ swh auth login + Password: + { + "access_token": ".......", + "expires_in": 600, + "refresh_expires_in": 0, + "refresh_token": ".......", + "token_type": "bearer", + "id_token": ".......", + "not-before-policy": 1584551170, + "session_state": "c14e1b7b-8263-4852-bd1c-adc7bc12a136", + "scope": "openid email profile offline_access" + } + +To authenticate yourself, you need to send the ``access_token`` value in +request headers when querying the Web APIs. +Considering you have stored the ``access_token`` value in a TOKEN environment +variable, you can perform an authenticated call the following way using ``curl``: + +.. code-block:: text + + $ curl -H "Authorization: Bearer ${TOKEN}" http://localhost:5004/api/1/ + +The access token has a short living period (usually ten minutes) and must be +renewed on a regular basis by passing the ``refresh_token`` value as argument +of the ``refresh`` subcommand of the CLI tool. The new access token will be +dumped in JSON format to standard output. Note that the refresh token has a +much longer living period (usually several dozens of days) so you can use +it anytime while it is valid to get an access token without having to login +again. + +.. code-block:: text + + $ swh auth refresh $REFRESH_TOKEN + "......." + +It is also possible to ``logout`` from the authenticated OpenID Connect session +which invalidates all previously emitted tokens. + + +.. code-block:: text + + $ swh auth logout $REFRESH_TOKEN + Successfully logged out from OpenID Connect session API Reference ------------- .. toctree:: :maxdepth: 2 /apidoc/swh.web.client + +.. _Software Heritage Identity Provider: + https://auth.softwareheritage.org/auth/realms/SoftwareHeritage/account/ \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index 5821059..2407c40 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest +pytest-mock requests_mock diff --git a/requirements.txt b/requirements.txt index 1179b5d..b9883da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Add here external Python modules dependencies, one per line. Module names # should match https://pypi.python.org/pypi names. For the full spec or # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html +click python-dateutil requests vcversioner diff --git a/setup.py b/setup.py index b3bb4d3..98442e3 100755 --- a/setup.py +++ b/setup.py @@ -1,68 +1,72 @@ #!/usr/bin/env python3 # Copyright (C) 2019 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from setuptools import setup, find_packages from os import path from io import open here = path.abspath(path.dirname(__file__)) # Get the long description from the README file with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read() def parse_requirements(name=None): if name: reqf = 'requirements-%s.txt' % name else: reqf = 'requirements.txt' requirements = [] if not path.exists(reqf): return requirements with open(reqf) as f: for line in f.readlines(): line = line.strip() if not line or line.startswith('#'): continue requirements.append(line) return requirements # Edit this part to match your module. # Full sample: # https://forge.softwareheritage.org/diffusion/DCORE/browse/master/setup.py setup( name='swh.web.client', # example: swh.loader.pypi description='Software Heritage Web client', long_description=long_description, long_description_content_type='text/x-rst', author='Software Heritage developers', author_email='swh-devel@inria.fr', url='https://forge.softwareheritage.org/source/swh-web-client/', packages=find_packages(), # packages's modules install_requires=parse_requirements() + parse_requirements('swh'), tests_require=parse_requirements('test'), setup_requires=['vcversioner'], extras_require={'testing': parse_requirements('test')}, vcversioner={}, include_package_data=True, classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Development Status :: 3 - Alpha", ], project_urls={ 'Bug Reports': 'https://forge.softwareheritage.org/maniphest', 'Funding': 'https://www.softwareheritage.org/donate', 'Source': 'https://forge.softwareheritage.org/source/swh-web-client', }, + entry_points=''' + [swh.cli.subcommands] + auth=swh.web.client.cli:auth + ''', ) diff --git a/swh/web/client/auth.py b/swh/web/client/auth.py new file mode 100644 index 0000000..c340178 --- /dev/null +++ b/swh/web/client/auth.py @@ -0,0 +1,95 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Any, Dict +from urllib.parse import urljoin + +import requests + +SWH_OIDC_SERVER_URL = 'https://auth.softwareheritage.org/auth/' +SWH_REALM_NAME = 'SoftwareHeritage' +SWH_WEB_CLIENT_ID = 'swh-web' + + +class OpenIDConnectSession: + """ + Simple class wrapping requests sent to an OpenID Connect server. + + Args: + oidc_server_url: URL of OpenID Connect server + realm_name: name of the OpenID Connect authentication realm + client_id: OpenID Connect client identifier in the realm + """ + + def __init__(self, oidc_server_url: str = SWH_OIDC_SERVER_URL, + realm_name: str = SWH_REALM_NAME, + client_id: str = SWH_WEB_CLIENT_ID): + realm_url = urljoin(oidc_server_url, f'realms/{realm_name}/') + self.client_id = client_id + self.token_url = urljoin(realm_url, + 'protocol/openid-connect/token/') + self.logout_url = urljoin(realm_url, + 'protocol/openid-connect/logout/') + + def login(self, username: str, password: str) -> Dict[str, Any]: + """ + Login and create new offline OpenID Connect session. + + Args: + username: an existing username in the realm + password: password associated to username + + Returns: + a dict filled with OpenID Connect profile info, notably access + and refresh tokens for API authentication. + """ + return requests.post( + url=self.token_url, + data={ + 'grant_type': 'password', + 'client_id': self.client_id, + 'scope': 'openid offline_access', + 'username': username, + 'password': password, + }, + ).json() + + def refresh(self, refresh_token: str) -> Dict[str, Any]: + """ + Refresh an offline OpenID Connect session to get new access token. + + Args: + refresh_token: a refresh token retrieved after login + + Returns: + a dict filled with OpenID Connect profile info, notably access + and refresh tokens for API authentication. + """ + return requests.post( + url=self.token_url, + data={ + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'scope': 'openid', + 'refresh_token': refresh_token, + }, + ).json() + + def logout(self, refresh_token: str): + """ + Logout from an offline OpenID Connect session and invalidate + previously emitted tokens. + + Args: + refresh_token: a refresh token retrieved after login + """ + requests.post( + url=self.logout_url, + data={ + 'client_id': self.client_id, + 'scope': 'openid', + 'refresh_token': refresh_token, + }, + ) diff --git a/swh/web/client/cli.py b/swh/web/client/cli.py new file mode 100644 index 0000000..96f1b42 --- /dev/null +++ b/swh/web/client/cli.py @@ -0,0 +1,105 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from getpass import getpass +import json + +import click +from click.core import Context + +from swh.web.client.auth import OpenIDConnectSession + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +def _output_json(obj): + print(json.dumps(obj, indent=4, sort_keys=True)) + + +@click.group(name='auth', context_settings=CONTEXT_SETTINGS) +@click.option('--oidc-server-url', 'oidc_server_url', + default='https://auth.softwareheritage.org/auth/', + help=('URL of OpenID Connect server (default to ' + '"https://auth.softwareheritage.org/auth/")')) +@click.option('--realm-name', 'realm_name', + default='SoftwareHeritage', + help=('Name of the OpenID Connect authentication realm ' + '(default to "SoftwareHeritage")')) +@click.option('--client-id', 'client_id', + default='swh-web', + help=('OpenID Connect client identifier in the realm ' + '(default to "swh-web")')) +@click.pass_context +def auth(ctx: Context, oidc_server_url: str, realm_name: str, client_id: str): + """ + Authenticate Software Heritage users with OpenID Connect. + + This CLI tool eases the retrieval of bearer tokens to authenticate + a user querying the Software Heritage Web API. + """ + ctx.ensure_object(dict) + ctx.obj['oidc_session'] = OpenIDConnectSession( + oidc_server_url, realm_name, client_id) + + +@auth.command('login') +@click.argument('username') +@click.pass_context +def login(ctx: Context, username: str): + """ + Login and create new offline OpenID Connect session. + + Login with USERNAME, create a new OpenID Connect session and get + access and refresh tokens. + + User will be prompted for his password and tokens will be printed in + JSON format to standard output. + + When its access token has expired, user can request a new one using the + session-refresh command of that CLI tool without having to authenticate + using a password again. + + The created OpenID Connect session is an offline one so the provided + refresh token has a much longer expiration time than classical OIDC + sessions (usually several dozens of days). + """ + password = getpass() + + oidc_profile = ctx.obj['oidc_session'].login(username, password) + _output_json(oidc_profile) + + +@auth.command('refresh') +@click.argument('refresh_token') +@click.pass_context +def refresh(ctx: Context, refresh_token: str): + """ + Refresh an offline OpenID Connect session. + + Get a new access token from REFRESH_TOKEN when previous one expired. + + New access token will be printed in JSON format to standard output. + """ + oidc_profile = ctx.obj['oidc_session'].refresh(refresh_token) + if 'access_token' in oidc_profile: + _output_json(oidc_profile['access_token']) + else: + # print oidc error + _output_json(oidc_profile) + + +@auth.command('logout') +@click.argument('refresh_token') +@click.pass_context +def logout(ctx: Context, refresh_token: str): + """ + Logout from an offline OpenID Connect session. + + Use REFRESH_TOKEN to logout from an offline OpenID Connect session. + + Access and refresh tokens are no more usable after that operation. + """ + ctx.obj['oidc_session'].logout(refresh_token) + print('Successfully logged out from OpenID Connect session') diff --git a/swh/web/client/tests/test_cli.py b/swh/web/client/tests/test_cli.py new file mode 100644 index 0000000..1a50b13 --- /dev/null +++ b/swh/web/client/tests/test_cli.py @@ -0,0 +1,67 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import json + +from click.testing import CliRunner + +from swh.web.client.cli import auth + +runner = CliRunner() + +_oidc_profile = { + 'access_token': 'some-access-token', + 'expires_in': 600, + 'refresh_expires_in': 0, + 'refresh_token': 'some-refresh-token', + 'token_type': 'bearer', + 'session_state': 'some-state', + 'scope': 'openid email profile offline_access', +} + + +def test_auth_login(mocker): + mock_getpass = mocker.patch('swh.web.client.cli.getpass') + mock_getpass.return_value = 'password' + mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession') + mock_login = mock_oidc_session.return_value.login + mock_login.return_value = _oidc_profile + + result = runner.invoke(auth, ['login', 'username'], input='password\n') + assert result.exit_code == 0 + assert json.loads(result.output) == _oidc_profile + + mock_login.side_effect = Exception('Auth error') + + result = runner.invoke(auth, ['login', 'username'], input='password\n') + assert result.exit_code == 1 + + +def test_auth_refresh(mocker): + + mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession') + mock_refresh = mock_oidc_session.return_value.refresh + mock_refresh.return_value = _oidc_profile + + result = runner.invoke(auth, ['refresh', _oidc_profile['refresh_token']]) + assert result.exit_code == 0 + assert json.loads(result.stdout) == _oidc_profile['access_token'] + + mock_refresh.side_effect = Exception('Auth error') + result = runner.invoke(auth, ['refresh', _oidc_profile['refresh_token']]) + assert result.exit_code == 1 + + +def test_auth_logout(mocker): + + mock_oidc_session = mocker.patch('swh.web.client.cli.OpenIDConnectSession') + mock_logout = mock_oidc_session.return_value.logout + + result = runner.invoke(auth, ['logout', _oidc_profile['refresh_token']]) + assert result.exit_code == 0 + + mock_logout.side_effect = Exception('Auth error') + result = runner.invoke(auth, ['logout', _oidc_profile['refresh_token']]) + assert result.exit_code == 1