Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/api/endpoints/organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
26 changes: 26 additions & 0 deletions src/sentry/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
18 changes: 18 additions & 0 deletions src/sentry/templates/sentry/emails/setup_2fa.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "sentry/emails/base.html" %}

{% load i18n %}

{% block main %}
<h3>Setup Two-Factor Authentication</h3>
<p>Hello {{ user.name }},</p>
<p>
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.
</p>
<p>
Click the button below to enable two-factor authentication.
</p>
<a href="{{ url }}" class="btn">Enable Two-Factor Authentication</a>
{% endblock %}
10 changes: 10 additions & 0 deletions src/sentry/templates/sentry/emails/setup_2fa.txt
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 3 additions & 0 deletions src/sentry/web/debug_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand Down
27 changes: 27 additions & 0 deletions src/sentry/web/frontend/debug/debug_setup_2fa_email.py
Original file line number Diff line number Diff line change
@@ -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)
91 changes: 72 additions & 19 deletions tests/sentry/api/endpoints/test_organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
37 changes: 36 additions & 1 deletion tests/sentry/models/test_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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