Merge pull request #115 from taigaio/cancel-account-with-token

Cancel account with token
remotes/origin/enhancement/email-actions
David Barragán Merino 2014-10-14 17:50:45 +02:00
commit e94ca914a4
13 changed files with 222 additions and 73 deletions

View File

@ -271,6 +271,9 @@ AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend", # default
)
MAX_AGE_AUTH_TOKEN = None
MAX_AGE_CANCEL_ACCOUNT = 30 * 24 * 60 * 60 # 30 days in seconds
ANONYMOUS_USER_ID = -1
MAX_SEARCH_RESULTS = 100

View File

@ -35,11 +35,10 @@ fraudulent modifications.
import base64
import re
from django.core import signing
from django.apps import apps
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from taiga.base import exceptions as exc
from .tokens import get_user_for_token
class Session(BaseAuthentication):
"""
@ -62,39 +61,6 @@ class Session(BaseAuthentication):
return (user, None)
def get_token_for_user(user):
"""
Generate a new signed token containing
a specified user.
"""
data = {"user_id": user.id}
return signing.dumps(data)
def get_user_for_token(token):
"""
Given a selfcontained token, try parse and
unsign it.
If token passes a validation, returns
a user instance corresponding with user_id stored
in the incoming token.
"""
try:
data = signing.loads(token)
except signing.BadSignature:
raise exc.NotAuthenticated("Invalid token")
model_cls = apps.get_model("users", "User")
try:
user = model_cls.objects.get(pk=data["user_id"])
except model_cls.DoesNotExist:
raise exc.NotAuthenticated("Invalid token")
else:
return user
class Token(BaseAuthentication):
"""
Self-contained stateles authentication implementatrion
@ -114,7 +80,10 @@ class Token(BaseAuthentication):
return None
token = token_rx_match.group(1)
user = get_user_for_token(token)
max_age_auth_token = getattr(settings, "MAX_AGE_AUTH_TOKEN", None)
user = get_user_for_token(token, "authentication",
max_age=max_age_auth_token)
return (user, token)
def authenticate_header(self, request):

View File

@ -35,31 +35,18 @@ from taiga.base import exceptions as exc
from taiga.users.serializers import UserSerializer
from taiga.users.services import get_and_validate_user
from .backends import get_token_for_user
from .tokens import get_token_for_user
from .signals import user_registered as user_registered_signal
def send_public_register_email(user) -> bool:
def send_register_email(user) -> bool:
"""
Given a user, send public register welcome email
Given a user, send register welcome email
message to specified user.
"""
context = {"user": user}
cancel_token = get_token_for_user(user, "cancel_account")
context = {"user": user, "cancel_token": cancel_token}
mbuilder = MagicMailBuilder()
email = mbuilder.public_register_user(user.email, context)
return bool(email.send())
def send_private_register_email(user, **kwargs) -> bool:
"""
Given a user, send private register welcome
email message to specified user.
"""
context = {"user": user}
context.update(kwargs)
mbuilder = MagicMailBuilder()
email = mbuilder.private_register_user(user.email, context)
email = mbuilder.registered_user(user.email, context)
return bool(email.send())
@ -125,7 +112,7 @@ def public_register(username:str, password:str, email:str, full_name:str):
except IntegrityError:
raise exc.WrongArguments("User is already register.")
# send_public_register_email(user)
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -149,7 +136,7 @@ def private_register_for_existing_user(token:str, username:str, password:str):
except IntegrityError:
raise exc.IntegrityError("Membership with user is already exists.")
# send_private_register_email(user)
send_register_email(user)
return user
@ -178,6 +165,7 @@ def private_register_for_new_user(token:str, username:str, email:str,
membership = get_membership_by_token(token)
membership.user = user
membership.save(update_fields=["user"])
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -205,6 +193,7 @@ def github_register(username:str, email:str, full_name:str, github_id:int, bio:s
membership.save(update_fields=["user"])
if created:
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -218,5 +207,5 @@ def make_auth_response_data(user) -> dict:
"""
serializer = UserSerializer(user)
data = dict(serializer.data)
data["auth_token"] = get_token_for_user(user)
data["auth_token"] = get_token_for_user(user, "authentication")
return data

54
taiga/auth/tokens.py Normal file
View File

@ -0,0 +1,54 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base import exceptions as exc
from django.apps import apps
from django.core import signing
def get_token_for_user(user, scope):
"""
Generate a new signed token containing
a specified user limited for a scope (identified as a string).
"""
data = {"user_%s_id"%(scope): user.id}
return signing.dumps(data)
def get_user_for_token(token, scope, max_age=None):
"""
Given a selfcontained token and a scope try to parse and
unsign it.
If max_age is specified it checks token expiration.
If token passes a validation, returns
a user instance corresponding with user_id stored
in the incoming token.
"""
try:
data = signing.loads(token, max_age=max_age)
except signing.BadSignature:
raise exc.NotAuthenticated("Invalid token")
model_cls = apps.get_model("users", "User")
try:
user = model_cls.objects.get(pk=data["user_%s_id"%(scope)])
except model_cls.DoesNotExist:
raise exc.NotAuthenticated("Invalid token")
else:
return user

View File

@ -22,6 +22,7 @@ urls = {
"login": "/login",
"change-password": "/change-password/{0}",
"change-email": "/change-email/{0}",
"cancel-account": "/cancel-account/{0}",
"invitation": "/invitation/{0}",
"project": "/project/{0}",

View File

@ -22,6 +22,7 @@ from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
from easy_thumbnails.source_generators import pil_image
@ -32,6 +33,7 @@ from rest_framework import status
from djmail.template_mail import MagicMailBuilder
from taiga.auth.tokens import get_user_for_token
from taiga.base.decorators import list_route, detail_route
from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet
@ -258,20 +260,27 @@ class UsersViewSet(ModelCrudViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@list_route(methods=["POST"])
def cancel(self, request, pk=None):
"""
Cancel an account via token
"""
serializer = serializers.CancelAccountSerializer(data=request.DATA, many=False)
if not serializer.is_valid():
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
try:
max_age_cancel_account = getattr(settings, "MAX_AGE_CANCEL_ACCOUNT", None)
user = get_user_for_token(serializer.data["cancel_token"], "cancel_account",
max_age=max_age_cancel_account)
except exc.NotAuthenticated:
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, pk=None):
user = self.get_object()
self.check_permissions(request, "destroy", user)
user.username = slugify_uniquely("deleted-user", models.User, slugfield="username")
user.email = "{}@taiga.io".format(user.username)
user.is_active = False
user.full_name = "Deleted user"
user.color = ""
user.bio = ""
user.default_language = ""
user.default_timezone = ""
user.colorize_tags = True
user.token = None
user.github_id = None
user.set_unusable_password()
user.save()
user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -19,6 +19,7 @@ import os
import os.path as path
import random
import re
import uuid
from unidecode import unidecode
@ -33,6 +34,7 @@ from django.template.defaultfilters import slugify
from djorm_pgarray.fields import TextArrayField
from taiga.auth.tokens import get_token_for_user
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.iterators import split_by_n
from taiga.permissions.permissions import MEMBERS_PERMISSIONS
@ -152,6 +154,24 @@ class User(AbstractBaseUser, PermissionsMixin):
def get_full_name(self):
return self.full_name or self.username or self.email
def save(self, *args, **kwargs):
get_token_for_user(self, "cancel_account")
super().save(*args, **kwargs)
def cancel(self):
self.username = slugify_uniquely("deleted-user", User, slugfield="username")
self.email = "{}@taiga.io".format(self.username)
self.is_active = False
self.full_name = "Deleted user"
self.color = ""
self.bio = ""
self.default_language = ""
self.default_timezone = ""
self.colorize_tags = True
self.token = None
self.github_id = None
self.set_unusable_password()
self.save()
class Role(models.Model):
name = models.CharField(max_length=200, null=False, blank=False,

View File

@ -69,3 +69,7 @@ class RecoverySerializer(serializers.Serializer):
class ChangeEmailSerializer(serializers.Serializer):
email_token = serializers.CharField(max_length=200)
class CancelAccountSerializer(serializers.Serializer):
cancel_token = serializers.CharField(max_length=200)

View File

@ -0,0 +1,24 @@
{% extends "emails/base.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<p>Welcome to Taiga, an Open Source, Agile Project Management Tool</p>
<p>You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:</p>
{{ resolve_front_url('cancel-account', cancel_token) }}
<p>We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done.</p>
<p>We hope you enjoy it.</p>
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
The Taiga development team.
</p>
{% endblock %}

View File

@ -0,0 +1,12 @@
Welcome to Taiga, an Open Source, Agile Project Management Tool
You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:
{{ resolve_front_url('cancel-account', cancel_token) }}
We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done.
We hope you enjoy it.
--
The Taiga development team.

View File

@ -0,0 +1 @@
You've been Taigatized!

View File

@ -20,10 +20,14 @@ from unittest.mock import patch, Mock
from django.apps import apps
from django.core.urlresolvers import reverse
from django.core import mail
from .. import factories
from taiga.base.connectors import github
from taiga.front import resolve as resolve_front_url
from taiga.users import models
from taiga.auth.tokens import get_token_for_user
pytestmark = pytest.mark.django_db
@ -68,6 +72,28 @@ def test_respond_201_with_invitation_without_public_registration(client, registe
assert response.status_code == 201, response.data
def test_response_200_in_public_registration(client, settings):
settings.PUBLIC_REGISTER_ENABLED = True
form = {
"type": "public",
"username": "mmcfly",
"full_name": "martin seamus mcfly",
"email": "mmcfly@bttf.com",
"password": "password",
}
response = client.post(reverse("auth-register"), form)
assert response.status_code == 201
assert response.data["username"] == "mmcfly"
assert response.data["email"] == "mmcfly@bttf.com"
assert response.data["full_name"] == "martin seamus mcfly"
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == "You've been Taigatized!"
user = models.User.objects.get(username="mmcfly")
cancel_token = get_token_for_user(user, "cancel_account")
cancel_url = resolve_front_url("cancel-account", cancel_token)
assert mail.outbox[0].body.index(cancel_url) > 0
def test_response_200_in_registration_with_github_account(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
form = {"type": "github",

View File

@ -6,6 +6,7 @@ from django.core.urlresolvers import reverse
from .. import factories as f
from taiga.users import models
from taiga.auth.tokens import get_token_for_user
pytestmark = pytest.mark.django_db
@ -113,3 +114,39 @@ def test_api_user_action_change_email_invalid_token(client):
assert response.status_code == 400
assert response.data['_error_message'] == 'Invalid, are you sure the token is correct and you didn\'t use it before?'
def test_api_user_delete(client):
user = f.UserFactory.create()
url = reverse('users-detail', kwargs={"pk": user.pk})
client.login(user)
response = client.delete(url)
assert response.status_code == 204
user = models.User.objects.get(pk=user.id)
assert user.full_name == "Deleted user"
def test_api_user_cancel_valid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
cancel_token = get_token_for_user(user, "cancel_account")
data = {"cancel_token": cancel_token}
client.login(user)
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 204
user = models.User.objects.get(pk=user.id)
assert user.full_name == "Deleted user"
def test_api_user_cancel_invalid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
data = {"cancel_token": "invalid_cancel_token"}
client.login(user)
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
assert response.data['_error_message'] == "Invalid, are you sure the token is correct?"