Merge pull request #115 from taigaio/cancel-account-with-token
Cancel account with tokenremotes/origin/enhancement/email-actions
commit
e94ca914a4
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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}",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 %}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
You've been Taigatized!
|
|
@ -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",
|
||||
|
|
|
@ -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?"
|
||||
|
|
Loading…
Reference in New Issue