Skip to content

Commit

Permalink
Adds views/URLs
Browse files Browse the repository at this point in the history
Addresses the following:

- Adds view/URL to support the sending of the token
- Adds view/URL to support the input/validation of the token
- Small renaming of existing templates to suit the current application
  context
- Adds some supplementary utility functions

Contributes towards: #107
  • Loading branch information
WayneLambert committed Aug 6, 2021
1 parent cae9a35 commit f802944
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 5 deletions.
5 changes: 4 additions & 1 deletion apps/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from two_factor.views import QRGeneratorView

from apps.users.views import (ProfileUpdateView, ProfileView, UserLoginView,
UserRegisterView, UserSetupQRView,)
UserRegisterView, UserSetupEmailTokenView,
UserSetupEmailView, UserSetupQRView,)


app_name = 'users'
Expand All @@ -20,6 +21,8 @@
path('login/', UserLoginView.as_view(), name='login'),
path('two-factor/setup/qr/', UserSetupQRView.as_view(), name='setup'),
path('two-factor/qrcode/', QRGeneratorView.as_view(), name='qr'),
path('two-factor/setup/email/', UserSetupEmailView.as_view(), name='setup_email'),
path('two-factor/setup/email/token/', UserSetupEmailTokenView.as_view(), name='setup_email_token'),
path('logout/', auth_views.LogoutView.as_view(template_name='users/logout.html'), name='logout'),
path('password-reset/',
auth_views.PasswordResetView.as_view(
Expand Down
22 changes: 22 additions & 0 deletions apps/users/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import datetime
import random

from django.conf import settings
from django.utils import timezone

from django_otp.util import hex_validator


def generate_token() -> str:
""" Generates a 6 digit random number including any leading zeros """
return str(random.randint(0, 999_999)).zfill(6)


def get_challenge_expiration_timestamp():
""" Sets expiration timestamp at point in future as per the project setting """
return timezone.now() + datetime.timedelta(seconds=settings.EMAIL_TOKEN_EXPIRATION_IN_SECS)


def token_validator(*args, **kwargs):
""" Wraps hex_validator generator satisfying `makemigrations` """
return hex_validator()(*args, **kwargs)
128 changes: 124 additions & 4 deletions apps/users/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
from typing import Any, Dict

from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate, get_user_model, login
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.mail.message import EmailMultiAlternatives
from django.http.request import split_domain_port
from django.shortcuts import get_object_or_404, redirect, reverse
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.views.generic import CreateView, DetailView
from django.views.generic.base import TemplateView

from shapeshifter.views import MultiModelFormView
from two_factor.forms import AuthenticationTokenForm
from two_factor.views.core import LoginView, SetupView
from users.utils import generate_token, get_challenge_expiration_timestamp

from apps.users.forms import (ProfileUpdateForm, UserRegisterForm, UserTOTPDeviceForm,
UserUpdateForm,)
from apps.users.models import Profile
from apps.users.forms import (EmailTokenSubmissionForm, ProfileUpdateForm,
UserRegisterForm, UserTOTPDeviceForm, UserUpdateForm,)
from apps.users.models import EmailToken, Profile


class UserRegisterView(CreateView):
Expand Down Expand Up @@ -57,7 +67,7 @@ class UserLoginView(LoginView):


class UserSetupQRView(SetupView):
template_name = 'two_factor/setup.html'
template_name = 'two_factor/setup_by_qr.html'
success_url = 'blog:home'

form_list = (
Expand All @@ -71,6 +81,116 @@ def get_method(self):
return 'generator'


class UserSetupEmailView(TemplateView):
template_name = 'two_factor/setup_by_email.html'
success_url = 'blog:users:setup_email_token'

def store_token_in_db(self, user, token):
""" Creates an email token onbject in the DB """
EmailToken.objects.create(
challenge_email_address=user.email,
challenge_token=token,
challenge_generation_timestamp=timezone.now(),
challenge_expiration_timestamp=get_challenge_expiration_timestamp(),
challenge_completed=False,
user_id=user.id
)

def build_html_content(self, user, token):
"""" Specifies the email template and context variables """
return render_to_string(
template_name='emails/token.html',
context={
'user': user,
'token': token,
}
)

def email_two_factor_token(self, user: get_user_model(), token) -> None:
""" Sends email containing one-time token """

subject = "Your One Time Token"
msg = EmailMultiAlternatives(
subject=subject,
body=self.build_html_content(user, token),
from_email=settings.DEFAULT_FROM_EMAIL_SES,
to=[user.email],
)
msg.content_subtype = 'html'
msg.mixed_subtype = 'related'
msg.send()


def post(self, request, *args, **kwargs):
""" Master func handling the user clicking the `Send Token by Email` button """
token = generate_token()
user = request.user
self.store_token_in_db(user, token)
self.email_two_factor_token(user, token)
return redirect(self.success_url)


class UserSetupEmailTokenView(TemplateView):

model = EmailToken
template_name = 'two_factor/setup_email_token.html'
success_url = reverse_lazy('blog:home')

def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
email = self.request.user.email.strip()
domain = email.split('@')[-1]
context['form'] = EmailTokenSubmissionForm(self.request.POST or None)
context['user_email'] = f"{email[0:2]}{'**********@'}{domain}"
return context

def get_email_token(self) -> str:
email_token = EmailToken.objects.filter(user_id=self.request.user.id).latest('id')
return email_token.challenge_token

def get_challenge_returned(self) -> str:
return str(self.request.POST['challenge_token_returned'].strip())

def does_challenge_pass(self, token_returned) -> bool:
token_to_match = self.get_email_token()
return token_returned == token_to_match

def is_token_within_expiry(self) -> bool:
email_token = EmailToken.objects.filter(user_id=self.request.user.id).latest('id')
return timezone.now() <= email_token.challenge_expiration_timestamp

def update_db(self, user):
email_token = EmailToken.objects.filter(user_id=user.id).latest('id')
email_token.challenge_completed_timestamp=timezone.now(),
email_token.challenge_completed=True
email_token.save()

def populate_message(self, challenge_passes, token_within_expiry):
if not challenge_passes:
msg = (
"The token you have entered is incorrect.<br /><br />" +
"Please re-check the code and try again."
)
messages.add_message(self.request, messages.INFO, mark_safe(msg))
elif not token_within_expiry:
msg = (
"The 5 minute expiration time has elapsed.<br /><br />" +
"Use the 'still no code?' link below to generate a new token " +
"which you will receive by email. Then re-enter the new code above."
)
messages.add_message(self.request, messages.INFO, mark_safe(msg))

def post(self, request, *args, **kwargs):
token_returned = self.get_challenge_returned()
challenge_passes = self.does_challenge_pass(token_returned)
token_within_expiry = self.is_token_within_expiry()
if challenge_passes and token_within_expiry:
self.update_db(request.user)
return redirect(self.success_url)
self.populate_message(challenge_passes, token_within_expiry)
return redirect('blog:users:setup_email_token')


class ProfileView(DetailView):
template_name = 'users/profile.html'

Expand Down

0 comments on commit f802944

Please sign in to comment.