From 57377d8fde7e7e8ba4bc2dec51b3226f2a3ded79 Mon Sep 17 00:00:00 2001 From: feng <1304903146@qq.com> Date: Mon, 15 Jun 2026 11:32:48 +0800 Subject: [PATCH] perf: Login asset otp --- apps/accounts/api/account/account.py | 31 +++++++- .../migrations/0008_account_otp_secret_key.py | 19 +++++ apps/accounts/models/account.py | 4 + apps/accounts/serializers/account/account.py | 73 +++++++++++++++++-- apps/authentication/api/connection_token.py | 16 +++- apps/authentication/backends/drf.py | 4 +- .../serializers/connection_token.py | 5 +- apps/common/utils/__init__.py | 1 + apps/common/utils/otp.py | 57 +++++++++++++++ apps/jumpserver/middleware.py | 13 +++- apps/jumpserver/settings/base.py | 20 +++-- apps/perms/serializers/user_permission.py | 7 +- apps/users/utils.py | 27 +------ 13 files changed, 230 insertions(+), 47 deletions(-) create mode 100644 apps/accounts/migrations/0008_account_otp_secret_key.py create mode 100644 apps/common/utils/otp.py diff --git a/apps/accounts/api/account/account.py b/apps/accounts/api/account/account.py index 93622c7c8..a5a7b6665 100644 --- a/apps/accounts/api/account/account.py +++ b/apps/accounts/api/account/account.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ @@ -18,7 +19,7 @@ from authentication.permissions import UserConfirmation, ConfirmType from common.api.mixin import ExtraFilterFieldsMixin from common.drf.filters import AttrRulesFilterBackend from common.permissions import IsValidUser -from common.utils import lazyproperty, get_logger +from common.utils import lazyproperty, get_logger, generate_otp_code from orgs.mixins.api import OrgBulkModelViewSet from orgs.utils import tmp_to_root_org from rbac.permissions import RBACPermission @@ -46,6 +47,7 @@ class AccountViewSet(OrgBulkModelViewSet): 'clear_secret': 'accounts.change_account', 'move_to_assets': 'accounts.delete_account', 'copy_to_assets': 'accounts.add_account', + 'generate_otp': 'accounts.add_account', 'chat': 'accounts.view_account', } export_as_zip = True @@ -173,7 +175,7 @@ class AccountViewSet(OrgBulkModelViewSet): asset_ids = request.data.get('assets', []) assets = Asset.objects.filter(id__in=asset_ids) field_names = [ - 'name', 'username', 'secret_type', 'secret', + 'name', 'username', 'secret_type', 'secret', 'otp_secret_key', 'privileged', 'is_active', 'source', 'source_id', 'comment' ] account_data = {field: getattr(account, field) for field in field_names} @@ -207,6 +209,12 @@ class AccountViewSet(OrgBulkModelViewSet): def copy_to_assets(self, request, *args, **kwargs): return self._copy_or_move_to_assets(request, move=False) + @action(methods=['post'], detail=False, url_path='generate-otp') + def generate_otp(self, request, *args, **kwargs): + serializer = serializers.AccountOTPGenerateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response(serializer.get_payload(), status=HTTP_200_OK) + @action(methods=['get'], detail=False, url_path='chat') def chat(self, request, *args, **kwargs): with tmp_to_root_org(): @@ -227,8 +235,27 @@ class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet): rbac_perms = { 'list': 'accounts.view_accountsecret', 'retrieve': 'accounts.view_accountsecret', + 'otp_code': 'accounts.view_accountsecret', } + @action(methods=['get'], detail=True, url_path='otp-code', permission_classes=[RBACPermission]) + def otp_code(self, request, *args, **kwargs): + account = self.get_object() + if not settings.SECURITY_ACCOUNT_SECRET_READ: + return Response( + {'detail': _('Account secret reading has been disabled by administrator')}, + status=HTTP_400_BAD_REQUEST + ) + if not account.otp_secret_key: + return Response( + {'detail': _('Account OTP secret key not configured')}, + status=HTTP_400_BAD_REQUEST + ) + return Response({ + 'otp_code': generate_otp_code(account.otp_secret_key), + 'has_otp_secret_key': True, + }, status=HTTP_200_OK) + class AssetAccountBulkCreateApi(CreateAPIView): serializer_class = serializers.AssetAccountBulkSerializer diff --git a/apps/accounts/migrations/0008_account_otp_secret_key.py b/apps/accounts/migrations/0008_account_otp_secret_key.py new file mode 100644 index 000000000..b64cbf68c --- /dev/null +++ b/apps/accounts/migrations/0008_account_otp_secret_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.13 on 2026-06-08 00:00 + +import common.db.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0007_alter_account_connectivity'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='otp_secret_key', + field=common.db.fields.EncryptCharField(blank=True, max_length=128, null=True, verbose_name='OTP secret key'), + ), + ] diff --git a/apps/accounts/models/account.py b/apps/accounts/models/account.py index d077080ce..aa0520585 100644 --- a/apps/accounts/models/account.py +++ b/apps/accounts/models/account.py @@ -5,6 +5,7 @@ from simple_history.models import HistoricalRecords from assets.models import Asset from assets.models.base import AbsConnectivity +from common.db import fields from common.utils import lazyproperty, get_logger from labels.mixins import LabeledMixin from .base import BaseAccount @@ -102,6 +103,9 @@ class Account(AbsConnectivity, LabeledMixin, BaseAccount, JSONFilterMixin): change_secret_status = models.CharField( max_length=16, null=True, blank=True, verbose_name=_('Change secret status') ) + otp_secret_key = fields.EncryptCharField( + max_length=128, blank=True, null=True, verbose_name=_('OTP secret key') + ) class Meta: verbose_name = _('Account') diff --git a/apps/accounts/serializers/account/account.py b/apps/accounts/serializers/account/account.py index 269e3637a..b17e54187 100644 --- a/apps/accounts/serializers/account/account.py +++ b/apps/accounts/serializers/account/account.py @@ -15,13 +15,25 @@ from accounts.tasks import push_accounts_to_assets_task from assets.const import Category, AllTypes from assets.models import Asset from common.serializers import SecretReadableMixin, SecretReadableCheckMixin, CommonBulkModelSerializer -from common.serializers.fields import ObjectRelatedField, LabeledChoiceField -from common.utils import get_logger +from common.serializers.fields import ObjectRelatedField, LabeledChoiceField, EncryptedField +from common.utils import ( + get_logger, generate_otp_secret_key, generate_otp_code, + normalize_otp_secret_key, is_otp_secret_key_valid, +) from .base import BaseAccountSerializer, AuthValidateMixin logger = get_logger(__name__) +def validate_otp_secret_key_value(value): + otp_secret_key = normalize_otp_secret_key(value) + if not otp_secret_key: + return '' + if not is_otp_secret_key_valid(otp_secret_key): + raise serializers.ValidationError(_('OTP secret key invalid')) + return otp_secret_key + + class AccountCreateUpdateSerializerMixin(serializers.Serializer): template = serializers.PrimaryKeyRelatedField( queryset=AccountTemplate.objects, required=False, @@ -234,6 +246,17 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize label=_('Su from'), attrs=('id', 'name', 'username') ) ds = ObjectRelatedField(read_only=True, label=_('Directory service'), attrs=('id', 'name', 'domain_name')) + has_otp_secret_key = serializers.SerializerMethodField( + label=_('Has OTP secret key'), read_only=True + ) + otp_secret_key = EncryptedField( + label=_('OTP secret key'), + required=False, + max_length=128, + allow_blank=True, + allow_null=True, + write_only=True, + ) class Meta(BaseAccountSerializer.Meta): model = Account @@ -243,9 +266,9 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize ] fields = BaseAccountSerializer.Meta.fields + [ 'su_from', 'asset', 'version', 'ds', - 'source', 'source_id', 'secret_reset', + 'source', 'source_id', 'has_otp_secret_key', 'otp_secret_key', 'secret_reset', ] + AccountCreateUpdateSerializerMixin.Meta.fields + automation_fields - read_only_fields = BaseAccountSerializer.Meta.read_only_fields + automation_fields + read_only_fields = BaseAccountSerializer.Meta.read_only_fields + automation_fields + ['has_otp_secret_key'] fields = [f for f in fields if f not in ['spec_info']] extra_kwargs = { **BaseAccountSerializer.Meta.extra_kwargs, @@ -280,6 +303,13 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize raise serializers.ValidationError(field_errors) return attrs + def validate_otp_secret_key(self, value): + return validate_otp_secret_key_value(value) + + @staticmethod + def get_has_otp_secret_key(obj): + return bool(getattr(obj, 'otp_secret_key', '')) + class AccountDetailSerializer(AccountSerializer): has_secret = serializers.BooleanField(label=_("Has secret"), read_only=True) @@ -305,11 +335,19 @@ class AssetAccountBulkSerializer( max_length=128, required=False, write_only=True, allow_null=True, label=_("Su from"), allow_blank=True, ) + otp_secret_key = EncryptedField( + label=_('OTP secret key'), + required=False, + max_length=128, + allow_blank=True, + allow_null=True, + write_only=True, + ) class Meta: model = Account fields = [ - 'name', 'username', 'secret', 'secret_type', 'secret_reset', + 'name', 'username', 'secret', 'secret_type', 'otp_secret_key', 'secret_reset', 'passphrase', 'privileged', 'is_active', 'comment', 'template', 'on_invalid', 'push_now', 'params', 'su_from_username', 'source', 'source_id', @@ -473,6 +511,31 @@ class AssetAccountBulkSerializer( res['asset'] = str(res['asset']) return results + def validate_otp_secret_key(self, value): + return validate_otp_secret_key_value(value) + + +class AccountOTPGenerateSerializer(serializers.Serializer): + otp_secret_key = EncryptedField( + label=_('OTP secret key'), + required=False, + max_length=128, + allow_blank=True, + allow_null=True, + write_only=True, + ) + otp_code = serializers.CharField(label=_('OTP code'), read_only=True) + + def validate_otp_secret_key(self, value): + return validate_otp_secret_key_value(value) + + def get_payload(self): + otp_secret_key = self.validated_data.get('otp_secret_key') or generate_otp_secret_key() + return { + 'otp_secret_key': otp_secret_key, + 'otp_code': generate_otp_code(otp_secret_key), + } + class AccountSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, AccountSerializer): spec_info = serializers.DictField(label=_('Spec info'), read_only=True) diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index d90e841d2..a94efb135 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -18,7 +18,7 @@ from accounts.const import AliasAccount from acls.notifications import AssetLoginReminderMsg from common.api import JMSModelViewSet from common.exceptions import JMSException -from common.utils import random_string, get_logger, get_request_ip_or_data +from common.utils import random_string, get_logger, get_request_ip_or_data, check_otp_code from common.utils.django import get_request_os from common.utils.http import is_true, is_false from orgs.mixins.api import RootOrgViewMixin @@ -412,8 +412,9 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi account_name = data.get('account') protocol = data.get('protocol') connect_method = data.get('connect_method') + otp_code = data.pop('otp_code', '') self.input_username = self.get_input_username(data) - _data = self._validate(user, asset, account_name, protocol, connect_method) + _data = self._validate(user, asset, account_name, protocol, connect_method, otp_code=otp_code) data.update(_data) return serializer @@ -426,7 +427,15 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi setattr(token, k, v) return token - def _validate(self, user, asset, account_alias, protocol, connect_method): + @staticmethod + def _validate_account_otp(account, otp_code): + otp_secret_key = getattr(account, 'otp_secret_key', '') + if not otp_secret_key: + return + if not otp_code or not check_otp_code(otp_secret_key, otp_code): + raise ValidationError({'otp_code': _('OTP code invalid, or server time error')}) + + def _validate(self, user, asset, account_alias, protocol, connect_method, otp_code=''): data = dict() data['org_id'] = asset.org_id data['user'] = user @@ -436,6 +445,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi raise ValidationError(_('Anonymous account is not supported for this asset')) account = self._validate_perm(user, asset, account_alias, protocol) + self._validate_account_otp(account, otp_code) if account.has_secret: data['input_secret'] = '' data['input_secret_type'] = account.secret_type diff --git a/apps/authentication/backends/drf.py b/apps/authentication/backends/drf.py index 985e521c9..fd67d2e67 100644 --- a/apps/authentication/backends/drf.py +++ b/apps/authentication/backends/drf.py @@ -3,6 +3,7 @@ import os from django.contrib.auth import get_user_model +from django.conf import settings from django.core.cache import cache from django.utils import timezone from django.utils.translation import gettext as _ @@ -112,7 +113,8 @@ class SessionAuthentication(authentication.SessionAuthentication): if not user or not user.is_active or not user.is_valid: return None - ignore_csrf_check = os.environ.get("DOMAINS", "") == "*" + domains = getattr(settings, 'DOMAINS', '') or os.environ.get("DOMAINS", "") + ignore_csrf_check = '*' in [domain.strip() for domain in domains.split(',') if domain.strip()] if not ignore_csrf_check: try: self.enforce_csrf(request) diff --git a/apps/authentication/serializers/connection_token.py b/apps/authentication/serializers/connection_token.py index c230841b7..3ed15886a 100644 --- a/apps/authentication/serializers/connection_token.py +++ b/apps/authentication/serializers/connection_token.py @@ -19,6 +19,9 @@ class ConnectionTokenSerializer(OrgResourceModelSerializerMixin): input_secret = EncryptedField( label=_("Input secret"), max_length=40960, required=False, allow_blank=True ) + otp_code = EncryptedField( + label=_("OTP code"), max_length=16, required=False, allow_blank=True, write_only=True + ) from_ticket_info = serializers.SerializerMethodField(label=_("Ticket info")) actions = ActionChoicesField(read_only=True, label=_("Actions")) @@ -26,7 +29,7 @@ class ConnectionTokenSerializer(OrgResourceModelSerializerMixin): model = ConnectionToken fields_mini = ['id', 'value'] fields_small = fields_mini + [ - 'user', 'asset', 'account', 'input_username', 'input_secret', 'input_secret_type', + 'user', 'asset', 'account', 'input_username', 'input_secret', 'otp_code', 'input_secret_type', 'connect_method', 'connect_options', 'protocol', 'actions', 'is_active', 'is_reusable', 'from_ticket', 'from_ticket_info', 'date_expired', 'date_created', 'date_updated', 'created_by', diff --git a/apps/common/utils/__init__.py b/apps/common/utils/__init__.py index d34592a3a..125e91a11 100644 --- a/apps/common/utils/__init__.py +++ b/apps/common/utils/__init__.py @@ -8,5 +8,6 @@ from .encode import * from .http import * from .ip import * from .jumpserver import * +from .otp import * from .random import * from .translate import * diff --git a/apps/common/utils/otp.py b/apps/common/utils/otp.py new file mode 100644 index 000000000..d1a2f7d22 --- /dev/null +++ b/apps/common/utils/otp.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# + +import base64 +import hashlib +import os + +import pyotp +from django.conf import settings + + +def get_otp_digest(): + return hashlib.sha256 if settings.OTP_DIGEST == 'sha256' else hashlib.sha1 + + +def normalize_otp_secret_key(otp_secret_key): + if not otp_secret_key: + return '' + return str(otp_secret_key).replace(' ', '').upper() + + +def generate_otp_secret_key(): + return base64.b32encode(os.urandom(10)).decode('utf-8') + + +def generate_otp_uri(username, otp_secret_key=None, issuer="JumpServer"): + otp_secret_key = normalize_otp_secret_key(otp_secret_key) or generate_otp_secret_key() + totp = pyotp.TOTP(otp_secret_key, digest=get_otp_digest()) + otp_issuer_name = settings.OTP_ISSUER_NAME or issuer + uri = totp.provisioning_uri(name=username, issuer_name=otp_issuer_name) + return uri, otp_secret_key + + +def generate_otp_code(otp_secret_key): + otp_secret_key = normalize_otp_secret_key(otp_secret_key) + totp = pyotp.TOTP(otp_secret_key, digest=get_otp_digest()) + return totp.now() + + +def check_otp_code(otp_secret_key, otp_code): + if not otp_secret_key or not otp_code: + return False + + otp_secret_key = normalize_otp_secret_key(otp_secret_key) + totp = pyotp.TOTP(otp_secret_key, digest=get_otp_digest()) + otp_valid_window = settings.OTP_VALID_WINDOW or 0 + return totp.verify(otp=str(otp_code), valid_window=otp_valid_window) + + +def is_otp_secret_key_valid(otp_secret_key): + if not otp_secret_key: + return True + try: + generate_otp_code(otp_secret_key) + except Exception: + return False + return True diff --git a/apps/jumpserver/middleware.py b/apps/jumpserver/middleware.py index 54bb886dd..af0c1b67d 100644 --- a/apps/jumpserver/middleware.py +++ b/apps/jumpserver/middleware.py @@ -21,7 +21,10 @@ from rest_framework import status from .utils import set_current_request from common.utils.common import text_hmac_sha256 -IGNORE_CSRF_CHECK = '*' in os.getenv("DOMAINS", "").split(',') + +def ignore_csrf_check(): + domains = getattr(settings, 'DOMAINS', '') or os.getenv("DOMAINS", "") + return '*' in [domain.strip() for domain in domains.split(',') if domain.strip()] class TimezoneMiddleware: @@ -198,8 +201,12 @@ class SafeRedirectMiddleware: class CsrfCheckMiddleware(CsrfViewMiddleware): - def _origin_verified(self, request): - if IGNORE_CSRF_CHECK: + def process_view(self, request, callback, callback_args, callback_kwargs): + if ignore_csrf_check(): request._dont_enforce_csrf_checks = True + return super().process_view(request, callback, callback_args, callback_kwargs) + + def _origin_verified(self, request): + if ignore_csrf_check(): return True return super()._origin_verified(request) diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 0e148c2ce..a435ac3fd 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -75,6 +75,7 @@ ALLOWED_DOMAINS = [host.strip() for host in ALLOWED_DOMAINS] ALLOWED_DOMAINS = [host.replace('http://', '').replace('https://', '') for host in ALLOWED_DOMAINS if host] ALLOWED_DOMAINS = [host.split('/')[0] for host in ALLOWED_DOMAINS if host] ALLOWED_DOMAINS = [re.sub(':80$|:443$', '', host) for host in ALLOWED_DOMAINS] +ALLOW_ALL_DOMAINS = '*' in ALLOWED_DOMAINS DEBUG_HOSTS = ('127.0.0.1', 'localhost', 'core') DEBUG_PORT = ['8080', '80', ] @@ -92,8 +93,7 @@ ALLOWED_HOSTS = ['*'] # https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS CSRF_TRUSTED_ORIGINS = [] for host_port in ALLOWED_DOMAINS: - if '*' in ALLOWED_DOMAINS: - CSRF_TRUSTED_ORIGINS = ['http://*', 'https://*'] + if ALLOW_ALL_DOMAINS: break origin = host_port.strip('.') @@ -111,13 +111,23 @@ for host_port in ALLOWED_DOMAINS: for schema in ['https', 'http']: if is_local_origin and schema == 'https': continue - CSRF_TRUSTED_ORIGINS.append('{}://*.{}'.format(schema, origin)) + exact_origin = '{}://{}'.format(schema, origin) + wildcard_origin = '{}://*.{}'.format(schema, origin) + CSRF_TRUSTED_ORIGINS.append(exact_origin) + if not is_local_origin: + CSRF_TRUSTED_ORIGINS.append(wildcard_origin) -CORS_ALLOWED_ORIGINS = [o.replace('*.', '') for o in CSRF_TRUSTED_ORIGINS] +CSRF_TRUSTED_ORIGINS = list(dict.fromkeys(CSRF_TRUSTED_ORIGINS)) +if ALLOW_ALL_DOMAINS: + CORS_ALLOW_ALL_ORIGINS = True + CORS_ALLOWED_ORIGINS = [] +else: + CORS_ALLOW_ALL_ORIGINS = False + CORS_ALLOWED_ORIGINS = list(dict.fromkeys(o.replace('*.', '') for o in CSRF_TRUSTED_ORIGINS)) CSRF_FAILURE_VIEW = 'jumpserver.views.other.csrf_failure' # print("CSRF_TRUSTED_ORIGINS: ") # for origin in CSRF_TRUSTED_ORIGINS: -# print(' - ' + origin) +# print(' - ' + origin) # Max post update field num DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000 diff --git a/apps/perms/serializers/user_permission.py b/apps/perms/serializers/user_permission.py index 33a346dc5..bb75bc814 100644 --- a/apps/perms/serializers/user_permission.py +++ b/apps/perms/serializers/user_permission.py @@ -61,16 +61,21 @@ class NodePermedSerializer(serializers.ModelSerializer): class AccountsPermedSerializer(serializers.ModelSerializer): actions = ActionChoicesField(read_only=True) username = serializers.CharField(source='full_username', read_only=True) + has_otp_secret_key = serializers.SerializerMethodField() date_expired = serializers.SerializerMethodField() class Meta: model = Account fields = [ 'id', 'alias', 'name', 'username', 'has_username', - 'has_secret', 'secret_type', 'actions', 'date_expired' + 'has_secret', 'has_otp_secret_key', 'secret_type', 'actions', 'date_expired' ] read_only_fields = fields + @staticmethod + def get_has_otp_secret_key(obj) -> bool: + return bool(getattr(obj, 'otp_secret_key', '')) + def get_date_expired(self, obj) -> str: dt = obj.date_expired if dt: diff --git a/apps/users/utils.py b/apps/users/utils.py index e56c75796..16f386b91 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -1,25 +1,19 @@ # ~*~ coding: utf-8 ~*~ # -import base64 import logging -import os import re import time from contextlib import contextmanager from urllib.parse import unquote -import hashlib - -import pyotp from django.conf import settings from django.core.cache import cache from django.utils import translation from common.tasks import send_mail_async -from common.utils import reverse, get_object_or_none, ip, safe_next_url +from common.utils import reverse, get_object_or_none, ip, safe_next_url, generate_otp_uri, check_otp_code from .models import User logger = logging.getLogger('jumpserver.users') -otp_digest = hashlib.sha256 if settings.OTP_DIGEST == 'sha256' else hashlib.sha1 def send_user_created_mail(user): @@ -68,25 +62,6 @@ def redirect_user_first_login_or_index(request, redirect_field_name): return url -def generate_otp_uri(username, otp_secret_key=None, issuer="JumpServer"): - if otp_secret_key is None: - otp_secret_key = base64.b32encode(os.urandom(10)).decode('utf-8') - - totp = pyotp.TOTP(otp_secret_key, digest=otp_digest) - otp_issuer_name = settings.OTP_ISSUER_NAME or issuer - uri = totp.provisioning_uri(name=username, issuer_name=otp_issuer_name) - return uri, otp_secret_key - - -def check_otp_code(otp_secret_key, otp_code): - if not otp_secret_key or not otp_code: - return False - - totp = pyotp.TOTP(otp_secret_key, digest=otp_digest) - otp_valid_window = settings.OTP_VALID_WINDOW or 0 - return totp.verify(otp=otp_code, valid_window=otp_valid_window) - - def get_password_check_rules(user): check_rules = [] for rule in settings.SECURITY_PASSWORD_RULES: