diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index c4b9a9ce8..4c8ae10ff 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -37,6 +37,8 @@ from passbook.stages.dummy.api import DummyStageViewSet from passbook.stages.email.api import EmailStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet 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.password.api import PasswordStageViewSet 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/invitation", InvitationStageViewSet) 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/password", PasswordStageViewSet) -router.register("stages/prompt/stages", PromptStageViewSet) router.register("stages/prompt/prompts", PromptViewSet) +router.register("stages/prompt/stages", PromptStageViewSet) router.register("stages/user_delete", UserDeleteStageViewSet) router.register("stages/user_login", UserLoginStageViewSet) router.register("stages/user_logout", UserLogoutStageViewSet) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 04b282221..0fccf7ce1 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -107,6 +107,7 @@ INSTALLED_APPS = [ "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", + "passbook.stages.otp_static.apps.PassbookStageOTPStaticConfig", "passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig", "passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig", diff --git a/passbook/stages/otp_static/api.py b/passbook/stages/otp_static/api.py new file mode 100644 index 000000000..fa706cb75 --- /dev/null +++ b/passbook/stages/otp_static/api.py @@ -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 diff --git a/passbook/stages/otp_static/forms.py b/passbook/stages/otp_static/forms.py new file mode 100644 index 000000000..5d94a17ec --- /dev/null +++ b/passbook/stages/otp_static/forms.py @@ -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(), + } diff --git a/passbook/stages/otp_static/migrations/0001_initial.py b/passbook/stages/otp_static/migrations/0001_initial.py new file mode 100644 index 000000000..93c26b3e5 --- /dev/null +++ b/passbook/stages/otp_static/migrations/0001_initial.py @@ -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",), + ), + ] diff --git a/passbook/stages/otp_static/migrations/__init__.py b/passbook/stages/otp_static/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/otp_static/models.py b/passbook/stages/otp_static/models.py new file mode 100644 index 000000000..d98872cc1 --- /dev/null +++ b/passbook/stages/otp_static/models.py @@ -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") diff --git a/passbook/stages/otp_static/settings.py b/passbook/stages/otp_static/settings.py new file mode 100644 index 000000000..9c3cc4952 --- /dev/null +++ b/passbook/stages/otp_static/settings.py @@ -0,0 +1,5 @@ +"""OTP Static settings""" + +INSTALLED_APPS = [ + "django_otp.plugins.otp_static", +] diff --git a/passbook/stages/otp_static/stage.py b/passbook/stages/otp_static/stage.py new file mode 100644 index 000000000..bf5299ca9 --- /dev/null +++ b/passbook/stages/otp_static/stage.py @@ -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() diff --git a/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html b/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html new file mode 100644 index 000000000..aa7b30cc0 --- /dev/null +++ b/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html @@ -0,0 +1,31 @@ +{% extends "user/base.html" %} + +{% load passbook_utils %} +{% load i18n %} + +{% block page %} +
+
+ {% trans "Time-based One-Time Passwords" %} +
+
+

+ {% blocktrans with state=state|yesno:"Enabled,Disabled" %} + Status: {{ state }} + {% endblocktrans %} + {% if state %} + + {% else %} + + {% endif %} +

+

+ {% if not state %} + {% trans "Enable Time-based OTP" %} + {% else %} + {% trans "Disable Time-based OTP" %} + {% endif %} +

+
+
+{% endblock %} diff --git a/passbook/stages/otp_static/urls.py b/passbook/stages/otp_static/urls.py new file mode 100644 index 000000000..7eb2ce2c8 --- /dev/null +++ b/passbook/stages/otp_static/urls.py @@ -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"), +] diff --git a/passbook/stages/otp_static/views.py b/passbook/stages/otp_static/views.py new file mode 100644 index 000000000..3b3feb1ad --- /dev/null +++ b/passbook/stages/otp_static/views.py @@ -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") diff --git a/passbook/stages/otp_time/forms.py b/passbook/stages/otp_time/forms.py index 494053fa0..633bc51b3 100644 --- a/passbook/stages/otp_time/forms.py +++ b/passbook/stages/otp_time/forms.py @@ -18,7 +18,6 @@ class PictureWidget(forms.widgets.Widget): class SetupForm(forms.Form): """Form to setup Time-based OTP""" - title = _("Set up OTP") device: Device = None qr_code = forms.CharField( diff --git a/passbook/stages/otp_time/stage.py b/passbook/stages/otp_time/stage.py index faec496ee..a5fb245f5 100644 --- a/passbook/stages/otp_time/stage.py +++ b/passbook/stages/otp_time/stage.py @@ -9,6 +9,7 @@ from lxml.etree import tostring # nosec from qrcode import QRCode from qrcode.image.svg import SvgFillImage 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.stage import StageView @@ -43,6 +44,11 @@ class OTPTimeStageView(FormView, StageView): 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 TOTPDevice.objects.filter(user=user).exists(): + return self.executor.stage_ok() + stage: OTPTimeStage = self.executor.current_stage if SESSION_TOTP_DEVICE not in self.request.session: diff --git a/passbook/stages/otp_time/views.py b/passbook/stages/otp_time/views.py index f801978c3..63f5afdfc 100644 --- a/passbook/stages/otp_time/views.py +++ b/passbook/stages/otp_time/views.py @@ -9,10 +9,6 @@ from django_otp.plugins.otp_totp.models import TOTPDevice 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): """View for user settings to control OTP""" diff --git a/swagger.yaml b/swagger.yaml index 47353de0a..a8aae399c 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -4037,6 +4037,260 @@ paths: required: true type: string 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/: get: operationId: stages_otp_validate_list @@ -6350,6 +6604,46 @@ definitions: fixed_data: title: Fixed data 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: required: - name