feat: 添加全局ip黑名单

pull/7182/head
feng626 2021-11-11 19:03:01 +08:00 committed by 老广
parent 353b66bf8f
commit 90477146ed
8 changed files with 103 additions and 14 deletions

View File

@ -180,6 +180,14 @@ class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
super().__init__(username=username, ip=ip) 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): class SessionEmptyError(AuthFailedError):
msg = session_empty_msg msg = session_empty_msg
error = 'session_empty' error = 'session_empty'

View File

@ -8,7 +8,6 @@ from typing import Callable
from django.utils.http import urlencode from django.utils.http import urlencode
from django.core.cache import cache from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.urls import reverse_lazy
from django.contrib import auth from django.contrib import auth
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework.request import Request 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 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 acls.models import LoginACL
from users.models import User from users.models import User
from users.utils import LoginBlockUtil, MFABlockUtils from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
from . import errors from . import errors
from .utils import rsa_decrypt, gen_key_pair from .utils import rsa_decrypt, gen_key_pair
from .signals import post_auth_success, post_auth_failed from .signals import post_auth_success, post_auth_failed
@ -76,7 +75,9 @@ def authenticate(request=None, **credentials):
return user return user
# The credentials supplied are invalid to all backends, fire signal # 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 auth.authenticate = authenticate
@ -209,6 +210,10 @@ class AuthPreCheckMixin:
def _check_is_block(self, username, raise_exception=True): def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip() ip = self.get_request_ip()
if LoginIpBlockUtil(ip).is_block():
raise errors.BlockGlobalIpLoginError(username=username, ip=ip)
is_block = LoginBlockUtil(username, ip).is_block() is_block = LoginBlockUtil(username, ip).is_block()
if not is_block: if not is_block:
return return
@ -224,6 +229,7 @@ class AuthPreCheckMixin:
username = self.request.data.get("username") username = self.request.data.get("username")
else: else:
username = self.request.POST.get("username") username = self.request.POST.get("username")
self._check_is_block(username, raise_exception) self._check_is_block(username, raise_exception)
def _check_only_allow_exists_user_auth(self, username): def _check_only_allow_exists_user_auth(self, username):

View File

@ -133,7 +133,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
errors.BlockMFAError, errors.BlockMFAError,
errors.MFACodeRequiredError, errors.MFACodeRequiredError,
errors.SMSCodeRequiredError, errors.SMSCodeRequiredError,
errors.UserPhoneNotSet errors.UserPhoneNotSet,
errors.BlockGlobalIpLoginError
) as e: ) as e:
form.add_error('code', e.msg) form.add_error('code', e.msg)
return super().form_invalid(form) return super().form_invalid(form)

View File

@ -292,6 +292,7 @@ class Config(dict):
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True, 'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
'SECURITY_VIEW_AUTH_NEED_MFA': True, 'SECURITY_VIEW_AUTH_NEED_MFA': True,
'SECURITY_LOGIN_LIMIT_COUNT': 7, 'SECURITY_LOGIN_LIMIT_COUNT': 7,
'SECURITY_LOGIN_IP_BLACK_LIST': [],
'SECURITY_LOGIN_LIMIT_TIME': 30, 'SECURITY_LOGIN_LIMIT_TIME': 30,
'SECURITY_MAX_IDLE_TIME': 30, 'SECURITY_MAX_IDLE_TIME': 30,
'SECURITY_PASSWORD_EXPIRATION_TIME': 9999, 'SECURITY_PASSWORD_EXPIRATION_TIME': 9999,

View File

@ -34,6 +34,7 @@ TERMINAL_REPLAY_STORAGE = CONFIG.TERMINAL_REPLAY_STORAGE
SECURITY_MFA_AUTH = CONFIG.SECURITY_MFA_AUTH SECURITY_MFA_AUTH = CONFIG.SECURITY_MFA_AUTH
SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION
SECURITY_LOGIN_LIMIT_COUNT = CONFIG.SECURITY_LOGIN_LIMIT_COUNT 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_LOGIN_LIMIT_TIME = CONFIG.SECURITY_LOGIN_LIMIT_TIME # Unit: minute
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_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

View File

@ -171,6 +171,16 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. "
msgid "Username" msgid "Username"
msgstr "用户名" 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 #: acls/serializers/login_asset_acl.py:24
msgid "" msgid ""
"Format for comma-delimited string, with * indicating a match all. Such as: " "Format for comma-delimited string, with * indicating a match all. Such as: "

View File

@ -1,6 +1,8 @@
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
class SecurityPasswordRuleSerializer(serializers.Serializer): class SecurityPasswordRuleSerializer(serializers.Serializer):
SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField(
@ -14,9 +16,24 @@ class SecurityPasswordRuleSerializer(serializers.Serializer):
SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField(
required=False, label=_('Must contain capital') required=False, label=_('Must contain capital')
) )
SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase')) SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(
SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric')) required=False, label=_('Must contain lowercase')
SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special')) )
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): class SecurityAuthSerializer(serializers.Serializer):
@ -40,6 +57,14 @@ class SecurityAuthSerializer(serializers.Serializer):
'no login is allowed during this time interval.' '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( SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField(
min_value=1, max_value=99999, required=True, min_value=1, max_value=99999, required=True,
label=_('User password expiration'), label=_('User password expiration'),
@ -72,7 +97,9 @@ class SecurityAuthSerializer(serializers.Serializer):
SECURITY_MFA_VERIFY_TTL = serializers.IntegerField( SECURITY_MFA_VERIFY_TTL = serializers.IntegerField(
min_value=5, max_value=60 * 60 * 10, min_value=5, max_value=60 * 60 * 10,
label=_("MFA verify TTL"), 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( SECURITY_LOGIN_CHALLENGE_ENABLED = serializers.BooleanField(
required=False, default=False, required=False, default=False,
@ -108,7 +135,9 @@ class SecurityAuthSerializer(serializers.Serializer):
class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSerializer): class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSerializer):
SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField( SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField(
required=True, label=_('Enable terminal register'), 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( SECURITY_WATERMARK_ENABLED = serializers.BooleanField(
required=True, label=_('Enable watermark'), required=True, label=_('Enable watermark'),
@ -142,6 +171,8 @@ class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSeri
) )
SECURITY_CHECK_DIFFERENT_CITY_LOGIN = serializers.BooleanField( SECURITY_CHECK_DIFFERENT_CITY_LOGIN = serializers.BooleanField(
required=False, label=_('Remote Login Protection'), required=False, label=_('Remote Login Protection'),
help_text=_('The system determines whether the login IP address belongs to a common login city. ' help_text=_(
'If the account is logged in from a common login city, the system sends a remote login reminder') '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'
)
) )

View File

@ -14,7 +14,6 @@ from common.tasks import send_mail_async
from common.utils import reverse, get_object_or_none from common.utils import reverse, get_object_or_none
from .models import User from .models import User
logger = logging.getLogger('jumpserver') 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 min_length = settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH
else: else:
min_length = settings.SECURITY_PASSWORD_MIN_LENGTH min_length = settings.SECURITY_PASSWORD_MIN_LENGTH
pattern += '.{' + str(min_length-1) + ',}$' pattern += '.{' + str(min_length - 1) + ',}$'
match_obj = re.match(pattern, password) match_obj = re.match(pattern, password)
return bool(match_obj) return bool(match_obj)
@ -173,6 +172,33 @@ class BlockUtilBase:
return bool(cache.get(self.block_key)) 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): class LoginBlockUtil(BlockUtilBase):
LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}_{}" LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}_{}"
BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}" BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}"
@ -183,6 +209,11 @@ class MFABlockUtils(BlockUtilBase):
BLOCK_KEY_TMPL = "_MFA_BLOCK_{}" BLOCK_KEY_TMPL = "_MFA_BLOCK_{}"
class LoginIpBlockUtil(BlockGlobalIpUtilBase):
LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}"
BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}"
def construct_user_email(username, email): def construct_user_email(username, email):
if '@' not in email: if '@' not in email:
if '@' in username: if '@' in username: