# HG changeset patch # User Gustavo Andres Morero # Date 1596750704 10800 # Thu Aug 06 18:51:44 2020 -0300 # Node ID bb3fb73065d4b4f2ae4bb530e10d34a00a1e8795 # Parent 0000000000000000000000000000000000000000 verify, OTP setup and qr views. related logic. basic templates. diff --git a/.hgignore b/.hgignore new file mode 100644 --- /dev/null +++ b/.hgignore @@ -0,0 +1,12 @@ +syntax:glob +.svn +.hgsvn +.*.swp +**.pyc +*.*~ +*.egg + +# virtualenv +syntax:regexp +^build/lib$ +^nlotp.egg-info$ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst +recursive-include nlotp/templates * diff --git a/README.rst b/README.rst new file mode 100644 --- /dev/null +++ b/README.rst @@ -0,0 +1,4 @@ +Netlandish one-time passwords (Two-Factor Authentication) +======================================================== + +Netlandish custom Django OTP app. diff --git a/nlotp/__init__.py b/nlotp/__init__.py new file mode 100644 --- /dev/null +++ b/nlotp/__init__.py @@ -0,0 +1,3 @@ +default_app_config = "nlotp.apps.NLOTPConfig" + +__version__ = "0.1.0" diff --git a/nlotp/apps.py b/nlotp/apps.py new file mode 100644 --- /dev/null +++ b/nlotp/apps.py @@ -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, + ) diff --git a/nlotp/checks.py b/nlotp/checks.py new file mode 100644 --- /dev/null +++ b/nlotp/checks.py @@ -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 diff --git a/nlotp/forms.py b/nlotp/forms.py new file mode 100644 --- /dev/null +++ b/nlotp/forms.py @@ -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 diff --git a/nlotp/middleware.py b/nlotp/middleware.py new file mode 100644 --- /dev/null +++ b/nlotp/middleware.py @@ -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 diff --git a/nlotp/mixins.py b/nlotp/mixins.py new file mode 100644 --- /dev/null +++ b/nlotp/mixins.py @@ -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 diff --git a/nlotp/templates/nlotp/two_factor_auth.html b/nlotp/templates/nlotp/two_factor_auth.html new file mode 100644 --- /dev/null +++ b/nlotp/templates/nlotp/two_factor_auth.html @@ -0,0 +1,83 @@ +{% load nlotp_tags %} + + + + + + Two-Step Verification (2FA) Preferences + + + {% if messages %} + + {% endif %} +
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% if show_codes %} +
+

Generated Backup Verification Codes (Single Use)

+

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.

+ {% with tokens=static_device.token_set.all %} + {% if tokens %} +
    + {% for t in static_device.token_set.all %} +
  1. {{ t.token }}
  2. + {% endfor %} +
+

Store these codes somewhere safe, once you navigate away from this page, you will not be able to view them again.

+ {% else %} +

You have no tokens left.

+ {% endif %} + {% endwith %} +
+ {% endif %} + + Status: {{ totp_device.confirmed|yesno:"Enabled,Disabled" }} + + {% if totp_device.confirmed %} +
+ {{ form.password.errors }} + + {{ form.password }} +
+ {% endif %} + +

Time Based One-Time Password (Google Authenticator)

+ {% if not totp_device.confirmed %} +

Please scan the QR code or manually enter the key, then enter an authentication code from your app in order to complete setup.

+

+ +

+

+ {{ totp_device.bin_key|b32encode_val }} +

+
+ {{ form.authentication_code.errors }} + + {{ form.authentication_code }} +
+ {% else %} +

Two-Step Verification code is configured and registered.

+ {% endif %} + {% if totp_device.confirmed %}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.{% endif %} + + + {% if totp_device.confirmed %} +

Backup Verification Codes (Single Use)

+ {% with tokens=static_device.token_set.all %} +

{{ tokens|length }} unused code{{ tokens|pluralize }} remaining.

+ If you choose to generate new backup codes then all of your current existing codes will no longer work. + + {% endwith %} + {% endif %} + +
+ + + diff --git a/nlotp/templates/nlotp/verify_otp.html b/nlotp/templates/nlotp/verify_otp.html new file mode 100644 --- /dev/null +++ b/nlotp/templates/nlotp/verify_otp.html @@ -0,0 +1,37 @@ + + + + + + Two-Step Verification (2FA) Verification + + +
+ {% csrf_token %} +

Two-Step Verification (2FA)

+ + {{ form.non_field_errors }} + +
+ {{ form.otp_device.errors }} + + {{ form.otp_device }} +
+ + +
+ {{ form.otp_token.errors }} + + {{ form.otp_token }} +
+ + + +
+ + diff --git a/nlotp/templatetags/__init__.py b/nlotp/templatetags/__init__.py new file mode 100644 diff --git a/nlotp/templatetags/nlotp_tags.py b/nlotp/templatetags/nlotp_tags.py new file mode 100644 --- /dev/null +++ b/nlotp/templatetags/nlotp_tags.py @@ -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") diff --git a/nlotp/urls.py b/nlotp/urls.py new file mode 100644 --- /dev/null +++ b/nlotp/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from . import views + +app_name = 'nlotp' + +urlpatterns = [ + path( + 'qr-code//', + 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', + ), +] diff --git a/nlotp/utils.py b/nlotp/utils.py new file mode 100644 --- /dev/null +++ b/nlotp/utils.py @@ -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 diff --git a/nlotp/views.py b/nlotp/views.py new file mode 100644 --- /dev/null +++ b/nlotp/views.py @@ -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) diff --git a/setup.py b/setup.py new file mode 100644 --- /dev/null +++ b/setup.py @@ -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"], +)