Browse Source

More secure unsubscribe links for monthly reports.

pull/140/head
Pēteris Caune 7 years ago
parent
commit
1fd5d0b3ce
5 changed files with 46 additions and 15 deletions
  1. +9
    -7
      hc/accounts/models.py
  2. +20
    -1
      hc/accounts/tests/test_unsubscribe_reports.py
  3. +1
    -1
      hc/accounts/urls.py
  4. +14
    -4
      hc/accounts/views.py
  5. +2
    -2
      templates/bad_link.html

+ 9
- 7
hc/accounts/models.py View File

@ -6,7 +6,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import signing
from django.core.signing import TimestampSigner
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -68,6 +68,12 @@ class Profile(models.Model):
def notifications_url(self): def notifications_url(self):
return settings.SITE_ROOT + reverse("hc-notifications") return settings.SITE_ROOT + reverse("hc-notifications")
def reports_unsub_url(self):
signer = TimestampSigner(salt="reports")
signed_username = signer.sign(self.user.username)
path = reverse("hc-unsubscribe-reports", args=[signed_username])
return settings.SITE_ROOT + path
def team(self): def team(self):
# compare ids to avoid SQL queries # compare ids to avoid SQL queries
if self.current_team_id and self.current_team_id != self.id: if self.current_team_id and self.current_team_id != self.id:
@ -137,10 +143,6 @@ class Profile(models.Model):
if nag and num_down == 0: if nag and num_down == 0:
return False return False
token = signing.Signer().sign(uuid.uuid4())
path = reverse("hc-unsubscribe-reports", args=[self.user.username])
unsub_link = "%s%s?token=%s" % (settings.SITE_ROOT, path, token)
# Sort checks by owner. Need this because will group by owner in # Sort checks by owner. Need this because will group by owner in
# template. # template.
checks = checks.order_by("user_id") checks = checks.order_by("user_id")
@ -149,8 +151,8 @@ class Profile(models.Model):
"checks": checks, "checks": checks,
"sort": self.sort, "sort": self.sort,
"now": timezone.now(), "now": timezone.now(),
"unsub_link": unsub_link,
"notifications_url": self.notifications_url,
"unsub_link": self.reports_unsub_url(),
"notifications_url": self.notifications_url(),
"nag": nag, "nag": nag,
"nag_period": self.nag_period.total_seconds(), "nag_period": self.nag_period.total_seconds(),
"num_down": num_down "num_down": num_down


+ 20
- 1
hc/accounts/tests/test_unsubscribe_reports.py View File

@ -6,7 +6,7 @@ from hc.test import BaseTestCase
class UnsubscribeReportsTestCase(BaseTestCase): class UnsubscribeReportsTestCase(BaseTestCase):
def test_it_works(self):
def test_token_works(self):
self.profile.nag_period = td(hours=1) self.profile.nag_period = td(hours=1)
self.profile.save() self.profile.save()
@ -18,3 +18,22 @@ class UnsubscribeReportsTestCase(BaseTestCase):
self.profile.refresh_from_db() self.profile.refresh_from_db()
self.assertFalse(self.profile.reports_allowed) self.assertFalse(self.profile.reports_allowed)
self.assertEqual(self.profile.nag_period.total_seconds(), 0) self.assertEqual(self.profile.nag_period.total_seconds(), 0)
def test_bad_token_gets_rejected(self):
url = "/accounts/unsubscribe_reports/alice/?token=invalid"
r = self.client.get(url)
self.assertContains(r, "Incorrect Link")
def test_signed_username_works(self):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "You have been unsubscribed")
self.profile.refresh_from_db()
self.assertFalse(self.profile.reports_allowed)
def test_bad_signature_gets_rejected(self):
url = "/accounts/unsubscribe_reports/invalid/"
r = self.client.get(url)
self.assertContains(r, "Incorrect Link")

+ 1
- 1
hc/accounts/urls.py View File

@ -18,7 +18,7 @@ urlpatterns = [
url(r'^profile/badges/$', views.badges, name="hc-badges"), url(r'^profile/badges/$', views.badges, name="hc-badges"),
url(r'^close/$', views.close, name="hc-close"), url(r'^close/$', views.close, name="hc-close"),
url(r'^unsubscribe_reports/([\w-]+)/$',
url(r'^unsubscribe_reports/([\w\:-]+)/$',
views.unsubscribe_reports, name="hc-unsubscribe-reports"), views.unsubscribe_reports, name="hc-unsubscribe-reports"),
url(r'^set_password/([\w-]+)/$', url(r'^set_password/([\w-]+)/$',


+ 14
- 4
hc/accounts/views.py View File

@ -348,10 +348,20 @@ def change_email_done(request):
def unsubscribe_reports(request, username): def unsubscribe_reports(request, username):
try:
signing.Signer().unsign(request.GET.get("token"))
except signing.BadSignature:
return HttpResponseBadRequest()
if ":" in username:
signer = signing.TimestampSigner(salt="reports")
try:
username = signer.unsign(username)
except signing.BadSignature:
return render(request, "bad_link.html")
else:
# Username is not signed but there should be a ?token=... parameter
# This is here for backwards compatibility and will be removed
# at some point.
try:
signing.Signer().unsign(request.GET.get("token"))
except signing.BadSignature:
return render(request, "bad_link.html")
user = User.objects.get(username=username) user = User.objects.get(username=username)
profile = Profile.objects.for_user(user) profile = Profile.objects.for_user(user)


+ 2
- 2
templates/bad_link.html View File

@ -4,10 +4,10 @@
<div class="row"> <div class="row">
<div class="col-sm-6 col-sm-offset-3"> <div class="col-sm-6 col-sm-offset-3">
<div class="hc-dialog"> <div class="hc-dialog">
<h1>Incorrect Confirmation Link</h1>
<h1>Incorrect Link</h1>
<div class="dialog-body"> <div class="dialog-body">
<p> <p>
The confirmation link you just used is incorrect.
The link you just used is incorrect.
If you copy-pasted the link, please make sure you did not If you copy-pasted the link, please make sure you did not
miss any characters. miss any characters.
</p> </p>


Loading…
Cancel
Save