Commit 363985ee authored by BaiJiangJie's avatar BaiJiangJie Committed by 老广

[Feature] 添加功能,密码过期间隔时间配置,检测用户密码过期 (#2043)

* [Update] user model 添加date_password_last_updated字段, 并在用户详情/个人信息页进行展示、重置密码/修改密码时进行更新;安全设置添加密码过期时间配置项;

* [Update] 修改依赖,deb_requirements 删除 gcc automake

* [Update] 添加定时任务: 检测用户密码是否过期

* [Update] 登录页面添加检测用户密码是否过期,并给出过期提示,拒绝登录

* [update] 用户密码过期时间5天以内,每天发送重置密码邮件

* [Update] api 登录认证,添加密码过期检测

* [Update] 添加提示用户密码即将过期信息

* [Update] 修改小细节

* [Update] User model 添加密码即将过期/已过期 property属性

* [Update] 修改用户api auth检测用户密码过期逻辑

* [Update] 添加翻译,用户密码过期

* [Update] 用户密码即将过期,发送密码过期提醒邮件

* [Update] 修改检测用户密码过期任务,修改interval为crontab

* [Update] 修改翻译小细节

* [Update] 修改翻译小细节

* [Bugfix] 修复在用户更新页面修改密码时, 不更新最后密码修改时间的bug

* [Update] 修复小细节

* [Update] 修改系统设置成功提示翻译信息
parent 16cc4a0f
......@@ -171,10 +171,11 @@ class SecuritySettingForm(BaseForm):
initial=30, min_value=5,
label=_("No logon interval"),
help_text=_(
"Tip :(unit/minute) if the user has failed to log in for a limited "
"Tip: (unit/minute) if the user has failed to log in for a limited "
"number of times, no login is allowed during this time interval."
)
)
# ssh max idle time
SECURITY_MAX_IDLE_TIME = forms.IntegerField(
initial=30, required=False,
label=_("Connection max idle time"),
......@@ -183,6 +184,18 @@ class SecuritySettingForm(BaseForm):
'Unit: minute'
),
)
# password expiration time
SECURITY_PASSWORD_EXPIRATION_TIME = forms.IntegerField(
initial=9999, label=_("Password expiration time"),
min_value=1,
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"
)
)
# min length
SECURITY_PASSWORD_MIN_LENGTH = forms.IntegerField(
initial=6, label=_("Password minimum length"),
......
......@@ -81,7 +81,9 @@ class Setting(models.Model):
@classmethod
def delete_storage(cls, name, storage_name):
obj = cls.objects.get(name=name)
obj = cls.objects.filter(name=name).first()
if not obj:
return False
value = obj.cleaned_value
value.pop(storage_name, '')
obj.cleaned_value = value
......
......@@ -151,7 +151,7 @@ function deleteStorage($this, the_url){
toastr.success("{% trans 'Delete succeed' %}");
};
var error = function(){
toastr.error("{% trans 'Delete failed' %}}");
toastr.error("{% trans 'Delete failed' %}");
};
ajaxAPI(the_url, JSON.stringify(data), success, error, method);
}
......
......@@ -26,7 +26,7 @@ class BasicSettingView(SuperUserRequiredMixin, TemplateView):
form = self.form_class(request.POST)
if form.is_valid():
form.save()
msg = _("Update setting successfully, please restart program")
msg = _("Update setting successfully")
messages.success(request, msg)
return redirect('settings:basic-setting')
else:
......@@ -78,7 +78,7 @@ class LDAPSettingView(SuperUserRequiredMixin, TemplateView):
form = self.form_class(request.POST)
if form.is_valid():
form.save()
msg = _("Update setting successfully, please restart program")
msg = _("Update setting successfully")
messages.success(request, msg)
return redirect('settings:ldap-setting')
else:
......@@ -109,7 +109,7 @@ class TerminalSettingView(SuperUserRequiredMixin, TemplateView):
form = self.form_class(request.POST)
if form.is_valid():
form.save()
msg = _("Update setting successfully, please restart program")
msg = _("Update setting successfully")
messages.success(request, msg)
return redirect('settings:terminal-setting')
else:
......@@ -159,7 +159,7 @@ class SecuritySettingView(SuperUserRequiredMixin, TemplateView):
form = self.form_class(request.POST)
if form.is_valid():
form.save()
msg = _("Update setting successfully, please restart program")
msg = _("Update setting successfully")
messages.success(request, msg)
return redirect('settings:security-setting')
else:
......
......@@ -468,7 +468,8 @@ SECURITY_MFA_AUTH = False
SECURITY_LOGIN_LIMIT_COUNT = 7
SECURITY_LOGIN_LIMIT_TIME = 30 # Unit: minute
SECURITY_MAX_IDLE_TIME = 30 # Unit: minute
SECURITY_PASSWORD_MIN_LENGTH = 6
SECURITY_PASSWORD_EXPIRATION_TIME = 9999 # Unit: day
SECURITY_PASSWORD_MIN_LENGTH = 6 # Unit: bit
SECURITY_PASSWORD_UPPER_CASE = False
SECURITY_PASSWORD_LOWER_CASE = False
SECURITY_PASSWORD_NUMBER = False
......
This diff is collapsed.
{% load i18n %}
{% block password_expired_message %}
{% url 'users:user-password-update' as user_password_update_url %}
{% if request.user.password_has_expired %}
<div class="alert alert-danger help-message alert-dismissable">
{% blocktrans %}
Your password has expired, please click <a href="{{ user_password_update_url }}"> this link </a> update password.
{% endblocktrans %}
<button aria-hidden="true" data-dismiss="alert" class="close" type="button" style="outline: none;">×</button>
</div>
{% elif request.user.password_will_expired %}
<div class="alert alert-danger help-message alert-dismissable">
{% trans 'Your password will at' %} {{ request.user.date_password_expired }} {% trans 'expired. ' %}
{% blocktrans %}
please click <a href="{{ user_password_update_url }}"> this link </a> to update your password.
{% endblocktrans %}
<button aria-hidden="true" data-dismiss="alert" class="close" type="button" style="outline: none;">×</button>
</div>
{% endif %}
{% endblock %}
{% block first_login_message %}
{% if request.user.is_authenticated and request.user.is_first_login %}
<div class="alert alert-danger help-message alert-dismissable">
......@@ -6,7 +27,6 @@
{% blocktrans %}
Your information was incomplete. Please click <a href="{{ first_login_url }}"> this link </a>to complete your information.
{% endblocktrans %}
<button aria-hidden="true" data-dismiss="alert" class="close" type="button" style="outline: none;">×</button>
</div>
{% endif %}
......
......@@ -56,6 +56,19 @@ class UserAuthApi(RootOrgViewMixin, APIView):
increase_login_failed_count(username, ip)
return Response({'msg': msg}, status=401)
if user.password_has_expired:
data = {
'username': user.username,
'mfa': int(user.otp_enabled),
'reason': LoginLog.REASON_PASSWORD_EXPIRED,
'status': False
}
self.write_login_log(request, data)
msg = _("The user {} password has expired, please update.".format(
user.username))
logger.info(msg)
return Response({'msg': msg}, status=401)
if not user.otp_enabled:
data = {
'username': user.username,
......@@ -68,10 +81,7 @@ class UserAuthApi(RootOrgViewMixin, APIView):
clean_failed_count(username, ip)
token = generate_token(request, user)
return Response(
{
'token': token,
'user': self.serializer_class(user).data
}
{'token': token, 'user': self.serializer_class(user).data}
)
seed = uuid.uuid4().hex
......
......@@ -120,8 +120,7 @@ class UserCreateUpdateForm(OrgModelForm):
public_key = self.cleaned_data.get('public_key')
user = super().save(commit=commit)
if password:
user.set_password(password)
user.save()
user.reset_password(password)
if otp_level:
user.otp_level = otp_level
user.save()
......@@ -217,8 +216,7 @@ class UserPasswordForm(forms.Form):
def save(self):
password = self.cleaned_data['new_password']
self.instance.set_password(password)
self.instance.save()
self.instance.reset_password(new_password=password)
return self.instance
......
......@@ -56,12 +56,14 @@ class LoginLog(models.Model):
REASON_PASSWORD = 1
REASON_MFA = 2
REASON_NOT_EXIST = 3
REASON_PASSWORD_EXPIRED = 4
REASON_CHOICE = (
(REASON_NOTHING, _('-')),
(REASON_PASSWORD, _('Username/password check failed')),
(REASON_MFA, _('MFA authentication failed')),
(REASON_NOT_EXIST, _("Username does not exist")),
(REASON_PASSWORD_EXPIRED, _("Password expired")),
)
STATUS_CHOICE = (
......
......@@ -96,6 +96,10 @@ class User(AbstractUser):
max_length=30, default=SOURCE_LOCAL, choices=SOURCE_CHOICES,
verbose_name=_('Source')
)
date_password_last_updated = models.DateTimeField(
auto_now_add=True, blank=True, null=True,
verbose_name=_('Date password last updated')
)
def __str__(self):
return '{0.name}({0.username})'.format(self)
......@@ -220,6 +224,34 @@ class User(AbstractUser):
def is_staff(self, value):
pass
@property
def is_local(self):
return self.source == self.SOURCE_LOCAL
@property
def date_password_expired(self):
interval = settings.SECURITY_PASSWORD_EXPIRATION_TIME
date_expired = self.date_password_last_updated + timezone.timedelta(
days=int(interval))
return date_expired
@property
def password_expired_remain_days(self):
date_remain = self.date_password_expired - timezone.now()
return date_remain.days
@property
def password_has_expired(self):
if self.is_local and self.password_expired_remain_days < 0:
return True
return False
@property
def password_will_expired(self):
if self.is_local and self.password_expired_remain_days < 5:
return True
return False
def save(self, *args, **kwargs):
if not self.name:
self.name = self.username
......@@ -258,7 +290,7 @@ class User(AbstractUser):
return False
def check_public_key(self, public_key):
if self.ssH_public_key == public_key:
if self.ssh_public_key == public_key:
return True
return False
......@@ -340,6 +372,7 @@ class User(AbstractUser):
def reset_password(self, new_password):
self.set_password(new_password)
self.date_password_last_updated = timezone.now()
self.save()
def delete(self, using=None, keep_parents=False):
......
......@@ -36,4 +36,3 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs):
if user:
user.source = user.SOURCE_LDAP
user.save()
......@@ -2,10 +2,46 @@
#
from celery import shared_task
from .utils import write_login_log
from ops.celery.utils import (
create_or_update_celery_periodic_tasks,
after_app_ready_start
)
from .models import User
from common.utils import get_logger
from .utils import write_login_log, send_password_expiration_reminder_mail
logger = get_logger(__file__)
@shared_task
def write_login_log_async(*args, **kwargs):
write_login_log(*args, **kwargs)
@shared_task
def check_password_expired():
users = User.objects.exclude(role=User.ROLE_APP)
for user in users:
if not user.password_will_expired:
continue
send_password_expiration_reminder_mail(user)
logger.info("The user {} password expires in {} days".format(
user, user.password_expired_remain_days)
)
@shared_task
@after_app_ready_start
def check_password_expired_periodic():
tasks = {
'check_password_expired_periodic': {
'task': check_password_expired.name,
'interval': None,
'crontab': '0 10 * * *',
'enabled': True,
}
}
create_or_update_celery_periodic_tasks(tasks)
......@@ -50,6 +50,8 @@
{% if block_login %}
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p>
{% elif password_expired %}
<p class="red-fonts">{% trans 'The user password has expired' %}</p>
{% elif form.errors %}
{% if 'captcha' in form.errors %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
......
......@@ -119,6 +119,10 @@
<td>{% trans 'Last login' %}:</td>
<td><b>{{ user_object.last_login|date:"Y-m-j H:i:s" }}</b></td>
</tr>
<tr>
<td>{% trans 'Last password updated' %}:</td>
<td><b>{{ user_object.date_password_last_updated|date:"Y-m-j H:i:s" }}</b></td>
</tr>
<tr>
<td>{% trans 'Comment' %}:</td>
<td><b>{{ user_object.comment }}</b></td>
......
......@@ -108,6 +108,10 @@
<td class="text-navy">{% trans 'Last login' %}</td>
<td>{{ user.last_login|date:"Y-m-d H:i:s" }}</td>
</tr>
<tr>
<td class="text-navy">{% trans 'Last password updated' %}</td>
<td>{{ user.date_password_last_updated|date:"Y-m-d H:i:s" }}</td>
</tr>
<tr>
<td class="text-navy">{% trans 'Date expired' %}</td>
<td>{{ user.date_expired|date:"Y-m-d H:i:s" }}</td>
......
......@@ -16,6 +16,7 @@ from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth import authenticate
from django.utils.translation import ugettext as _
from django.core.cache import cache
from datetime import datetime
from common.tasks import send_mail_async
from common.utils import reverse, get_object_or_none
......@@ -109,6 +110,44 @@ def send_reset_password_mail(user):
send_mail_async.delay(subject, message, recipient_list, html_message=message)
def send_password_expiration_reminder_mail(user):
subject = _('Security notice')
recipient_list = [user.email]
message = _("""
Hello %(name)s:
</br>
Your password will expire in %(date_password_expired)s,
</br>
For your account security, please click on the link below to update your password in time
</br>
<a href="%(update_password_url)s">Click here update password</a>
</br>
If your password has expired, please click
<a href="%(forget_password_url)s?email=%(email)s">Password expired</a>
to apply for a password reset email.
</br>
---
</br>
<a href="%(login_url)s">Login direct</a>
</br>
""") % {
'name': user.name,
'date_password_expired': datetime.fromtimestamp(datetime.timestamp(
user.date_password_expired)).strftime('%Y-%m-%d %H:%M'),
'update_password_url': reverse('users:user-password-update', external=True),
'forget_password_url': reverse('users:forgot-password', external=True),
'email': user.email,
'login_url': reverse('users:login', external=True),
}
if settings.DEBUG:
logger.debug(message)
send_mail_async.delay(subject, message, recipient_list, html_message=message)
def send_reset_ssh_key_mail(user):
subject = _('SSH Key Reset')
recipient_list = [user.email]
......
......@@ -68,7 +68,20 @@ class UserLoginView(FormView):
if not self.request.session.test_cookie_worked():
return HttpResponse(_("Please enable cookies and try again."))
set_tmp_user_to_cache(self.request, form.get_user())
user = form.get_user()
# user password expired
if user.password_has_expired:
data = {
'username': user.username,
'mfa': int(user.otp_enabled),
'reason': LoginLog.REASON_PASSWORD_EXPIRED,
'status': False
}
self.write_login_log(data)
return self.render_to_response(self.get_context_data(password_expired=True))
set_tmp_user_to_cache(self.request, user)
username = form.cleaned_data.get('username')
ip = get_request_ip(self.request)
# 登陆成功,清除缓存计数
......@@ -86,7 +99,6 @@ class UserLoginView(FormView):
'reason': reason,
'status': False
}
self.write_login_log(data)
# limit user login failed count
......
libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.5-dev tk8.5-dev python-tk python-dev openssl libssl-dev libldap2-dev libsasl2-dev sqlite gcc automake libkrb5-dev sshpass
libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.5-dev tk8.5-dev python-tk python-dev openssl libssl-dev libldap2-dev libsasl2-dev sqlite libkrb5-dev sshpass
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment