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"],
+)