diff --git a/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.html b/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.html new file mode 100644 index 0000000..42f3464 --- /dev/null +++ b/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.html @@ -0,0 +1,25 @@ +{% load i18n demotags %} + + + + + + + +
+ +

+ {% trans "Follow this link to validate your email:" %}
+ {% url "pages:confirm-email" token=confirmation.external_id as confirmation_url %} + {% frontend_base_url as base_url %} + {{ base_url }}{{ confirmation_url }} +

+ +

+ {% trans "Or send an API request to simulate a front-end application:" %}
+ HTTP POST {{ base_url }}{% url "auth:confirm" %} email="{{ user.email }}" token="{{ confirmation.external_id }}" +

+ +
+ + diff --git a/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.txt b/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.txt new file mode 100644 index 0000000..51d09b7 --- /dev/null +++ b/demo/demo/accounts/templates/rest_auth_toolkit/email_confirmation.txt @@ -0,0 +1,11 @@ +{% autoescape off %} +{% load i18n demotags %} + +{% trans "Follow this link to validate your email:" %} +{% frontend_base_url as base_url %} +{{ base_url }}{% url "pages:confirm-email" token=confirmation.external_id %} + +{% trans "Or send an API request to simulate a front-end application:" %} + HTTP POST {{ base_url }}{% url "auth:confirm" %} email="{{ user.email }}" token="{{ confirmation.external_id }}" + +{% endautoescape %} diff --git a/demo/demo/accounts/templatetags/__init__.py b/demo/demo/accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/demo/accounts/templatetags/demotags.py b/demo/demo/accounts/templatetags/demotags.py new file mode 100644 index 0000000..3dd9a11 --- /dev/null +++ b/demo/demo/accounts/templatetags/demotags.py @@ -0,0 +1,10 @@ +from django import template + + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def frontend_base_url(context): + request = context['request'] + return request.build_absolute_uri('/')[:-1] diff --git a/demo/demo/pages/auth_urls.py b/demo/demo/pages/auth_urls.py deleted file mode 100644 index 6979f2a..0000000 --- a/demo/demo/pages/auth_urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from .views import email_view - - -app_name = 'app-auth' - -urlpatterns = [ - path('emails//', email_view, name='email-confirmation'), -] diff --git a/demo/demo/pages/templates/base.html b/demo/demo/pages/templates/base.html new file mode 100644 index 0000000..88bf32e --- /dev/null +++ b/demo/demo/pages/templates/base.html @@ -0,0 +1,13 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + {% block title %}{% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/demo/demo/pages/templates/error.html b/demo/demo/pages/templates/error.html index cb2a99e..a791ac2 100644 --- a/demo/demo/pages/templates/error.html +++ b/demo/demo/pages/templates/error.html @@ -1,18 +1,10 @@ +{% extends "base.html" %} {% load i18n %} -{% get_current_language as LANGUAGE_CODE %} - - - - - - {{ site_name }} - - +{% block title %}Error! {{ site_name }}{% endblock %} +{% block body %}

{% trans "Error!" %}

{{ error }}

- - - +{% endblock %} diff --git a/demo/demo/pages/templates/index.html b/demo/demo/pages/templates/index.html index 2d42d3a..3022ceb 100644 --- a/demo/demo/pages/templates/index.html +++ b/demo/demo/pages/templates/index.html @@ -1,13 +1,7 @@ -{% load i18n %} -{% get_current_language as LANGUAGE_CODE %} - - - - - - {{ site_name }} - - +{% extends "base.html" %} +{% block title %}{{ site_name }}{% endblock %} + +{% block body %} - - +{% endblock %} diff --git a/demo/demo/pages/templates/welcome.html b/demo/demo/pages/templates/welcome.html new file mode 100644 index 0000000..e21a569 --- /dev/null +++ b/demo/demo/pages/templates/welcome.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}Success! {{ site_name }}{% endblock %} + +{% block body %} +
+

{% trans "Success!" %}

+

Your address {{ email }} is now confirmed.

+
+{% endblock %} diff --git a/demo/demo/pages/urls.py b/demo/demo/pages/urls.py index 90b65eb..8caefe1 100644 --- a/demo/demo/pages/urls.py +++ b/demo/demo/pages/urls.py @@ -8,4 +8,5 @@ urlpatterns = [ path('', views.index, name='root'), + path('welcome//', views.confirm_email, name='confirm-email'), ] diff --git a/demo/demo/pages/views.py b/demo/demo/pages/views.py index 7c95058..c0fd954 100644 --- a/demo/demo/pages/views.py +++ b/demo/demo/pages/views.py @@ -14,12 +14,12 @@ def index(request): return render(request, 'index.html', context=ctx) -def email_view(request, external_id): +def confirm_email(request, token): """Landing page for links in confirmation emails.""" error = None try: - confirmation = EmailConfirmation.objects.get(external_id=external_id) + confirmation = EmailConfirmation.objects.get(external_id=token) confirmation.confirm() except EmailConfirmation.DoesNotExist: error = _('Invalid link') @@ -33,4 +33,8 @@ def email_view(request, external_id): } return render(request, 'error.html', context=ctx) else: - return index(request) + ctx = { + 'site_name': 'Demo', + 'email': confirmation.user.email, + } + return render(request, 'welcome.html', context=ctx) diff --git a/demo/demo/settings.py b/demo/demo/settings.py index b1ceb25..c70275e 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -121,6 +121,5 @@ REST_AUTH_TOOLKIT = { 'email_confirmation_class': 'demo.accounts.models.EmailConfirmation', 'email_confirmation_from': 'auth-demo@localhost', - 'email_confirmation_lookup_field': 'external_id', 'api_token_class': 'demo.accounts.models.APIToken', } diff --git a/demo/demo/urls.py b/demo/demo/urls.py index 8c03624..6a998be 100644 --- a/demo/demo/urls.py +++ b/demo/demo/urls.py @@ -6,7 +6,13 @@ from rest_framework.documentation import include_docs_urls from rest_framework.renderers import DocumentationRenderer -from rest_auth_toolkit.views import FacebookLoginView, LoginView, LogoutView, SignupView +from rest_auth_toolkit.views import ( + EmailConfirmationView, + FacebookLoginView, + LoginView, + LogoutView, + SignupView, +) class GoAwayRenderer(DocumentationRenderer): @@ -16,6 +22,7 @@ class GoAwayRenderer(DocumentationRenderer): auth_urlpatterns = [ path('signup/', SignupView.as_view(), name='signup'), + path('confirm/', EmailConfirmationView.as_view(), name='confirm'), path('login/', LoginView.as_view(), name='login'), path('logout/', LogoutView.as_view(), name='logout'), path('fb-login/', FacebookLoginView.as_view(), name='fb-login'), @@ -32,5 +39,4 @@ class GoAwayRenderer(DocumentationRenderer): path('admin/', admin.site.urls), path('api/', include(api_urlpatterns)), path('', include('demo.pages.urls')), - path('', include('demo.pages.auth_urls')), ] diff --git a/rest_auth_toolkit/app.py b/rest_auth_toolkit/app.py index 594ce45..48edb28 100644 --- a/rest_auth_toolkit/app.py +++ b/rest_auth_toolkit/app.py @@ -5,7 +5,7 @@ class RestAuthToolkitConfig(AppConfig): """Default app config for RATK. This installs a signal handler to set user.is_active when - email_confirmed is emitted. + email_confirmed is emitted by EmailConfirmationView. """ name = 'rest_auth_toolkit' diff --git a/rest_auth_toolkit/serializers.py b/rest_auth_toolkit/serializers.py index dc7dfbe..a895834 100644 --- a/rest_auth_toolkit/serializers.py +++ b/rest_auth_toolkit/serializers.py @@ -8,6 +8,8 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from .utils import get_object_from_setting + try: import facepy except ImportError: @@ -17,6 +19,7 @@ User = get_user_model() +EmailConfirmation = get_object_from_setting('email_confirmation_class') class SignupDeserializer(serializers.ModelSerializer): @@ -54,6 +57,31 @@ def create(self, validated_data): ) +class EmailConfirmationDeserializer(serializers.Serializer): + email = serializers.EmailField() + token = serializers.CharField() + + def validate(self, data): + msg = None + + try: + confirmation = EmailConfirmation.objects.get( + external_id=data['token'], + user__email=data['email'], + ) + confirmation.confirm() + except EmailConfirmation.DoesNotExist: + msg = _('Invalid link') + except EmailConfirmation.IsExpired: + # FIXME it's not possible to register with the same email + msg = _('Email expired, please register again') + + if msg: + raise ValidationError({'errors': [msg]}) + + return {'user': confirmation.user} + + class LoginDeserializer(serializers.Serializer): """Deserializer to find a user from credentials.""" diff --git a/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.html b/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.html deleted file mode 100644 index 3ad3539..0000000 --- a/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.html +++ /dev/null @@ -1,16 +0,0 @@ -{% load i18n %} - - - - - - - -
- -

{% trans "Follow this link to validate your email:" %}
- {{ confirmation_url }}

- -
- - diff --git a/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.txt b/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.txt deleted file mode 100644 index 22a01d5..0000000 --- a/rest_auth_toolkit/templates/rest_auth_toolkit/email_confirmation.txt +++ /dev/null @@ -1,7 +0,0 @@ -{% autoescape off %} -{% load i18n %} - -{% trans "Follow this link to validate your email:" %} -{{ confirmation_url }} - -{% endautoescape %} diff --git a/rest_auth_toolkit/views.py b/rest_auth_toolkit/views.py index 011b433..064c14a 100644 --- a/rest_auth_toolkit/views.py +++ b/rest_auth_toolkit/views.py @@ -1,14 +1,15 @@ from django.contrib.auth import get_user_model from django.core.mail import send_mail from django.template.loader import render_to_string -from django.urls import reverse from django.utils.translation import gettext as _ from rest_framework import generics, status, views from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .serializers import FacebookLoginDeserializer, LoginDeserializer, SignupDeserializer +from .models import email_confirmed +from .serializers import (FacebookLoginDeserializer, LoginDeserializer, + SignupDeserializer, EmailConfirmationDeserializer) from .utils import get_object_from_setting, get_setting, MissingSetting try: @@ -26,11 +27,27 @@ class SignupView(generics.GenericAPIView): If the setting email_confirmation_send_email is true (default), the function send_email will be called. That function requires - that your app define a route named app-auth:email-confirmation - with an id parameter; the view for this route should get an - email confirmation instance using the ID and call the confirm - method. To use a field that's not named 'id', define the setting - email_confirmation_lookup_param (this will change the URL pattern). + that your project defines defines two email templates: + - rest_auth_toolkit/email_confirmation.txt + - rest_auth_toolkit/email_confirmation.html + + The templates will be passed the User and EmailConfirmation instances + (as variables *user* and *confirmation*) as well as the request; + note that template context processors are not available in email + teamplates. + + It is up to your project to generate a link that will work, using + your own template code or custom tags. + + The demo app shows one way to handle this: a Django view validates + the email confirmation token in its URL; the link is generated with + a custom template tag because Django doesn't offer a tag to create + full URLs. For a project with a front-end site (e.g. a JavaScript app) + on a different domain than the API powered by Django, the template tag + could for example use a setting to know the front-end domain name + a + mapping of front-end routes to generate the path portion of the links. + + If the setting is false, the user will be active immediately. """ authentication_classes = () permission_classes = () @@ -48,19 +65,71 @@ def post(self, request): """ deserializer = self.get_serializer(data=request.data) deserializer.is_valid(raise_exception=True) - user = deserializer.save() - if self.email_confirmation_class is None: - raise MissingSetting('email_confirmation_string') + confirm_email = get_setting('email_confirmation_send_email', True) - confirmation = self.email_confirmation_class.objects.create(user=user) - if get_setting('email_confirmation_send_email', True): + if not confirm_email: + deserializer.save(is_active=True) + else: + user = deserializer.save() + + if self.email_confirmation_class is None: + raise MissingSetting('email_confirmation_class') + + confirmation = self.email_confirmation_class.objects.create(user=user) email_field = user.get_email_field_name() send_email(request, user, getattr(user, email_field), confirmation) return Response(deserializer.data, status=status.HTTP_201_CREATED) +def send_email(request, user, address, confirmation): + """Send the confirmation email for a new user.""" + subject = _('Confirm your email address') + from_address = get_setting('email_confirmation_from') + + context = { + 'user': user, + 'confirmation': confirmation, + } + txt_content = render_to_string('rest_auth_toolkit/email_confirmation.txt', + context, request=request) + html_content = render_to_string('rest_auth_toolkit/email_confirmation.html', + context, request=request) + + send_mail(subject=subject, + from_email=from_address, recipient_list=[address], + message=txt_content, html_message=html_content, + fail_silently=False) + + +class EmailConfirmationView(generics.GenericAPIView): + """Validate an email address after sign-up. + + Response + + `200 OK` + + Error response (code 400): + + ```json + {"errors": {"token": "Error message"}} + ``` + """ + authentication_classes = () + permission_classes = () + serializer_class = get_object_from_setting('email_confirmation_serializer_class', + EmailConfirmationDeserializer) + + def post(self, request): + deserializer = self.get_serializer(data=request.data) + deserializer.is_valid(raise_exception=True) + + email_confirmed.send(sender=self.__class__, + user=deserializer.validated_data['user']) + return Response() + + class LoginView(generics.GenericAPIView): """Email address log-in endpoint. @@ -145,28 +214,6 @@ def post(self, request): return Response(status=status.HTTP_200_OK) -def send_email(request, user, address, confirmation): - """Send the confirmation email for a new user.""" - subject = _('Confirm your email address') - from_address = get_setting('email_confirmation_from') - - lookup_field = get_setting('email_confirmation_lookup_field', 'id') - confirmation_url = request.build_absolute_uri( - reverse('app-auth:email-confirmation', - kwargs={lookup_field: getattr(confirmation, lookup_field)})) - # The url template tag doesn't include scheme/domain/port, pass a helper - base_url = request.build_absolute_uri('/')[:-1] - - context = {'base_url': base_url, 'confirmation_url': confirmation_url} - txt_content = render_to_string('rest_auth_toolkit/email_confirmation.txt', context) - html_content = render_to_string('rest_auth_toolkit/email_confirmation.html', context) - - send_mail(subject=subject, - from_email=from_address, recipient_list=[address], - message=txt_content, html_message=html_content, - fail_silently=False) - - def activate_user(sender, **kwargs): """Mark user as active when a confirmation link is visited. diff --git a/tests/functional/test_email.py b/tests/functional/test_email.py index b5fe5b3..e48e4f9 100644 --- a/tests/functional/test_email.py +++ b/tests/functional/test_email.py @@ -67,8 +67,8 @@ def test_confirm_email(db, django_app, user0, emailconfirmation0): assert not user0.is_active assert emailconfirmation0.confirmed is None - django_app.get(reverse("app-auth:email-confirmation", - kwargs={"external_id": emailconfirmation0.external_id})) + django_app.get(reverse("pages:confirm-email", + kwargs={"token": emailconfirmation0.external_id})) user0.refresh_from_db() emailconfirmation0.refresh_from_db()