mirror of
https://github.com/jumpserver/jumpserver.git
synced 2026-01-14 03:54:28 +00:00
* [Update] 初始化操作日志 * [Feature] 完成操作日志记录 * [Update] 修改mfa失败提示 * [Update] 修改增加created by内容 * [Update] 增加改密日志 * [Update] 登录日志迁移到日志审计中 * [Update] change block user logic, if login success, clean block limit * [Update] 更新中/英文翻译(ALL) (#1662) * Revert "授权页面分页问题" * 增加命令导出 (#1566) * [Update] gunicorn不使用eventlet * [Update] 添加eventlet * 替换淘宝IP查询接口 * [Feature] 添加命令记录下载功能 (#1559) * [Feature] 添加命令记录下载功能 * [Update] 文案修改,导出记录、提交,取消全部命令导出 * [Update] 命令导出,修复时间问题 * [Update] paramiko => 2.4.1 * [Update] 修改settings * [Update] 修改权限判断 * Dev (#1646) * [Update] 添加org * [Update] 修改url * [Update] 完成基本框架 * [Update] 修改一些逻辑 * [Update] 修改用户view * [Update] 修改资产 * [Update] 修改asset api * [Update] 修改协议小问题 * [Update] stash it * [Update] 修改约束 * [Update] 修改外键为org_id * [Update] 删掉Premiddleware * [Update] 修改Node * [Update] 修改get_current_org 为 proxy对象 current_org * [Bugfix] 解决Node.root() 死循环,移动AdminRequired到permission中 (#1571) * [Update] 修改permission (#1574) * Tmp org (#1579) * [Update] 添加org api, 升级到django 2.0 * [Update] fix some bug * [Update] 修改一些bug * [Update] 添加授权规则org (#1580) * [Update] 修复创建授权规则,显示org_name不是有效UUID的bug * [Update] 更新org之间隔离授权规则,解决QuerySet与Manager问题;修复创建用户,显示org_name不是有效UUID之bug; * Tmp org (#1583) * [Update] 修改一些内容 * [Update] 修改datatable 支持process * [Bugfix] 修复asset queryset 没有valid方法的bug * [Update] 在线/历史/命令model添加org;修复命令记录保存org失败bug (#1584) * [Update] 修复创建授权规则,显示org_name不是有效UUID的bug * [Update] 更新org之间隔离授权规则,解决QuerySet与Manager问题;修复创建用户,显示org_name不是有效UUID之bug; * [Update] 在线/历史/命令model添加org * [Bugfix] 修复命令记录,保存org不成功bug * [Update] Org功能修改 * [Bugfix] 修复merge带来的问题 * [Update] org admin显示资产详情右侧选项卡;修复资产授权添加用户,会显示其他org用户的bug (#1594) * [Bugfix] 修复资产授权添加用户,显示其他org的用户bug * [Update] org admin 显示资产详情右侧选项卡 * Tmp org (#1596) * [Update] 修改index view * [Update] 修改nav * [Update] 修改profile * [Bugfix] 修复org下普通用户打开web终端看不到已被授权的资产和节点bug * [Update] 修改get_all_assets * [Bugfix] 修复节点前面有个空目录 * [Bugfix] 修复merge引起的bug * [Update] Add init * [Update] Node get_all_assets 过滤游离资产,条件nodes_key=None -> nodes=None * [Update] 恢复原来的api地址 * [Update] 修改api * [Bugfix] 修复org下用户查看我的资产不显示已授权节点/资产的bug * [Bugfix] Fix perm name unique * [Bugfix] 修复校验失败api * [Update] Merge with org * [Merge] 修改一下bug * [Update] 暂时修改一些url * [Update] 修改url 为django 2.0 path * [Update] 优化datatable 和显示组织优化 * [Update] 升级url * [Bugfix] 修复coco启动失败(load_config_from_server)、硬件刷新,测试连接,str 没有 decode(… (#1613) * [Bugfix] 修复coco启动失败(load_config_from_server)、硬件刷新,测试连接,str 没有 decode() method的bug * [Bugfix] (task任务系统)修复资产连接性测试、硬件刷新和系统用户连接性测试失败等bug * [Bugfix] 修复一些bug * [Bugfix] 修复一些bug * [Update] 更新org下普通用户的资产详情 (#1619) * [Update] 更新org下普通用户查看资产详情,只显示数据 * [Update] 优化org下普通用户查看资产详情前端代码 * [Update] 创建/更新用户的role选项;密码强度提示信息中英文; (#1623) * [Update] 修改 超级管理员/组织管理员 在 创建/更新 用户时role的选项 问题 * [Update] 用户密码强度提示信息支持中英文 * [Update] 修改token返回 * [Update] Asset返回org name * [Update] 修改支持xpack * [Update] 修改url * [Bugfix] 修复不登录就能查看资产的bug * [Update] 用户修改 * [Bugfix] ... * [Bugfix] 修复跳转错误的问题 * [Update] xpack/orgs组织添加删除功能-js; 修复Label继承Org后bug; (#1644) * [Update] 更新xpack下orgs的翻译信息 * [Update] 更新model Label,继承OrgModelMixin; * [Update] xpack/orgs组织添加删除功能-js; 修复Label继承Org后bug; * [Bugfix] 修复小bug * [Update] 优化一些api * [Update] 优化用户资产页面 * [Update] 更新 xpack/orgs 删除功能:限制在当前org下删除当前org (#1645) * [Update] 修改版本号 * [Update] 添加功能: 语言切换(中/英);修改 header_bar <商业支持、文档>显示方式 * [Update] 中/英切换文案修改;修改django_language key 从 settings 中获取 * [Update] 修改Dashboard页面文案,支持英文 * [Update] 更新中/英文翻译(ALL) * [Update] 解决翻译文件冲突 * [Update] 系统用户支持单独隋松 * [Update] 重置用户MFA * [Update] 设置session空闲时间 * [Update] 加密setting配置 * [Update] 修改单独推送和测试资产可连接性 * [Update] 添加功能:用户个人详情页添加 更改MFA操作 (#1748) * [Update] 添加功能:用户个人详情页添加 更改MFA操作 * [Update] 删除print * [Bugfix] 添加部分views的权限控制;从组织移除用户,同时从授权规则和用户组中移除此用户。 (#1746) * [Bugfix] 修复上传command log 为空 * [Update] 修复执行任务的bug * [Bugfix] 修复将用户从组内移除,其依然具有之前的组权限的bug, perms and user_groups * [Bugfix] 修复组管理员可以访问部分url-views的bug(如: /settings/)添加views权限控制 * [Update] 修改日志滚动 * [Bugfix] 修复组织权限控制的bug (#1763) * [Bugfix] 修复将用户从组内移除,其依然具有之前的组权限的bug, perms and user_groups * [Bugfix] 修复组管理员可以访问部分url-views的bug(如: /settings/)添加views权限控制
375 lines
12 KiB
Python
375 lines
12 KiB
Python
# ~*~ coding: utf-8 ~*~
|
|
#
|
|
from __future__ import unicode_literals
|
|
import os
|
|
import re
|
|
import pyotp
|
|
import base64
|
|
import logging
|
|
import uuid
|
|
|
|
import requests
|
|
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
|
|
from django.utils.translation import ugettext as _
|
|
from django.core.cache import cache
|
|
|
|
from common.tasks import send_mail_async
|
|
from common.utils import reverse, get_object_or_none
|
|
from common.models import common_settings, Setting
|
|
from common.forms import SecuritySettingForm
|
|
from .models import User, LoginLog
|
|
|
|
|
|
logger = logging.getLogger('jumpserver')
|
|
|
|
|
|
class AdminUserRequiredMixin(UserPassesTestMixin):
|
|
def test_func(self):
|
|
if not self.request.user.is_authenticated:
|
|
return False
|
|
elif not self.request.user.is_superuser:
|
|
self.raise_exception = True
|
|
return False
|
|
return True
|
|
|
|
|
|
def send_user_created_mail(user):
|
|
subject = _('Create account successfully')
|
|
recipient_list = [user.email]
|
|
message = _("""
|
|
Hello %(name)s:
|
|
</br>
|
|
Your account has been created successfully
|
|
</br>
|
|
Username: %(username)s
|
|
</br>
|
|
<a href="%(rest_password_url)s?token=%(rest_password_token)s">click here to set your password</a>
|
|
</br>
|
|
This link is valid for 1 hour. After it expires, <a href="%(forget_password_url)s?email=%(email)s">request new one</a>
|
|
|
|
</br>
|
|
---
|
|
|
|
</br>
|
|
<a href="%(login_url)s">Login direct</a>
|
|
|
|
</br>
|
|
""") % {
|
|
'name': user.name,
|
|
'username': user.username,
|
|
'rest_password_url': reverse('users:reset-password', external=True),
|
|
'rest_password_token': user.generate_reset_token(),
|
|
'forget_password_url': reverse('users:forgot-password', external=True),
|
|
'email': user.email,
|
|
'login_url': reverse('users:login', external=True),
|
|
}
|
|
if settings.DEBUG:
|
|
try:
|
|
print(message)
|
|
except OSError:
|
|
pass
|
|
|
|
send_mail_async.delay(subject, message, recipient_list, html_message=message)
|
|
|
|
|
|
def send_reset_password_mail(user):
|
|
subject = _('Reset password')
|
|
recipient_list = [user.email]
|
|
message = _("""
|
|
Hello %(name)s:
|
|
</br>
|
|
Please click the link below to reset your password, if not your request, concern your account security
|
|
</br>
|
|
<a href="%(rest_password_url)s?token=%(rest_password_token)s">Click here reset password</a>
|
|
</br>
|
|
This link is valid for 1 hour. After it expires, <a href="%(forget_password_url)s?email=%(email)s">request new one</a>
|
|
|
|
</br>
|
|
---
|
|
|
|
</br>
|
|
<a href="%(login_url)s">Login direct</a>
|
|
|
|
</br>
|
|
""") % {
|
|
'name': user.name,
|
|
'rest_password_url': reverse('users:reset-password', external=True),
|
|
'rest_password_token': user.generate_reset_token(),
|
|
'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]
|
|
message = _("""
|
|
Hello %(name)s:
|
|
</br>
|
|
Your ssh public key has been reset by site administrator.
|
|
Please login and reset your ssh public key.
|
|
</br>
|
|
<a href="%(login_url)s">Login direct</a>
|
|
|
|
</br>
|
|
""") % {
|
|
'name': user.name,
|
|
'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 check_user_valid(**kwargs):
|
|
password = kwargs.pop('password', None)
|
|
public_key = kwargs.pop('public_key', None)
|
|
email = kwargs.pop('email', None)
|
|
username = kwargs.pop('username', None)
|
|
|
|
if username:
|
|
user = get_object_or_none(User, username=username)
|
|
elif email:
|
|
user = get_object_or_none(User, email=email)
|
|
else:
|
|
user = None
|
|
|
|
if user is None:
|
|
return None, _('User not exist')
|
|
elif not user.is_valid:
|
|
return None, _('Disabled or expired')
|
|
|
|
if password and authenticate(username=username, password=password):
|
|
return user, ''
|
|
|
|
if public_key and user.public_key:
|
|
public_key_saved = user.public_key.split()
|
|
if len(public_key_saved) == 1:
|
|
if public_key == public_key_saved[0]:
|
|
return user, ''
|
|
elif len(public_key_saved) > 1:
|
|
if public_key == public_key_saved[1]:
|
|
return user, ''
|
|
return None, _('Password or SSH public key invalid')
|
|
|
|
|
|
def refresh_token(token, user, expiration=settings.TOKEN_EXPIRATION or 3600):
|
|
cache.set(token, user.id, expiration)
|
|
|
|
|
|
def generate_token(request, user):
|
|
expiration = settings.TOKEN_EXPIRATION or 3600
|
|
remote_addr = request.META.get('REMOTE_ADDR', '')
|
|
if not isinstance(remote_addr, bytes):
|
|
remote_addr = remote_addr.encode("utf-8")
|
|
remote_addr = base64.b16encode(remote_addr) # .replace(b'=', '')
|
|
token = cache.get('%s_%s' % (user.id, remote_addr))
|
|
if not token:
|
|
token = uuid.uuid4().hex
|
|
cache.set(token, user.id, expiration)
|
|
cache.set('%s_%s' % (user.id, remote_addr), token, expiration)
|
|
return token
|
|
|
|
|
|
def validate_ip(ip):
|
|
try:
|
|
ipaddress.ip_address(ip)
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
return False
|
|
|
|
|
|
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)
|
|
kwargs.update({'ip': ip, 'city': city})
|
|
LoginLog.objects.create(**kwargs)
|
|
|
|
|
|
def get_ip_city(ip, timeout=10):
|
|
# Taobao ip api: http://ip.taobao.com/service/getIpInfo.php?ip=8.8.8.8
|
|
# Sina ip api: http://int.dpool.sina.com.cn/iplookup/iplookup.php?ip=8.8.8.8&format=json
|
|
|
|
url = 'http://ip.taobao.com/service/getIpInfo.php?ip=%s' % ip
|
|
try:
|
|
r = requests.get(url, timeout=timeout)
|
|
except:
|
|
r = None
|
|
city = 'Unknown'
|
|
if r and r.status_code == 200:
|
|
try:
|
|
data = r.json()
|
|
if not isinstance(data, int) and data['code'] == 0:
|
|
country = data['data']['country']
|
|
_city = data['data']['city']
|
|
if country == 'XX':
|
|
city = _city
|
|
else:
|
|
city = ' '.join([country, _city])
|
|
except ValueError:
|
|
pass
|
|
return city
|
|
|
|
|
|
def get_user_or_tmp_user(request):
|
|
user = request.user
|
|
tmp_user = get_tmp_user_from_cache(request)
|
|
if user.is_authenticated:
|
|
return user
|
|
elif tmp_user:
|
|
return tmp_user
|
|
else:
|
|
raise Http404("Not found this user")
|
|
|
|
|
|
def get_tmp_user_from_cache(request):
|
|
if not request.session.session_key:
|
|
return None
|
|
user = cache.get(request.session.session_key+'user')
|
|
return user
|
|
|
|
|
|
def set_tmp_user_to_cache(request, user):
|
|
cache.set(request.session.session_key+'user', user, 600)
|
|
|
|
|
|
def redirect_user_first_login_or_index(request, redirect_field_name):
|
|
if request.user.is_first_login:
|
|
return reverse('users:user-first-login')
|
|
return request.POST.get(
|
|
redirect_field_name,
|
|
request.GET.get(redirect_field_name, reverse('index')))
|
|
|
|
|
|
def generate_otp_uri(request, issuer="Jumpserver"):
|
|
user = get_user_or_tmp_user(request)
|
|
otp_secret_key = cache.get(request.session.session_key+'otp_key', '')
|
|
if not otp_secret_key:
|
|
otp_secret_key = base64.b32encode(os.urandom(10)).decode('utf-8')
|
|
cache.set(request.session.session_key+'otp_key', otp_secret_key, 600)
|
|
totp = pyotp.TOTP(otp_secret_key)
|
|
return totp.provisioning_uri(name=user.username, issuer_name=issuer)
|
|
|
|
|
|
def check_otp_code(otp_secret_key, otp_code):
|
|
if not otp_secret_key or not otp_code:
|
|
return False
|
|
totp = pyotp.TOTP(otp_secret_key)
|
|
return totp.verify(otp_code)
|
|
|
|
|
|
def get_password_check_rules():
|
|
check_rules = []
|
|
min_length = settings.DEFAULT_PASSWORD_MIN_LENGTH
|
|
min_name = 'SECURITY_PASSWORD_MIN_LENGTH'
|
|
base_filed = SecuritySettingForm.base_fields
|
|
password_setting = Setting.objects.filter(name__startswith='SECURITY_PASSWORD')
|
|
|
|
if not password_setting:
|
|
# 用户还没有设置过密码校验规则
|
|
label = base_filed.get(min_name).label
|
|
label += ' ' + str(min_length) + _('Bit')
|
|
id = 'rule_' + min_name
|
|
rules = {'id': id, 'label': label}
|
|
check_rules.append(rules)
|
|
|
|
for setting in password_setting:
|
|
if setting.cleaned_value:
|
|
id = 'rule_' + setting.name
|
|
label = base_filed.get(setting.name).label
|
|
if setting.name == min_name:
|
|
label += str(setting.cleaned_value) + _('Bit')
|
|
min_length = setting.cleaned_value
|
|
rules = {'id': id, 'label': label}
|
|
check_rules.append(rules)
|
|
|
|
return check_rules, min_length
|
|
|
|
|
|
def check_password_rules(password):
|
|
min_field_name = 'SECURITY_PASSWORD_MIN_LENGTH'
|
|
upper_field_name = 'SECURITY_PASSWORD_UPPER_CASE'
|
|
lower_field_name = 'SECURITY_PASSWORD_LOWER_CASE'
|
|
number_field_name = 'SECURITY_PASSWORD_NUMBER'
|
|
special_field_name = 'SECURITY_PASSWORD_SPECIAL_CHAR'
|
|
min_length = getattr(common_settings, min_field_name) or \
|
|
settings.DEFAULT_PASSWORD_MIN_LENGTH
|
|
|
|
password_setting = Setting.objects.filter(name__startswith='SECURITY_PASSWORD')
|
|
if not password_setting:
|
|
pattern = r"^.{" + str(min_length) + ",}$"
|
|
else:
|
|
pattern = r"^"
|
|
for setting in password_setting:
|
|
if setting.cleaned_value and setting.name == upper_field_name:
|
|
pattern += '(?=.*[A-Z])'
|
|
elif setting.cleaned_value and setting.name == lower_field_name:
|
|
pattern += '(?=.*[a-z])'
|
|
elif setting.cleaned_value and setting.name == number_field_name:
|
|
pattern += '(?=.*\d)'
|
|
elif setting.cleaned_value and setting.name == special_field_name:
|
|
pattern += '(?=.*[`~!@#\$%\^&\*\(\)-=_\+\[\]\{\}\|;:\'",\.<>\/\?])'
|
|
pattern += '[a-zA-Z\d`~!@#\$%\^&\*\(\)-=_\+\[\]\{\}\|;:\'",\.<>\/\?]'
|
|
|
|
match_obj = re.match(pattern, password)
|
|
return bool(match_obj)
|
|
|
|
|
|
key_prefix_limit = "_LOGIN_LIMIT_{}_{}"
|
|
key_prefix_block = "_LOGIN_BLOCK_{}"
|
|
|
|
|
|
# def increase_login_failed_count(key_limit, key_block):
|
|
def increase_login_failed_count(username, ip):
|
|
key_limit = key_prefix_limit.format(username, ip)
|
|
count = cache.get(key_limit)
|
|
count = count + 1 if count else 1
|
|
|
|
limit_time = common_settings.SECURITY_LOGIN_LIMIT_TIME or \
|
|
settings.DEFAULT_LOGIN_LIMIT_TIME
|
|
cache.set(key_limit, count, int(limit_time)*60)
|
|
|
|
|
|
def clean_failed_count(username, ip):
|
|
key_limit = key_prefix_limit.format(username, ip)
|
|
key_block = key_prefix_block.format(username)
|
|
cache.delete(key_limit)
|
|
cache.delete(key_block)
|
|
|
|
|
|
def is_block_login(username, ip):
|
|
key_limit = key_prefix_limit.format(username, ip)
|
|
key_block = key_prefix_block.format(username)
|
|
count = cache.get(key_limit, 0)
|
|
|
|
limit_count = common_settings.SECURITY_LOGIN_LIMIT_COUNT or \
|
|
settings.DEFAULT_LOGIN_LIMIT_COUNT
|
|
limit_time = common_settings.SECURITY_LOGIN_LIMIT_TIME or \
|
|
settings.DEFAULT_LOGIN_LIMIT_TIME
|
|
|
|
if count >= limit_count:
|
|
cache.set(key_block, 1, int(limit_time)*60)
|
|
if count and count >= limit_count:
|
|
return True
|
|
|
|
|
|
def is_need_unblock(key_block):
|
|
if not cache.get(key_block):
|
|
return False
|
|
return True
|