stages/otp_static: start implementing static stage
This commit is contained in:
parent
3716bda76e
commit
d2bf579ff6
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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(),
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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")
|
|
@ -0,0 +1,5 @@
|
|||
"""OTP Static settings"""
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django_otp.plugins.otp_static",
|
||||
]
|
|
@ -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()
|
|
@ -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 %}
|
|
@ -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"),
|
||||
]
|
|
@ -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")
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"""
|
||||
|
|
294
swagger.yaml
294
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
|
||||
|
|
Reference in New Issue