perf: Login asset otp

This commit is contained in:
feng
2026-06-15 11:32:48 +08:00
parent 14a3e3f3f8
commit 57377d8fde
13 changed files with 230 additions and 47 deletions

View File

@@ -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

View File

@@ -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'),
),
]

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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 *

57
apps/common/utils/otp.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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: