diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 179376828..b51c7dfba 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -259,6 +259,7 @@ class Config(dict): 'FTP_LOG_KEEP_DAYS': 200, 'ASSETS_PERM_CACHE_TIME': 3600 * 24, 'SECURITY_MFA_VERIFY_TTL': 3600, + 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, 'ASSETS_PERM_CACHE_ENABLE': HAS_XPACK, 'SYSLOG_ADDR': '', # '192.168.0.1:514' 'SYSLOG_FACILITY': 'user', diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 936b27582..ed37ea49c 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -38,6 +38,7 @@ 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_MIN_LENGTH = CONFIG.SECURITY_PASSWORD_MIN_LENGTH # Unit: bit +OLD_PASSWORD_HISTORY_LIMIT_COUNT = CONFIG.OLD_PASSWORD_HISTORY_LIMIT_COUNT SECURITY_PASSWORD_UPPER_CASE = CONFIG.SECURITY_PASSWORD_UPPER_CASE SECURITY_PASSWORD_LOWER_CASE = CONFIG.SECURITY_PASSWORD_LOWER_CASE SECURITY_PASSWORD_NUMBER = CONFIG.SECURITY_PASSWORD_NUMBER diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index f47606a0d..bdb351ceb 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 7afb36d4f..6124b8b26 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -2283,35 +2283,46 @@ msgstr "" "提示:(单位:天)如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期" "提醒邮件将在密码过期前5天内由系统(每天)自动发送给用户" -#: settings/serializers/settings.py:172 +#: settings/serializers/settings.py:168 +msgid "Number of repeated historical passwords" +msgstr "历史密码可重复次数" + +#: settings/serializers/settings.py:169 +msgid "" +"Tip: When the user resets the password, it cannot be the previous n " +"historical passwords of the user (the value of n here is the value filled in " +"the input box)" +msgstr "提示:用户重置密码时,不能为该用户前n次历史密码 (此处的n值即为输入框中填写的值)" + +#: settings/serializers/settings.py:173 msgid "Password minimum length" msgstr "密码最小长度" -#: settings/serializers/settings.py:175 +#: settings/serializers/settings.py:176 msgid "Must contain capital" msgstr "必须包含大写字符" -#: settings/serializers/settings.py:177 +#: settings/serializers/settings.py:178 msgid "Must contain lowercase" msgstr "必须包含小写字符" -#: settings/serializers/settings.py:178 +#: settings/serializers/settings.py:179 msgid "Must contain numeric" msgstr "必须包含数字" -#: settings/serializers/settings.py:179 +#: settings/serializers/settings.py:180 msgid "Must contain special" msgstr "必须包含特殊字符" -#: settings/serializers/settings.py:180 +#: settings/serializers/settings.py:181 msgid "Insecure command alert" msgstr "危险命令告警" -#: settings/serializers/settings.py:182 +#: settings/serializers/settings.py:183 msgid "Email recipient" msgstr "邮件收件人" -#: settings/serializers/settings.py:183 +#: settings/serializers/settings.py:184 msgid "Multiple user using , split" msgstr "多个用户,使用 , 分割" @@ -3721,7 +3732,11 @@ msgstr "旧密码错误" msgid "Password does not match security rules" msgstr "密码不满足安全规则" -#: users/serializers/profile.py:43 +#: users/serializers/profile.py:40 +msgid "The new password cannot be the last {} passwords" +msgstr "新密码不能是最近 {} 次的密码" + +#: users/serializers/profile.py:48 msgid "The newly set password is inconsistent" msgstr "两次密码不一致" @@ -4361,6 +4376,10 @@ msgstr "重置密码成功,返回到登录页面" msgid "Token invalid or expired" msgstr "Token错误或失效" +#: users/views/profile/reset.py:133 +msgid "* The new password cannot be the last {} passwords" +msgstr "* 新密码不能是最近 {} 次的密码" + #: users/views/profile/reset.py:120 msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 8aafee063..1fa578c50 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -112,6 +112,7 @@ class PublicSettingApi(generics.RetrieveAPIView): "LOGIN_CONFIRM_ENABLE": settings.LOGIN_CONFIRM_ENABLE, "SECURITY_VIEW_AUTH_NEED_MFA": settings.SECURITY_VIEW_AUTH_NEED_MFA, "SECURITY_MFA_VERIFY_TTL": settings.SECURITY_MFA_VERIFY_TTL, + "OLD_PASSWORD_HISTORY_LIMIT_COUNT": settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT, "SECURITY_COMMAND_EXECUTION": settings.SECURITY_COMMAND_EXECUTION, "SECURITY_PASSWORD_EXPIRATION_TIME": settings.SECURITY_PASSWORD_EXPIRATION_TIME, "XPACK_LICENSE_IS_VALID": has_valid_xpack_license(), diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index 95757eb8b..b64b95cb6 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -167,6 +167,11 @@ class SecuritySettingSerializer(serializers.Serializer): label=_('User password expiration'), help_text=_('Tip: (unit: day) If the user does not update the password during the time, the user password will expire failure;The password expiration reminder mail will be automatic sent to the user by system within 5 days (daily) before the password expires') ) + OLD_PASSWORD_HISTORY_LIMIT_COUNT = serializers.IntegerField( + min_value=0, max_value=99999, required=True, + label=_('Number of repeated historical passwords'), + help_text=_('Tip: When the user resets the password, it cannot be the previous n historical passwords of the user (the value of n here is the value filled in the input box)') + ) SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( min_value=6, max_value=30, required=True, label=_('Password minimum length') diff --git a/apps/users/migrations/0032_userpasswordhistory.py b/apps/users/migrations/0032_userpasswordhistory.py new file mode 100644 index 000000000..8fcf1d2d1 --- /dev/null +++ b/apps/users/migrations/0032_userpasswordhistory.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2021-04-27 12:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0031_auto_20201118_1801'), + ] + + operations = [ + migrations.CreateModel( + name='UserPasswordHistory', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('password', models.CharField(max_length=128)), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history_passwords', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 1f8b2418a..9fd46e2cb 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -7,8 +7,11 @@ import string import random import datetime +from functools import partial + from django.conf import settings from django.contrib.auth.models import AbstractUser +from django.contrib.auth.hashers import check_password, make_password from django.core.cache import cache from django.db import models from django.db.models import TextChoices @@ -70,6 +73,22 @@ class AuthMixin: def can_use_ssh_key_login(): return settings.TERMINAL_PUBLIC_KEY_AUTH + def is_history_password(self, password): + allow_history_password_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT + history_passwords = self.history_passwords.all().order_by('-date_created')[:int(allow_history_password_count)] + + for history_password in history_passwords: + if check_password(password, history_password.password): + return True + else: + return False + + def save_history_password(self, password): + UserPasswordHistory.objects.create( + user=self, password=make_password(password), + date_created=self.date_password_last_updated + ) + def is_public_key_valid(self): """ Check if the user's ssh public key is valid. @@ -729,3 +748,11 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): if self.email and self.source == self.Source.local.value: return True return False + + +class UserPasswordHistory(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + password = models.CharField(max_length=128) + user = models.ForeignKey("users.User", related_name='history_passwords', + on_delete=models.CASCADE, verbose_name=_('User')) + date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created")) diff --git a/apps/users/serializers/profile.py b/apps/users/serializers/profile.py index 68e387245..261dd6f01 100644 --- a/apps/users/serializers/profile.py +++ b/apps/users/serializers/profile.py @@ -30,12 +30,17 @@ class UserUpdatePasswordSerializer(serializers.ModelSerializer): raise serializers.ValidationError(msg) return value - @staticmethod - def validate_new_password(value): + def validate_new_password(self, value): from ..utils import check_password_rules if not check_password_rules(value): msg = _('Password does not match security rules') raise serializers.ValidationError(msg) + if self.instance.is_history_password(value): + limit_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT + msg = _('The new password cannot be the last {} passwords').format(limit_count) + raise serializers.ValidationError(msg) + else: + self.instance.save_history_password(value) return value def validate_new_password_again(self, value): diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index 73c34396b..8b1d9a102 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -128,6 +128,14 @@ class UserResetPasswordView(FormView): form.add_error('new_password', error) return self.form_invalid(form) + if user.is_history_password(password): + limit_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT + error = _('* The new password cannot be the last {} passwords').format(limit_count) + form.add_error('new_password', error) + return self.form_invalid(form) + else: + user.save_history_password(password) + user.reset_password(password) User.expired_reset_password_token(token) send_reset_password_success_mail(self.request, user)