factors: -> stage
This commit is contained in:
0
passbook/stages/otp/__init__.py
Normal file
0
passbook/stages/otp/__init__.py
Normal file
21
passbook/stages/otp/api.py
Normal file
21
passbook/stages/otp/api.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""OTPStage API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.stages.otp.models import OTPStage
|
||||
|
||||
|
||||
class OTPStageSerializer(ModelSerializer):
|
||||
"""OTPStage Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OTPStage
|
||||
fields = ["pk", "name", "enforced"]
|
||||
|
||||
|
||||
class OTPStageViewSet(ModelViewSet):
|
||||
"""OTPStage Viewset"""
|
||||
|
||||
queryset = OTPStage.objects.all()
|
||||
serializer_class = OTPStageSerializer
|
||||
12
passbook/stages/otp/apps.py
Normal file
12
passbook/stages/otp/apps.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""passbook OTP AppConfig"""
|
||||
|
||||
from django.apps.config import AppConfig
|
||||
|
||||
|
||||
class PassbookStageOTPConfig(AppConfig):
|
||||
"""passbook OTP AppConfig"""
|
||||
|
||||
name = "passbook.stages.otp"
|
||||
label = "passbook_stages_otp"
|
||||
verbose_name = "passbook Stages.OTP"
|
||||
mountpoint = "user/otp/"
|
||||
78
passbook/stages/otp/forms.py
Normal file
78
passbook/stages/otp/forms.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""passbook OTP Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
|
||||
from passbook.stages.otp.models import OTPStage
|
||||
|
||||
OTP_CODE_VALIDATOR = RegexValidator(
|
||||
r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.")
|
||||
)
|
||||
|
||||
|
||||
class PictureWidget(forms.widgets.Widget):
|
||||
"""Widget to render value as img-tag"""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
return mark_safe(f'<img src="{value}" />') # nosec
|
||||
|
||||
|
||||
class OTPVerifyForm(forms.Form):
|
||||
"""Simple Form to verify OTP Code"""
|
||||
|
||||
order = ["code"]
|
||||
|
||||
code = forms.CharField(
|
||||
label=_("Code"),
|
||||
validators=[OTP_CODE_VALIDATOR],
|
||||
widget=forms.TextInput(attrs={"autocomplete": "off", "placeholder": "Code"}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# This is a little helper so the field is focused by default
|
||||
self.fields["code"].widget.attrs.update(
|
||||
{"autofocus": "autofocus", "autocomplete": "off"}
|
||||
)
|
||||
|
||||
|
||||
class OTPSetupForm(forms.Form):
|
||||
"""OTP Setup form"""
|
||||
|
||||
title = _("Set up OTP")
|
||||
device: Device = None
|
||||
qr_code = forms.CharField(
|
||||
widget=PictureWidget,
|
||||
disabled=True,
|
||||
required=False,
|
||||
label=_("Scan this Code with your OTP App."),
|
||||
)
|
||||
code = forms.CharField(
|
||||
label=_("Code"),
|
||||
validators=[OTP_CODE_VALIDATOR],
|
||||
widget=forms.TextInput(attrs={"placeholder": _("One-Time Password")}),
|
||||
)
|
||||
|
||||
tokens = forms.MultipleChoiceField(disabled=True, required=False)
|
||||
|
||||
def clean_code(self):
|
||||
"""Check code with new otp device"""
|
||||
if self.device is not None:
|
||||
if not self.device.verify_token(int(self.cleaned_data.get("code"))):
|
||||
raise forms.ValidationError(_("OTP Code does not match"))
|
||||
return self.cleaned_data.get("code")
|
||||
|
||||
|
||||
class OTPStageForm(forms.ModelForm):
|
||||
"""Form to edit OTPStage instances"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OTPStage
|
||||
fields = ["name", "enforced"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
||||
41
passbook/stages/otp/migrations/0001_initial.py
Normal file
41
passbook/stages/otp/migrations/0001_initial.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Generated by Django 3.0.3 on 2020-05-08 17:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OTPStage",
|
||||
fields=[
|
||||
(
|
||||
"stage_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_flows.Stage",
|
||||
),
|
||||
),
|
||||
(
|
||||
"enforced",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Enforce enabled OTP for Users this stage applies to.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",},
|
||||
bases=("passbook_flows.stage",),
|
||||
),
|
||||
]
|
||||
0
passbook/stages/otp/migrations/__init__.py
Normal file
0
passbook/stages/otp/migrations/__init__.py
Normal file
34
passbook/stages/otp/models.py
Normal file
34
passbook/stages/otp/models.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""OTP Stage"""
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.flows.models import Stage
|
||||
|
||||
|
||||
class OTPStage(Stage):
|
||||
"""OTP Stage"""
|
||||
|
||||
enforced = models.BooleanField(
|
||||
default=False,
|
||||
help_text=("Enforce enabled OTP for Users " "this stage applies to."),
|
||||
)
|
||||
|
||||
type = "passbook.stages.otp.stages.OTPStage"
|
||||
form = "passbook.stages.otp.forms.OTPStageForm"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> UIUserSettings:
|
||||
return UIUserSettings(
|
||||
name="OTP",
|
||||
icon="pficon-locked",
|
||||
view_name="passbook_stages_otp:otp-user-settings",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"OTP Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OTP Stage")
|
||||
verbose_name_plural = _("OTP Stages")
|
||||
10
passbook/stages/otp/settings.py
Normal file
10
passbook/stages/otp/settings.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""passbook OTP Settings"""
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_otp.middleware.OTPMiddleware",
|
||||
]
|
||||
INSTALLED_APPS = [
|
||||
"django_otp",
|
||||
"django_otp.plugins.otp_static",
|
||||
"django_otp.plugins.otp_totp",
|
||||
]
|
||||
59
passbook/stages/otp/stage.py
Normal file
59
passbook/stages/otp/stage.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""OTP Stage logic"""
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
from django_otp import match_token, user_has_device
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.stage import AuthenticationStage
|
||||
from passbook.stages.otp.forms import OTPVerifyForm
|
||||
from passbook.stages.otp.views import OTP_SETTING_UP_KEY, EnableView
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class OTPStage(FormView, AuthenticationStage):
|
||||
"""OTP Stage View"""
|
||||
|
||||
template_name = "stages/otp/stage.html"
|
||||
form_class = OTPVerifyForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["title"] = _("Enter Verification Code")
|
||||
return kwargs
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Check if User has OTP enabled and if OTP is enforced"""
|
||||
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
if not user_has_device(pending_user):
|
||||
LOGGER.debug("User doesn't have OTP Setup.")
|
||||
if self.executor.current_stage.enforced:
|
||||
# Redirect to setup view
|
||||
LOGGER.debug("OTP is enforced, redirecting to setup")
|
||||
request.user = pending_user
|
||||
messages.info(request, _("OTP is enforced. Please setup OTP."))
|
||||
return EnableView.as_view()(request)
|
||||
LOGGER.debug("OTP is not enforced, skipping form")
|
||||
return self.executor.user_ok()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Check if setup is in progress and redirect to EnableView"""
|
||||
if OTP_SETTING_UP_KEY in request.session:
|
||||
LOGGER.debug("Passing POST to EnableView")
|
||||
request.user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
return EnableView.as_view()(request)
|
||||
return super().post(self, request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: OTPVerifyForm):
|
||||
"""Verify OTP Token"""
|
||||
device = match_token(
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
|
||||
form.cleaned_data.get("code"),
|
||||
)
|
||||
if device:
|
||||
return self.executor.stage_ok()
|
||||
messages.error(self.request, _("Invalid OTP."))
|
||||
return self.form_invalid(form)
|
||||
8
passbook/stages/otp/templates/stages/otp/factor.html
Normal file
8
passbook/stages/otp/templates/stages/otp/factor.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% extends 'login/form_with_user.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
{{ block.super }}
|
||||
<p><b>{% trans 'Enter the Verification Code from your Authenticator App.' %}</b></p>
|
||||
{% endblock %}
|
||||
48
passbook/stages/otp/templates/stages/otp/user_settings.html
Normal file
48
passbook/stages/otp/templates/stages/otp/user_settings.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends "user/base.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page %}
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<h1>{% trans "One-Time Passwords" %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card-footer">
|
||||
<p>
|
||||
{% blocktrans with state=state|yesno:"Enabled,Disabled" %}
|
||||
Status: {{ state }}
|
||||
{% endblocktrans %}
|
||||
{% if state %}
|
||||
<i class="pf-icon pf-icon-ok"></i>
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
{% if not state %}
|
||||
<a href="{% url 'passbook_stages_otp:otp-enable' %}"
|
||||
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'passbook_stages_otp:otp-disable' %}"
|
||||
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans "Your Backup tokens:" %}
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<pre>{% for token in static_tokens %}{{ token.token }}
|
||||
{% empty %}{% trans 'N/A' %}{% endfor %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
passbook/stages/otp/urls.py
Normal file
12
passbook/stages/otp/urls.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""passbook OTP Urls"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from passbook.stages.otp import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.UserSettingsView.as_view(), name="otp-user-settings"),
|
||||
path("qr/", views.QRView.as_view(), name="otp-qr"),
|
||||
path("enable/", views.EnableView.as_view(), name="otp-enable"),
|
||||
path("disable/", views.DisableView.as_view(), name="otp-disable"),
|
||||
]
|
||||
17
passbook/stages/otp/utils.py
Normal file
17
passbook/stages/otp/utils.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""passbook OTP Utils"""
|
||||
|
||||
from django.utils.http import urlencode
|
||||
|
||||
|
||||
def otpauth_url(accountname, secret, issuer=None, digits=6):
|
||||
"""Create otpauth according to
|
||||
https://github.com/google/google-authenticator/wiki/Key-Uri-Format"""
|
||||
# Ensure that the secret parameter is the FIRST parameter of the URI, this
|
||||
# allows Microsoft Authenticator to work.
|
||||
query = [
|
||||
("secret", secret),
|
||||
("digits", digits),
|
||||
("issuer", "passbook"),
|
||||
]
|
||||
|
||||
return "otpauth://totp/%s:%s?%s" % (issuer, accountname, urlencode(query))
|
||||
166
passbook/stages/otp/views.py
Normal file
166
passbook/stages/otp/views.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""passbook OTP Views"""
|
||||
from base64 import b32encode
|
||||
from binascii import unhexlify
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.generic import FormView, TemplateView
|
||||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from qrcode import make
|
||||
from qrcode.image.svg import SvgPathImage
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.stages.otp.forms import OTPSetupForm
|
||||
from passbook.stages.otp.utils import otpauth_url
|
||||
|
||||
OTP_SESSION_KEY = "passbook_stages_otp_key"
|
||||
OTP_SETTING_UP_KEY = "passbook_stages_otp_setup"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||
"""View for user settings to control OTP"""
|
||||
|
||||
template_name = "otp/user_settings.html"
|
||||
|
||||
# TODO: Check if OTP Stage exists and applies to user
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
static = StaticDevice.objects.filter(user=self.request.user, confirmed=True)
|
||||
if static.exists():
|
||||
kwargs["static_tokens"] = StaticToken.objects.filter(
|
||||
device=static.first()
|
||||
).order_by("token")
|
||||
totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True)
|
||||
kwargs["state"] = totp_devices.exists() and static.exists()
|
||||
return kwargs
|
||||
|
||||
|
||||
class DisableView(LoginRequiredMixin, View):
|
||||
"""Disable TOTP for user"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Delete all the devices for user"""
|
||||
static = get_object_or_404(StaticDevice, user=request.user, confirmed=True)
|
||||
static_tokens = StaticToken.objects.filter(device=static).order_by("token")
|
||||
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
|
||||
static.delete()
|
||||
totp.delete()
|
||||
for token in static_tokens:
|
||||
token.delete()
|
||||
messages.success(request, "Successfully disabled OTP")
|
||||
# Create event with email notification
|
||||
Event.new(EventAction.CUSTOM, message="User disabled OTP.").from_http(request)
|
||||
return redirect(reverse("passbook_stages_otp:otp-user-settings"))
|
||||
|
||||
|
||||
class EnableView(LoginRequiredMixin, FormView):
|
||||
"""View to set up OTP"""
|
||||
|
||||
title = _("Set up OTP")
|
||||
form_class = OTPSetupForm
|
||||
template_name = "login/form.html"
|
||||
|
||||
totp_device = None
|
||||
static_device = None
|
||||
|
||||
# TODO: Check if OTP Stage exists and applies to user
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = _("Configure OTP")
|
||||
kwargs["primary_action"] = _("Setup")
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
# Check if user has TOTP setup already
|
||||
finished_totp_devices = TOTPDevice.objects.filter(
|
||||
user=request.user, confirmed=True
|
||||
)
|
||||
finished_static_devices = StaticDevice.objects.filter(
|
||||
user=request.user, confirmed=True
|
||||
)
|
||||
if finished_totp_devices.exists() and finished_static_devices.exists():
|
||||
messages.error(request, _("You already have TOTP enabled!"))
|
||||
del request.session[OTP_SETTING_UP_KEY]
|
||||
return redirect("passbook_stages_otp:otp-user-settings")
|
||||
request.session[OTP_SETTING_UP_KEY] = True
|
||||
# Check if there's an unconfirmed device left to set up
|
||||
totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False)
|
||||
if not totp_devices.exists():
|
||||
# Create new TOTPDevice and save it, but not confirm it
|
||||
self.totp_device = TOTPDevice(user=request.user, confirmed=False)
|
||||
self.totp_device.save()
|
||||
else:
|
||||
self.totp_device = totp_devices.first()
|
||||
|
||||
# Check if we have a static device already
|
||||
static_devices = StaticDevice.objects.filter(user=request.user, confirmed=False)
|
||||
if not static_devices.exists():
|
||||
# Create new static device and some codes
|
||||
self.static_device = StaticDevice(user=request.user, confirmed=False)
|
||||
self.static_device.save()
|
||||
# Create 9 tokens and save them
|
||||
# TODO: Send static tokens via E-Mail
|
||||
for _counter in range(0, 9):
|
||||
token = StaticToken(
|
||||
device=self.static_device, token=StaticToken.random_token()
|
||||
)
|
||||
token.save()
|
||||
else:
|
||||
self.static_device = static_devices.first()
|
||||
|
||||
# Somehow convert the generated key to base32 for the QR code
|
||||
rawkey = unhexlify(self.totp_device.key.encode("ascii"))
|
||||
request.session[OTP_SESSION_KEY] = b32encode(rawkey).decode("utf-8")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class=form_class)
|
||||
form.device = self.totp_device
|
||||
form.fields["qr_code"].initial = reverse("passbook_stages_otp:otp-qr")
|
||||
tokens = [(x.token, x.token) for x in self.static_device.token_set.all()]
|
||||
form.fields["tokens"].choices = tokens
|
||||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
# Save device as confirmed
|
||||
LOGGER.debug("Saved OTP Devices")
|
||||
self.totp_device.confirmed = True
|
||||
self.totp_device.save()
|
||||
self.static_device.confirmed = True
|
||||
self.static_device.save()
|
||||
del self.request.session[OTP_SETTING_UP_KEY]
|
||||
Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http(
|
||||
self.request
|
||||
)
|
||||
return redirect("passbook_stages_otp:otp-user-settings")
|
||||
|
||||
|
||||
@method_decorator(never_cache, name="dispatch")
|
||||
class QRView(View):
|
||||
"""View returns an SVG image with the OTP token information"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""View returns an SVG image with the OTP token information"""
|
||||
# Get the data from the session
|
||||
try:
|
||||
key = request.session[OTP_SESSION_KEY]
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
url = otpauth_url(accountname=request.user.username, secret=key)
|
||||
# Make and return QR code
|
||||
img = make(url, image_factory=SvgPathImage)
|
||||
resp = HttpResponse(content_type="image/svg+xml; charset=utf-8")
|
||||
img.save(resp)
|
||||
return resp
|
||||
Reference in New Issue
Block a user