diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 19b13ab8e..1e9000e67 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -180,6 +180,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' diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index a7d845662..69daf6330 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -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): diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index e14f47256..79b6839b4 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -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) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index c634ca723..9d95609d2 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -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, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index edc46c795..a4be80f5b 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -34,6 +34,7 @@ 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 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index e5071bf14..2b057ff8f 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -171,6 +171,16 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " msgid "Username" msgstr "用户名" +msgid "IP Black List" +msgstr "IP 黑名单" + +msgid "" +"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" +msgstr "" +"格式为逗号分隔的字符串。例如: 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 (支持网域)" + #: acls/serializers/login_asset_acl.py:24 msgid "" "Format for comma-delimited string, with * indicating a match all. Such as: " diff --git a/apps/settings/serializers/security.py b/apps/settings/serializers/security.py index 51d03eab5..70b015948 100644 --- a/apps/settings/serializers/security.py +++ b/apps/settings/serializers/security.py @@ -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' + ) ) diff --git a/apps/users/utils.py b/apps/users/utils.py index f00e363a6..033bc1081 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -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: