stages/otp_static: start implementing static stage

This commit is contained in:
Jens Langhammer 2020-06-30 13:49:23 +02:00
parent 3716bda76e
commit d2bf579ff6
16 changed files with 568 additions and 6 deletions

View File

@ -37,6 +37,8 @@ from passbook.stages.dummy.api import DummyStageViewSet
from passbook.stages.email.api import EmailStageViewSet from passbook.stages.email.api import EmailStageViewSet
from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet
from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet
from passbook.stages.otp_static.api import OTPStaticStageViewSet
from passbook.stages.otp_time.api import OTPTimeStageViewSet
from passbook.stages.otp_validate.api import OTPValidateStageViewSet from passbook.stages.otp_validate.api import OTPValidateStageViewSet
from passbook.stages.password.api import PasswordStageViewSet from passbook.stages.password.api import PasswordStageViewSet
from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet
@ -91,10 +93,12 @@ router.register("stages/email", EmailStageViewSet)
router.register("stages/identification", IdentificationStageViewSet) router.register("stages/identification", IdentificationStageViewSet)
router.register("stages/invitation", InvitationStageViewSet) router.register("stages/invitation", InvitationStageViewSet)
router.register("stages/invitation/invitations", InvitationViewSet) router.register("stages/invitation/invitations", InvitationViewSet)
router.register("stages/otp_static", OTPStaticStageViewSet)
router.register("stages/otp_time", OTPTimeStageViewSet)
router.register("stages/otp_validate", OTPValidateStageViewSet) router.register("stages/otp_validate", OTPValidateStageViewSet)
router.register("stages/password", PasswordStageViewSet) router.register("stages/password", PasswordStageViewSet)
router.register("stages/prompt/stages", PromptStageViewSet)
router.register("stages/prompt/prompts", PromptViewSet) router.register("stages/prompt/prompts", PromptViewSet)
router.register("stages/prompt/stages", PromptStageViewSet)
router.register("stages/user_delete", UserDeleteStageViewSet) router.register("stages/user_delete", UserDeleteStageViewSet)
router.register("stages/user_login", UserLoginStageViewSet) router.register("stages/user_login", UserLoginStageViewSet)
router.register("stages/user_logout", UserLogoutStageViewSet) router.register("stages/user_logout", UserLogoutStageViewSet)

View File

@ -107,6 +107,7 @@ INSTALLED_APPS = [
"passbook.stages.user_login.apps.PassbookStageUserLoginConfig", "passbook.stages.user_login.apps.PassbookStageUserLoginConfig",
"passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig",
"passbook.stages.user_write.apps.PassbookStageUserWriteConfig", "passbook.stages.user_write.apps.PassbookStageUserWriteConfig",
"passbook.stages.otp_static.apps.PassbookStageOTPStaticConfig",
"passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig", "passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig",
"passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig", "passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig",
"passbook.stages.password.apps.PassbookStagePasswordConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig",

View File

@ -0,0 +1,21 @@
"""OTPStaticStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.otp_static.models import OTPStaticStage
class OTPStaticStageSerializer(ModelSerializer):
"""OTPStaticStage Serializer"""
class Meta:
model = OTPStaticStage
fields = ["pk", "name", "token_count"]
class OTPStaticStageViewSet(ModelViewSet):
"""OTPStaticStage Viewset"""
queryset = OTPStaticStage.objects.all()
serializer_class = OTPStaticStageSerializer

View File

@ -0,0 +1,28 @@
"""OTP Static forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.stages.otp_static.models import OTPStaticStage
class SetupForm(forms.Form):
"""Form to setup Static OTP"""
tokens = forms.MultipleChoiceField(disabled=True, required=False)
def __init__(self, tokens, *args, **kwargs):
super().__init__(*args, **kwargs)
print(tokens)
self.fields['tokens'].choices = [(x.token, x.token) for x in tokens]
class OTPStaticStageForm(forms.ModelForm):
"""OTP Static Stage setup form"""
class Meta:
model = OTPStaticStage
fields = ["name", "token_count"]
widgets = {
"name": forms.TextInput(),
}

View File

@ -0,0 +1,38 @@
# Generated by Django 3.0.7 on 2020-06-30 11:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_flows", "0006_auto_20200629_0857"),
]
operations = [
migrations.CreateModel(
name="OTPStaticStage",
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",
),
),
("token_count", models.IntegerField(default=6)),
],
options={
"verbose_name": "OTP Static Setup Stage",
"verbose_name_plural": "OTP Static Setup Stages",
},
bases=("passbook_flows.stage",),
),
]

View File

@ -0,0 +1,33 @@
"""OTP Static-based models"""
from typing import Optional
from django.db import models
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _
from passbook.core.types import UIUserSettings
from passbook.flows.models import Stage
class OTPStaticStage(Stage):
"""Generate static tokens for the user as a backup"""
token_count = models.IntegerField(default=6)
type = "passbook.stages.otp_static.stage.OTPStaticStageView"
form = "passbook.stages.otp_static.forms.OTPStaticStageForm"
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
return UIUserSettings(
name="Static-based OTP",
url=reverse("passbook_stages_otp_static:user-settings"),
)
def __str__(self) -> str:
return f"OTP Static Stage {self.name}"
class Meta:
verbose_name = _("OTP Static Setup Stage")
verbose_name_plural = _("OTP Static Setup Stages")

View File

@ -0,0 +1,5 @@
"""OTP Static settings"""
INSTALLED_APPS = [
"django_otp.plugins.otp_static",
]

View File

@ -0,0 +1,57 @@
"""Static OTP Setup stage"""
from typing import Any, Dict
from django.http import HttpRequest, HttpResponse
from django.views.generic import FormView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from structlog import get_logger
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
from passbook.stages.otp_static.forms import SetupForm
from passbook.stages.otp_static.models import OTPStaticStage
LOGGER = get_logger()
SESSION_STATIC_DEVICE = "static_device"
SESSION_STATIC_TOKENS = "static_device_tokens"
class OTPStaticStageView(FormView, StageView):
"""Static OTP Setup stage"""
form_class = SetupForm
def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
kwargs = super().get_form_kwargs(**kwargs)
tokens = self.request.session[SESSION_STATIC_TOKENS]
kwargs["tokens"] = tokens
return kwargs
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user:
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
# Currently, this stage only supports one device per user. If the user already
# has a device, just skip to the next stage
if StaticDevice.objects.filter(user=user).exists():
return self.executor.stage_ok()
stage: OTPStaticStage = self.executor.current_stage
if SESSION_STATIC_DEVICE not in self.request.session:
device = StaticDevice(user=user, confirmed=True)
tokens = [StaticToken(device=device, token=StaticToken.random_token()) for _ in range(0, stage.token_count)]
self.request.session[SESSION_STATIC_DEVICE] = device
self.request.session[SESSION_STATIC_TOKENS] = tokens
return super().get(request, *args, **kwargs)
def form_valid(self, form: SetupForm) -> HttpResponse:
"""Verify OTP Token"""
device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
device.save()
[x.save() for x in self.request.session[SESSION_STATIC_TOKENS]]
del self.request.session[SESSION_STATIC_DEVICE]
del self.request.session[SESSION_STATIC_TOKENS]
return self.executor.stage_ok()

View File

@ -0,0 +1,31 @@
{% extends "user/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block page %}
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
{% trans "Time-based One-Time Passwords" %}
</div>
<div class="pf-c-card__body">
<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_time:otp-enable' %}" class="btn btn-success btn-sm">{% trans "Enable Time-based OTP" %}</a>
{% else %}
<a href="{% url 'passbook_stages_otp_time:disable' %}" class="btn btn-danger btn-sm">{% trans "Disable Time-based OTP" %}</a>
{% endif %}
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,9 @@
"""OTP static urls"""
from django.urls import path
from passbook.stages.otp_static.views import DisableView, UserSettingsView
urlpatterns = [
path("settings", UserSettingsView.as_view(), name="user-settings"),
path("disable", DisableView.as_view(), name="disable"),
]

View File

@ -0,0 +1,40 @@
"""otp Static view Tokens"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.views import View
from django.views.generic import TemplateView
from django_otp.plugins.otp_static.models import StaticDevice
from passbook.audit.models import Event, EventAction
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP"""
template_name = "stages/otp_static/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_devices = StaticDevice.objects.filter(
user=self.request.user, confirmed=True
)
kwargs["state"] = static_devices.exists()
return kwargs
class DisableView(LoginRequiredMixin, View):
"""Disable Static Tokens for user"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Delete all the devices for user"""
devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
devices.delete()
messages.success(request, "Successfully disabled Static OTP Tokens")
# Create event with email notification
Event.new(
EventAction.CUSTOM, message="User disabled Static OTP Tokens."
).from_http(request)
return redirect("passbook_stages_otp:otp-user-settings")

View File

@ -18,7 +18,6 @@ class PictureWidget(forms.widgets.Widget):
class SetupForm(forms.Form): class SetupForm(forms.Form):
"""Form to setup Time-based OTP""" """Form to setup Time-based OTP"""
title = _("Set up OTP")
device: Device = None device: Device = None
qr_code = forms.CharField( qr_code = forms.CharField(

View File

@ -9,6 +9,7 @@ from lxml.etree import tostring # nosec
from qrcode import QRCode from qrcode import QRCode
from qrcode.image.svg import SvgFillImage from qrcode.image.svg import SvgFillImage
from structlog import get_logger from structlog import get_logger
from django_otp.plugins.otp_totp.models import TOTPDevice
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView from passbook.flows.stage import StageView
@ -43,6 +44,11 @@ class OTPTimeStageView(FormView, StageView):
LOGGER.debug("No pending user, continuing") LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok() return self.executor.stage_ok()
# Currently, this stage only supports one device per user. If the user already
# has a device, just skip to the next stage
if TOTPDevice.objects.filter(user=user).exists():
return self.executor.stage_ok()
stage: OTPTimeStage = self.executor.current_stage stage: OTPTimeStage = self.executor.current_stage
if SESSION_TOTP_DEVICE not in self.request.session: if SESSION_TOTP_DEVICE not in self.request.session:

View File

@ -9,10 +9,6 @@ from django_otp.plugins.otp_totp.models import TOTPDevice
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event, EventAction
# from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
# from passbook.flows.views import SESSION_KEY_PLAN
# from passbook.stages.otp_time.models import OTPTimeStage
class UserSettingsView(LoginRequiredMixin, TemplateView): class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP""" """View for user settings to control OTP"""

View File

@ -4037,6 +4037,260 @@ paths:
required: true required: true
type: string type: string
format: uuid format: uuid
/stages/otp_static/:
get:
operationId: stages_otp_static_list
description: OTPStaticStage Viewset
parameters:
- name: ordering
in: query
description: Which field to use when ordering the results.
required: false
type: string
- name: search
in: query
description: A search term.
required: false
type: string
- name: limit
in: query
description: Number of results to return per page.
required: false
type: integer
- name: offset
in: query
description: The initial index from which to return the results.
required: false
type: integer
responses:
'200':
description: ''
schema:
required:
- count
- results
type: object
properties:
count:
type: integer
next:
type: string
format: uri
x-nullable: true
previous:
type: string
format: uri
x-nullable: true
results:
type: array
items:
$ref: '#/definitions/OTPStaticStage'
tags:
- stages
post:
operationId: stages_otp_static_create
description: OTPStaticStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPStaticStage'
responses:
'201':
description: ''
schema:
$ref: '#/definitions/OTPStaticStage'
tags:
- stages
parameters: []
/stages/otp_static/{stage_uuid}/:
get:
operationId: stages_otp_static_read
description: OTPStaticStage Viewset
parameters: []
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPStaticStage'
tags:
- stages
put:
operationId: stages_otp_static_update
description: OTPStaticStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPStaticStage'
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPStaticStage'
tags:
- stages
patch:
operationId: stages_otp_static_partial_update
description: OTPStaticStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPStaticStage'
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPStaticStage'
tags:
- stages
delete:
operationId: stages_otp_static_delete
description: OTPStaticStage Viewset
parameters: []
responses:
'204':
description: ''
tags:
- stages
parameters:
- name: stage_uuid
in: path
description: A UUID string identifying this OTP Static Setup Stage.
required: true
type: string
format: uuid
/stages/otp_time/:
get:
operationId: stages_otp_time_list
description: OTPTimeStage Viewset
parameters:
- name: ordering
in: query
description: Which field to use when ordering the results.
required: false
type: string
- name: search
in: query
description: A search term.
required: false
type: string
- name: limit
in: query
description: Number of results to return per page.
required: false
type: integer
- name: offset
in: query
description: The initial index from which to return the results.
required: false
type: integer
responses:
'200':
description: ''
schema:
required:
- count
- results
type: object
properties:
count:
type: integer
next:
type: string
format: uri
x-nullable: true
previous:
type: string
format: uri
x-nullable: true
results:
type: array
items:
$ref: '#/definitions/OTPTimeStage'
tags:
- stages
post:
operationId: stages_otp_time_create
description: OTPTimeStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPTimeStage'
responses:
'201':
description: ''
schema:
$ref: '#/definitions/OTPTimeStage'
tags:
- stages
parameters: []
/stages/otp_time/{stage_uuid}/:
get:
operationId: stages_otp_time_read
description: OTPTimeStage Viewset
parameters: []
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPTimeStage'
tags:
- stages
put:
operationId: stages_otp_time_update
description: OTPTimeStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPTimeStage'
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPTimeStage'
tags:
- stages
patch:
operationId: stages_otp_time_partial_update
description: OTPTimeStage Viewset
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/OTPTimeStage'
responses:
'200':
description: ''
schema:
$ref: '#/definitions/OTPTimeStage'
tags:
- stages
delete:
operationId: stages_otp_time_delete
description: OTPTimeStage Viewset
parameters: []
responses:
'204':
description: ''
tags:
- stages
parameters:
- name: stage_uuid
in: path
description: A UUID string identifying this OTP Time (TOTP) Setup Stage.
required: true
type: string
format: uuid
/stages/otp_validate/: /stages/otp_validate/:
get: get:
operationId: stages_otp_validate_list operationId: stages_otp_validate_list
@ -6350,6 +6604,46 @@ definitions:
fixed_data: fixed_data:
title: Fixed data title: Fixed data
type: object type: object
OTPStaticStage:
required:
- name
type: object
properties:
pk:
title: Stage uuid
type: string
format: uuid
readOnly: true
name:
title: Name
type: string
minLength: 1
token_count:
title: Token count
type: integer
maximum: 2147483647
minimum: -2147483648
OTPTimeStage:
required:
- name
- digits
type: object
properties:
pk:
title: Stage uuid
type: string
format: uuid
readOnly: true
name:
title: Name
type: string
minLength: 1
digits:
title: Digits
type: integer
enum:
- 6
- 8
OTPValidateStage: OTPValidateStage:
required: required:
- name - name