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
30 changes: 30 additions & 0 deletions src/sentry/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
import warnings

from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.core.urlresolvers import reverse
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from sentry.db.models import BaseManager, BaseModel, BoundedAutoField
from sentry.utils.http import absolute_uri


class UserManager(BaseManager, UserManager):
Expand Down Expand Up @@ -88,6 +90,9 @@ def has_module_perms(self, app_label):
warnings.warn('User.has_module_perms is deprecated', DeprecationWarning)
return self.is_superuser

def has_unverified_emails(self):
return self.emails.filter(is_verified=False).exists()

def get_label(self):
return self.email or self.username or self.id

Expand All @@ -106,6 +111,31 @@ def get_avatar_type(self):
return avatar.get_avatar_type_display()
return 'letter_avatar'

def send_confirm_emails(self, is_new_user=False):
from sentry import options
from sentry.utils.email import MessageBuilder

for email in self.emails.filter(is_verified=False):
if not email.hash_is_valid():
email.set_hash()
email.save()

context = {
'user': self,
'url': absolute_uri(reverse(
'sentry-account-confirm-email',
args=[self.id, email.validation_hash]
)),
'is_new_user': is_new_user,
}
msg = MessageBuilder(
subject='%sConfirm Email' % (options.get('mail.subject-prefix'),),
template='sentry/emails/confirm_email.txt',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add type='user.confirm_email', below this line to get sweet sweet mail.{queued,sent} logging events.

If you run tests and they wig out or something doesn't work, rebasing off of origin/master will fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

type='user.confirm_email',
context=context,
)
msg.send_async([email.email])

def merge_to(from_user, to_user):
# TODO: we could discover relations automatically and make this useful
from sentry import roles
Expand Down
39 changes: 39 additions & 0 deletions src/sentry/models/useremail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import absolute_import

from datetime import timedelta

from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _

from sentry.db.models import FlexibleForeignKey, Model, sane_repr

CHARACTERS = u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'


class UserEmail(Model):

user = FlexibleForeignKey(settings.AUTH_USER_MODEL,
related_name='emails')
email = models.EmailField(_('email address'))
validation_hash = models.CharField(max_length=32)
date_hash_added = models.DateTimeField(default=timezone.now)
is_verified = models.BooleanField(
_('verified'), default=False,
help_text=_('Designates whether this user has confirmed their email.'))

class Meta:
app_label = 'sentry'
db_table = 'sentry_useremail'
unique_together = (('user', 'email'),)

__repr__ = sane_repr('user_id', 'email')

def set_hash(self):
self.date_hash_added = timezone.now()
self.validation_hash = get_random_string(32, CHARACTERS)

def hash_is_valid(self):
return self.validation_hash and self.date_hash_added > timezone.now() - timedelta(hours=48)
21 changes: 21 additions & 0 deletions src/sentry/receivers/useremail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import absolute_import

from django.db import IntegrityError
from django.db.models.signals import post_save

from sentry.models import User, UserEmail


def create_user_email(instance, created, **kwargs):
if created:
try:
UserEmail.objects.create(email=instance.email, user=instance)
except IntegrityError:
pass

post_save.connect(
create_user_email,
sender=User,
dispatch_uid="create_user_email",
weak=False
)

Large diffs are not rendered by default.

618 changes: 618 additions & 0 deletions src/sentry/south_migrations/0260_populate_email_addresses.py

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions src/sentry/templates/sentry/account/confirm_email/failure.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "sentry/bases/auth.html" %}

{% load crispy_forms_tags %}
{% load i18n %}

{% block title %}{% trans "Confirm Email" %} | {{ block.super }}{% endblock %}

{% block auth_main %}
<h2>{% trans "Oops, something happened" %}</h2>

<p>
{% url 'sentry-account-settings' as settings_url %}
{% blocktrans %}
There was an error confirming your email. Please try again or visit your <a href={{ settings_url }}>Account Settings</a> to resend the verification email.
{% endblocktrans %}
</p>
{% endblock %}
21 changes: 21 additions & 0 deletions src/sentry/templates/sentry/account/confirm_email/send.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "sentry/bases/auth.html" %}

{% load crispy_forms_tags %}
{% load i18n %}

{% block title %}{% trans "Confirm Email" %} | {{ block.super }}{% endblock %}

{% block auth_main %}

{% if has_unverified_emails %}
<h2>{% trans "Confirmation sent" %}</h2>
<p>{% trans "A verification email has been sent to " %}<strong>{{ request.user.email }}</strong>.</p>
{% else %}
<h2>{% trans "Already confirmed" %}</h2>
<p>
{% blocktrans with user_email=request.user.email %}
Your email <strong>{{ user_email }}</strong> has already been verified.
{% endblocktrans %}
</p>
{% endif %}
{% endblock %}
12 changes: 12 additions & 0 deletions src/sentry/templates/sentry/account/confirm_email/success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% extends "sentry/bases/auth.html" %}

{% load crispy_forms_tags %}
{% load i18n %}

{% block title %}{% trans "Confirm Email" %} | {{ block.super }}{% endblock %}

{% block auth_main %}
<h2>{% trans "Confirmation successful" %}</h2>
<p>{% trans "Thanks for confirming your email." %}</p>
<a class="btn btn-primary" href="{% url 'sentry-login' %}">{% trans "Sign in" %}</a>
{% endblock %}
7 changes: 7 additions & 0 deletions src/sentry/templates/sentry/account/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
{% csrf_token %}
{{ form|as_crispy_errors }}

{% if request.user.has_unverified_emails %}
<div class="alert alert-warning alert-block">
{% trans "Your email address has not been verified. " %}
<a href="{% url 'sentry-account-confirm-email-send' %}">{% trans "Resend Verification Email" %}</a>.
</div>
{% endif %}

<legend style="margin-top: 0;">Your details</legend>

<div class="account-settings-overview">
Expand Down
15 changes: 15 additions & 0 deletions src/sentry/templates/sentry/emails/confirm_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% if is_new_user %}
Thanks for signing up for Sentry!
{% endif %}

Please confirm your email ({{ user.username|safe }}) by clicking the link below:

{{ url|safe }}

This link will expire in 48 hours.

{% if is_new_user %}
If you did not sign up, you may simply ignore this email.
{% else %}
If you did not make this request, you may simply ignore this email.
{% endif %}
46 changes: 43 additions & 3 deletions src/sentry/web/frontend/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
from django.contrib.auth import login as login_user, authenticate
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db import IntegrityError, transaction
from django.http import HttpResponseRedirect, Http404
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.utils import timezone
from sudo.decorators import sudo_required

from sentry.models import (
LostPasswordHash, Project, ProjectStatus, UserOption, Authenticator)
UserEmail, LostPasswordHash, Project, ProjectStatus, UserOption, Authenticator)
from sentry.plugins import plugins
from sentry.web.decorators import login_required, signed_auth_required
from sentry.web.forms.accounts import (
Expand Down Expand Up @@ -122,6 +122,34 @@ def recover_confirm(request, user_id, hash):
return render_to_response(tpl, context, request)


@login_required
def start_confirm_email(request):
has_unverified_emails = request.user.has_unverified_emails()
if has_unverified_emails:
request.user.send_confirm_emails()
return render_to_response('sentry/account/confirm_email/send.html',
{'has_unverified_emails': has_unverified_emails},
request)


def confirm_email(request, user_id, hash):
try:
email = UserEmail.objects.get(user=user_id, validation_hash=hash)
if not email.hash_is_valid():
raise UserEmail.DoesNotExist
except UserEmail.DoesNotExist:
if request.user.is_authenticated() and not request.user.has_unverified_emails():
tpl = 'sentry/account/confirm_email/success.html'
else:
tpl = 'sentry/account/confirm_email/failure.html'
else:
tpl = 'sentry/account/confirm_email/success.html'
email.is_verified = True
email.validation_hash = ''
email.save()
return render_to_response(tpl, {}, request)


@csrf_protect
@never_cache
@login_required
Expand All @@ -134,7 +162,19 @@ def settings(request):
'name': request.user.name,
})
if form.is_valid():
form.save()
old_email = request.user.email
user = form.save()
if user.email != old_email:
UserEmail.objects.get(user=request.user, email=old_email).delete()
try:
with transaction.atomic():
user_email = UserEmail.objects.create(user=user, email=user.email)
except IntegrityError:
pass
else:
user_email.set_hash()
user_email.save()
user.send_confirm_emails()
messages.add_message(request, messages.SUCCESS, 'Your settings were saved.')
return HttpResponseRedirect(request.path)

Expand Down
1 change: 1 addition & 0 deletions src/sentry/web/frontend/auth_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def handle_basic_auth(self, request):

if can_register and register_form.is_valid():
user = register_form.save()
user.send_confirm_emails(is_new_user=True)

# HACK: grab whatever the first backend is and assume it works
user.backend = settings.AUTHENTICATION_BACKENDS[0]
Expand Down
1 change: 1 addition & 0 deletions src/sentry/web/frontend/auth_organization_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def handle_basic_auth(self, request, organization):

if can_register and register_form.is_valid():
user = register_form.save()
user.send_confirm_emails(is_new_user=True)

defaults = {
'role': 'member',
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ def init_all_applications():
url(r'^register/$', AuthLoginView.as_view(),
name='sentry-register'),
url(r'^account/sudo/$', SudoView.as_view(), name='sentry-sudo'),
url(r'^account/confirm-email/$', accounts.start_confirm_email,
name='sentry-account-confirm-email-send'),
url(r'^account/confirm-email/(?P<user_id>[\d]+)/(?P<hash>[0-9a-zA-Z]+)/$', accounts.confirm_email,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the hash just md5? That's just [a-f0-9]{32}

name='sentry-account-confirm-email'),
url(r'^account/recover/$', accounts.recover,
name='sentry-account-recover'),
url(r'^account/recover/confirm/(?P<user_id>[\d]+)/(?P<hash>[0-9a-zA-Z]+)/$', accounts.recover_confirm,
Expand Down
36 changes: 35 additions & 1 deletion tests/sentry/web/frontend/accounts/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from exam import fixture
from social_auth.models import UserSocialAuth

from sentry.models import UserOption, LostPasswordHash, User, ProjectStatus
from sentry.models import UserEmail, LostPasswordHash, ProjectStatus, User, UserOption
from sentry.testutils import TestCase


Expand Down Expand Up @@ -230,3 +230,37 @@ def test_change_password(self):
assert resp.status_code == 302
user = User.objects.get(id=self.user.id)
assert user.check_password('bar')


class ConfirmEmailSendTest(TestCase):
@mock.patch('sentry.models.User.send_confirm_emails')
def test_valid(self, send_confirm_email):
self.login_as(self.user)
resp = self.client.get(reverse('sentry-account-confirm-email-send'))
assert resp.status_code == 200
self.assertTemplateUsed(resp, 'sentry/account/confirm_email/send.html')
send_confirm_email.assert_called_once_with()


class ConfirmEmailTest(TestCase):

def test_invalid(self):
self.user.save()
resp = self.client.get(reverse('sentry-account-confirm-email',
args=[self.user.id, '5b1f2f266efa03b721cc9ea0d4742c5e']))
assert resp.status_code == 200
self.assertTemplateUsed(resp, 'sentry/account/confirm_email/failure.html')
email = UserEmail.objects.get(email=self.user.email)
assert not email.is_verified

def test_valid(self):
self.user.save()
self.login_as(self.user)
self.client.get(reverse('sentry-account-confirm-email-send'))
email = self.user.emails.first()
resp = self.client.get(reverse('sentry-account-confirm-email',
args=[self.user.id, email.validation_hash]))
assert resp.status_code == 200
self.assertTemplateUsed(resp, 'sentry/account/confirm_email/success.html')
email = self.user.emails.first()
assert email.is_verified