diff --git a/src/sentry/api/endpoints/organization_details.py b/src/sentry/api/endpoints/organization_details.py index 8db19c60eca7e8..2c02cda2a1bcbd 100644 --- a/src/sentry/api/endpoints/organization_details.py +++ b/src/sentry/api/endpoints/organization_details.py @@ -153,6 +153,8 @@ def save(self): avatar=self.init_data.get('avatar'), filename='{}.png'.format(org.slug), ) + if 'require2FA' in self.init_data and self.init_data['require2FA'] is True: + org.send_setup_2fa_emails() return org diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index 9454b4ad9f41ff..5868614ae2c6cd 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -24,6 +24,7 @@ from sentry.db.models.utils import slugify_instance from sentry.utils.http import absolute_uri from sentry.utils.retries import TimedRetryPolicy +from sentry.models import Authenticator # TODO(dcramer): pull in enum library @@ -328,3 +329,28 @@ def send_delete_confirmation(self, audit_log_entry, countdown): def flag_has_changed(self, flag_name): "Returns ``True`` if ``flag`` has changed since initialization." return getattr(self.old_value('flags'), flag_name, None) != getattr(self.flags, flag_name) + + def send_setup_2fa_emails(self): + from sentry import options + from sentry.utils.email import MessageBuilder + from sentry.models import User + + for user in User.objects.filter( + is_active=True, + sentry_orgmember_set__organization=self, + ): + if not Authenticator.objects.user_has_2fa(user): + context = { + 'user': user, + 'url': absolute_uri(reverse('sentry-account-settings-2fa')), + 'organization': self + } + message = MessageBuilder( + subject='%s %s Mandatory: Enable Two-Factor Authentication' % ( + options.get('mail.subject-prefix'), self.name), + template='sentry/emails/setup_2fa.txt', + html_template='sentry/emails/setup_2fa.html', + type='user.setup_2fa', + context=context, + ) + message.send_async([user.email]) diff --git a/src/sentry/templates/sentry/emails/setup_2fa.html b/src/sentry/templates/sentry/emails/setup_2fa.html new file mode 100644 index 00000000000000..d3fe46ecd87e35 --- /dev/null +++ b/src/sentry/templates/sentry/emails/setup_2fa.html @@ -0,0 +1,18 @@ +{% extends "sentry/emails/base.html" %} + +{% load i18n %} + +{% block main %} +
Hello {{ user.name }},
++ The {{ organization.name }} organization now requires that all members enable + two-factor authentication. Effective immediately, you will be unable to access + projects under the {{ organization.name }} organization unless you enable at + least one form of two-factor authentication. +
++ Click the button below to enable two-factor authentication. +
+ Enable Two-Factor Authentication +{% endblock %} diff --git a/src/sentry/templates/sentry/emails/setup_2fa.txt b/src/sentry/templates/sentry/emails/setup_2fa.txt new file mode 100644 index 00000000000000..5aa7cbf9fde2b9 --- /dev/null +++ b/src/sentry/templates/sentry/emails/setup_2fa.txt @@ -0,0 +1,10 @@ +Setup Two-Factor Authentication + +Hello {{ user.name }}, + +The {{ organization.name }} organization now requires that all members enable +two-factor authentication. Effective immediately, you will be unable to access +projects under the {{ organization.name }} organization unless you enable at +least one form of two-factor authentication. + +Enable two-factor authentication: {{ url }} diff --git a/src/sentry/web/debug_urls.py b/src/sentry/web/debug_urls.py index 716691d6c954eb..5ccfac03bdb3a3 100644 --- a/src/sentry/web/debug_urls.py +++ b/src/sentry/web/debug_urls.py @@ -36,6 +36,7 @@ DebugSsoUnlinkedEmailView, DebugSsoUnlinkedNoPasswordEmailView, ) +from sentry.web.frontend.debug.debug_setup_2fa_email import DebugSetup2faEmailView from sentry.web.frontend.debug import debug_auth_views from sentry.web.frontend.debug.debug_oauth_authorize import ( DebugOAuthAuthorizeView, @@ -82,6 +83,8 @@ url(r'^debug/mail/sso-unlinked/$', DebugSsoUnlinkedEmailView.as_view()), url(r'^debug/mail/sso-unlinked/no-password$', DebugSsoUnlinkedNoPasswordEmailView.as_view()), + url(r'^debug/mail/setup-2fa/$', DebugSetup2faEmailView.as_view()), + url(r'^debug/embed/error-page/$', DebugErrorPageEmbedView.as_view()), url(r'^debug/trigger-error/$', DebugTriggerErrorView.as_view()), url(r'^debug/auth-confirm-identity/$', debug_auth_views.DebugAuthConfirmIdentity.as_view()), diff --git a/src/sentry/web/frontend/debug/debug_setup_2fa_email.py b/src/sentry/web/frontend/debug/debug_setup_2fa_email.py new file mode 100644 index 00000000000000..cd9f8eb782a4bd --- /dev/null +++ b/src/sentry/web/frontend/debug/debug_setup_2fa_email.py @@ -0,0 +1,27 @@ +from __future__ import absolute_import + +from django.core.urlresolvers import reverse +from sentry.utils.http import absolute_uri +from django.views.generic import View + +from .mail import MailPreview + +from sentry.models import Organization + + +class DebugSetup2faEmailView(View): + def get(self, request): + context = { + 'user': request.user, + 'url': absolute_uri(reverse('sentry-account-settings-2fa')), + 'organization': Organization( + id=1, + slug='organization', + name='Sentry Corp', + ) + } + return MailPreview( + html_template='sentry/emails/setup_2fa.html', + text_template='sentry/emails/setup_2fa.txt', + context=context, + ).render(request) diff --git a/tests/sentry/api/endpoints/test_organization_details.py b/tests/sentry/api/endpoints/test_organization_details.py index cb474d77898c78..d7df1f97508d64 100644 --- a/tests/sentry/api/endpoints/test_organization_details.py +++ b/tests/sentry/api/endpoints/test_organization_details.py @@ -398,23 +398,37 @@ def assert_can_enable_org_2fa(self, organization, user, status_code=200): def assert_cannot_enable_org_2fa(self, organization, user, status_code): self.__helper_enable_organization_2fa(organization, user, status_code) + def enable_org_2fa(self, organization, user): + self.login_as(user) + url = reverse( + 'sentry-api-0-organization-details', kwargs={ + 'organization_slug': organization.slug, + } + ) + response = self.client.put( + url, + data={ + 'require2FA': True, + } + ) + return response + + def disable_org_2fa(self, organization, user): + url = reverse( + 'sentry-api-0-organization-details', kwargs={ + 'organization_slug': organization.slug, + } + ) + response = self.client.put( + url, + data={ + 'require2FA': False, + } + ) + return response + def __helper_enable_organization_2fa(self, organization, user, status_code): - def enable_org_2fa(): - self.login_as(user) - url = reverse( - 'sentry-api-0-organization-details', kwargs={ - 'organization_slug': organization.slug, - } - ) - response = self.client.put( - url, - data={ - 'require2FA': True, - } - ) - return response - - response = enable_org_2fa() + response = self.enable_org_2fa(organization, user) assert response.status_code == status_code, response.content organization = Organization.objects.get(id=organization.id) @@ -424,6 +438,17 @@ def enable_org_2fa(): else: assert not organization.flags.require_2fa + def add_2fa_users_to_org(self, organization, num_of_users, num_with_2fa): + non_compliant_members = [] + for num in range(0, num_of_users): + user = self.create_user('foo_%s@example.com' % num) + self.create_member(organization=organization, user=user) + if num % num_with_2fa: + TotpInterface().enroll(user) + else: + non_compliant_members.append(user.email) + return non_compliant_members + def test_cannot_enforce_2fa_without_2fa_enabled(self): owner = self.create_user() organization = self.create_organization(owner=owner) @@ -436,16 +461,23 @@ def test_owner_can_set_2fa(self): organization = self.create_organization(owner=owner) self.enable_user_2fa(owner) - self.assert_can_enable_org_2fa(organization, owner) + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + self.assert_can_enable_org_2fa(organization, owner) + assert len(mail.outbox) == 0 def test_manager_can_set_2fa(self): manager = self.create_user() - organization = self.create_organization(owner=self.create_user()) + owner = self.create_user() + organization = self.create_organization(owner=owner) self.create_member(organization=organization, user=manager, role="manager") self.assert_cannot_enable_org_2fa(organization, manager, 400) self.enable_user_2fa(manager) - self.assert_can_enable_org_2fa(organization, manager) + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + self.assert_can_enable_org_2fa(organization, manager) + + assert len(mail.outbox) == 1 + assert mail.outbox[0].to[0] == owner.email def test_members_cannot_set_2fa(self): member = self.create_user() @@ -455,3 +487,24 @@ def test_members_cannot_set_2fa(self): self.assert_cannot_enable_org_2fa(organization, member, 403) self.enable_user_2fa(member) self.assert_cannot_enable_org_2fa(organization, member, 403) + + def test_owner_can_disable_org_2fa(self): + owner = self.create_user() + organization = self.create_organization(owner=owner) + user_emails_without_2fa = self.add_2fa_users_to_org(organization, 10, 2) + self.enable_user_2fa(owner) + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + self.assert_can_enable_org_2fa(organization, owner) + assert len(mail.outbox) == 5 + assert sorted([email.to[0] for email in mail.outbox]) == sorted(user_emails_without_2fa) + + # Empty the test outbox + mail.outbox = [] + + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + response = self.disable_org_2fa(organization, owner) + + assert response.status_code == 200 + org_disabled_2fa = Organization.objects.get(id=organization.id) + assert not org_disabled_2fa.flags.require_2fa + assert len(mail.outbox) == 0 diff --git a/tests/sentry/models/test_organization.py b/tests/sentry/models/test_organization.py index a384c4d19cf17e..234cc8ce56bf10 100644 --- a/tests/sentry/models/test_organization.py +++ b/tests/sentry/models/test_organization.py @@ -2,9 +2,10 @@ from sentry.models import ( Commit, File, OrganizationMember, OrganizationMemberTeam, Project, Release, ReleaseCommit, - ReleaseEnvironment, ReleaseFile, Team + ReleaseEnvironment, ReleaseFile, Team, TotpInterface ) from sentry.testutils import TestCase +from django.core import mail class OrganizationTest(TestCase): @@ -133,3 +134,37 @@ def test_flags_have_changed(self): org.flags.early_adopter = True assert org.flag_has_changed('early_adopter') assert org.flag_has_changed('allow_joinleave') is False + + def test_send_setup_2fa_emails(self): + owner = self.create_user('foo@example.com') + TotpInterface().enroll(owner) + org = self.create_organization(owner=owner) + non_compliant_members = [] + for num in range(0, 10): + user = self.create_user('foo_%s@example.com' % num) + self.create_member(organization=org, user=user) + if num % 2: + TotpInterface().enroll(user) + else: + non_compliant_members.append(user.email) + + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + org.send_setup_2fa_emails() + + assert len(mail.outbox) == len(non_compliant_members) + assert sorted([email.to[0] for email in mail.outbox]) == sorted(non_compliant_members) + + def test_send_setup_2fa_emails_no_non_compliant_members(self): + owner = self.create_user('foo@example.com') + TotpInterface().enroll(owner) + org = self.create_organization(owner=owner) + + for num in range(0, 10): + user = self.create_user('foo_%s@example.com' % num) + self.create_member(organization=org, user=user) + TotpInterface().enroll(user) + + with self.options({'system.url-prefix': 'http://example.com'}), self.tasks(): + org.send_setup_2fa_emails() + + assert len(mail.outbox) == 0