diff --git a/.travis.yml b/.travis.yml index 150e48978b6f17689d62fbe7b685bc977ed0bf4c..d26b3402bfaa6eb60f5b86c6adee7b7b0d91d5e6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -63,7 +63,10 @@ script: - python manage.py check # Check for missing migrations - - python manage.py makemigrations --check --noinput + - python manage.py makemigrations --check --noinput --verbosity=0 + + # Collect static + - python manage.py collectstatic --noinput --verbosity=0 # Run tests - coverage run --source='.' manage.py test opentech diff --git a/opentech/apply/users/decorators.py b/opentech/apply/users/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..1ed11e35dc33bcd48ff59a7c2f76dbb47c3956d3 --- /dev/null +++ b/opentech/apply/users/decorators.py @@ -0,0 +1,14 @@ +from django.core.exceptions import PermissionDenied + +from .utils import can_use_oauth_check + + +def require_oauth_whitelist(view_func): + """Simple decorator that limits the use of OAuth to the configure whitelisted domains""" + def decorated_view(request, *args, **kwargs): + if can_use_oauth_check(request.user): + return view_func(request, *args, **kwargs) + + raise PermissionDenied + + return decorated_view diff --git a/opentech/apply/users/templates/users/account.html b/opentech/apply/users/templates/users/account.html index 725d615316bb18d3700f3dc45ed61a22253c7ad1..38dc57c67f42fb10a9823b672a61418348709802 100644 --- a/opentech/apply/users/templates/users/account.html +++ b/opentech/apply/users/templates/users/account.html @@ -1,12 +1,17 @@ {% extends 'base.html' %} -{% load i18n %} +{% load i18n users_tags %} {% block title %}Account{% endblock %} {% block content %} <h2>Welcome {{ user }}</h2> -{% if show_change_password %} +{% if show_change_password and user.has_usable_password and not backends.associated %} <a href="{% url 'users:password_change' %}">{% trans "Change password" %}</a> {% endif %} + +{% can_use_oauth as show_oauth_link %} +{% if show_oauth_link %} + <a href="{% url 'users:oauth' %}">{% trans "Manage OAuth" %}</a> +{% endif %} {% endblock %} diff --git a/opentech/apply/users/templates/users/login.html b/opentech/apply/users/templates/users/login.html index e2355d27b3b166c1b34a8edc4ea0b2c1ab02c3c3..8934f777685119e186be90537fbb5837776511c9 100644 --- a/opentech/apply/users/templates/users/login.html +++ b/opentech/apply/users/templates/users/login.html @@ -9,4 +9,6 @@ {{ form.as_p }} <button type="submit">Login</button> </form> + + <a href="{% url "social:begin" "google-oauth2" %}?next={% url "users:account" %}">Log in with your OTF email</a> {% endblock %} diff --git a/opentech/apply/users/templates/users/oauth.html b/opentech/apply/users/templates/users/oauth.html new file mode 100644 index 0000000000000000000000000000000000000000..c51d40a154c75fec0b114c33488eba4b9b142349 --- /dev/null +++ b/opentech/apply/users/templates/users/oauth.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% load users_tags %} + +{% block title %}OAuth{% endblock %} + +{% block content %} +<div class="social"> + {% if backends.associated %} + <h3>Providers</h3> + <ul> + {% for association in backends.associated %} + <li> + {% if user.has_usable_password %} + <form id="{{ association.provider|backend_name }}-disconnect" class="disconnect-form col-md-2" action="{% url 'social:disconnect_individual' backend=association.provider association_id=association.id %}?next={% url "users:account" %}" + method="post"> + <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}"> + <button class="btn btn-danger" name="{{ association.provider|backend_class }}"> + Disconnect {{ association.provider|backend_name }} + </button> + </form> + {% else %} + {{ association.provider|backend_name }} + {% endif %} + </li> + {% endfor %} + </ul> + {% endif %} + {% if backends.not_associated %} + <h3>Available providers</h3> + <ul> + {% for backend in backends.not_associated %} + <li> + <a id="{{ name }}-button" class="col-md-2 btn btn-default" name="{{ backend }}" href="{% url 'social:begin' backend=backend %}"> + {{ backend|backend_name }} + </a> + </li> + {% endfor %} + </ul> + {% endif %} +</div> +{% endblock %} diff --git a/opentech/apply/users/templatetags/__init__.py b/opentech/apply/users/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/users/templatetags/users_tags.py b/opentech/apply/users/templatetags/users_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..a3e8b5432f9fe4e1221c013a0061350e111a21e1 --- /dev/null +++ b/opentech/apply/users/templatetags/users_tags.py @@ -0,0 +1,27 @@ +from django import template + +from ..utils import can_use_oauth_check + +register = template.Library() + + +@register.filter +def backend_name(name): + """Human readable mapping for the social auth backend""" + return { + 'google-oauth': 'Google OAuth', + 'google-oauth2': 'Google OAuth', + 'google-openidconnect': 'Google OpenId', + }.get(name, name) + + +@register.filter +def backend_class(backend): + return backend.replace('-', ' ') + + +@register.simple_tag(takes_context=True) +def can_use_oauth(context): + user = context.get('user') + + return can_use_oauth_check(user) diff --git a/opentech/apply/users/tests/__init__.py b/opentech/apply/users/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/users/tests/test_oauth_access.py b/opentech/apply/users/tests/test_oauth_access.py new file mode 100644 index 0000000000000000000000000000000000000000..c425c7f0ec4dd89964346ad51c11dc23c7d30fb4 --- /dev/null +++ b/opentech/apply/users/tests/test_oauth_access.py @@ -0,0 +1,58 @@ +from django.contrib.auth import get_user_model +from django.conf import settings +from django.test import TestCase, override_settings +from django.urls import reverse + + +class TestOAuthAccess(TestCase): + + def test_oauth_page_requires_login(self): + """ + This checks that /account/oauth requires the user to be logged in + """ + oauth_page = reverse('users:oauth') + response = self.client.get(oauth_page, follow=True) + self.assertRedirects(response, reverse( + 'users:login') + '?next=' + reverse('users:oauth'), status_code=301, target_status_code=200) + + @override_settings() + def test_oauth_not_set_up(self): + del settings.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS + + self.login() + response = self.client.get(reverse('users:oauth'), follow=True) + self.assertEqual(response.status_code, 403) + + def test_oauth_user_email_not_whitelisted(self): + self.login() + response = self.client.get(reverse('users:oauth'), follow=True) + self.assertEqual(response.status_code, 403) + + @override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS=['email.com']) + def test_oauth_whitelisted_user_can_see_link_to_oauth_settings_page(self): + self.login() + + response = self.client.get(reverse('users:account'), follow=True) + self.assertContains(response, 'Manage OAuth') + + @override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS=['email.com']) + def test_oauth_whitelisted_user_can_access_oauth_settings_page(self): + """ + Checks that the test user can access the OAuth page as their email is whitelisted + """ + self.login() + + response = self.client.get(reverse('users:oauth'), follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Google OAuth') + self.assertNotContains(response, 'Disconnect Google OAuth') + + self.assertTemplateUsed(response, 'users/oauth.html') + + def login(self): + user = get_user_model().objects.create_user(username='test', email='test@email.com', password='password') + self.assertTrue( + self.client.login(username='test', password='password') + ) + + return user diff --git a/opentech/apply/users/urls.py b/opentech/apply/users/urls.py index 4942e2dada305c2d44cd8b4df19f80b9d882ab6b..1fc074d79c5e3e79e3cba737a10a1915552ce18c 100644 --- a/opentech/apply/users/urls.py +++ b/opentech/apply/users/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url from django.contrib.auth import views as auth_views from django.urls import reverse_lazy -from opentech.apply.users.views import account +from opentech.apply.users.views import account, oauth urlpatterns = [ url(r'^$', account, name='account'), @@ -53,4 +53,5 @@ urlpatterns = [ auth_views.PasswordResetCompleteView.as_view(template_name='users/password_reset/complete.html'), name='password_reset_complete' ), + url(r'^oauth$', oauth, name='oauth'), ] diff --git a/opentech/apply/users/utils.py b/opentech/apply/users/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0e290b3203a99a774513b2f2dc5bd9134b4b3cfd --- /dev/null +++ b/opentech/apply/users/utils.py @@ -0,0 +1,16 @@ +from django.conf import settings + + +def can_use_oauth_check(user): + """ + Checks that the user belongs to the whitelisted domains. + Anonymous or non-whitelisted email domains cannot log in + or associate OAuth accounts + """ + try: + domain = user.email.split('@')[-1] + return domain in settings.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS + except AttributeError: + # Anonymous user or setting not defined + pass + return False diff --git a/opentech/apply/users/views.py b/opentech/apply/users/views.py index c531b183ae221c1441c401f0f17f36ff2fe32980..c9bad14dfb283aa4c1c8caa5f2e769cf20708025 100644 --- a/opentech/apply/users/views.py +++ b/opentech/apply/users/views.py @@ -1,9 +1,12 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import render +from django.template.response import TemplateResponse from django.urls import reverse_lazy from wagtail.wagtailadmin.views.account import password_management_enabled +from .decorators import require_oauth_whitelist + @login_required(login_url=reverse_lazy('users:login')) def account(request): @@ -12,3 +15,13 @@ def account(request): return render(request, 'users/account.html', { 'show_change_password': password_management_enabled() and request.user.has_usable_password(), }) + + +@login_required(login_url=reverse_lazy('users:login')) +@require_oauth_whitelist +def oauth(request): + """ + Generic, empty view for the OAuth associations. + """ + + return TemplateResponse(request, 'users/oauth.html', {}) diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 22546b424a49d35b8533c7bca1123adcd054378f..9d805608db649bdf1fe68bbbf3fb1c351c3a5aea 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -31,6 +31,8 @@ INSTALLED_APPS = [ 'opentech.public.standardpages', 'opentech.public.utils', + 'social_django', + 'wagtail.contrib.modeladmin', 'wagtail.contrib.postgres_search', 'wagtail.contrib.settings', @@ -72,6 +74,8 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'social_django.middleware.SocialAuthExceptionMiddleware', + 'wagtail.wagtailcore.middleware.SiteMiddleware', 'wagtail.wagtailredirects.middleware.RedirectMiddleware', 'opentech.public.esi.middleware.ESIMiddleware', @@ -94,6 +98,8 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'wagtail.contrib.settings.context_processors.settings', 'opentech.public.utils.context_processors.global_vars', + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', ], }, }, @@ -188,6 +194,11 @@ AUTH_USER_MODEL = 'users.User' LOGIN_URL = 'users:login' LOGIN_REDIRECT_URL = '/' +AUTHENTICATION_BACKENDS = ( + 'social_core.backends.google.GoogleOAuth2', + 'django.contrib.auth.backends.ModelBackend', +) + # Logging LOGGING = { @@ -266,3 +277,31 @@ ESI_ENABLED = False # Custom settings ENABLE_STYLEGUIDE = False + +# Social Auth +SOCIAL_AUTH_URL_NAMESPACE = 'social' + +# Set the Google OAuth2 credentials in ENV variables or local.py +# To create a new set of credentials, go to https://console.developers.google.com/apis/credentials +# Make sure the Google+ API is enabled for your API project +SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ['opentechfund.org'] +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' + +SOCIAL_AUTH_LOGIN_ERROR_URL = 'users:account' +SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL = 'users:account' + +# For pipelines, see http://python-social-auth.readthedocs.io/en/latest/pipeline.html?highlight=pipelines#authentication-pipeline +# Create / associate accounts (including by email) +SOCIAL_AUTH_PIPELINE = ( + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.auth_allowed', + 'social_core.pipeline.social_auth.social_user', + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.social_auth.associate_by_email', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', +) diff --git a/opentech/urls.py b/opentech/urls.py index f15ca87d8a2f1e85d1753b4bb7f3d778f2348ec7..9048a2eedac7677a8273b30a5ec743eb795a84b8 100644 --- a/opentech/urls.py +++ b/opentech/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ url('^sitemap\.xml$', sitemap), url('^', include(public_urls)), url('^', include(apply_urls)), + url('^', include('social_django.urls', namespace='social')), ] diff --git a/requirements.txt b/requirements.txt index b4faca247b766cd071cf0f20e18e3861dcb15956..83d06ba2b9b890ff1af1af148881eadcb7987443 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,8 @@ factory_boy==2.9.2 wagtail_factories==0.3.0 flake8 +social_auth_app_django==2.1.0 + # Production dependencies dj-database-url==0.4.1 whitenoise==2.0.4