diff --git a/settings/common.py b/settings/common.py index d726741f..9834a2de 100644 --- a/settings/common.py +++ b/settings/common.py @@ -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 diff --git a/taiga/auth/backends.py b/taiga/auth/backends.py index d0331fdf..b514f1e7 100644 --- a/taiga/auth/backends.py +++ b/taiga/auth/backends.py @@ -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): diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 17209711..348e41d4 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -35,7 +35,7 @@ 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_register_email(user) -> bool: @@ -43,8 +43,8 @@ def send_register_email(user) -> bool: 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.registered_user(user.email, context) return bool(email.send()) @@ -207,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 diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py new file mode 100644 index 00000000..6b5afd7b --- /dev/null +++ b/taiga/auth/tokens.py @@ -0,0 +1,54 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +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 diff --git a/taiga/users/admin.py b/taiga/users/admin.py index 701e9da6..a3452616 100644 --- a/taiga/users/admin.py +++ b/taiga/users/admin.py @@ -48,7 +48,7 @@ class UserAdmin(DjangoUserAdmin): fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('full_name', 'email', 'bio', 'photo')}), - (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email', 'cancel_token')}), + (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email')}), (_('Permissions'), {'fields': ('is_active', 'is_superuser',)}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) diff --git a/taiga/users/api.py b/taiga/users/api.py index 6a38ab35..7e2a0105 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -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 @@ -268,8 +270,10 @@ class UsersViewSet(ModelCrudViewSet): raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) try: - user = models.User.objects.get(cancel_token=serializer.data["cancel_token"]) - except models.User.DoesNotExist: + 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() diff --git a/taiga/users/migrations/0006_user_cancel_token.py b/taiga/users/migrations/0006_user_cancel_token.py deleted file mode 100644 index d854a31a..00000000 --- a/taiga/users/migrations/0006_user_cancel_token.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0005_alter_user_photo'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='cancel_token', - field=models.CharField(default=None, max_length=200, blank=True, null=True, verbose_name='email token'), - preserve_default=True, - ), - ] diff --git a/taiga/users/models.py b/taiga/users/models.py index 782dd0b7..e38f5417 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -31,6 +31,7 @@ from django.utils.encoding import force_bytes 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 @@ -124,9 +125,6 @@ class User(AbstractBaseUser, PermissionsMixin): github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID")) - cancel_token = models.CharField(max_length=200, null=True, blank=True, default=None, - verbose_name=_("cancel account token")) - USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] @@ -151,9 +149,7 @@ class User(AbstractBaseUser, PermissionsMixin): return self.full_name or self.username or self.email def save(self, *args, **kwargs): - if not self.cancel_token: - self.cancel_token = str(uuid.uuid1()) - + get_token_for_user(self, "cancel_account") super().save(*args, **kwargs) def cancel(self): diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja index ba48feb9..e82d0606 100644 --- a/taiga/users/templates/emails/registered_user-body-html.jinja +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -7,8 +7,7 @@

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', user.cancel_token) }} + {{ 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.

@@ -23,5 +22,3 @@ The Taiga development team.

{% endblock %} - - diff --git a/taiga/users/templates/emails/registered_user-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja index 9db5151a..dde7e8b0 100644 --- a/taiga/users/templates/emails/registered_user-body-text.jinja +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -2,7 +2,7 @@ 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', user.cancel_token) }} +{{ 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. diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 6db03f83..60dced10 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -27,6 +27,7 @@ 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 @@ -89,7 +90,8 @@ def test_response_200_in_public_registration(client, settings): assert len(mail.outbox) == 1 assert mail.outbox[0].subject == "You've been Taigatized!" user = models.User.objects.get(username="mmcfly") - cancel_url = resolve_front_url("cancel-account", user.cancel_token) + 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): diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index fb79ef24..2beb3762 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -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 @@ -130,7 +131,8 @@ def test_api_user_delete(client): def test_api_user_cancel_valid_token(client): user = f.UserFactory.create() url = reverse('users-cancel') - data = {"cancel_token": user.cancel_token} + 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")