verify, OTP setup and qr views. related logic. basic templates.
A => .hgignore +12 -0
@@ 0,0 1,12 @@ 
+syntax:glob
+.svn
+.hgsvn
+.*.swp
+**.pyc
+*.*~
+*.egg
+
+# virtualenv
+syntax:regexp
+^build/lib$
+^nlotp.egg-info$

          
A => MANIFEST.in +2 -0
@@ 0,0 1,2 @@ 
+include README.rst
+recursive-include nlotp/templates *

          
A => README.rst +4 -0
@@ 0,0 1,4 @@ 
+Netlandish one-time passwords (Two-Factor Authentication)
+========================================================
+
+Netlandish custom Django OTP app.

          
A => nlotp/__init__.py +3 -0
@@ 0,0 1,3 @@ 
+default_app_config = "nlotp.apps.NLOTPConfig"
+
+__version__ = "0.1.0"

          
A => nlotp/apps.py +26 -0
@@ 0,0 1,26 @@ 
+from django.apps import AppConfig
+from django.core.checks import register
+
+from . import checks
+
+
+class NLOTPConfig(AppConfig):
+    name = "nlotp"
+
+    def ready(self):
+        register(
+            checks.check_required_installed_apps,
+            checks.Tags.required_installed_apps,
+        )
+        register(
+            checks.check_required_settings,
+            checks.Tags.required_settings,
+        )
+        register(
+            checks.check_required_middlewares,
+            checks.Tags.required_middlewares,
+        )
+        register(
+            checks.check_suggested_settings,
+            checks.Tags.suggested_settings,
+        )

          
A => nlotp/checks.py +132 -0
@@ 0,0 1,132 @@ 
+from django.apps import apps
+from django.conf import settings
+from django.core.checks import Error, Warning
+
+REQUIRED_INSTALLED_APPS = (
+    {
+        "key": "django_otp",
+        "code": "E001",
+        "hint": (
+            "Needs to be included before `nlotp`, "
+            "`django_otp.plugins.otp_totp` "
+            "and `django_otp.plugins.otp_static`."
+        ),
+    },
+    {
+        "key": "django_otp.plugins.otp_totp",
+        "code": "E002",
+        "hint": "Needs to be included before `nlotp` and after `django_otp`.",
+    },
+    {
+        "key": "django_otp.plugins.otp_static",
+        "code": "E003",
+        "hint": "Needs to be included before `nlotp` and after `django_otp`.",
+    },
+)
+
+REQUIRED_SETTINGS = (
+    {
+        "key": "NLOTP_EXCLUDED_URLS",
+        "code": "E004",
+        "hint": (
+            "A list of urls excluded from redirect if user "
+            "is not OTP verified."
+        ),
+    },
+)
+
+REQUIRED_MIDDLEWARES = (
+    {
+        "key": "django_otp.middleware.OTPMiddleware",
+        "code": "E005",
+        "hint": (
+            "Must be installed after "
+            "`django.contrib.auth.middleware.AuthenticationMiddleware` "
+            "and before `nlotp.middleware.OTPCheckMiddleware`."
+        ),
+    },
+    {
+        "key": "nlotp.middleware.OTPCheckMiddleware",
+        "code": "E006",
+        "hint": (
+            "Must be installed after `django_otp.middleware.OTPMiddleware`."
+        ),
+    },
+)
+
+SUGGESTED_SETTINGS = (
+    {
+        "key": "OTP_TOTP_ISSUER",
+        "code": "W001",
+        "hint": (
+            "See https://django-otp-official.readthedocs.io/en/stable/overview.html#totp-settings"  # NOQA
+        ),
+    },
+)
+
+
+class Tags:
+    required_installed_apps = "required_installed_apps"
+    required_settings = "required_settings"
+    required_middlewares = "required_middlewares"
+    suggested_settings = "suggested_settings"
+
+
+def check_required_installed_apps(app_configs, **kwargs):
+    errors = []
+    for app in REQUIRED_INSTALLED_APPS:
+        if not apps.is_installed(app["key"]):
+            errors.append(
+                Error(
+                    f"needs '{app['key']}' in INSTALLED_APPS.",
+                    obj="nlotp",
+                    hint=app["hint"],
+                    id=f"nlopt.{app['code']}",
+                )
+            )
+    return errors
+
+
+def check_required_settings(app_configs, **kwargs):
+    errors = []
+    for setting in REQUIRED_SETTINGS:
+        if getattr(settings, setting["key"], None) is None:
+            errors.append(
+                Error(
+                    f"needs 'settings.{setting['key']}' defined.",
+                    obj="nlotp",
+                    hint=setting["hint"],
+                    id=f"nlopt.{setting['code']}",
+                )
+            )
+    return errors
+
+
+def check_required_middlewares(app_configs, **kwargs):
+    errors = []
+    for middleware in REQUIRED_MIDDLEWARES:
+        if middleware["key"] not in settings.MIDDLEWARE:
+            errors.append(
+                Error(
+                    f"needs '{middleware['key']}' in settings.MIDDLEWARE.",
+                    obj="nlotp",
+                    hint=middleware["hint"],
+                    id=f"nlopt.{middleware['code']}",
+                )
+            )
+    return errors
+
+
+def check_suggested_settings(app_configs, **kwargs):
+    warnings = []
+    for setting in SUGGESTED_SETTINGS:
+        if getattr(settings, setting["key"], None) is None:
+            warnings.append(
+                Warning(
+                    f"'settings.{setting['key']}' is missing.",
+                    obj="nlotp",
+                    hint=setting["hint"],
+                    id=f"nlopt.{setting['code']}",
+                )
+            )
+    return warnings

          
A => nlotp/forms.py +90 -0
@@ 0,0 1,90 @@ 
+from django import forms
+
+from django_otp.forms import OTPTokenForm
+from django_otp import devices_for_user
+from django.contrib.auth import get_user_model
+
+
+class TokenForm(OTPTokenForm):
+    def __init__(self, user, request=None, *args, **kwargs):
+        super().__init__(user, request, *args, **kwargs)
+        self.fields['otp_device'].widget = forms.HiddenInput()
+        try:
+            self.fields['otp_device'].initial = self.fields[
+                'otp_device'
+            ].choices[0][0]
+        except IndexError:
+            pass
+        self.otp_error_messages.update(
+            {
+                'invalid_token': (
+                    'Invalid code. '
+                    'Please make sure you have entered it correctly.'
+                ),
+                'token_required': 'Please enter your code.',
+            }
+        )
+
+    def clean_otp(self, user):
+        if user is None:
+            return
+        token = self.cleaned_data.get('otp_token')
+        if not token:
+            raise forms.ValidationError(
+                self.otp_error_messages['token_required'],
+                code='token_required',
+            )
+        user.otp_device = None
+        exception = None
+        for device in devices_for_user(user):
+            try:
+                user.otp_device = self._verify_token(user, token, device)
+            except forms.ValidationError as e:
+                exception = e
+            if user.otp_device is not None:
+                break
+        if user.otp_device is None:
+            self._update_form(user)
+            if exception is not None:
+                raise exception
+
+
+class TwoFactorAuthForm(forms.ModelForm):
+    authentication_code = forms.CharField(required=False)
+    password = forms.CharField(
+        label='Account Password',
+        widget=forms.PasswordInput(attrs={'class': 'short text'}),
+        max_length=25,
+        required=False,
+    )
+
+    class Meta:
+        model = get_user_model()
+        fields = ('authentication_code', 'password')
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.get('instance')
+        self.device = kwargs.pop('device')
+        self.generate_codes = kwargs.pop('generate_codes')
+        super().__init__(*args, **kwargs)
+        self.fields['password'].help_text = (
+            'To disable 2FA or to generate new backup codes, you must enter '
+            'your account password here and click the appropriate button below'
+        )
+
+    def clean(self):
+        if not self.device.confirmed and not self.generate_codes:
+            auth_code = self.cleaned_data['authentication_code']
+            if not auth_code:
+                raise forms.ValidationError(
+                    'Please provide the authentication code for '
+                    'Time Based One-Time Password'
+                )
+        else:
+            password = self.cleaned_data['password']
+            if not self.user.check_password(password):
+                raise forms.ValidationError(
+                    'Password is not valid, please provide '
+                    'your account password.'
+                )
+        return self.cleaned_data

          
A => nlotp/middleware.py +23 -0
@@ 0,0 1,23 @@ 
+from django.conf import settings
+from django.http import HttpResponseRedirect
+from django.urls import reverse_lazy
+from django.utils.deprecation import MiddlewareMixin
+from django_otp import user_has_device
+
+NLOTP_LOGIN_URL = getattr(
+    settings,
+    "NLOTP_LOGIN_URL",
+    reverse_lazy("nlotp:verify-otp"),
+)
+
+
+class OTPCheckMiddleware(MiddlewareMixin):
+    def process_request(self, request):
+        user = request.user
+        if user.is_authenticated:
+            if user.is_verified() or not user_has_device(user):
+                return None
+            else:
+                if request.path not in settings.NLOTP_EXCLUDED_URLS:
+                    return HttpResponseRedirect(NLOTP_LOGIN_URL)
+        return None

          
A => nlotp/mixins.py +10 -0
@@ 0,0 1,10 @@ 
+from django.views.generic.base import ContextMixin
+
+from . import utils
+
+
+class TOTPDeviceMixin(ContextMixin):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["totp_device"] = utils.get_user_totp_device(self.request.user)
+        return context

          
A => nlotp/templates/nlotp/two_factor_auth.html +83 -0
@@ 0,0 1,83 @@ 
+{% load nlotp_tags %}
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>Two-Step Verification (2FA) Preferences</title>
+  </head>
+  <body>
+    {% if messages %}
+    <ul class="messages">
+      {% for message in messages %}
+      <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
+      {% endfor %}
+    </ul>
+    {% endif %}
+    <form method="POST" action="{% url 'nlotp:two-factor-auth' %}" id="manage-two-factor-auth-form" autocomplete="on">
+      {% csrf_token %}
+
+      {{ form.non_field_errors }}
+
+      {% if show_codes %}
+      <hr/>
+      <h2>Generated Backup Verification Codes (Single Use)</h2>
+      <p>These are your recovery codes. Please save them somewhere safe and secure. If you ever lose access to your 2FA device (ie, lose your phone or get a new phone) you can use one of these codes to access your helpyoufind.me account. Each code can be used just once.</p>
+      {% with tokens=static_device.token_set.all %}
+      {% if tokens %}
+      <ol id="backup_codes_list">
+        {% for t in static_device.token_set.all %}
+        <li>{{ t.token }}</li>
+        {% endfor %}
+      </ol>
+      <p>Store these codes somewhere safe, once you navigate away from this page, you will not be able to view them again.</p>
+      {% else %}
+      <p>You have no tokens left.</p>
+      {% endif %}
+      {% endwith %}
+      <hr/>
+      {% endif %}
+      
+      <b>Status:</b> <span>{{ totp_device.confirmed|yesno:"Enabled,Disabled" }}</span>
+
+      {% if totp_device.confirmed %}
+      <div>
+        {{ form.password.errors }}
+        <label for="{{ form.password.id_for_label }}">Password:</label>
+        {{ form.password }}
+      </div>
+      {% endif %}
+
+      <h2>Time Based One-Time Password (Google Authenticator)</h2>
+      {% if not totp_device.confirmed %}
+      <p>Please scan the QR code or manually enter the key, then enter an authentication code from your app in order to complete setup.</p>
+      <p id="qrcode">
+        <img width="200" height="200" src="{% url 'nlotp:qr-code' totp_device.id %}" />
+      </p>
+      <p>
+        <label>Key:</label> {{ totp_device.bin_key|b32encode_val }}
+      </p>
+      <div>
+        {{ form.authentication_code.errors }}
+        <label for="{{ form.authentication_code.id_for_label }}">Authentication Code:</label>
+        {{ form.authentication_code }}
+      </div>
+      {% else %}
+      <p>Two-Step Verification code is configured and registered.</p>
+      {% endif %}
+      {% if totp_device.confirmed %}<span>If you disable 2FA, you will need to remove the helpyoufind.me code from your mobile app before you're able to re-add 2FA to your helpyoufind.me account in the future.</span>{% endif %}
+      <input type="submit" name="totp_toggle" value="{{ totp_device.confirmed|yesno:'Disable,Enable' }} 2FA" id="submit-id-reset-key" />
+
+      {% if totp_device.confirmed %}
+      <h2>Backup Verification Codes (Single Use)</h2>
+      {% with tokens=static_device.token_set.all %}
+      <p>{{ tokens|length }} unused code{{ tokens|pluralize }} remaining.</p>
+      <span>If you choose to generate new backup codes then all of your current existing codes will no longer work.</span>
+      <button type="submit" name="auth-generate_codes">Generate New Codes</button>
+      {% endwith %}
+      {% endif %}
+
+    </form>
+
+  </body>
+</html>

          
A => nlotp/templates/nlotp/verify_otp.html +37 -0
@@ 0,0 1,37 @@ 
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>Two-Step Verification (2FA) Verification</title>
+  </head>
+  <body>
+    <form action="{% url 'nlotp:verify-otp' %}?next={{ next }}" method="POST" autocomplete="on">
+      {% csrf_token %}
+      <h2>Two-Step Verification (2FA)</h2>
+
+      {{ form.non_field_errors }}
+            
+      <div>
+        {{ form.otp_device.errors }}
+        <label for="{{ form.otp_device.id_for_label }}">OTP Device:</label>
+        {{ form.otp_device }}
+      </div>
+            
+      <label>
+        <span>
+          <p>Open the Two-Step Verification app on your mobile device to get the verification code.</p>
+          <p>Don't have access to your mobile device? Enter a recovery code.</p>
+        </span>
+      </label>
+      <div>
+        {{ form.otp_token.errors }}
+        <label for="{{ form.otp_token.id_for_label }}">OTP Token:</label>
+        {{ form.otp_token }}
+      </div>
+
+      <input type="submit" name="submit" value="Verify" id="submit-id-submit">            
+      
+    </form>
+  </body>
+</html>

          
A => nlotp/templatetags/__init__.py +0 -0

        
A => nlotp/templatetags/nlotp_tags.py +10 -0
@@ 0,0 1,10 @@ 
+from base64 import b32encode
+
+from django import template
+
+register = template.Library()
+
+
+@register.filter
+def b32encode_val(value):
+    return b32encode(value).decode("utf-8")

          
A => nlotp/urls.py +23 -0
@@ 0,0 1,23 @@ 
+from django.urls import path
+
+from . import views
+
+app_name = 'nlotp'
+
+urlpatterns = [
+    path(
+        'qr-code/<int:device_id>/',
+        views.QRCodeView.as_view(),
+        name='qr-code',
+    ),
+    path(
+        'verify/',
+        views.VerifyOTPView.as_view(),
+        name='verify-otp',
+    ),
+    path(
+        'two-factor-auth/',
+        views.TwoFactorAuthView.as_view(),
+        name='two-factor-auth',
+    ),
+]

          
A => nlotp/utils.py +26 -0
@@ 0,0 1,26 @@ 
+from django_otp.plugins.otp_static.lib import add_static_token
+
+
+def get_user_totp_device(user):
+    dev = user.totpdevice_set.first()
+    if not dev:
+        dev = user.totpdevice_set.create(
+            name="Time Based One-Time Password (Google Authenticator)",
+            confirmed=False,
+        )
+    return dev
+
+
+def generate_user_static_tokens(user, count=10):
+    for i in range(count):
+        add_static_token(user.get_username())
+
+
+def get_user_static_device(user):
+    dev = user.staticdevice_set.first()
+    if not dev:
+        dev = user.staticdevice_set.create(
+            name="Backup Verification Codes (Single Use)", confirmed=False,
+        )
+        generate_user_static_tokens(user)
+    return dev

          
A => nlotp/views.py +116 -0
@@ 0,0 1,116 @@ 
+import qrcode
+import qrcode.image.svg
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.urls import reverse_lazy
+from django.views.generic.edit import FormView, View
+from django_otp import login as otp_login
+from django_otp.views import LoginView
+
+from . import forms, utils
+
+
+class QRCodeView(LoginRequiredMixin, View):
+    """ Generates QR code for the TOTP device """
+
+    def get(self, request, device_id, *args, **kwargs):
+        device = get_object_or_404(
+            request.user.totpdevice_set.all(), id=device_id
+        )
+        img = qrcode.make(
+            device.config_url, image_factory=qrcode.image.svg.SvgImage
+        )
+        response = HttpResponse(content_type="image/svg+xml")
+        img.save(response)
+        return response
+
+
+class VerifyOTPView(LoginRequiredMixin, LoginView):
+    """ 2FA verify token page (after user login if 2FA is enabled) """
+
+    template_name = "nlotp/verify_otp.html"
+    otp_token_form = forms.TokenForm
+
+
+class TwoFactorAuthView(LoginRequiredMixin, FormView):
+    """ 2FA user settings """
+
+    template_name = "nlotp/two_factor_auth.html"
+    form_class = forms.TwoFactorAuthForm
+    success_url = reverse_lazy("nlotp:two-factor-auth")
+
+    def setup(self, request, *args, **kwargs):
+        super().setup(request, *args, **kwargs)
+        self.totp_device = utils.get_user_totp_device(self.request.user)
+        self.static_device = utils.get_user_static_device(self.request.user)
+        self.generate_codes = "auth-generate_codes" in self.request.POST
+        self.show_codes = False
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context.update(
+            {
+                "totp_device": self.totp_device,
+                "static_device": self.static_device,
+                "show_codes": self.show_codes,
+            }
+        )
+        return context
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.update(
+            {
+                "instance": self.request.user,
+                "device": self.totp_device,
+                "generate_codes": self.generate_codes,
+                "prefix": "auth",
+            }
+        )
+        return kwargs
+
+    def form_valid(self, form):
+        if self.generate_codes:
+            self.static_device.token_set.all().delete()
+            utils.generate_user_static_tokens(self.request.user)
+            self.show_codes = True
+            messages.success(
+                self.request,
+                "Backup verification codes successfully generated.",
+            )
+        elif not self.totp_device.confirmed:
+            auth_code = form.cleaned_data.get("authentication_code")
+            if auth_code and self.totp_device.verify_token(auth_code):
+                self.totp_device.confirmed = True
+                self.totp_device.save()
+                otp_login(self.request, self.totp_device)
+                self.static_device.confirmed = True
+                self.static_device.save()
+                self.show_codes = True
+                messages.success(
+                    self.request,
+                    "Two-Step Verification configured and registered.",
+                )
+            else:
+                messages.error(
+                    self.request, "Invalid Two-Step Verification code.",
+                )
+        else:
+            self.totp_device.delete()
+            self.totp_device = utils.get_user_totp_device(self.request.user)
+            self.static_device.confirmed = False
+            self.static_device.save()
+            messages.success(
+                self.request,
+                "Two-Step Verification has been disabled.",
+            )
+        context = self.get_context_data()
+        context["form"] = self.form_class(
+            instance=self.request.user,
+            device=self.totp_device,
+            generate_codes=self.generate_codes,
+            prefix="auth",
+        )
+        return self.render_to_response(context)

          
A => setup.py +59 -0
@@ 0,0 1,59 @@ 
+import os
+
+from setuptools import setup
+
+project_name = "nlotp"
+long_description = open("README.rst").read()
+
+# Idea from django-registration setup.py
+packages, data_files = [], []
+root_dir = os.path.dirname(__file__)
+if root_dir:
+    os.chdir(root_dir)
+
+for dirpath, dirnames, filenames in os.walk(project_name):
+    # Ignore dirnames that start with '.'
+    for i, dirname in enumerate(dirnames):
+        if dirname.startswith("."):
+            del dirnames[i]
+    if "__init__.py" in filenames:
+        pkg = dirpath.replace(os.path.sep, ".")
+        if os.path.altsep:
+            pkg = pkg.replace(os.path.altsep, ".")
+        packages.append(pkg)
+    elif filenames:
+        prefix = dirpath[(len(project_name) + 1):]
+        for f in filenames:
+            data_files.append(os.path.join(prefix, f))
+
+
+setup(
+    name=project_name,
+    version=__import__("nlotp").__version__,
+    package_dir={project_name: project_name},
+    packages=packages,
+    package_data={project_name: data_files},
+    description="Netlandish one-time passwords custom setup.",
+    author="Netlandish Inc.",
+    author_email="gustavo@netlandish.com",
+    license="BSD License",
+    url="https://hg.code.netlandish.com/~netlandish/nlotp",
+    long_description=long_description,
+    platforms=["any"],
+    classifiers=[
+        "Development Status :: 4 - Beta",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: BSD License",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Environment :: Web Environment",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3 :: Only",
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+    ],
+    install_requires=["Django>=3.0", "django-otp>=0.9.3", "qrcode>=6.1"],
+)