mirror of https://github.com/jumpserver/jumpserver
commit
5a82174c54
|
@ -16,6 +16,11 @@ def create_internal_platform(apps, schema_editor):
|
|||
name=name, defaults=defaults
|
||||
)
|
||||
|
||||
win2016 = model.objects.filter(name='Windows2016').first()
|
||||
if win2016:
|
||||
win2016.internal = False
|
||||
win2016.save(update_fields=['internal'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
|
|
@ -103,6 +103,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
|||
'port': {'write_only': True},
|
||||
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
|
||||
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
|
||||
'cpu_info': {'label': _('CPU info')},
|
||||
}
|
||||
|
||||
def get_fields(self):
|
||||
|
|
|
@ -42,7 +42,7 @@ class UserLoginLogSerializer(serializers.ModelSerializer):
|
|||
fields = fields_small
|
||||
extra_kwargs = {
|
||||
"user_agent": {'label': _('User agent')},
|
||||
"reason_display": {'label': _('Reason')}
|
||||
"reason_display": {'label': _('Reason display')}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ from orgs.mixins.api import RootOrgViewMixin
|
|||
from common.http import is_true
|
||||
from perms.utils.asset.permission import get_asset_system_user_ids_with_actions_by_user
|
||||
from perms.models.asset_permission import Action
|
||||
from authentication.errors import NotHaveUpDownLoadPerm
|
||||
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
|
@ -96,22 +95,26 @@ class ClientProtocolMixin:
|
|||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||
token = self.create_token(user, asset, application, system_user)
|
||||
|
||||
# 设置磁盘挂载
|
||||
if drives_redirect and asset:
|
||||
systemuser_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
|
||||
actions = systemuser_actions_mapper.get(system_user.id, [])
|
||||
actions = systemuser_actions_mapper.get(system_user.id, 0)
|
||||
if actions & Action.UPDOWNLOAD:
|
||||
options['drivestoredirect:s'] = '*'
|
||||
else:
|
||||
raise NotHaveUpDownLoadPerm
|
||||
|
||||
# 全屏
|
||||
options['screen mode id:i'] = '2' if full_screen else '1'
|
||||
|
||||
# RDP Server 地址
|
||||
address = settings.TERMINAL_RDP_ADDR
|
||||
if not address or address == 'localhost:3389':
|
||||
address = self.request.get_host().split(':')[0] + ':3389'
|
||||
options['full address:s'] = address
|
||||
# 用户名
|
||||
options['username:s'] = '{}|{}'.format(user.username, token)
|
||||
if system_user.ad_domain:
|
||||
options['domain:s'] = system_user.ad_domain
|
||||
# 宽高
|
||||
if width and height:
|
||||
options['desktopwidth:i'] = width
|
||||
options['desktopheight:i'] = height
|
||||
|
@ -160,13 +163,16 @@ class ClientProtocolMixin:
|
|||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
protocol = system_user.protocol
|
||||
username = user.username
|
||||
name = ''
|
||||
|
||||
if protocol == 'rdp':
|
||||
name, config = self.get_rdp_file_content(serializer)
|
||||
elif protocol == 'vnc':
|
||||
raise HttpResponse(status=404, data={"error": "VNC not support"})
|
||||
else:
|
||||
elif protocol == 'ssh':
|
||||
# Todo:
|
||||
name = ''
|
||||
config = 'ssh://system_user@asset@user@jumpserver-ssh'
|
||||
else:
|
||||
raise ValueError('Protocol not support: {}'.format(protocol))
|
||||
|
||||
filename = "{}-{}-jumpserver".format(username, name)
|
||||
data = {
|
||||
"filename": filename,
|
||||
|
@ -179,8 +185,13 @@ class ClientProtocolMixin:
|
|||
@action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser])
|
||||
def get_client_protocol_url(self, request, *args, **kwargs):
|
||||
serializer = self.get_valid_serializer()
|
||||
protocol_data = self.get_client_protocol_data(serializer)
|
||||
protocol_data = base64.b64encode(json.dumps(protocol_data).encode()).decode()
|
||||
try:
|
||||
protocol_data = self.get_client_protocol_data(serializer)
|
||||
except ValueError as e:
|
||||
return Response({'error': str(e)}, status=401)
|
||||
|
||||
protocol_data = json.dumps(protocol_data).encode()
|
||||
protocol_data = base64.b64encode(protocol_data).decode()
|
||||
data = {
|
||||
'url': 'jms://{}'.format(protocol_data),
|
||||
}
|
||||
|
@ -348,14 +359,12 @@ class UserConnectionTokenViewSet(
|
|||
raise serializers.ValidationError("User not valid, disabled or expired")
|
||||
|
||||
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
||||
|
||||
asset = None
|
||||
app = None
|
||||
if value.get('type') == 'asset':
|
||||
asset = get_object_or_404(Asset, id=value.get('asset'))
|
||||
if not asset.is_active:
|
||||
raise serializers.ValidationError("Asset disabled")
|
||||
|
||||
has_perm, expired_at = asset_validate_permission(user, asset, system_user, 'connect')
|
||||
else:
|
||||
app = get_object_or_404(Application, id=value.get('application'))
|
||||
|
|
|
@ -44,7 +44,7 @@ class MFASendCodeApi(AuthMixin, CreateAPIView):
|
|||
else:
|
||||
user = get_object_or_404(User, username=username)
|
||||
|
||||
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
|
||||
mfa_backend = user.get_active_mfa_backend_by_type(mfa_type)
|
||||
if not mfa_backend or not mfa_backend.challenge_required:
|
||||
raise ValidationError('MFA type not support: {} {}'.format(mfa_type, mfa_backend))
|
||||
mfa_backend.send_challenge()
|
||||
|
@ -100,7 +100,7 @@ class UserOtpVerifyApi(CreateAPIView):
|
|||
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
||||
return Response({"ok": "1"})
|
||||
else:
|
||||
return Response({"error": _("Code is invalid") + ", " + error}, status=400)
|
||||
return Response({"error": _("Code is invalid, {}").format(error)}, status=400)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method.lower() == 'get' \
|
||||
|
|
|
@ -33,8 +33,8 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
|
|||
self.check_user_mfa_if_need(user)
|
||||
self.check_user_login_confirm_if_need(user)
|
||||
self.send_auth_signal(success=True, user=user)
|
||||
self.clear_auth_mark()
|
||||
resp = super().create(request, *args, **kwargs)
|
||||
self.clear_auth_mark()
|
||||
return resp
|
||||
except errors.AuthFailedError as e:
|
||||
return Response(e.as_data(), status=400)
|
||||
|
|
|
@ -60,13 +60,12 @@ block_mfa_msg = _(
|
|||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_error_msg = _(
|
||||
"{error},"
|
||||
"{error}, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_required_msg = _("MFA required")
|
||||
mfa_unset_msg = _("MFA not set, please set it first")
|
||||
otp_unset_msg = _("OTP not set, please set it first")
|
||||
login_confirm_required_msg = _("Login confirm required")
|
||||
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
|
||||
login_confirm_error_msg = _("Login confirm ticket was {}")
|
||||
|
@ -162,13 +161,11 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
|||
super().__init__(username=username, request=request, ip=ip)
|
||||
|
||||
|
||||
class MFAUnsetError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
class MFAUnsetError(Exception):
|
||||
error = reason_mfa_unset
|
||||
msg = mfa_unset_msg
|
||||
|
||||
def __init__(self, user, request, url):
|
||||
super().__init__(username=user.username, request=request)
|
||||
self.user = user
|
||||
self.url = url
|
||||
|
||||
|
||||
|
@ -180,6 +177,14 @@ class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
|
|||
super().__init__(username=username, ip=ip)
|
||||
|
||||
|
||||
class BlockGlobalIpLoginError(AuthFailedError):
|
||||
error = 'block_global_ip_login'
|
||||
|
||||
def __init__(self, username, ip):
|
||||
self.msg = _("IP is not allowed")
|
||||
super().__init__(username=username, ip=ip)
|
||||
|
||||
|
||||
class SessionEmptyError(AuthFailedError):
|
||||
msg = session_empty_msg
|
||||
error = 'session_empty'
|
||||
|
@ -340,27 +345,16 @@ class PasswordInvalid(JMSException):
|
|||
default_detail = _('Your password is invalid')
|
||||
|
||||
|
||||
class NotHaveUpDownLoadPerm(JMSException):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
code = 'not_have_up_down_load_perm'
|
||||
default_detail = _('No upload or download permission')
|
||||
|
||||
|
||||
class OTPBindRequiredError(JMSException):
|
||||
default_detail = otp_unset_msg
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class MFACodeRequiredError(AuthFailedError):
|
||||
error = 'mfa_code_required'
|
||||
msg = _("Please enter MFA code")
|
||||
|
||||
|
||||
class SMSCodeRequiredError(AuthFailedError):
|
||||
error = 'sms_code_required'
|
||||
msg = _("Please enter SMS code")
|
||||
|
||||
|
||||
class UserPhoneNotSet(AuthFailedError):
|
||||
error = 'phone_not_set'
|
||||
msg = _('Phone not set')
|
||||
|
|
|
@ -10,6 +10,7 @@ otp_failed_msg = _("OTP code invalid, or server time error")
|
|||
class MFAOtp(BaseMFA):
|
||||
name = 'otp'
|
||||
display_name = _('OTP')
|
||||
placeholder = _('OTP verification code')
|
||||
|
||||
def check_code(self, code):
|
||||
from users.utils import check_otp_code
|
||||
|
|
|
@ -9,7 +9,8 @@ mfa_failed_msg = _("Radius verify code invalid")
|
|||
|
||||
class MFARadius(BaseMFA):
|
||||
name = 'otp_radius'
|
||||
display_name = _('Radius MFA')
|
||||
display_name = 'Radius'
|
||||
placeholder = _("Radius verification code")
|
||||
|
||||
def check_code(self, code):
|
||||
assert self.is_authenticated()
|
||||
|
|
|
@ -19,8 +19,12 @@ class MFASms(BaseMFA):
|
|||
|
||||
def check_code(self, code):
|
||||
assert self.is_authenticated()
|
||||
ok = self.sms.verify(code)
|
||||
msg = '' if ok else sms_failed_msg
|
||||
ok = False
|
||||
msg = ''
|
||||
try:
|
||||
ok = self.sms.verify(code)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
return ok, msg
|
||||
|
||||
def is_active(self):
|
||||
|
|
|
@ -8,7 +8,6 @@ from typing import Callable
|
|||
from django.utils.http import urlencode
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import auth
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework.request import Request
|
||||
|
@ -18,10 +17,10 @@ from django.contrib.auth import (
|
|||
)
|
||||
from django.shortcuts import reverse, redirect, get_object_or_404
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from common.utils import get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from acls.models import LoginACL
|
||||
from users.models import User
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
from . import errors
|
||||
from .utils import rsa_decrypt, gen_key_pair
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
|
@ -76,7 +75,9 @@ def authenticate(request=None, **credentials):
|
|||
return user
|
||||
|
||||
# The credentials supplied are invalid to all backends, fire signal
|
||||
user_login_failed.send(sender=__name__, credentials=_clean_credentials(credentials), request=request)
|
||||
user_login_failed.send(
|
||||
sender=__name__, credentials=_clean_credentials(credentials), request=request
|
||||
)
|
||||
|
||||
|
||||
auth.authenticate = authenticate
|
||||
|
@ -209,6 +210,10 @@ class AuthPreCheckMixin:
|
|||
|
||||
def _check_is_block(self, username, raise_exception=True):
|
||||
ip = self.get_request_ip()
|
||||
|
||||
if LoginIpBlockUtil(ip).is_block():
|
||||
raise errors.BlockGlobalIpLoginError(username=username, ip=ip)
|
||||
|
||||
is_block = LoginBlockUtil(username, ip).is_block()
|
||||
if not is_block:
|
||||
return
|
||||
|
@ -224,6 +229,7 @@ class AuthPreCheckMixin:
|
|||
username = self.request.data.get("username")
|
||||
else:
|
||||
username = self.request.POST.get("username")
|
||||
|
||||
self._check_is_block(username, raise_exception)
|
||||
|
||||
def _check_only_allow_exists_user_auth(self, username):
|
||||
|
@ -242,16 +248,24 @@ class MFAMixin:
|
|||
get_user_from_session: Callable
|
||||
get_request_ip: Callable
|
||||
|
||||
def _check_if_no_active_mfa(self, user):
|
||||
active_mfa_mapper = user.active_mfa_backends_mapper
|
||||
if not active_mfa_mapper:
|
||||
url = reverse('authentication:user-otp-enable-start')
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
|
||||
def _check_login_page_mfa_if_need(self, user):
|
||||
if not settings.SECURITY_MFA_IN_LOGIN_PAGE:
|
||||
return
|
||||
self._check_if_no_active_mfa(user)
|
||||
|
||||
request = self.request
|
||||
data = request.data if hasattr(request, 'data') else request.POST
|
||||
code = data.get('code')
|
||||
mfa_type = data.get('mfa_type', 'otp')
|
||||
|
||||
if not code:
|
||||
raise errors.MFACodeRequiredError
|
||||
return
|
||||
self._do_check_user_mfa(code, mfa_type, user=user)
|
||||
|
||||
def check_user_mfa_if_need(self, user):
|
||||
|
@ -260,10 +274,9 @@ class MFAMixin:
|
|||
if not user.mfa_enabled:
|
||||
return
|
||||
|
||||
self._check_if_no_active_mfa(user)
|
||||
|
||||
active_mfa_mapper = user.active_mfa_backends_mapper
|
||||
if not active_mfa_mapper:
|
||||
url = reverse('authentication:user-otp-enable-start')
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
raise errors.MFARequiredError(mfa_types=tuple(active_mfa_mapper.keys()))
|
||||
|
||||
def mark_mfa_ok(self, mfa_type):
|
||||
|
@ -299,10 +312,13 @@ class MFAMixin:
|
|||
|
||||
ok = False
|
||||
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
|
||||
if mfa_backend:
|
||||
ok, msg = mfa_backend.check_code(code)
|
||||
backend_error = _('The MFA type ({}) is not enabled')
|
||||
if not mfa_backend:
|
||||
msg = backend_error.format(mfa_type)
|
||||
elif not mfa_backend.is_active():
|
||||
msg = backend_error.format(mfa_backend.display_name)
|
||||
else:
|
||||
msg = _('The MFA type({}) is not supported'.format(mfa_type))
|
||||
ok, msg = mfa_backend.check_code(code)
|
||||
|
||||
if ok:
|
||||
self.mark_mfa_ok(mfa_type)
|
||||
|
|
|
@ -54,9 +54,9 @@ class BearerTokenSerializer(serializers.Serializer):
|
|||
user.last_login = timezone.now()
|
||||
user.save(update_fields=['last_login'])
|
||||
|
||||
def create(self, validated_data):
|
||||
def get_request_user(self):
|
||||
request = self.context.get('request')
|
||||
if request.user and not request.user.is_anonymous:
|
||||
if request.user and request.user.is_authenticated:
|
||||
user = request.user
|
||||
else:
|
||||
user_id = request.session.get('user_id')
|
||||
|
@ -65,6 +65,12 @@ class BearerTokenSerializer(serializers.Serializer):
|
|||
raise serializers.ValidationError(
|
||||
"user id {} not exist".format(user_id)
|
||||
)
|
||||
return user
|
||||
|
||||
def create(self, validated_data):
|
||||
request = self.context.get('request')
|
||||
user = self.get_request_user()
|
||||
|
||||
token, date_expired = user.create_bearer_token(request)
|
||||
self.update_last_login(user)
|
||||
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{% load i18n %}
|
||||
<p>{% trans 'Hello' %} {{ name }},</p>
|
||||
|
||||
<p>
|
||||
{% trans 'Your public key has just been successfully updated' %}
|
||||
</p>
|
||||
<p>
|
||||
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br />
|
||||
<b>{% trans 'Browser' %}:</b> {{ browser }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans 'If the public key update was not initiated by you, your account may have security issues' %} <br />
|
||||
{% trans 'If you have any questions, you can contact the administrator' %}
|
||||
</p>
|
|
@ -109,7 +109,7 @@
|
|||
}
|
||||
|
||||
.select-con {
|
||||
width: 22%;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.mfa-div {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% trans 'MFA' %}
|
||||
{% trans 'MFA Auth' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -122,10 +122,10 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||
self.request.session.set_test_cookie()
|
||||
return self.render_to_response(context)
|
||||
except (
|
||||
errors.MFAUnsetError,
|
||||
errors.PasswordTooSimple,
|
||||
errors.PasswordRequireResetError,
|
||||
errors.PasswordNeedUpdate,
|
||||
errors.OTPBindRequiredError
|
||||
errors.PasswordNeedUpdate
|
||||
) as e:
|
||||
return redirect(e.url)
|
||||
except (
|
||||
|
@ -133,7 +133,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||
errors.BlockMFAError,
|
||||
errors.MFACodeRequiredError,
|
||||
errors.SMSCodeRequiredError,
|
||||
errors.UserPhoneNotSet
|
||||
errors.UserPhoneNotSet,
|
||||
errors.BlockGlobalIpLoginError
|
||||
) as e:
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
|
|
|
@ -292,6 +292,7 @@ class Config(dict):
|
|||
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
|
||||
'SECURITY_VIEW_AUTH_NEED_MFA': True,
|
||||
'SECURITY_LOGIN_LIMIT_COUNT': 7,
|
||||
'SECURITY_LOGIN_IP_BLACK_LIST': [],
|
||||
'SECURITY_LOGIN_LIMIT_TIME': 30,
|
||||
'SECURITY_MAX_IDLE_TIME': 30,
|
||||
'SECURITY_PASSWORD_EXPIRATION_TIME': 9999,
|
||||
|
|
|
@ -34,9 +34,10 @@ TERMINAL_REPLAY_STORAGE = CONFIG.TERMINAL_REPLAY_STORAGE
|
|||
SECURITY_MFA_AUTH = CONFIG.SECURITY_MFA_AUTH
|
||||
SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION
|
||||
SECURITY_LOGIN_LIMIT_COUNT = CONFIG.SECURITY_LOGIN_LIMIT_COUNT
|
||||
SECURITY_LOGIN_IP_BLACK_LIST = CONFIG.SECURITY_LOGIN_IP_BLACK_LIST
|
||||
SECURITY_LOGIN_LIMIT_TIME = CONFIG.SECURITY_LOGIN_LIMIT_TIME # Unit: minute
|
||||
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute
|
||||
SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day
|
||||
SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day
|
||||
SECURITY_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_PASSWORD_MIN_LENGTH # Unit: bit
|
||||
SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH # Unit: bit
|
||||
OLD_PASSWORD_HISTORY_LIMIT_COUNT = CONFIG.OLD_PASSWORD_HISTORY_LIMIT_COUNT
|
||||
|
@ -118,7 +119,6 @@ REFERER_CHECK_ENABLED = CONFIG.REFERER_CHECK_ENABLED
|
|||
CONNECTION_TOKEN_ENABLED = CONFIG.CONNECTION_TOKEN_ENABLED
|
||||
FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL
|
||||
|
||||
|
||||
# 自定义默认组织名
|
||||
GLOBAL_ORG_DISPLAY_NAME = CONFIG.GLOBAL_ORG_DISPLAY_NAME
|
||||
HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN
|
||||
|
@ -135,7 +135,6 @@ CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS
|
|||
|
||||
XRDP_ENABLED = CONFIG.XRDP_ENABLED
|
||||
|
||||
|
||||
# SMS enabled
|
||||
SMS_ENABLED = CONFIG.SMS_ENABLED
|
||||
SMS_BACKEND = CONFIG.SMS_BACKEND
|
||||
|
@ -155,3 +154,7 @@ TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES
|
|||
# 公告
|
||||
ANNOUNCEMENT_ENABLED = CONFIG.ANNOUNCEMENT_ENABLED
|
||||
ANNOUNCEMENT = CONFIG.ANNOUNCEMENT
|
||||
|
||||
# help
|
||||
HELP_DOCUMENT_URL = CONFIG.HELP_DOCUMENT_URL
|
||||
HELP_SUPPORT_URL = CONFIG.HELP_SUPPORT_URL
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:925c5a219a4ee6835ad59e3b8e9f7ea5074ee3df6527c0f73ef1a50eaedaf59c
|
||||
size 91777
|
||||
oid sha256:4fea2cdf5a5477757cb95ff36016ed754fd65f839c12adbac9247ebdcca138ef
|
||||
size 93440
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -28,7 +28,7 @@ class AssetSystemUserSerializer(serializers.ModelSerializer):
|
|||
model = SystemUser
|
||||
only_fields = (
|
||||
'id', 'name', 'username', 'priority', 'protocol', 'login_mode',
|
||||
'sftp_root', 'username_same_with_user',
|
||||
'sftp_root', 'username_same_with_user', 'su_enabled', 'su_from',
|
||||
)
|
||||
fields = list(only_fields) + ["actions"]
|
||||
read_only_fields = fields
|
||||
|
|
|
@ -86,7 +86,7 @@ def get_asset_system_user_ids_with_actions_by_user(user: User, asset: Asset):
|
|||
|
||||
def has_asset_system_permission(user: User, asset: Asset, system_user: SystemUser):
|
||||
systemuser_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
|
||||
actions = systemuser_actions_mapper.get(system_user.id, [])
|
||||
actions = systemuser_actions_mapper.get(system_user.id, 0)
|
||||
if actions:
|
||||
return True
|
||||
return False
|
||||
|
|
|
@ -59,6 +59,8 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
|||
"XRDP_ENABLED": settings.XRDP_ENABLED,
|
||||
"ANNOUNCEMENT_ENABLED": settings.ANNOUNCEMENT_ENABLED,
|
||||
"ANNOUNCEMENT": settings.ANNOUNCEMENT,
|
||||
"HELP_DOCUMENT_URL": settings.HELP_DOCUMENT_URL,
|
||||
"HELP_SUPPORT_URL": settings.HELP_SUPPORT_URL,
|
||||
}
|
||||
}
|
||||
return instance
|
||||
|
|
|
@ -25,3 +25,8 @@ class CleaningSerializer(serializers.Serializer):
|
|||
min_value=1, max_value=9999,
|
||||
label=_("Cloud sync record keep days"), help_text=_("Unit: day")
|
||||
)
|
||||
TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField(
|
||||
min_value=1, max_value=99999, required=True, label=_('Session keep duration'),
|
||||
help_text=_('Unit: days, Session, record, command will be delete if more than duration, only in database')
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
|
||||
|
||||
|
||||
class SecurityPasswordRuleSerializer(serializers.Serializer):
|
||||
SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField(
|
||||
|
@ -14,9 +16,24 @@ class SecurityPasswordRuleSerializer(serializers.Serializer):
|
|||
SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField(
|
||||
required=False, label=_('Must contain capital')
|
||||
)
|
||||
SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase'))
|
||||
SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric'))
|
||||
SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special'))
|
||||
SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(
|
||||
required=False, label=_('Must contain lowercase')
|
||||
)
|
||||
SECURITY_PASSWORD_NUMBER = serializers.BooleanField(
|
||||
required=False, label=_('Must contain numeric')
|
||||
)
|
||||
SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(
|
||||
required=False, label=_('Must contain special')
|
||||
)
|
||||
|
||||
|
||||
def ip_child_validator(ip_child):
|
||||
is_valid = is_ip_address(ip_child) \
|
||||
or is_ip_network(ip_child) \
|
||||
or is_ip_segment(ip_child)
|
||||
if not is_valid:
|
||||
error = _('IP address invalid: `{}`').format(ip_child)
|
||||
raise serializers.ValidationError(error)
|
||||
|
||||
|
||||
class SecurityAuthSerializer(serializers.Serializer):
|
||||
|
@ -40,6 +57,14 @@ class SecurityAuthSerializer(serializers.Serializer):
|
|||
'no login is allowed during this time interval.'
|
||||
)
|
||||
)
|
||||
SECURITY_LOGIN_IP_BLACK_LIST = serializers.ListField(
|
||||
default=[], label=_('IP Black List'), allow_empty=True,
|
||||
child=serializers.CharField(max_length=1024, validators=[ip_child_validator]),
|
||||
help_text=_(
|
||||
'Format for comma-delimited string. Such as: '
|
||||
'192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64'
|
||||
)
|
||||
)
|
||||
SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField(
|
||||
min_value=1, max_value=99999, required=True,
|
||||
label=_('User password expiration'),
|
||||
|
@ -72,7 +97,9 @@ class SecurityAuthSerializer(serializers.Serializer):
|
|||
SECURITY_MFA_VERIFY_TTL = serializers.IntegerField(
|
||||
min_value=5, max_value=60 * 60 * 10,
|
||||
label=_("MFA verify TTL"),
|
||||
help_text=_("Unit: second, The verification MFA takes effect only when you view the account password"),
|
||||
help_text=_(
|
||||
"Unit: second, The verification MFA takes effect only when you view the account password"
|
||||
)
|
||||
)
|
||||
SECURITY_LOGIN_CHALLENGE_ENABLED = serializers.BooleanField(
|
||||
required=False, default=False,
|
||||
|
@ -108,7 +135,9 @@ class SecurityAuthSerializer(serializers.Serializer):
|
|||
class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSerializer):
|
||||
SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField(
|
||||
required=True, label=_('Enable terminal register'),
|
||||
help_text=_("Allow terminal register, after all terminal setup, you should disable this for security")
|
||||
help_text=_(
|
||||
"Allow terminal register, after all terminal setup, you should disable this for security"
|
||||
)
|
||||
)
|
||||
SECURITY_WATERMARK_ENABLED = serializers.BooleanField(
|
||||
required=True, label=_('Enable watermark'),
|
||||
|
@ -142,6 +171,8 @@ class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSeri
|
|||
)
|
||||
SECURITY_CHECK_DIFFERENT_CITY_LOGIN = serializers.BooleanField(
|
||||
required=False, label=_('Remote Login Protection'),
|
||||
help_text=_('The system determines whether the login IP address belongs to a common login city. '
|
||||
'If the account is logged in from a common login city, the system sends a remote login reminder')
|
||||
help_text=_(
|
||||
'The system determines whether the login IP address belongs to a common login city. '
|
||||
'If the account is logged in from a common login city, the system sends a remote login reminder'
|
||||
)
|
||||
)
|
||||
|
|
|
@ -25,10 +25,6 @@ class TerminalSettingSerializer(serializers.Serializer):
|
|||
TERMINAL_ASSET_LIST_SORT_BY = serializers.ChoiceField(SORT_BY_CHOICES, required=False, label=_('List sort by'))
|
||||
TERMINAL_ASSET_LIST_PAGE_SIZE = serializers.ChoiceField(PAGE_SIZE_CHOICES, required=False,
|
||||
label=_('List page size'))
|
||||
TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField(
|
||||
min_value=1, max_value=99999, required=True, label=_('Session keep duration'),
|
||||
help_text=_('Unit: days, Session, record, command will be delete if more than duration, only in database')
|
||||
)
|
||||
TERMINAL_TELNET_REGEX = serializers.CharField(
|
||||
allow_blank=True, max_length=1024, required=False, label=_('Telnet login regex'),
|
||||
help_text=_("The login success message varies with devices. "
|
||||
|
|
|
@ -54,9 +54,13 @@
|
|||
$(document).ready(function () {
|
||||
const mfaSelectRef = document.getElementById('mfa-select');
|
||||
const preferMFA = localStorage.getItem(preferMFAKey);
|
||||
if (preferMFA) {
|
||||
const valueSelector = "value=" + preferMFA
|
||||
const preferMFADisabled = $(`#mfa-select option[${valueSelector}]`).attr('disabled')
|
||||
|
||||
if (preferMFA && !preferMFADisabled) {
|
||||
mfaSelectRef.value = preferMFA;
|
||||
}
|
||||
|
||||
const mfaSelect = mfaSelectRef.value;
|
||||
if (mfaSelect !== null) {
|
||||
selectChange(mfaSelect, true);
|
||||
|
@ -71,9 +75,18 @@
|
|||
}
|
||||
|
||||
$('.input-style').each(function (i, ele){
|
||||
$(ele).attr('name', '').attr('required', false)
|
||||
$(ele).attr('name', '')
|
||||
})
|
||||
$('#mfa-' + name + ' .input-style').attr('name', 'code').attr('required', true)
|
||||
|
||||
const currentMFAInputRef = $('#mfa-' + name + ' .input-style')
|
||||
currentMFAInputRef.attr('name', 'code').attr('required', true)
|
||||
|
||||
// 登录页时,不应该默认focus
|
||||
const usernameRef = $('input[name="username"]')
|
||||
if (!usernameRef || usernameRef.length === 0) {
|
||||
currentMFAInputRef.focus()
|
||||
}
|
||||
currentMFAInputRef.attr('name', 'code')
|
||||
}
|
||||
|
||||
function sendChallengeCode(currentBtn) {
|
||||
|
|
|
@ -55,8 +55,18 @@ class Status(models.Model):
|
|||
stat = cls(**data)
|
||||
stat.terminal = terminal
|
||||
stat.is_alive = terminal.is_alive
|
||||
stat.keep_one_decimal_place()
|
||||
return stat
|
||||
|
||||
def keep_one_decimal_place(self):
|
||||
keys = ['cpu_load', 'memory_used', 'disk_used']
|
||||
for key in keys:
|
||||
value = getattr(self, key, 0)
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
value = '%.1f' % value
|
||||
setattr(self, key, float(value))
|
||||
|
||||
def save(self, force_insert=False, force_update=False, using=None,
|
||||
update_fields=None):
|
||||
self.terminal.set_alive(ttl=120)
|
||||
|
|
|
@ -164,4 +164,4 @@ class ReplayStorage(CommonStorageModelMixin, CommonModelMixin):
|
|||
return storage.is_valid(src, target)
|
||||
|
||||
def is_use(self):
|
||||
return Terminal.objects.filter(replay_storage=self.name).exists()
|
||||
return Terminal.objects.filter(replay_storage=self.name, is_deleted=False).exists()
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import copy
|
||||
from rest_framework import serializers
|
||||
from urllib.parse import urlparse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
@ -9,6 +8,7 @@ from common.drf.serializers import MethodSerializer
|
|||
from common.drf.fields import ReadableHiddenField
|
||||
from ..models import ReplayStorage, CommandStorage
|
||||
from .. import const
|
||||
from rest_framework.validators import UniqueValidator
|
||||
|
||||
|
||||
# Replay storage serializers
|
||||
|
@ -220,6 +220,9 @@ class CommandStorageSerializer(BaseStorageSerializer):
|
|||
|
||||
class Meta(BaseStorageSerializer.Meta):
|
||||
model = CommandStorage
|
||||
extra_kwargs = {
|
||||
'name': {'validators': [UniqueValidator(queryset=CommandStorage.objects.all())]}
|
||||
}
|
||||
|
||||
|
||||
# ReplayStorageSerializer
|
||||
|
@ -230,4 +233,6 @@ class ReplayStorageSerializer(BaseStorageSerializer):
|
|||
|
||||
class Meta(BaseStorageSerializer.Meta):
|
||||
model = ReplayStorage
|
||||
|
||||
extra_kwargs = {
|
||||
'name': {'validators': [UniqueValidator(queryset=ReplayStorage.objects.all())]}
|
||||
}
|
||||
|
|
|
@ -77,14 +77,19 @@ class ApplySerializer(serializers.Serializer):
|
|||
type = self.root.initial_data['meta'].get('apply_type')
|
||||
org_id = self.root.initial_data.get('org_id')
|
||||
with tmp_to_org(org_id):
|
||||
applications = Application.objects.filter(id__in=apply_applications, type=type).values_list('id', flat=True)
|
||||
applications = Application.objects.filter(
|
||||
id__in=apply_applications, type=type
|
||||
).values_list('id', flat=True)
|
||||
return list(applications)
|
||||
|
||||
def validate(self, attrs):
|
||||
apply_date_start = attrs['apply_date_start']
|
||||
apply_date_expired = attrs['apply_date_expired']
|
||||
apply_date_start = attrs['apply_date_start'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
apply_date_expired = attrs['apply_date_expired'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if apply_date_expired <= apply_date_start:
|
||||
error = _('The expiration date should be greater than the start date')
|
||||
raise serializers.ValidationError({'apply_date_expired': error})
|
||||
|
||||
attrs['apply_date_start'] = apply_date_start
|
||||
attrs['apply_date_expired'] = apply_date_expired
|
||||
return attrs
|
||||
|
|
|
@ -64,10 +64,13 @@ class ApplySerializer(serializers.Serializer):
|
|||
))
|
||||
|
||||
def validate(self, attrs):
|
||||
apply_date_start = attrs['apply_date_start']
|
||||
apply_date_expired = attrs['apply_date_expired']
|
||||
apply_date_start = attrs['apply_date_start'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
apply_date_expired = attrs['apply_date_expired'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if apply_date_expired <= apply_date_start:
|
||||
error = _('The expiration date should be greater than the start date')
|
||||
raise serializers.ValidationError({'apply_date_expired': error})
|
||||
|
||||
attrs['apply_date_start'] = apply_date_start
|
||||
attrs['apply_date_expired'] = apply_date_expired
|
||||
return attrs
|
||||
|
|
|
@ -5,7 +5,10 @@ from rest_framework import generics
|
|||
from common.permissions import IsOrgAdmin
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from users.notifications import ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg
|
||||
from users.notifications import (
|
||||
ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg,
|
||||
ResetPublicKeySuccessMsg,
|
||||
)
|
||||
from common.permissions import (
|
||||
IsCurrentUserOrReadOnly
|
||||
)
|
||||
|
@ -87,4 +90,4 @@ class UserPublicKeyApi(generics.RetrieveUpdateAPIView):
|
|||
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
ResetPasswordSuccessMsg(self.get_object(), self.request).publish_async()
|
||||
ResetPublicKeySuccessMsg(self.get_object(), self.request).publish_async()
|
||||
|
|
|
@ -28,7 +28,7 @@ from ..filters import OrgRoleUserFilterBackend, UserFilter
|
|||
logger = get_logger(__name__)
|
||||
__all__ = [
|
||||
'UserViewSet', 'UserChangePasswordApi',
|
||||
'UserUnblockPKApi', 'UserResetOTPApi',
|
||||
'UserUnblockPKApi', 'UserResetMFAApi',
|
||||
]
|
||||
|
||||
|
||||
|
@ -199,7 +199,7 @@ class UserUnblockPKApi(UserQuerysetMixin, generics.UpdateAPIView):
|
|||
MFABlockUtils.unblock_user(username)
|
||||
|
||||
|
||||
class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
|
||||
class UserResetMFAApi(UserQuerysetMixin, generics.RetrieveAPIView):
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
serializer_class = serializers.ResetOTPSerializer
|
||||
|
||||
|
@ -209,9 +209,10 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
|
|||
msg = _("Could not reset self otp, use profile reset instead")
|
||||
return Response({"error": msg}, status=401)
|
||||
|
||||
if user.mfa_enabled:
|
||||
user.reset_mfa()
|
||||
user.save()
|
||||
backends = user.active_mfa_backends_mapper
|
||||
for backend in backends:
|
||||
if backend.can_disable():
|
||||
backend.disable()
|
||||
|
||||
ResetMFAMsg(user).publish_async()
|
||||
ResetMFAMsg(user).publish_async()
|
||||
return Response({"msg": "success"})
|
||||
|
|
|
@ -507,8 +507,14 @@ class MFAMixin:
|
|||
backends = [cls(user) for cls in MFA_BACKENDS if cls.global_enabled()]
|
||||
return backends
|
||||
|
||||
def get_active_mfa_backend_by_type(self, mfa_type):
|
||||
backend = self.get_mfa_backend_by_type(mfa_type)
|
||||
if not backend or not backend.is_active():
|
||||
return None
|
||||
return backend
|
||||
|
||||
def get_mfa_backend_by_type(self, mfa_type):
|
||||
mfa_mapper = self.active_mfa_backends_mapper
|
||||
mfa_mapper = {b.name: b for b in self.get_user_mfa_backends(self)}
|
||||
backend = mfa_mapper.get(mfa_type)
|
||||
if not backend:
|
||||
return None
|
||||
|
|
|
@ -105,6 +105,38 @@ class ResetPasswordSuccessMsg(UserMessage):
|
|||
return cls(user, request)
|
||||
|
||||
|
||||
class ResetPublicKeySuccessMsg(UserMessage):
|
||||
def __init__(self, user, request):
|
||||
super().__init__(user)
|
||||
self.ip_address = get_request_ip_or_data(request)
|
||||
self.browser = get_request_user_agent(request)
|
||||
|
||||
def get_html_msg(self) -> dict:
|
||||
user = self.user
|
||||
|
||||
subject = _('Reset public key success')
|
||||
context = {
|
||||
'name': user.name,
|
||||
'ip_address': self.ip_address,
|
||||
'browser': self.browser,
|
||||
}
|
||||
message = render_to_string('authentication/_msg_rest_public_key_success.html', context)
|
||||
return {
|
||||
'subject': subject,
|
||||
'message': message
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def gen_test_msg(cls):
|
||||
from users.models import User
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.request import Request
|
||||
factory = APIRequestFactory()
|
||||
request = Request(factory.get('/notes/'))
|
||||
user = User.objects.first()
|
||||
return cls(user, request)
|
||||
|
||||
|
||||
class PasswordExpirationReminderMsg(UserMessage):
|
||||
def get_html_msg(self) -> dict:
|
||||
user = self.user
|
||||
|
|
|
@ -23,8 +23,8 @@ urlpatterns = [
|
|||
path('profile/', api.UserProfileApi.as_view(), name='user-profile'),
|
||||
path('profile/password/', api.UserPasswordApi.as_view(), name='user-password'),
|
||||
path('profile/public-key/', api.UserPublicKeyApi.as_view(), name='user-public-key'),
|
||||
path('otp/reset/', api.UserResetOTPApi.as_view(), name='my-otp-reset'),
|
||||
path('users/<uuid:pk>/otp/reset/', api.UserResetOTPApi.as_view(), name='user-reset-otp'),
|
||||
path('profile/mfa/reset/', api.UserResetMFAApi.as_view(), name='my-mfa-reset'),
|
||||
path('users/<uuid:pk>/mfa/reset/', api.UserResetMFAApi.as_view(), name='user-reset-mfa'),
|
||||
path('users/<uuid:pk>/password/', api.UserChangePasswordApi.as_view(), name='change-user-password'),
|
||||
path('users/<uuid:pk>/password/reset/', api.UserResetPasswordApi.as_view(), name='user-reset-password'),
|
||||
path('users/<uuid:pk>/pubkey/reset/', api.UserResetPKApi.as_view(), name='user-public-key-reset'),
|
||||
|
|
|
@ -14,7 +14,6 @@ from common.tasks import send_mail_async
|
|||
from common.utils import reverse, get_object_or_none
|
||||
from .models import User
|
||||
|
||||
|
||||
logger = logging.getLogger('jumpserver')
|
||||
|
||||
|
||||
|
@ -101,7 +100,7 @@ def check_password_rules(password, is_org_admin=False):
|
|||
min_length = settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH
|
||||
else:
|
||||
min_length = settings.SECURITY_PASSWORD_MIN_LENGTH
|
||||
pattern += '.{' + str(min_length-1) + ',}$'
|
||||
pattern += '.{' + str(min_length - 1) + ',}$'
|
||||
match_obj = re.match(pattern, password)
|
||||
return bool(match_obj)
|
||||
|
||||
|
@ -173,6 +172,33 @@ class BlockUtilBase:
|
|||
return bool(cache.get(self.block_key))
|
||||
|
||||
|
||||
class BlockGlobalIpUtilBase:
|
||||
LIMIT_KEY_TMPL: str
|
||||
BLOCK_KEY_TMPL: str
|
||||
|
||||
def __init__(self, ip):
|
||||
self.ip = ip
|
||||
self.limit_key = self.LIMIT_KEY_TMPL.format(ip)
|
||||
self.block_key = self.BLOCK_KEY_TMPL.format(ip)
|
||||
self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60
|
||||
|
||||
def sign_limit_key_and_block_key(self):
|
||||
count = cache.get(self.limit_key, 0)
|
||||
count += 1
|
||||
cache.set(self.limit_key, count, self.key_ttl)
|
||||
|
||||
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
|
||||
if count >= limit_count:
|
||||
cache.set(self.block_key, True, self.key_ttl)
|
||||
|
||||
def is_block(self):
|
||||
if self.ip in settings.SECURITY_LOGIN_IP_BLACK_LIST:
|
||||
self.sign_limit_key_and_block_key()
|
||||
return bool(cache.get(self.block_key))
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class LoginBlockUtil(BlockUtilBase):
|
||||
LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}_{}"
|
||||
BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}"
|
||||
|
@ -183,6 +209,11 @@ class MFABlockUtils(BlockUtilBase):
|
|||
BLOCK_KEY_TMPL = "_MFA_BLOCK_{}"
|
||||
|
||||
|
||||
class LoginIpBlockUtil(BlockGlobalIpUtilBase):
|
||||
LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}"
|
||||
BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}"
|
||||
|
||||
|
||||
def construct_user_email(username, email):
|
||||
if '@' not in email:
|
||||
if '@' in username:
|
||||
|
|
Loading…
Reference in New Issue