diff --git a/docs/usage.rst b/docs/usage.rst index c7a3d1c1..924c9d1f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -13,6 +13,11 @@ GitHub .. automodule:: invenio_oauthclient.contrib.github +GitLab +------ + +.. automodule:: invenio_oauthclient.contrib.gitlab + ORCID ----- diff --git a/invenio_oauthclient/contrib/gitlab.py b/invenio_oauthclient/contrib/gitlab.py new file mode 100644 index 00000000..05c057fc --- /dev/null +++ b/invenio_oauthclient/contrib/gitlab.py @@ -0,0 +1,375 @@ +"""Pre-configured remote application for enabling sign in/up with GitLab. + +Besides the public https://gitlab.com, GitLab can also be installed on +premises (e.g, ``https://gitlab.example.com``). By default, ``gitlab.com`` +is used, but you can set custom values for your own/on premises instance. +The sections below cover both cases. + +1. First thing to do is to create a new application in GitLab + (see https://docs.gitlab.com/ee/integration/oauth_provider.html for + instructions on how to register it). Basically, you wanna go to + ``https:///-/profile/applications``. Make sure to: + * check scopes ``read_user`` and ``email`` + * set redirect URI to ``CFG_SITE_SECURE_URL/oauth/authorized/gitlab/`` + + +2. Once the application is registered you'll have access to the Application ID + and *Secret* keys. Those will be used in the next step inside your (Invenio) + instance configuration file (``invenio.cfg``). + + +3. Edit your Invenio instance configuration and add the GitLab app secret keys: + + .. code-block:: python + + from invenio_oauthclient.contrib import gitlab + + OAUTHCLIENT_REMOTE_APPS = dict( + gitlab=gitlab.REMOTE_APP, + ) + + GITLAB_APP_CREDENTIALS = dict( + consumer_key='', + consumer_secret='', + ) + + +3. *IF* the GitLab server is different from ``gitlab.com``, running on your + premises at ``gitlab.example.com``, for example, you have to say so: + + .. code-block:: python + + from invenio_oauthclient.contrib import gitlab + + _gl_ = 'https://gitlab.exampl.com' + + mygitlab = gitlab.GitlabOAuthSettingsHelper( + access_token_url = f"{_gl_}/oauth/token" + authorize_url = f"{_gl_}/oauth/authorize" + base_url = f"{_gl_}/api/v4" + ) + + OAUTHCLIENT_REMOTE_APPS = dict( + gitlab = mygitlab.remote_app, + ) + + GITLAB_APP_CREDENTIALS = dict( + consumer_key = '', + consumer_secret = '', + ) + + +5. Now go to ``CFG_SITE_SECURE_URL/oauth/login/gitlab/`` (e.g. + http://127.0.0.1:5000/oauth/login/gitlab/) + +6. Also, you should see GitLab listed under Linked accounts: + http://127.0.0.1:5000/account/settings/linkedaccounts/ + +By default the GitLab module will try first look if a link already exists +between a GitLab account and a user. If no link is found, the module tries to +retrieve the user email address from GitHub to match it with a local user. If +this fails, the user is asked to provide an email address to sign-up. + +In templates you can add a sign in/up link: + +.. code-block:: jinja + + + Sign in with GitLab + + +For more details you can play with a :doc:`working example `. +""" + +import os + +from invenio_db import db + +from invenio_oauthclient.contrib.settings import OAuthSettingsHelper +from invenio_oauthclient.errors import OAuthResponseError +from invenio_oauthclient.handlers import \ + authorized_signup_handler, oauth_error_handler +from invenio_oauthclient.handlers.rest import \ + authorized_signup_handler as authorized_signup_rest_handler +from invenio_oauthclient.handlers.rest import \ + oauth_resp_remote_error_handler, response_handler +from invenio_oauthclient.handlers.utils import \ + require_more_than_one_external_account +from invenio_oauthclient.models import RemoteAccount +from invenio_oauthclient.utils import \ + oauth_link_external_id, oauth_unlink_external_id + +from flask import current_app, redirect, url_for +from flask_login import current_user + + +class GitlabOAuthSettingsHelper(OAuthSettingsHelper): + """Default configuration for GitLab OAuth provider.""" + + def __init__(self, + title=None, + description=None, + base_url=None, + app_key=None, + icon=None, + access_token_url=None, + authorize_url=None, + access_token_method="POST", + request_token_params=None, + request_token_url=None, + precedence_mask=None, + ): + """Constructor.""" + _glcom_ = 'https://gitlab.com' + kwargs = dict( + access_token_method="POST", + request_token_url=request_token_url, + access_token_url=( + access_token_url or f"{_glcom_}/oauth/token" + ), + authorize_url=( + authorize_url or f"{_glcom_}/oauth/authorize" + ), + base_url=( + base_url or f"{_glcom_}/api/v4" + ), + app_key=( + app_key or "GITLAB_APP_CREDENTIALS" + ), + request_token_params=( + request_token_params or {'scope': 'read_user email'} + ), + precedence_mask=( + precedence_mask or {'email': True} + ), + title=title or "Gitlab", + icon=icon or "fa fa-gitlab", + description=( + description or "Gitlab/OAuth server instance" + ), + ) + super().__init__(**kwargs) + + def get_handlers(self): + """Return GitLab auth handlers.""" + return dict( + authorized_handler='invenio_oauthclient.handlers' + ':authorized_signup_handler', + disconnect_handler=gitlab_disconnect_handler, + signup_handler=dict( + info=gitlab_account_info, + setup=gitlab_account_setup, + view='invenio_oauthclient.handlers:signup_handler', + ) + ) + + def get_rest_handlers(self): + """Return GitLab auth REST handlers.""" + return dict( + authorized_handler='invenio_oauthclient.handlers.rest' + ':authorized_signup_handler', + disconnect_handler=gitlab_disconnect_rest_handler, + signup_handler=dict( + info=gitlab_account_info, + setup=gitlab_account_setup, + view='invenio_oauthclient.handlers.rest:signup_handler', + ), + response_handler='invenio_oauthclient.handlers.rest' + ':default_remote_response_handler', + authorized_redirect_url='/', + disconnect_redirect_url='/', + signup_redirect_url='/', + error_redirect_url='/' + ) + + +def _request_user_info(remote, resp): + """Retrieve remote account information used to find local user. + + It returns the JSON response + + :param remote: The remote application. + :param resp: The response. + :returns: A dictionary representing the response (JSON). + """ + # We could here, like in contrib.github, use an auxiliary library + # I've chosen not to use to not add a dependency for such small use. + # The equivalent in python-gitlab for the request below is: + # ``` + # import gitlab + # gl = gitlab.Gitlab('https://gitlab.com',oauth_token=resp['access_token']) + # gl.auth() + # user_info = gl.user.attributes + # ``` + import requests + headers = {'Authorization': f'{resp["token_type"]} {resp["access_token"]}'} + r = requests.get(remote.base_url + '/user', headers=headers) + return r.json() + + +def gitlab_account_info(remote, resp): + """Retrieve remote account information used to find local user. + + It returns a dictionary with the following structure: + + .. code-block:: python + + { + 'user': { + 'email': '...', + 'profile': { + 'username': '...', + 'full_name': '...', + } + }, + 'external_id': 'gitlab-unique-identifier', + 'external_method': 'gitlab', + } + + Information inside the user dictionary are available for other modules. + For example, they are used from the module invenio-userprofiles to fill + the user profile. + + :param remote: The remote application. + :param resp: The response. + :returns: A dictionary with the user information. + """ + user_info = _request_user_info(remote, resp) + _id = str(user_info['id']) + _email = user_info['email'] + _username = user_info['username'] + _full_name = user_info['name'] + return dict( + user=dict( + email=_email, + profile=dict( + username=_username, + full_name=_full_name + ), + ), + external_id=_id, + external_method='gitlab' + ) + + +def gitlab_account_setup(remote, token, resp): + """Perform additional setup after user have been logged in. + + :param remote: The remote application. + :param token: The token value. + :param resp: The response. + """ + user_info = _request_user_info(remote, resp) + + _id = str(user_info['id']) + _email = user_info['email'] + _username = user_info['username'] + _full_name = user_info['name'] + + with db.session.begin_nested(): + token.remote_account.extra_data = {'username': _username, 'id': _id} + + # Create user <-> external id link. + oauth_link_external_id( + token.remote_account.user, dict( + id=_id, + method='gitlab') + ) + + +@require_more_than_one_external_account +def _disconnect(remote, *args, **kwargs): + """Handle unlinking of remote account. + + :param remote: The remote application. + :returns: The HTML response. + """ + if not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + + remote_account = RemoteAccount.get(user_id=current_user.get_id(), + client_id=remote.consumer_key) + external_method = 'gitlab' + external_ids = [i.id for i in current_user.external_identifiers + if i.method == external_method] + + if external_ids: + oauth_unlink_external_id(dict(id=external_ids[0], + method=external_method)) + if remote_account: + with db.session.begin_nested(): + remote_account.delete() + + +def gitlab_disconnect_handler(remote, *args, **kwargs): + """Handle unlinking of remote account. + + :param remote: The remote application. + :returns: The HTML response. + """ + _disconnect(remote, *args, **kwargs) + return redirect(url_for('invenio_oauthclient_settings.index')) + + +def gitlab_disconnect_rest_handler(remote, *args, **kwargs): + """Handle unlinking of remote account. + + :param remote: The remote application. + :returns: The HTML response. + """ + _disconnect(remote, *args, **kwargs) + redirect_url = current_app.config['OAUTHCLIENT_REST_REMOTE_APPS'][ + remote.name]['disconnect_redirect_url'] + return response_handler(remote, redirect_url) + + +@oauth_error_handler +def authorized(resp, remote): + """Authorized callback handler for GitLab. + + :param resp: The response. + :param remote: The remote application. + """ + if resp and 'error' in resp: + if resp['error'] == 'bad_verification_code': + return redirect(url_for('invenio_oauthclient.login', + remote_app='gitlab')) + elif resp['error'] in ['incorrect_client_credentials', + 'redirect_uri_mismatch']: + raise OAuthResponseError( + 'Application mis-configuration in GitLab', remote, resp + ) + + return authorized_signup_handler(resp, remote) + + +@oauth_resp_remote_error_handler +def authorized_rest(resp, remote): + """Authorized callback handler for GitLab. + + :param resp: The response. + :param remote: The remote application. + """ + if resp and 'error' in resp: + if resp['error'] == 'bad_verification_code': + return redirect(url_for('invenio_oauthclient.rest_login', + remote_app='gitlab')) + elif resp['error'] in ['incorrect_client_credentials', + 'redirect_uri_mismatch']: + raise OAuthResponseError( + 'Application mis-configuration in GitLab', remote, resp + ) + + return authorized_signup_rest_handler(resp, remote) + + +_gitlab = GitlabOAuthSettingsHelper() + +BASE_APP = _gitlab.base_app +"""GitLab.COM base application configuration.""" + +REMOTE_APP = _gitlab.remote_app +"""GitLab.COM remote application configuration.""" + +REMOTE_REST_APP = _gitlab.remote_rest_app +"""GitLab.COM remote REST application configuration.""" diff --git a/run-tests.sh b/run-tests.sh index f23b3a24..ea776f05 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -8,7 +8,9 @@ # under the terms of the MIT License; see LICENSE file for more details. # Usage: -# env DB=postgresql ./run-tests.sh +# env DB=postgresql ./run-tests.sh [cli-options] [] +# Eg: +# ./run-tests.sh -x tests/test_contrib_gitlab.py # Quit on errors set -o errexit @@ -25,6 +27,6 @@ trap cleanup EXIT python -m check_manifest --ignore ".*-requirements.txt" python -m sphinx.cmd.build -qnNW docs docs/_build/html eval "$(docker-services-cli up --db ${DB:-postgresql} --env)" -python -m pytest +python -m pytest $@ tests_exit_code=$? exit "$tests_exit_code" diff --git a/tests/conftest.py b/tests/conftest.py index 647d2168..16ae6102 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,20 +27,30 @@ drop_database from invenio_oauthclient import InvenioOAuthClient, InvenioOAuthClientREST +# CERN from invenio_oauthclient.contrib.cern import REMOTE_APP as CERN_REMOTE_APP from invenio_oauthclient.contrib.cern import \ REMOTE_REST_APP as CERN_REMOTE_REST_APP +# CERN/OpenID from invenio_oauthclient.contrib.cern_openid import \ REMOTE_APP as CERN_OPENID_REMOTE_APP from invenio_oauthclient.contrib.cern_openid import \ REMOTE_REST_APP as CERN_OPENID_REMOTE_REST_APP +# GitHub from invenio_oauthclient.contrib.github import REMOTE_APP as GITHUB_REMOTE_APP from invenio_oauthclient.contrib.github import \ REMOTE_REST_APP as GITHUB_REMOTE_REST_APP +# GitLab +from invenio_oauthclient.contrib.gitlab import REMOTE_APP as GITLAB_REMOTE_APP +from invenio_oauthclient.contrib.gitlab import \ + REMOTE_REST_APP as GITLAB_REMOTE_REST_APP +# Globus from invenio_oauthclient.contrib.globus import REMOTE_APP as GLOBUS_REMOTE_APP from invenio_oauthclient.contrib.globus import \ REMOTE_REST_APP as GLOBUS_REMOTE_REST_APP +# KeyCloak from invenio_oauthclient.contrib.keycloak import KeycloakSettingsHelper +# OrcID from invenio_oauthclient.contrib.orcid import REMOTE_APP as ORCID_REMOTE_APP from invenio_oauthclient.contrib.orcid import \ REMOTE_REST_APP as ORCID_REMOTE_REST_APP @@ -48,7 +58,6 @@ from invenio_oauthclient.views.client import blueprint as blueprint_client from invenio_oauthclient.views.client import rest_blueprint from invenio_oauthclient.views.settings import blueprint as blueprint_settings - from invenio_oauthclient._compat import monkey_patch_werkzeug # noqa isort:skip try: @@ -84,6 +93,7 @@ def base_app(request): cern_openid=CERN_OPENID_REMOTE_APP, orcid=ORCID_REMOTE_APP, github=GITHUB_REMOTE_APP, + gitlab=GITLAB_REMOTE_APP, globus=GLOBUS_REMOTE_APP, keycloak=KEYCLOAK_REMOTE_APP, ), @@ -92,6 +102,7 @@ def base_app(request): cern_openid=CERN_OPENID_REMOTE_REST_APP, orcid=ORCID_REMOTE_REST_APP, github=GITHUB_REMOTE_REST_APP, + gitlab=GITLAB_REMOTE_REST_APP, globus=GLOBUS_REMOTE_REST_APP, ), OAUTHCLIENT_STATE_EXPIRES=300, @@ -99,6 +110,10 @@ def base_app(request): consumer_key='github_key_changeme', consumer_secret='github_secret_changeme', ), + GITLAB_APP_CREDENTIALS=dict( + consumer_key='gitlab_key_changeme', + consumer_secret='gitlab_secret_changeme', + ), ORCID_APP_CREDENTIALS=dict( consumer_key='orcid_key_changeme', consumer_secret='orcid_secret_changeme', @@ -377,6 +392,23 @@ def example_github(request): } +@pytest.fixture +def example_gitlab(request): + """ + Gitlab example data. + + Example responses: https://docs.gitlab.com/ee/api/oauth2.html + """ + return { + # 'name': 'Josiah Carberry', + 'expires_in': 3599, + 'access_token': 'test_access_token', + 'refresh_token': 'test_refresh_token', + # 'scope': '/authenticate', + 'token_type': 'bearer', + } + + @pytest.fixture def example_globus(request): """Globus example data.""" diff --git a/tests/test_contrib_gitlab.py b/tests/test_contrib_gitlab.py new file mode 100644 index 00000000..6035fd14 --- /dev/null +++ b/tests/test_contrib_gitlab.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2016-2018 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""Test case for gitlab oauth remote app.""" + +from collections import namedtuple + +import mock +import pytest +from flask import session, url_for +from flask_login import current_user +from flask_security import login_user +from flask_security.utils import hash_password +from helpers import check_redirect_location, mock_response +from invenio_accounts.models import User +from invenio_db import db +from six.moves.urllib_parse import parse_qs, urlparse + +from invenio_oauthclient._compat import _create_identifier +from invenio_oauthclient.contrib.gitlab import authorized +from invenio_oauthclient.errors import OAuthResponseError +from invenio_oauthclient.models import RemoteAccount, RemoteToken, UserIdentity +from invenio_oauthclient.views.client import serializer + + +def _get_state(): + return serializer.dumps({'app': 'gitlab', 'sid': _create_identifier(), + 'next': None, }) + + +def test_login(app): + """Test gitlab login.""" + client = app.test_client() + + resp = client.get( + url_for('invenio_oauthclient.login', remote_app='gitlab', + next='/someurl/') + ) + assert resp.status_code == 302 + + params = parse_qs(urlparse(resp.location).query) + assert params['response_type'], ['code'] + assert params['scope'] == ['read_user email'] + assert params['redirect_uri'] + assert params['client_id'] + assert params['state'] + + +class MockGl(object): + """Mock gl.""" + + def __init__(self, email): + """Init.""" + self._email = email + + def json(self): + """Mock Requests result.json""" + return dict(id='gitlabuser', name='John', username='mynick', + email=self._email) + + +def test_authorized_signup_valid_user(app, example_gitlab): + """Test authorized callback with sign-up.""" + example_email = 'info@inveniosoftware.org' + + with app.test_client() as c: + # User login with email 'info' + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info@inveniosoftware.org') + + # Ensure remote apps have been loaded (due to before first + # request) + resp = c.get(url_for('invenio_oauthclient.login', + remote_app='gitlab')) + + assert resp.status_code == 302 + + mock_response(app.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + assert resp.location == ('http://localhost/account/settings/' + + 'linkedaccounts/') + + # Assert database state (Sign-up complete) + user = User.query.filter_by(email=example_email).one() + remote = RemoteAccount.query.filter_by(user_id=user.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + assert user.active + + # Disconnect link + # should not work, because it's the user's only means of login + resp = c.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab') + ) + assert resp.status_code == 400 + + user = User.query.filter_by(email=example_email).one() + assert 1 == UserIdentity.query.filter_by( + method='gitlab', id_user=user.id, + id='gitlabuser' + ).count() + + # set a password for the user + user.password = hash_password("1234") + db.session.commit() + + # Disconnect again + resp = c.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab')) + assert resp.status_code == 302 + + user = User.query.filter_by(email=example_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=user.id, + id='gitlabuser' + ).count() + + assert RemoteAccount.query.filter_by(user_id=user.id).count() == 0 + assert RemoteToken.query.count() == 0 + + # User login with another email ('info2') + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info2@inveniosoftware.org') + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + assert resp.location == ( + 'http://localhost/' + + 'account/settings/linkedaccounts/' + ) + + # check that exist only one account + user = User.query.filter_by(email=example_email).one() + assert User.query.count() == 1 + + +def test_authorized_signup_username_already_exists(app, example_gitlab, user): + """Test authorized callback with sign-up.""" + example_email = 'another@email.it' + + with app.test_client() as c: + # User login with email 'info' + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email=example_email) + + # Ensure remote apps have been loaded (due to before first + # request) + resp = c.get(url_for('invenio_oauthclient.login', + remote_app='gitlab')) + + assert resp.status_code == 302 + + mock_response(app.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + assert resp.location == ( + 'http://localhost' + + url_for('invenio_oauthclient.signup', remote_app='gitlab') + ) + + # User fills form to register + resp = c.post( + resp.headers['Location'], + data={ + 'email': example_email, + 'password': '123456', + 'profile.username': 'pippo2', + 'profile.full_name': 'pluto', + } + ) + assert resp.status_code == 302 + + # Assert database state (Sign-up complete) + my_user = User.query.filter_by(email=example_email).one() + remote = RemoteAccount.query.filter_by(user_id=my_user.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + assert my_user.active + + # Disconnect link + # should not work, because it's the user's only means of login + resp = c.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab') + ) + assert resp.status_code == 400 + + my_user = User.query.filter_by(email=example_email).one() + assert 1 == UserIdentity.query.filter_by( + method='gitlab', id_user=my_user.id, + id='gitlabuser' + ).count() + + # set a password for the user + my_user.password = hash_password("1234") + db.session.commit() + + # Disconnect again + resp = c.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab')) + assert resp.status_code == 302 + + my_user = User.query.filter_by(email=example_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=my_user.id, + id='gitlabuser' + ).count() + assert RemoteAccount.query.filter_by( + user_id=my_user.id).count() == 0 + assert RemoteToken.query.count() == 0 + assert User.query.count() == 2 + + +def test_authorized_reject(app): + """Test a rejected request.""" + with app.test_client() as c: + c.get(url_for('invenio_oauthclient.login', remote_app='gitlab')) + resp = c.get( + url_for('invenio_oauthclient.authorized', + remote_app='gitlab', error='access_denied', + error_description='User denied access', + state=_get_state())) + assert resp.status_code in (301, 302) + assert resp.location == ( + 'http://localhost/' + ) + # Check message flash + assert session['_flashes'][0][0] == 'info' + + +def test_authorized_already_authenticated(app, models_fixture, example_gitlab): + """Test authorized callback with sign-up.""" + datastore = app.extensions['invenio-accounts'].datastore + login_manager = app.login_manager + + existing_email = 'existing@inveniosoftware.org' + user = datastore.find_user(email=existing_email) + + @login_manager.user_loader + def load_user(user_id): + return user + + @app.route('/foo_login') + def login(): + login_user(user) + return 'Logged In' + + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info@inveniosoftware.org') + + with app.test_client() as client: + + # make a fake login (using my login function) + client.get('/foo_login', follow_redirects=True) + + # Ensure remote apps have been loaded (due to before first + # request) + client.get(url_for('invenio_oauthclient.login', + remote_app='gitlab')) + + # Mock access token request + mock_response(app.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User then goes to 'Linked accounts' and clicks 'Connect' + resp = client.get( + url_for('invenio_oauthclient.login', remote_app='gitlab', + next='/someurl/') + ) + assert resp.status_code == 302 + + # User authorized the requests and is redirected back + resp = client.get( + url_for('invenio_oauthclient.authorized', + remote_app='gitlab', code='test', + state=_get_state())) + + # Assert database state (Sign-up complete) + u = User.query.filter_by(email=existing_email).one() + remote = RemoteAccount.query.filter_by(user_id=u.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + + # Disconnect link + resp = client.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab')) + assert resp.status_code == 302 + + # User exists + u = User.query.filter_by(email=existing_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=u.id, + id='gitlabuser' + ).count() + assert RemoteAccount.query.filter_by(user_id=u.id).count() == 0 + assert RemoteToken.query.count() == 0 + + +def test_not_authenticated(app): + """Test disconnect when user is not authenticated.""" + with app.test_client() as client: + assert not current_user.is_authenticated + resp = client.get( + url_for('invenio_oauthclient.disconnect', remote_app='gitlab')) + assert resp.status_code == 302 + + +def test_authorized_handler(app, remote): + """Test authorized callback handler.""" + # General error + example_response = {'error': 'error'} + resp = authorized(example_response, remote) + check_redirect_location(resp, '/') + + # Bad verification error + example_response = {'error': 'bad_verification_code'} + resp = authorized(example_response, remote) + check_redirect_location(resp, '/oauth/login/gitlab/') + + # Incorrect client credentials + example_response = {'error': 'incorrect_client_credentials'} + with pytest.raises(OAuthResponseError): + authorized(example_response, remote) + + # Redirect uri mismatch + example_response = {'error': 'redirect_uri_mismatch'} + with pytest.raises(OAuthResponseError): + authorized(example_response, remote) diff --git a/tests/test_contrib_gitlab_rest.py b/tests/test_contrib_gitlab_rest.py new file mode 100644 index 00000000..0e3ab0d9 --- /dev/null +++ b/tests/test_contrib_gitlab_rest.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2016-2018 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""Test case for gitlab oauth remote app.""" + +from collections import namedtuple + +import mock +import pytest +from flask import url_for +from flask_login import current_user +from flask_security import login_user +from flask_security.utils import hash_password +from helpers import check_redirect_location, \ + check_response_redirect_url_args, mock_response +from invenio_accounts.models import User +from invenio_db import db +from six.moves.urllib_parse import parse_qs, urlparse + +from invenio_oauthclient import current_oauthclient +from invenio_oauthclient._compat import _create_identifier +from invenio_oauthclient.contrib.gitlab import authorized_rest +from invenio_oauthclient.errors import OAuthResponseError +from invenio_oauthclient.models import RemoteAccount, RemoteToken, UserIdentity +from invenio_oauthclient.views.client import serializer + + +def _get_state(): + return serializer.dumps({'app': 'gitlab', 'sid': _create_identifier(), + 'next': None, }) + + +def test_login(app_rest): + """Test gitlab login.""" + client = app_rest.test_client() + + resp = client.get( + url_for('invenio_oauthclient.rest_login', remote_app='gitlab', + next='/someurl/') + ) + assert resp.status_code == 302 + + params = parse_qs(urlparse(resp.location).query) + assert params['response_type'], ['code'] + assert params['scope'] == ['read_user email'] + assert params['redirect_uri'] + assert params['client_id'] + assert params['state'] + + +class MockGl(object): + """Mock gl.""" + + def __init__(self, email): + """Init.""" + self._email = email + + def json(self): + """Mock Requests result.json""" + return dict(id='gitlabuser', name='John', username='mynick', + email=self._email) + + +def test_authorized_signup_valid_user(app_rest, example_gitlab): + """Test authorized callback with sign-up.""" + example_email = 'info@inveniosoftware.org' + + with app_rest.test_client() as c: + # User login with email 'info' + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info@inveniosoftware.org') + + # Ensure remote apps have been loaded (due to before first + # request) + resp = c.get(url_for('invenio_oauthclient.rest_login', + remote_app='gitlab')) + + assert resp.status_code == 302 + + mock_response(app_rest.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.rest_authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + expected_url_args = { + "message": "Successfully authorized.", + "code": 200, + } + check_response_redirect_url_args(resp, expected_url_args) + + # Assert database state (Sign-up complete) + user = User.query.filter_by(email=example_email).one() + remote = RemoteAccount.query.filter_by(user_id=user.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + assert user.active + + # Disconnect link + # should not work, because it's the user's only means of login + resp = c.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab' + ) + ) + assert resp.status_code == 400 + + user = User.query.filter_by(email=example_email).one() + assert 1 == UserIdentity.query.filter_by( + method='gitlab', id_user=user.id, + id='gitlabuser' + ).count() + + # set a password for the user + user.password = hash_password("1234") + db.session.commit() + + # Disconnect again + resp = c.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab')) + assert resp.status_code == 302 + + # User exists + user = User.query.filter_by(email=example_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=user.id, + id='gitlabuser' + ).count() + assert RemoteAccount.query.filter_by(user_id=user.id).count() == 0 + assert RemoteToken.query.count() == 0 + + # User login with another email ('info2') + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info2@inveniosoftware.org') + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.rest_authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + check_response_redirect_url_args(resp, expected_url_args) + + # check that exist only one account + user = User.query.filter_by(email=example_email).one() + assert user.email == example_email + + +def test_authorized_signup_username_already_exists( + app_rest, example_gitlab, + user_rest): + """Test authorized callback with sign-up.""" + example_email = 'another@email.it' + + with app_rest.test_client() as c: + # User login with email 'info' + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email=example_email) + + # Ensure remote apps have been loaded (due to before first + # request) + resp = c.get(url_for('invenio_oauthclient.rest_login', + remote_app='gitlab')) + + assert resp.status_code == 302 + + mock_response(app_rest.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User authorized the requests and is redirect back + resp = c.get( + url_for('invenio_oauthclient.rest_authorized', + remote_app='gitlab', code='test', + state=_get_state())) + assert resp.status_code == 302 + assert resp.location == ( + 'http://localhost' + + url_for('invenio_oauthclient.rest_signup', remote_app='gitlab') + ) + + # User fills form to register + resp = c.post( + resp.headers['Location'], + data={ + 'email': example_email, + 'password': '123456', + 'profile.username': 'pippo2', + 'profile.full_name': 'pluto', + } + ) + assert resp.status_code == 200 + expected_json = { + "message": "Successfully signed up.", + "code": 200, + } + assert resp.json == expected_json + + # Assert database state (Sign-up complete) + my_user = User.query.filter_by(email=example_email).one() + remote = RemoteAccount.query.filter_by(user_id=my_user.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + assert my_user.active + + # Disconnect link + # should not work, because it's the user's only means of login + resp = c.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab' + ) + ) + assert resp.status_code == 400 + + my_user = User.query.filter_by(email=example_email).one() + assert 1 == UserIdentity.query.filter_by( + method='gitlab', id_user=my_user.id, + id='gitlabuser' + ).count() + + # set a password for the user + my_user.password = hash_password("1234") + db.session.commit() + + # Disconnect again + resp = c.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab')) + assert resp.status_code == 302 + + my_user = User.query.filter_by(email=example_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=my_user.id, + id='gitlabuser' + ).count() + assert RemoteAccount.query.filter_by( + user_id=my_user.id).count() == 0 + assert RemoteToken.query.count() == 0 + # assert User.query.count() == 2 + + +def test_authorized_reject(app_rest): + """Test a rejected request.""" + with app_rest.test_client() as c: + c.get(url_for('invenio_oauthclient.rest_login', remote_app='gitlab')) + resp = c.get( + url_for('invenio_oauthclient.rest_authorized', + remote_app='gitlab', error='access_denied', + error_description='User denied access', + state=_get_state())) + assert resp.status_code in (301, 302) + expected_url_args = { + "message": "You rejected the authentication request.", + "code": 400, + } + check_response_redirect_url_args(resp, expected_url_args) + + +def test_authorized_already_authenticated(app_rest, models_fixture, + example_gitlab): + """Test authorized callback with sign-up.""" + datastore = app_rest.extensions['invenio-accounts'].datastore + login_manager = app_rest.login_manager + + existing_email = 'existing@inveniosoftware.org' + user = datastore.find_user(email=existing_email) + + @login_manager.user_loader + def load_user(user_id): + return user + + @app_rest.route('/foo_login') + def login(): + login_user(user) + return 'Logged In' + + with mock.patch('requests.get') as MockLogin: + MockLogin.return_value = MockGl(email='info@inveniosoftware.org') + + with app_rest.test_client() as client: + + # make a fake login (using my login function) + client.get('/foo_login', follow_redirects=True) + + # Ensure remote apps have been loaded (due to before first + # request) + client.get(url_for('invenio_oauthclient.rest_login', + remote_app='gitlab')) + + # Mock access token request + mock_response(app_rest.extensions['oauthlib.client'], 'gitlab', + example_gitlab) + + # User then goes to 'Linked accounts' and clicks 'Connect' + resp = client.get( + url_for('invenio_oauthclient.rest_login', remote_app='gitlab', + next='/someurl/') + ) + assert resp.status_code == 302 + + # User authorized the requests and is redirected back + resp = client.get( + url_for('invenio_oauthclient.rest_authorized', + remote_app='gitlab', code='test', + state=_get_state())) + + # Assert database state (Sign-up complete) + u = User.query.filter_by(email=existing_email).one() + remote = RemoteAccount.query.filter_by(user_id=u.id).one() + RemoteToken.query.filter_by(id_remote_account=remote.id).one() + + # Disconnect link + resp = client.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab')) + assert resp.status_code == 302 + + # User exists + u = User.query.filter_by(email=existing_email).one() + assert 0 == UserIdentity.query.filter_by( + method='gitlab', id_user=u.id, + id='gitlabuser' + ).count() + assert RemoteAccount.query.filter_by(user_id=u.id).count() == 0 + assert RemoteToken.query.count() == 0 + + +def test_not_authenticated(app_rest): + """Test disconnect when user is not authenticated.""" + with app_rest.test_client() as client: + assert not current_user.is_authenticated + resp = client.get( + url_for( + 'invenio_oauthclient.rest_disconnect', + remote_app='gitlab')) + assert resp.status_code == 302 + + +def test_authorized_rest_handler(app_rest): + """Test authorized callback handler.""" + oauth = current_oauthclient.oauth + remote = oauth.remote_apps['gitlab'] + # General error + example_response = {'error': 'error'} + resp = authorized_rest(example_response, remote) + expected_url_args = { + "message": "Authorization with remote service failed.", + "code": 400, + } + check_response_redirect_url_args(resp, expected_url_args) + # Bad verification error + example_response = {'error': 'bad_verification_code'} + resp = authorized_rest(example_response, remote) + check_redirect_location(resp, '/oauth/login/gitlab/') + + # Incorrect client credentials + example_response = {'error': 'incorrect_client_credentials'} + with pytest.raises(OAuthResponseError): + authorized_rest(example_response, remote) + + # Redirect uri mismatch + example_response = {'error': 'redirect_uri_mismatch'} + with pytest.raises(OAuthResponseError): + authorized_rest(example_response, remote)