diff --git a/apps/common/forms.py b/apps/common/forms.py index 87b2f4f12..a11420498 100644 --- a/apps/common/forms.py +++ b/apps/common/forms.py @@ -170,7 +170,7 @@ class TerminalSettingForm(BaseForm): class SecuritySettingForm(BaseForm): - # MFA全局设置 + # MFA global setting SECURITY_MFA_AUTH = forms.BooleanField( initial=False, required=False, label=_("MFA Secondary certification"), @@ -179,12 +179,26 @@ class SecuritySettingForm(BaseForm): 'authentication (valid for all users, including administrators)' ) ) - # 最小长度 + # limit login count + SECURITY_LOGIN_LIMIT_COUNT = forms.IntegerField( + initial=3, min_value=3, + label=_("Limit the number of login failures") + ) + # limit login time + SECURITY_LOGIN_LIMIT_TIME = forms.IntegerField( + 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 " + "number of times, no login is allowed during this time interval." + ) + ) + # min length SECURITY_PASSWORD_MIN_LENGTH = forms.IntegerField( initial=6, label=_("Password minimum length"), min_value=6 ) - # 大写字母 + # upper case SECURITY_PASSWORD_UPPER_CASE = forms.BooleanField( initial=False, required=False, @@ -193,21 +207,21 @@ class SecuritySettingForm(BaseForm): 'After opening, the user password changes ' 'and resets must contain uppercase letters') ) - # 小写字母 + # lower case SECURITY_PASSWORD_LOWER_CASE = forms.BooleanField( initial=False, required=False, label=_("Must contain lowercase letters"), help_text=_('After opening, the user password changes ' 'and resets must contain lowercase letters') ) - # 数字 + # number SECURITY_PASSWORD_NUMBER = forms.BooleanField( initial=False, required=False, label=_("Must contain numeric characters"), help_text=_('After opening, the user password changes ' 'and resets must contain numeric characters') ) - # 特殊字符 + # special char SECURITY_PASSWORD_SPECIAL_CHAR= forms.BooleanField( initial=False, required=False, label=_("Must contain special characters"), diff --git a/apps/common/templates/common/security_setting.html b/apps/common/templates/common/security_setting.html index 2260b76b9..08d978d23 100644 --- a/apps/common/templates/common/security_setting.html +++ b/apps/common/templates/common/security_setting.html @@ -39,9 +39,9 @@ {% endif %} {% csrf_token %} -

{% trans "MFA setting" %}

+

{% trans "User login settings" %}

{% for field in form %} - {% if forloop.counter == 2 %} + {% if forloop.counter == 4 %}

{% trans "Password check rule" %}

{% endif %} diff --git a/apps/i18n/zh/LC_MESSAGES/django.mo b/apps/i18n/zh/LC_MESSAGES/django.mo index d842930e4..78d56177e 100644 Binary files a/apps/i18n/zh/LC_MESSAGES/django.mo and b/apps/i18n/zh/LC_MESSAGES/django.mo differ diff --git a/apps/i18n/zh/LC_MESSAGES/django.po b/apps/i18n/zh/LC_MESSAGES/django.po index c596db6ac..0cf576ebe 100644 --- a/apps/i18n/zh/LC_MESSAGES/django.po +++ b/apps/i18n/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Jumpserver 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-06-25 12:19+0800\n" +"POT-Creation-Date: 2018-07-06 13:11+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: Jumpserver team\n" @@ -118,7 +118,7 @@ msgstr "端口" msgid "Asset" msgstr "资产" -#: assets/forms/domain.py:54 assets/forms/user.py:79 assets/forms/user.py:138 +#: assets/forms/domain.py:54 assets/forms/user.py:79 assets/forms/user.py:139 #: assets/models/base.py:21 assets/models/cluster.py:18 #: assets/models/domain.py:17 assets/models/group.py:20 #: assets/models/label.py:17 assets/templates/assets/admin_user_detail.html:56 @@ -147,16 +147,16 @@ msgstr "资产" msgid "Name" msgstr "名称" -#: assets/forms/domain.py:55 assets/forms/user.py:80 assets/forms/user.py:139 +#: assets/forms/domain.py:55 assets/forms/user.py:80 assets/forms/user.py:140 #: assets/models/base.py:22 assets/templates/assets/admin_user_detail.html:60 #: assets/templates/assets/admin_user_list.html:24 #: assets/templates/assets/domain_gateway_list.html:60 #: assets/templates/assets/system_user_detail.html:62 #: assets/templates/assets/system_user_list.html:27 #: perms/templates/perms/asset_permission_user.html:55 users/forms.py:13 -#: users/forms.py:31 users/models/authentication.py:45 users/models/user.py:47 +#: users/forms.py:31 users/models/authentication.py:70 users/models/user.py:47 #: users/templates/users/_select_user_modal.html:14 -#: users/templates/users/login.html:56 +#: users/templates/users/login.html:60 #: users/templates/users/login_log_list.html:49 #: users/templates/users/user_detail.html:67 #: users/templates/users/user_list.html:24 @@ -170,7 +170,7 @@ msgstr "密码或密钥密码" #: assets/forms/user.py:25 assets/models/base.py:23 common/forms.py:113 #: users/forms.py:15 users/forms.py:33 users/forms.py:45 -#: users/templates/users/login.html:59 +#: users/templates/users/login.html:63 #: users/templates/users/reset_password.html:53 #: users/templates/users/user_create.html:10 #: users/templates/users/user_password_authentication.html:14 @@ -192,21 +192,21 @@ msgstr "ssh密钥不合法" msgid "Password and private key file must be input one" msgstr "密码和私钥, 必须输入一个" -#: assets/forms/user.py:124 +#: assets/forms/user.py:125 msgid "* Automatic login mode, must fill in the username." msgstr "自动登录模式,必须填写用户名" -#: assets/forms/user.py:144 +#: assets/forms/user.py:145 msgid "Auto push system user to asset" msgstr "自动推送系统用户到资产" -#: assets/forms/user.py:145 +#: assets/forms/user.py:146 msgid "" "High level will be using login asset as default, if user was granted more " "than 2 system user" msgstr "高优先级的系统用户将会作为默认登录用户" -#: assets/forms/user.py:147 +#: assets/forms/user.py:148 msgid "" "If you choose manual login mode, you do not need to fill in the username and " "password." @@ -480,7 +480,7 @@ msgstr "手动登录" #: assets/views/asset.py:197 assets/views/domain.py:29 #: assets/views/domain.py:45 assets/views/domain.py:61 #: assets/views/domain.py:74 assets/views/domain.py:98 -#: assets/views/domain.py:126 assets/views/domain.py:150 +#: assets/views/domain.py:126 assets/views/domain.py:145 #: assets/views/label.py:26 assets/views/label.py:42 assets/views/label.py:58 #: assets/views/system_user.py:28 assets/views/system_user.py:44 #: assets/views/system_user.py:60 assets/views/system_user.py:74 @@ -685,7 +685,7 @@ msgstr "重置" #: common/templates/common/security_setting.html:71 #: common/templates/common/terminal_setting.html:108 #: perms/templates/perms/asset_permission_create_update.html:70 -#: terminal/templates/terminal/session_list.html:124 +#: terminal/templates/terminal/session_list.html:126 #: terminal/templates/terminal/terminal_update.html:48 #: users/templates/users/_user.html:47 #: users/templates/users/forgot_password.html:44 @@ -847,7 +847,7 @@ msgstr "比例" #: ops/templates/ops/adhoc_history.html:59 ops/templates/ops/task_adhoc.html:64 #: ops/templates/ops/task_history.html:65 ops/templates/ops/task_list.html:42 #: perms/templates/perms/asset_permission_list.html:60 -#: terminal/templates/terminal/session_list.html:80 +#: terminal/templates/terminal/session_list.html:81 #: terminal/templates/terminal/terminal_list.html:36 #: users/templates/users/user_group_list.html:15 #: users/templates/users/user_list.html:29 @@ -1189,7 +1189,7 @@ msgstr "网域详情" msgid "Domain gateway list" msgstr "域网关列表" -#: assets/views/domain.py:151 +#: assets/views/domain.py:146 msgid "Update gateway" msgstr "创建网关" @@ -1237,7 +1237,7 @@ msgid "Filename" msgstr "文件名" #: audits/models.py:15 audits/templates/audits/ftp_log_list.html:77 -#: ops/templates/ops/task_list.html:39 +#: ops/templates/ops/task_list.html:39 users/models/authentication.py:66 msgid "Success" msgstr "成功" @@ -1246,7 +1246,7 @@ msgstr "成功" #: ops/templates/ops/adhoc_history_detail.html:61 #: ops/templates/ops/task_history.html:58 perms/models.py:36 #: perms/templates/perms/asset_permission_detail.html:86 terminal/models.py:137 -#: terminal/templates/terminal/session_list.html:77 +#: terminal/templates/terminal/session_list.html:78 msgid "Date start" msgstr "开始日期" @@ -1433,45 +1433,60 @@ msgid "" "for all users, including administrators)" msgstr "开启后,用户登录必须使用MFA二次认证(对所有用户有效,包括管理员)" -#: common/forms.py:184 +#: common/forms.py:185 +msgid "Limit the number of login failures" +msgstr "限制登录失败次数" + +#: common/forms.py:190 +msgid "No logon interval" +msgstr "禁止登录时间间隔" + +#: common/forms.py:192 +msgid "" +"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." +msgstr "" +"提示:(单位 / 分钟)当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录." + +#: common/forms.py:198 msgid "Password minimum length" msgstr "密码最小长度 " -#: common/forms.py:191 +#: common/forms.py:205 msgid "Must contain capital letters" msgstr "必须包含大写字母" -#: common/forms.py:193 +#: common/forms.py:207 msgid "" "After opening, the user password changes and resets must contain uppercase " "letters" msgstr "开启后,用户密码修改、重置必须包含大写字母" -#: common/forms.py:199 +#: common/forms.py:213 msgid "Must contain lowercase letters" msgstr "必须包含小写字母" -#: common/forms.py:200 +#: common/forms.py:214 msgid "" "After opening, the user password changes and resets must contain lowercase " "letters" msgstr "开启后,用户密码修改、重置必须包含小写字母" -#: common/forms.py:206 +#: common/forms.py:220 msgid "Must contain numeric characters" msgstr "必须包含数字字符" -#: common/forms.py:207 +#: common/forms.py:221 msgid "" "After opening, the user password changes and resets must contain numeric " "characters" msgstr "开启后,用户密码修改、重置必须包含数字字符" -#: common/forms.py:213 +#: common/forms.py:227 msgid "Must contain special characters" msgstr "必须包含特殊字符" -#: common/forms.py:214 +#: common/forms.py:228 msgid "" "After opening, the user password changes and resets must contain special " "characters" @@ -1485,7 +1500,8 @@ msgstr "" msgid "discard time" msgstr "" -#: common/models.py:29 users/templates/users/user_detail.html:96 +#: common/models.py:29 users/models/authentication.py:51 +#: users/templates/users/user_detail.html:96 msgid "Enabled" msgstr "启用" @@ -1531,8 +1547,8 @@ msgid "Security setting" msgstr "安全设置" #: common/templates/common/security_setting.html:42 -msgid "MFA setting" -msgstr "MFA 设置" +msgid "User login settings" +msgstr "用户登录设置" #: common/templates/common/security_setting.html:46 msgid "Password check rule" @@ -1803,7 +1819,7 @@ msgid "Versions" msgstr "版本" #: ops/templates/ops/task_list.html:40 -#: users/templates/users/login_log_list.html:54 +#: users/templates/users/login_log_list.html:57 msgid "Date" msgstr "日期" @@ -2005,7 +2021,7 @@ msgid "Logout" msgstr "注销登录" #: templates/_header_bar.html:49 users/templates/users/login.html:44 -#: users/templates/users/login.html:64 +#: users/templates/users/login.html:68 msgid "Login" msgstr "登录" @@ -2045,7 +2061,7 @@ msgstr "关闭" #: templates/_nav.html:10 users/views/group.py:28 users/views/group.py:44 #: users/views/group.py:62 users/views/group.py:79 users/views/group.py:95 -#: users/views/login.py:277 users/views/login.py:335 users/views/user.py:65 +#: users/views/login.py:330 users/views/login.py:388 users/views/user.py:65 #: users/views/user.py:80 users/views/user.py:102 users/views/user.py:175 #: users/views/user.py:330 users/views/user.py:380 users/views/user.py:415 msgid "Users" @@ -2161,14 +2177,14 @@ msgstr "线程数" msgid "Boot Time" msgstr "运行时间" -#: terminal/models.py:132 terminal/templates/terminal/session_list.html:102 +#: terminal/models.py:132 terminal/templates/terminal/session_list.html:104 msgid "Replay" msgstr "回放" #: terminal/models.py:133 terminal/templates/terminal/command_list.html:55 #: terminal/templates/terminal/command_list.html:71 #: terminal/templates/terminal/session_detail.html:48 -#: terminal/templates/terminal/session_list.html:76 +#: terminal/templates/terminal/session_list.html:77 msgid "Command" msgstr "命令" @@ -2219,24 +2235,28 @@ msgstr "监控" msgid "Terminate session" msgstr "终止会话" -#: terminal/templates/terminal/session_list.html:79 +#: terminal/templates/terminal/session_list.html:76 +msgid "Login from" +msgstr "登录来源" + +#: terminal/templates/terminal/session_list.html:80 msgid "Duration" msgstr "时长" -#: terminal/templates/terminal/session_list.html:104 +#: terminal/templates/terminal/session_list.html:106 msgid "Monitor" msgstr "监控" -#: terminal/templates/terminal/session_list.html:106 #: terminal/templates/terminal/session_list.html:108 +#: terminal/templates/terminal/session_list.html:110 msgid "Terminate" msgstr "终断" -#: terminal/templates/terminal/session_list.html:120 +#: terminal/templates/terminal/session_list.html:122 msgid "Terminate selected" msgstr "终断所选" -#: terminal/templates/terminal/session_list.html:140 +#: terminal/templates/terminal/session_list.html:142 msgid "Terminate task send, waiting ..." msgstr "终断任务已发送,请等待" @@ -2306,6 +2326,10 @@ msgid "" "You should use your ssh client tools connect terminal: {}

{}" msgstr "你可以使用ssh客户端工具连接终端" +#: users/api.py:208 users/templates/users/login.html:50 +msgid "Log in frequently and try again later" +msgstr "登录频繁, 稍后重试" + #: users/authentication.py:56 msgid "Invalid signature header. No credentials provided." msgstr "" @@ -2406,8 +2430,9 @@ msgstr "" msgid "* Enable MFA authentication to make the account more secure." msgstr "* 启用MFA认证,使账号更加安全." -#: users/forms.py:143 users/models/user.py:71 +#: users/forms.py:143 users/models/authentication.py:75 users/models/user.py:71 #: users/templates/users/first_login.html:45 +#: users/templates/users/login_log_list.html:54 msgid "MFA" msgstr "MFA" @@ -2467,23 +2492,53 @@ msgstr "ssh公钥" msgid "Private Token" msgstr "ssh密钥" -#: users/models/authentication.py:46 +#: users/models/authentication.py:50 users/templates/users/user_detail.html:98 +msgid "Disabled" +msgstr "禁用" + +#: users/models/authentication.py:52 users/models/authentication.py:60 +msgid "-" +msgstr "" + +#: users/models/authentication.py:61 +msgid "Username/password check failed" +msgstr "用户名/密码 校验失败" + +#: users/models/authentication.py:62 +msgid "MFA authentication failed" +msgstr "MFA 认证失败" + +#: users/models/authentication.py:67 +msgid "Failed" +msgstr "失败" + +#: users/models/authentication.py:71 msgid "Login type" msgstr "登录方式" -#: users/models/authentication.py:47 +#: users/models/authentication.py:72 msgid "Login ip" msgstr "登录IP" -#: users/models/authentication.py:48 +#: users/models/authentication.py:73 msgid "Login city" msgstr "登录城市" -#: users/models/authentication.py:49 +#: users/models/authentication.py:74 msgid "User agent" msgstr "Agent" -#: users/models/authentication.py:50 +#: users/models/authentication.py:76 +#: users/templates/users/login_log_list.html:55 +msgid "Reason" +msgstr "原因" + +#: users/models/authentication.py:77 +#: users/templates/users/login_log_list.html:56 +msgid "Status" +msgstr "状态" + +#: users/models/authentication.py:78 msgid "Date login" msgstr "登录日期" @@ -2609,7 +2664,7 @@ msgid " for more information" msgstr "获取更多信息" #: users/templates/users/forgot_password.html:26 -#: users/templates/users/login.html:73 +#: users/templates/users/login.html:77 msgid "Forgot password" msgstr "忘记密码" @@ -2617,7 +2672,7 @@ msgstr "忘记密码" msgid "Input your email, that will send a mail to your" msgstr "输入您的邮箱, 将会发一封重置邮件到您的邮箱中" -#: users/templates/users/login.html:50 +#: users/templates/users/login.html:53 msgid "Captcha invalid" msgstr "验证码错误" @@ -2696,10 +2751,6 @@ msgstr "授权的资产" msgid "Force enabled" msgstr "强制启用" -#: users/templates/users/user_detail.html:98 -msgid "Disabled" -msgstr "禁用" - #: users/templates/users/user_detail.html:119 #: users/templates/users/user_profile.html:108 msgid "Last login" @@ -3001,7 +3052,7 @@ msgstr "禁用或失效" msgid "Password or SSH public key invalid" msgstr "密码或密钥不合法" -#: users/utils.py:290 users/utils.py:300 +#: users/utils.py:289 users/utils.py:299 msgid "Bit" msgstr " 位" @@ -3017,60 +3068,60 @@ msgstr "更新用户组" msgid "User group granted asset" msgstr "用户组授权资产" -#: users/views/login.py:62 +#: users/views/login.py:75 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: users/views/login.py:128 users/views/user.py:500 users/views/user.py:525 +#: users/views/login.py:178 users/views/user.py:500 users/views/user.py:525 msgid "MFA code invalid" msgstr "MFA码认证失败" -#: users/views/login.py:154 +#: users/views/login.py:207 msgid "Logout success" msgstr "退出登录成功" -#: users/views/login.py:155 +#: users/views/login.py:208 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: users/views/login.py:171 +#: users/views/login.py:224 msgid "Email address invalid, please input again" msgstr "邮箱地址错误,重新输入" -#: users/views/login.py:184 +#: users/views/login.py:237 msgid "Send reset password message" msgstr "发送重置密码邮件" -#: users/views/login.py:185 +#: users/views/login.py:238 msgid "Send reset password mail success, login your mail box and follow it " msgstr "" "发送重置邮件成功, 请登录邮箱查看, 按照提示操作 (如果没收到,请等待3-5分钟)" -#: users/views/login.py:198 +#: users/views/login.py:251 msgid "Reset password success" msgstr "重置密码成功" -#: users/views/login.py:199 +#: users/views/login.py:252 msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: users/views/login.py:220 users/views/login.py:233 +#: users/views/login.py:273 users/views/login.py:286 msgid "Token invalid or expired" msgstr "Token错误或失效" -#: users/views/login.py:229 +#: users/views/login.py:282 msgid "Password not same" msgstr "密码不一致" -#: users/views/login.py:239 users/views/user.py:118 users/views/user.py:398 +#: users/views/login.py:292 users/views/user.py:118 users/views/user.py:398 msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/views/login.py:277 +#: users/views/login.py:330 msgid "First login" msgstr "首次登陆" -#: users/views/login.py:336 +#: users/views/login.py:389 msgid "Login log list" msgstr "登录日志" @@ -3117,3 +3168,6 @@ msgstr "MFA 解绑成功" #: users/views/user.py:555 msgid "MFA disable success, return login page" msgstr "MFA 解绑成功,返回登录页面" + +#~ msgid "MFA setting" +#~ msgstr "MFA 设置" diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index c8ed1505e..c0a536276 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -405,6 +405,8 @@ TERMINAL_REPLAY_STORAGE = { DEFAULT_PASSWORD_MIN_LENGTH = 6 +DEFAULT_LOGIN_LIMIT_COUNT = 3 +DEFAULT_LOGIN_LIMIT_TIME = 30 # Django bootstrap3 setting, more see http://django-bootstrap3.readthedocs.io/en/latest/settings.html BOOTSTRAP3 = { diff --git a/apps/terminal/templates/terminal/session_list.html b/apps/terminal/templates/terminal/session_list.html index 8cea8e217..33ae09877 100644 --- a/apps/terminal/templates/terminal/session_list.html +++ b/apps/terminal/templates/terminal/session_list.html @@ -73,6 +73,7 @@ {% trans 'System user' %} {% trans 'Remote addr' %} {% trans 'Protocol' %} + {% trans 'Login from' %} {% trans 'Command' %} {% trans 'Date start' %} {# {% trans 'Date last active' %}#} @@ -92,6 +93,7 @@ {{ session.system_user }} {{ session.remote_addr|default:"" }} {{ session.protocol }} + {{ session.get_login_from_display }} {{ session.id | get_session_command_amount }} {{ session.date_start }} diff --git a/apps/users/api.py b/apps/users/api.py index ae4e47b60..c23112384 100644 --- a/apps/users/api.py +++ b/apps/users/api.py @@ -3,6 +3,7 @@ import uuid from django.core.cache import cache from django.urls import reverse +from django.utils.translation import ugettext as _ from rest_framework import generics from rest_framework.permissions import AllowAny, IsAuthenticated @@ -14,10 +15,11 @@ from .serializers import UserSerializer, UserGroupSerializer, \ UserGroupUpdateMemeberSerializer, UserPKUpdateSerializer, \ UserUpdateGroupSerializer, ChangeUserPasswordSerializer from .tasks import write_login_log_async -from .models import User, UserGroup +from .models import User, UserGroup, LoginLog from .permissions import IsSuperUser, IsValidUser, IsCurrentUserOrReadOnly, \ IsSuperUserOrAppUser -from .utils import check_user_valid, generate_token, get_login_ip, check_otp_code +from .utils import check_user_valid, generate_token, get_login_ip, \ + check_otp_code, set_user_login_failed_count_to_cache, is_block_login from common.mixins import IDInFilterMixin from common.utils import get_logger @@ -149,10 +151,23 @@ class UserOtpAuthApi(APIView): return Response({'msg': '请先进行用户名和密码验证'}, status=401) if not check_otp_code(user.otp_secret_key, otp_code): + data = { + 'username': user.username, + 'mfa': int(user.otp_enabled), + 'reason': LoginLog.REASON_MFA, + 'status': False + } + self.write_login_log(request, data) return Response({'msg': 'MFA认证失败'}, status=401) + data = { + 'username': user.username, + 'mfa': int(user.otp_enabled), + 'reason': LoginLog.REASON_NOTHING, + 'status': True + } + self.write_login_log(request, data) token = generate_token(request, user) - self.write_login_log(request, user) return Response( { 'token': token, @@ -161,7 +176,7 @@ class UserOtpAuthApi(APIView): ) @staticmethod - def write_login_log(request, user): + def write_login_log(request, data): login_ip = request.data.get('remote_addr', None) login_type = request.data.get('login_type', '') user_agent = request.data.get('HTTP_USER_AGENT', '') @@ -169,25 +184,52 @@ class UserOtpAuthApi(APIView): if not login_ip: login_ip = get_login_ip(request) - write_login_log_async.delay( - user.username, ip=login_ip, - type=login_type, user_agent=user_agent, - ) + tmp_data = { + 'ip': login_ip, + 'type': login_type, + 'user_agent': user_agent + } + data.update(tmp_data) + write_login_log_async.delay(**data) class UserAuthApi(APIView): permission_classes = (AllowAny,) serializer_class = UserSerializer + key_prefix_limit = "_LOGIN_LIMIT_{}_{}" def post(self, request): - user, msg = self.check_user_valid(request) + # limit login + username = request.data.get('username') + ip = request.data.get('remote_addr', None) + ip = ip if ip else get_login_ip(request) + key_limit = self.key_prefix_limit.format(ip, username) + if is_block_login(key_limit): + msg = _("Log in frequently and try again later") + return Response({'msg': msg}, status=401) + user, msg = self.check_user_valid(request) if not user: + data = { + 'username': request.data.get('username', ''), + 'mfa': LoginLog.MFA_UNKNOWN, + 'reason': LoginLog.REASON_PASSWORD, + 'status': False + } + self.write_login_log(request, data) + + set_user_login_failed_count_to_cache(key_limit) return Response({'msg': msg}, status=401) if not user.otp_enabled: + data = { + 'username': user.username, + 'mfa': int(user.otp_enabled), + 'reason': LoginLog.REASON_NOTHING, + 'status': True + } + self.write_login_log(request, data) token = generate_token(request, user) - self.write_login_log(request, user) return Response( { 'token': token, @@ -204,7 +246,8 @@ class UserAuthApi(APIView): 'otp_url': reverse('api-users:user-otp-auth'), 'seed': seed, 'user': self.serializer_class(user).data - }, status=300) + }, status=300 + ) @staticmethod def check_user_valid(request): @@ -218,7 +261,7 @@ class UserAuthApi(APIView): return user, msg @staticmethod - def write_login_log(request, user): + def write_login_log(request, data): login_ip = request.data.get('remote_addr', None) login_type = request.data.get('login_type', '') user_agent = request.data.get('HTTP_USER_AGENT', '') @@ -226,10 +269,14 @@ class UserAuthApi(APIView): if not login_ip: login_ip = get_login_ip(request) - write_login_log_async.delay( - user.username, ip=login_ip, - type=login_type, user_agent=user_agent, - ) + tmp_data = { + 'ip': login_ip, + 'type': login_type, + 'user_agent': user_agent, + } + data.update(tmp_data) + + write_login_log_async.delay(**data) class UserConnectionTokenApi(APIView): diff --git a/apps/users/models/authentication.py b/apps/users/models/authentication.py index 5169a79d2..cb0b7d85f 100644 --- a/apps/users/models/authentication.py +++ b/apps/users/models/authentication.py @@ -41,12 +41,40 @@ class LoginLog(models.Model): ('W', 'Web'), ('T', 'Terminal'), ) + + MFA_DISABLED = 0 + MFA_ENABLED = 1 + MFA_UNKNOWN = 2 + + MFA_CHOICE = ( + (MFA_DISABLED, _('Disabled')), + (MFA_ENABLED, _('Enabled')), + (MFA_UNKNOWN, _('-')), + ) + + REASON_NOTHING = 0 + REASON_PASSWORD = 1 + REASON_MFA = 2 + + REASON_CHOICE = ( + (REASON_NOTHING, _('-')), + (REASON_PASSWORD, _('Username/password check failed')), + (REASON_MFA, _('MFA authentication failed')), + ) + + STATUS_CHOICE = ( + (True, _('Success')), + (False, _('Failed')) + ) id = models.UUIDField(default=uuid.uuid4, primary_key=True) username = models.CharField(max_length=20, verbose_name=_('Username')) type = models.CharField(choices=LOGIN_TYPE_CHOICE, max_length=2, verbose_name=_('Login type')) ip = models.GenericIPAddressField(verbose_name=_('Login ip')) city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_('Login city')) user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_('User agent')) + mfa = models.SmallIntegerField(default=MFA_UNKNOWN, choices=MFA_CHOICE, verbose_name=_('MFA')) + reason = models.SmallIntegerField(default=REASON_NOTHING, choices=REASON_CHOICE, verbose_name=_('Reason')) + status = models.BooleanField(max_length=2, default=True, choices=STATUS_CHOICE, verbose_name=_('Status')) datetime = models.DateTimeField(auto_now_add=True, verbose_name=_('Date login')) class Meta: diff --git a/apps/users/templates/users/login.html b/apps/users/templates/users/login.html index 7dd3f5d0a..6b55a58bf 100644 --- a/apps/users/templates/users/login.html +++ b/apps/users/templates/users/login.html @@ -45,13 +45,17 @@
{% csrf_token %} - {% if form.errors %} + + {% if block_login %} +

{% trans 'Log in frequently and try again later' %}

+ {% elif form.errors %} {% if 'captcha' in form.errors %}

{% trans 'Captcha invalid' %}

{% else %}

{{ form.non_field_errors.as_text }}

{% endif %} {% endif %} +
diff --git a/apps/users/templates/users/login_log_list.html b/apps/users/templates/users/login_log_list.html index 4a08c28db..afaf671a5 100644 --- a/apps/users/templates/users/login_log_list.html +++ b/apps/users/templates/users/login_log_list.html @@ -51,6 +51,9 @@ {% trans 'UA' %} {% trans 'IP' %} {% trans 'City' %} + {% trans 'MFA' %} + {% trans 'Reason' %} + {% trans 'Status' %} {% trans 'Date' %} {% endblock %} @@ -65,6 +68,9 @@ {{ login_log.ip }} {{ login_log.city }} + {{ login_log.get_mfa_display }} + {{ login_log.get_reason_display }} + {{ login_log.get_status_display }} {{ login_log.datetime }} {% endfor %} diff --git a/apps/users/utils.py b/apps/users/utils.py index 989632e2c..937a90867 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -13,7 +13,7 @@ import ipaddress from django.http import Http404 from django.conf import settings from django.contrib.auth.mixins import UserPassesTestMixin -from django.contrib.auth import authenticate, login as auth_login +from django.contrib.auth import authenticate from django.utils.translation import ugettext as _ from django.core.cache import cache @@ -200,16 +200,15 @@ def get_login_ip(request): return login_ip -def write_login_log(username, type='', ip='', user_agent=''): +def write_login_log(*args, **kwargs): + ip = kwargs.get('ip', '') if not (ip and validate_ip(ip)): ip = ip[:15] city = "Unknown" else: city = get_ip_city(ip) - LoginLog.objects.create( - username=username, type=type, - ip=ip, city=city, user_agent=user_agent - ) + kwargs.update({'ip': ip, 'city': city}) + LoginLog.objects.create(**kwargs) def get_ip_city(ip, timeout=10): @@ -332,3 +331,29 @@ def check_password_rules(password): match_obj = re.match(pattern, password) return bool(match_obj) + + +def set_user_login_failed_count_to_cache(key_limit): + count = cache.get(key_limit) + count = count + 1 if count else 1 + + setting_limit_time = Setting.objects.filter( + name='SECURITY_LOGIN_LIMIT_TIME' + ).first() + limit_time = setting_limit_time.cleaned_value if setting_limit_time \ + else settings.DEFAULT_LOGIN_LIMIT_TIME + + cache.set(key_limit, count, int(limit_time)*60) + + +def is_block_login(key_limit): + count = cache.get(key_limit) + + setting_limit_count = Setting.objects.filter( + name='SECURITY_LOGIN_LIMIT_COUNT' + ).first() + limit_count = setting_limit_count.cleaned_value if setting_limit_count \ + else settings.DEFAULT_LOGIN_LIMIT_COUNT + + if count and count >= limit_count: + return True diff --git a/apps/users/views/login.py b/apps/users/views/login.py index feaf47e89..94071924f 100644 --- a/apps/users/views/login.py +++ b/apps/users/views/login.py @@ -25,8 +25,10 @@ from common.utils import get_object_or_none from common.mixins import DatetimeSearchMixin, AdminUserRequiredMixin from common.models import Setting from ..models import User, LoginLog -from ..utils import send_reset_password_mail, check_otp_code, get_login_ip, redirect_user_first_login_or_index, \ - get_user_or_tmp_user, set_tmp_user_to_cache, get_password_check_rules, check_password_rules +from ..utils import send_reset_password_mail, check_otp_code, get_login_ip, \ + redirect_user_first_login_or_index, get_user_or_tmp_user, \ + set_tmp_user_to_cache, get_password_check_rules, check_password_rules, \ + is_block_login, set_user_login_failed_count_to_cache from ..tasks import write_login_log_async from .. import forms @@ -47,7 +49,8 @@ class UserLoginView(FormView): form_class = forms.UserLoginForm form_class_captcha = forms.UserLoginCaptchaForm redirect_field_name = 'next' - key_prefix = "_LOGIN_INVALID_{}" + key_prefix_captcha = "_LOGIN_INVALID_{}" + key_prefix_limit = "_LOGIN_LIMIT_{}_{}" def get(self, request, *args, **kwargs): if request.user.is_staff: @@ -57,6 +60,16 @@ class UserLoginView(FormView): request.session.set_test_cookie() return super().get(request, *args, **kwargs) + def post(self, request, *args, **kwargs): + # limit login authentication + ip = get_login_ip(request) + username = self.request.POST.get('username') + key_limit = self.key_prefix_limit.format(ip, username) + if is_block_login(key_limit): + return self.render_to_response(self.get_context_data(block_login=True)) + + return super().post(request, *args, **kwargs) + def form_valid(self, form): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) @@ -65,8 +78,23 @@ class UserLoginView(FormView): return redirect(self.get_success_url()) def form_invalid(self, form): + # write login failed log + username = form.cleaned_data.get('username') + data = { + 'username': username, + 'mfa': LoginLog.MFA_UNKNOWN, + 'reason': LoginLog.REASON_PASSWORD, + 'status': False + } + self.write_login_log(data) + + # limit user login failed count ip = get_login_ip(self.request) - cache.set(self.key_prefix.format(ip), 1, 3600) + key_limit = self.key_prefix_limit.format(ip, username) + set_user_login_failed_count_to_cache(key_limit) + + # show captcha + cache.set(self.key_prefix_captcha.format(ip), 1, 3600) old_form = form form = self.form_class_captcha(data=form.data) form._errors = old_form.errors @@ -74,7 +102,7 @@ class UserLoginView(FormView): def get_form_class(self): ip = get_login_ip(self.request) - if cache.get(self.key_prefix.format(ip)): + if cache.get(self.key_prefix_captcha.format(ip)): return self.form_class_captcha else: return self.form_class @@ -91,7 +119,13 @@ class UserLoginView(FormView): elif not user.otp_enabled: # 0 & T,F auth_login(self.request, user) - self.write_login_log() + data = { + 'username': self.request.user.username, + 'mfa': int(self.request.user.otp_enabled), + 'reason': LoginLog.REASON_NOTHING, + 'status': True + } + self.write_login_log(data) return redirect_user_first_login_or_index(self.request, self.redirect_field_name) def get_context_data(self, **kwargs): @@ -101,13 +135,16 @@ class UserLoginView(FormView): kwargs.update(context) return super().get_context_data(**kwargs) - def write_login_log(self): + def write_login_log(self, data): login_ip = get_login_ip(self.request) user_agent = self.request.META.get('HTTP_USER_AGENT', '') - write_login_log_async.delay( - self.request.user.username, type='W', - ip=login_ip, user_agent=user_agent - ) + tmp_data = { + 'ip': login_ip, + 'type': 'W', + 'user_agent': user_agent + } + data.update(tmp_data) + write_login_log_async.delay(**data) class UserLoginOtpView(FormView): @@ -122,22 +159,38 @@ class UserLoginOtpView(FormView): if check_otp_code(otp_secret_key, otp_code): auth_login(self.request, user) - self.write_login_log() + data = { + 'username': self.request.user.username, + 'mfa': int(self.request.user.otp_enabled), + 'reason': LoginLog.REASON_NOTHING, + 'status': True + } + self.write_login_log(data) return redirect(self.get_success_url()) else: + data = { + 'username': user.username, + 'mfa': int(user.otp_enabled), + 'reason': LoginLog.REASON_MFA, + 'status': False + } + self.write_login_log(data) form.add_error('otp_code', _('MFA code invalid')) return super().form_invalid(form) def get_success_url(self): return redirect_user_first_login_or_index(self.request, self.redirect_field_name) - def write_login_log(self): + def write_login_log(self, data): login_ip = get_login_ip(self.request) user_agent = self.request.META.get('HTTP_USER_AGENT', '') - write_login_log_async.delay( - self.request.user.username, type='W', - ip=login_ip, user_agent=user_agent - ) + tmp_data = { + 'ip': login_ip, + 'type': 'W', + 'user_agent': user_agent + } + data.update(tmp_data) + write_login_log_async.delay(**data) @method_decorator(never_cache, name='dispatch')