Unverified Commit 722bf786 authored by 老广's avatar 老广 Committed by GitHub

Merge pull request #1542 from jumpserver/dev

Dev to master
parents 91b3b7ce 2cb5876d
......@@ -19,25 +19,25 @@ Jumpserver采纳分布式架构,支持多机房跨区域部署,中心节点
----
### 功能
![Jumpserver功能](https://jumpserver-release.oss-cn-hangzhou.aliyuncs.com/Jumpserver13.jpg "Jumpserver功能")
### 开始使用
快速开始文档 [Docker安装](http://docs.jumpserver.org/zh/latest/quickstart.html)
快速开始文档 [Docker安装](http://docs.jumpserver.org/zh/docs/dockerinstall.html)
一步一步安装文档 [详细部署](http://docs.jumpserver.org/zh/latest/step_by_step.html)
一步一步安装文档 [详细部署](http://docs.jumpserver.org/zh/docs/step_by_step.html)
也可以查看我们完整文档包括了使用和开发 [文档](http://docs.jumpserver.org)
### Demo 和 截图
### Demo 和 截图
我们提供了DEMO和截图可以让你快速了解Jumpserver
[DEMO](http://demo.jumpserver.org)
[截图](http://docs.jumpserver.org/zh/docs/snapshot.html)
### SDK
### SDK
我们还编写了一些SDK,供你其它系统快速和Jumpserver APi交互,
......
......@@ -2,4 +2,4 @@
# -*- coding: utf-8 -*-
#
__version__ = "1.3.2"
__version__ = "1.3.3"
# -*- coding: utf-8 -*-
#
import random
from rest_framework import generics
from rest_framework.response import Response
from rest_framework_bulk import BulkModelViewSet
......@@ -22,7 +24,8 @@ from ..utils import LabelFilter
logger = get_logger(__file__)
__all__ = [
'AssetViewSet', 'AssetListUpdateApi',
'AssetRefreshHardwareApi', 'AssetAdminUserTestApi'
'AssetRefreshHardwareApi', 'AssetAdminUserTestApi',
'AssetGatewayApi'
]
......@@ -106,3 +109,20 @@ class AssetAdminUserTestApi(generics.RetrieveAPIView):
asset = get_object_or_404(Asset, pk=asset_id)
task = test_asset_connectability_manual.delay(asset)
return Response({"task": task.id})
class AssetGatewayApi(generics.RetrieveAPIView):
queryset = Asset.objects.all()
permission_classes = (IsSuperUserOrAppUser,)
def retrieve(self, request, *args, **kwargs):
asset_id = kwargs.get('pk')
asset = get_object_or_404(Asset, pk=asset_id)
if asset.domain and \
asset.domain.gateways.filter(protocol=asset.protocol).exists():
gateway = random.choice(asset.domain.gateways.filter(protocol=asset.protocol))
serializer = serializers.GatewayWithAuthSerializer(instance=gateway)
return Response(serializer.data)
else:
return Response({"msg": "Not have gateway"}, status=404)
\ No newline at end of file
......@@ -16,7 +16,7 @@ class AssetCreateForm(forms.ModelForm):
fields = [
'hostname', 'ip', 'public_ip', 'port', 'comment',
'nodes', 'is_active', 'admin_user', 'labels', 'platform',
'domain',
'domain', 'protocol',
]
widgets = {
......@@ -56,7 +56,7 @@ class AssetUpdateForm(forms.ModelForm):
fields = [
'hostname', 'ip', 'port', 'nodes', 'is_active', 'platform',
'public_ip', 'number', 'comment', 'admin_user', 'labels',
'domain',
'domain', 'protocol',
]
widgets = {
'nodes': forms.SelectMultiple(attrs={
......
......@@ -93,14 +93,21 @@ class SystemUserForm(PasswordAndKeyAuthForm):
# Because we define custom field, so we need rewrite :method: `save`
system_user = super().save()
password = self.cleaned_data.get('password', '') or None
login_mode = self.cleaned_data.get('login_mode', '') or None
protocol = self.cleaned_data.get('protocol') or None
auto_generate_key = self.cleaned_data.get('auto_generate_key', False)
private_key, public_key = super().gen_keys()
if login_mode == SystemUser.MANUAL_LOGIN or protocol == SystemUser.TELNET_PROTOCOL:
system_user.auto_push = 0
system_user.save()
if auto_generate_key:
logger.info('Auto generate key and set system user auth')
system_user.auto_gen_auth()
else:
system_user.set_auth(password=password, private_key=private_key, public_key=public_key)
return system_user
def clean(self):
......@@ -109,12 +116,24 @@ class SystemUserForm(PasswordAndKeyAuthForm):
if not self.instance and not auto_generate:
super().validate_password_key()
def is_valid(self):
validated = super().is_valid()
username = self.cleaned_data.get('username')
login_mode = self.cleaned_data.get('login_mode')
if login_mode == SystemUser.AUTO_LOGIN and not username:
self.add_error(
"username", _('* Automatic login mode,'
' must fill in the username.')
)
return False
return validated
class Meta:
model = SystemUser
fields = [
'name', 'username', 'protocol', 'auto_generate_key',
'password', 'private_key_file', 'auto_push', 'sudo',
'comment', 'shell', 'priority',
'comment', 'shell', 'priority', 'login_mode',
]
widgets = {
'name': forms.TextInput(attrs={'placeholder': _('Name')}),
......@@ -124,5 +143,8 @@ class SystemUserForm(PasswordAndKeyAuthForm):
'name': '* required',
'username': '* required',
'auto_push': _('Auto push system user to asset'),
'priority': _('High level will be using login asset as default, if user was granted more than 2 system user'),
}
\ No newline at end of file
'priority': _('High level will be using login asset as default, '
'if user was granted more than 2 system user'),
'login_mode': _('If you choose manual login mode, you do not '
'need to fill in the username and password.')
}
......@@ -57,13 +57,27 @@ class Asset(models.Model):
('MacOS', 'MacOS'),
('BSD', 'BSD'),
('Windows', 'Windows'),
('Windows2016', 'Windows(2016)'),
('Other', 'Other'),
)
SSH_PROTOCOL = 'ssh'
RDP_PROTOCOL = 'rdp'
TELNET_PROTOCOL = 'telnet'
PROTOCOL_CHOICES = (
(SSH_PROTOCOL, 'ssh'),
(RDP_PROTOCOL, 'rdp'),
(TELNET_PROTOCOL, 'telnet (beta)'),
)
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
ip = models.GenericIPAddressField(max_length=32, verbose_name=_('IP'),
db_index=True)
hostname = models.CharField(max_length=128, unique=True,
verbose_name=_('Hostname'))
protocol = models.CharField(max_length=128, default=SSH_PROTOCOL,
choices=PROTOCOL_CHOICES,
verbose_name=_('Protocol'))
port = models.IntegerField(default=22, verbose_name=_('Port'))
platform = models.CharField(max_length=128, choices=PLATFORM_CHOICES,
default='Linux', verbose_name=_('Platform'))
......
......@@ -19,7 +19,7 @@ signer = get_signer()
class AssetUser(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
name = models.CharField(max_length=128, unique=True, verbose_name=_('Name'))
username = models.CharField(max_length=32, verbose_name=_('Username'), validators=[alphanumeric])
username = models.CharField(max_length=32, blank=True, verbose_name=_('Username'), validators=[alphanumeric])
_password = models.CharField(max_length=256, blank=True, null=True, verbose_name=_('Password'))
_private_key = models.TextField(max_length=4096, blank=True, null=True, verbose_name=_('SSH private key'), validators=[private_key_validator, ])
_public_key = models.TextField(max_length=4096, blank=True, verbose_name=_('SSH public key'))
......
......@@ -95,9 +95,18 @@ class AdminUser(AssetUser):
class SystemUser(AssetUser):
SSH_PROTOCOL = 'ssh'
RDP_PROTOCOL = 'rdp'
TELNET_PROTOCOL = 'telnet'
PROTOCOL_CHOICES = (
(SSH_PROTOCOL, 'ssh'),
(RDP_PROTOCOL, 'rdp'),
(TELNET_PROTOCOL, 'telnet (beta)'),
)
AUTO_LOGIN = 'auto'
MANUAL_LOGIN = 'manual'
LOGIN_MODE_CHOICES = (
(AUTO_LOGIN, _('Automatic login')),
(MANUAL_LOGIN, _('Manually login'))
)
nodes = models.ManyToManyField('assets.Node', blank=True, verbose_name=_("Nodes"))
......@@ -107,6 +116,7 @@ class SystemUser(AssetUser):
auto_push = models.BooleanField(default=True, verbose_name=_('Auto push'))
sudo = models.TextField(default='/bin/whoami', verbose_name=_('Sudo'))
shell = models.CharField(max_length=64, default='/bin/bash', verbose_name=_('Shell'))
login_mode = models.CharField(choices=LOGIN_MODE_CHOICES, default=AUTO_LOGIN, max_length=10, verbose_name=_('Login mode'))
def __str__(self):
return '{0.name}({0.username})'.format(self)
......
......@@ -43,7 +43,7 @@ class AssetGrantedSerializer(serializers.ModelSerializer):
fields = (
"id", "hostname", "ip", "port", "system_users_granted",
"is_active", "system_users_join", "os", 'domain',
"platform", "comment"
"platform", "comment", "protocol",
)
@staticmethod
......
......@@ -18,6 +18,13 @@ class SystemUserSerializer(serializers.ModelSerializer):
model = SystemUser
exclude = ('_password', '_private_key', '_public_key')
def get_field_names(self, declared_fields, info):
fields = super(SystemUserSerializer, self).get_field_names(declared_fields, info)
fields.extend([
'get_login_mode_display',
])
return fields
@staticmethod
def get_unreachable_assets(obj):
return obj.unreachable_assets
......@@ -46,7 +53,7 @@ class SystemUserAuthSerializer(AuthSerializer):
model = SystemUser
fields = [
"id", "name", "username", "protocol",
"password", "private_key",
"login_mode", "password", "private_key",
]
......@@ -56,7 +63,10 @@ class AssetSystemUserSerializer(serializers.ModelSerializer):
"""
class Meta:
model = SystemUser
fields = ('id', 'name', 'username', 'priority', 'protocol', 'comment',)
fields = (
'id', 'name', 'username', 'priority',
'protocol', 'comment', 'login_mode'
)
class SystemUserSimpleSerializer(serializers.ModelSerializer):
......
......@@ -36,12 +36,13 @@
{% endif %}
<h3>{% trans 'Basic' %}</h3>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.login_mode layout="horizontal" %}
{% bootstrap_field form.username layout="horizontal" %}
{% bootstrap_field form.priority layout="horizontal" %}
{% bootstrap_field form.protocol layout="horizontal" %}
<h3 id="auth_title_id">{% trans 'Auth' %}</h3>
{% block auth %}
<h3>{% trans 'Auth' %}</h3>
<div class="auto-generate">
<div class="form-group">
<label for="{{ form.auto_generate_key.id_for_label }}" class="col-sm-2 control-label">{% trans 'Auto generate key' %}</label>
......@@ -80,15 +81,22 @@
{% endblock %}
{% block custom_foot_js %}
<script>
var auto_generate_key = '#'+'{{ form.auto_generate_key.id_for_label }}';
var protocol_id = '#' + '{{ form.protocol.id_for_label }}';
var login_mode_id = '#' + '{{ form.login_mode.id_for_label }}';
var auto_generate_key = '#'+'{{ form.auto_generate_key.id_for_label }}';
var password_id = '#' + '{{ form.password.id_for_label }}';
var private_key_id = '#' + '{{ form.private_key_file.id_for_label }}';
var auto_push_id = '#' + '{{ form.auto_push.id_for_label }}';
var sudo_id = '#' + '{{ form.sudo.id_for_label }}';
var shell_id = '#' + '{{ form.shell.id_for_label }}';
var need_change_field = [
auto_generate_key, private_key_id, auto_push_id, sudo_id, shell_id
];
var need_change_field_login_mode = [
auto_generate_key, private_key_id, auto_push_id, password_id
];
function protocolChange() {
if ($(protocol_id + " option:selected").text() === 'rdp') {
......@@ -96,7 +104,19 @@ function protocolChange() {
$.each(need_change_field, function (index, value) {
$(value).closest('.form-group').addClass('hidden')
});
} else {
}
else if ($(protocol_id + " option:selected").text() === 'telnet (beta)') {
$('.auth-fields').removeClass('hidden');
$.each(need_change_field, function (index, value) {
$(value).closest('.form-group').addClass('hidden')
});
}
else {
if($(login_mode_id).val() === 'manual'){
$(sudo_id).closest('.form-group').removeClass('hidden');
$(shell_id).closest('.form-group').removeClass('hidden');
return
}
authFieldsDisplay();
$.each(need_change_field, function (index, value) {
$(value).closest('.form-group').removeClass('hidden')
......@@ -111,18 +131,35 @@ function authFieldsDisplay() {
$('.auth-fields').removeClass('hidden');
}
}
function loginModeChange(){
if ($(login_mode_id).val() === 'manual'){
$('#auth_title_id').addClass('hidden');
$.each(need_change_field_login_mode, function(index, value){
$(value).closest('.form-group').addClass('hidden')
})
}
else if($(login_mode_id).val() === 'auto'){
$('#auth_title_id').removeClass('hidden');
$(password_id).closest('.form-group').removeClass('hidden')
protocolChange();
}
}
$(document).ready(function () {
$('.select2').select2();
authFieldsDisplay();
protocolChange();
loginModeChange();
})
.on('change', protocol_id, function(){
protocolChange();
})
.on('change', auto_generate_key, function(){
authFieldsDisplay();
});
})
.on('change', login_mode_id, function(){
loginModeChange();
})
</script>
{% endblock %}
\ No newline at end of file
......@@ -5,7 +5,7 @@
{% block help_message %}
<div class="alert alert-info help-message">
管理用户是服务器的root,或拥有 NOPASSWD: ALL sudo权限的用户,Jumpserver使用该用户来 `推送系统用户`、`获取资产硬件信息`等。
管理用户是资产(被控服务器)上的root,或拥有 NOPASSWD: ALL sudo权限的用户,Jumpserver使用该用户来 `推送系统用户`、`获取资产硬件信息`等。
Windows或其它硬件可以随意设置一个
</div>
{% endblock %}
......@@ -107,6 +107,3 @@ $(document).ready(function(){
});
</script>
{% endblock %}
......@@ -17,6 +17,7 @@
{% bootstrap_field form.hostname layout="horizontal" %}
{% bootstrap_field form.platform layout="horizontal" %}
{% bootstrap_field form.ip layout="horizontal" %}
{% bootstrap_field form.protocol layout="horizontal" %}
{% bootstrap_field form.port layout="horizontal" %}
{% bootstrap_field form.public_ip layout="horizontal" %}
{% bootstrap_field form.domain layout="horizontal" %}
......@@ -85,14 +86,14 @@ $(document).ready(function () {
allowClear: true,
templateSelection: format
});
$("#id_platform").change(function (){
var platform = $("#id_platform option:selected").text();
$("#id_protocol").change(function (){
var protocol = $("#id_protocol option:selected").text();
var port = 22;
if(platform === 'Windows'){
if(protocol === 'rdp'){
port = 3389;
}
if(platform === 'Other'){
port = null;
if(protocol === 'telnet (beta)'){
port = 23;
}
$("#id_port").val(port);
});
......
......@@ -21,6 +21,7 @@
<h3>{% trans 'Basic' %}</h3>
{% bootstrap_field form.hostname layout="horizontal" %}
{% bootstrap_field form.ip layout="horizontal" %}
{% bootstrap_field form.protocol layout="horizontal" %}
{% bootstrap_field form.port layout="horizontal" %}
{% bootstrap_field form.platform layout="horizontal" %}
{% bootstrap_field form.public_ip layout="horizontal" %}
......
{% extends '_base_list.html' %}
{% load i18n static %}
{% block table_search %}{% endblock %}
{% block help_message %}
<div class="alert alert-info help-message">
网域功能是为了解决部分环境(如:混合云)无法直接连接而新增的功能,原理是通过网关服务器进行跳转登
录。
</div>
{% endblock %}
{% block table_container %}
<div class="uc pull-left m-r-5">
<a href="{% url 'assets:domain-create' %}" class="btn btn-sm btn-primary"> {% trans "Create domain" %} </a>
......@@ -69,6 +77,3 @@ $(document).ready(function(){
});
</script>
{% endblock %}
......@@ -42,7 +42,7 @@
{% bootstrap_field form.domain layout="horizontal" %}
{% block auth %}
<h3>{% trans 'Auth' %}</h3>
<h3 id="auth_title">{% trans 'Auth' %}</h3>
<div class="auth-fields">
{% bootstrap_field form.username layout="horizontal" %}
{% bootstrap_field form.password layout="horizontal" %}
......@@ -72,14 +72,23 @@
var protocol_id = '#' + '{{ form.protocol.id_for_label }}';
var private_key_id = '#' + '{{ form.private_key_file.id_for_label }}';
var port = '#' + '{{ form.port.id_for_label }}';
var username = '#' + '{{ form.username.id_for_label }}';
var password = '#' + '{{ form.password.id_for_label }}';
var auth_title = '#auth_title';
function protocolChange() {
if ($(protocol_id + " option:selected").text() === 'rdp') {
$(port).val(3389);
$(private_key_id).closest('.form-group').addClass('hidden')
{#$(port).val(3389);#}
$(private_key_id).closest('.form-group').addClass('hidden');
$(username).closest('.form-group').addClass('hidden');
$(password).closest('.form-group').addClass('hidden');
$(auth_title).addClass('hidden');
} else {
$(port).val(22);
$(private_key_id).closest('.form-group').removeClass('hidden')
{#$(port).val(22);#}
$(private_key_id).closest('.form-group').removeClass('hidden');
$(username).closest('.form-group').removeClass('hidden');
$(password).closest('.form-group').removeClass('hidden');
$(auth_title).removeClass('hidden');
}
}
......
......@@ -62,6 +62,10 @@
<td>{% trans 'Username' %}:</td>
<td><b>{{ system_user.username }}</b></td>
</tr>
<tr>
<td>{% trans 'Login mode' %}:</td>
<td><b>{{ system_user.get_login_mode_display }}</b></td>
</tr>
<tr>
<td>{% trans 'Protocol' %}:</td>
<td><b id="id_protocol_type">{{ system_user.protocol }}</b></td>
......@@ -148,15 +152,14 @@
</span>
</td>
</tr>
<tr>
<td width="50%">{% trans 'Clear auth' %}:</td>
<td>
<span style="float: right">
<button type="button" class="btn btn-primary btn-xs btn-clear-auth" style="width: 54px">{% trans 'Clear' %}</button>
</span>
</td>
</tr>
{# <tr>#}
{# <td width="50%">{% trans 'Clear auth' %}:</td>#}
{# <td>#}
{# <span style="float: right">#}
{# <button type="button" class="btn btn-primary btn-xs btn-clear-auth" style="width: 54px">{% trans 'Clear' %}</button>#}
{# </span>#}
{# </td>#}
{# </tr>#}
{# <tr>#}
{# <td width="50%">{% trans 'Change auth period' %}:</td>#}
......@@ -333,10 +336,22 @@ $(document).ready(function () {
});
}).on('click', '.btn-clear-auth', function () {
var the_url = '{% url "api-assets:system-user-auth-info" pk=system_user.id %}';
APIUpdateAttr({
url: the_url,
method: 'DELETE',
success_message: "{% trans 'Clear auth' %}" + " {% trans 'success' %}"
var name = '{{ system_user.name }}';
swal({
title: '你确定清除该系统用户的认证信息吗 ?',
text: " [" + name + "] ",
type: "warning",
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonColor: "#ed5565",
confirmButtonText: '确认',
closeOnConfirm: true
}, function () {
APIUpdateAttr({
url: the_url,
method: 'DELETE',
success_message: "{% trans 'Clear auth' %}" + " {% trans 'success' %}"
});
});
})
</script>
......
......@@ -26,6 +26,7 @@
<th class="text-center">{% trans 'Name' %}</th>
<th class="text-center">{% trans 'Username' %}</th>
<th class="text-center">{% trans 'Protocol' %}</th>
<th class="text-center">{% trans 'Login mode' %}</th>
<th class="text-center">{% trans 'Asset' %}</th>
<th class="text-center">{% trans 'Reachable' %}</th>
<th class="text-center">{% trans 'Unreachable' %}</th>
......@@ -48,7 +49,7 @@ function initTable() {
var detail_btn = '<a href="{% url "assets:system-user-detail" pk=DEFAULT_PK %}">' + cellData + '</a>';
$(td).html(detail_btn.replace('{{ DEFAULT_PK }}', rowData.id));
}},
{targets: 5, createdCell: function (td, cellData) {
{targets: 6, createdCell: function (td, cellData) {
var innerHtml = "";
if (cellData !== 0) {
innerHtml = "<span class='text-navy'>" + cellData + "</span>";
......@@ -57,7 +58,7 @@ function initTable() {
}
$(td).html('<span href="javascript:void(0);" data-toggle="tooltip" title="' + cellData +'">' + innerHtml + '</span>');
}},
{targets: 6, createdCell: function (td, cellData) {
{targets: 7, createdCell: function (td, cellData) {
var innerHtml = "";
if (cellData !== 0) {
innerHtml = "<span class='text-danger'>" + cellData + "</span>";
......@@ -66,7 +67,7 @@ function initTable() {
}
$(td).html('<span href="javascript:void(0);" data-toggle="tooltip" title="' + cellData + '">' + innerHtml + '</span>');
}},
{targets: 7, createdCell: function (td, cellData, rowData) {
{targets: 8, createdCell: function (td, cellData, rowData) {
var val = 0;
var innerHtml = "";
var total = rowData.assets_amount;
......@@ -84,14 +85,14 @@ function initTable() {
$(td).html('<span href="javascript:void(0);" data-toggle="tooltip" title="' + cellData + '">' + innerHtml + '</span>');
}},
{targets: 9, createdCell: function (td, cellData, rowData) {
{targets: 10, createdCell: function (td, cellData, rowData) {
var update_btn = '<a href="{% url "assets:system-user-update" pk=DEFAULT_PK %}" class="btn btn-xs m-l-xs btn-info">{% trans "Update" %}</a>'.replace('{{ DEFAULT_PK }}', cellData);
var del_btn = '<a class="btn btn-xs btn-danger m-l-xs btn_admin_user_delete" data-uid="{{ DEFAULT_PK }}">{% trans "Delete" %}</a>'.replace('{{ DEFAULT_PK }}', cellData);
$(td).html(update_btn + del_btn)
}}],
ajax_url: '{% url "api-assets:system-user-list" %}',
columns: [
{data: "id" }, {data: "name" }, {data: "username" }, {data: "protocol"}, {data: "assets_amount" },
{data: "id" }, {data: "name" }, {data: "username" }, {data: "protocol"}, {data: "get_login_mode_display"}, {data: "assets_amount" },
{data: "reachable_amount"}, {data: "unreachable_amount"}, {data: "id"}, {data: "comment" }, {data: "id" }
],
op_html: $('#actions').html()
......
......@@ -4,7 +4,6 @@
{% load bootstrap3 %}
{% block auth %}
<h3>{% trans 'Auth' %}</h3>
{% bootstrap_field form.password layout="horizontal" %}
{% bootstrap_field form.private_key_file layout="horizontal" %}
<div class="form-group">
......
......@@ -23,6 +23,8 @@ urlpatterns = [
api.AssetRefreshHardwareApi.as_view(), name='asset-refresh'),
url(r'^v1/assets/(?P<pk>[0-9a-zA-Z\-]{36})/alive/$',
api.AssetAdminUserTestApi.as_view(), name='asset-alive-test'),
url(r'^v1/assets/(?P<pk>[0-9a-zA-Z\-]{36})/gateway/$',
api.AssetGatewayApi.as_view(), name='asset-gateway'),
url(r'^v1/admin-user/(?P<pk>[0-9a-zA-Z\-]{36})/nodes/$',
api.ReplaceNodesAdminUserApi.as_view(), name='replace-nodes-admin-user'),
url(r'^v1/admin-user/(?P<pk>[0-9a-zA-Z\-]{36})/auth/$',
......
......@@ -50,4 +50,3 @@ urlpatterns = [
url(r'^domain/(?P<pk>[0-9a-zA-Z\-]{36})/gateway/create/$', views.DomainGatewayCreateView.as_view(), name='domain-gateway-create'),
url(r'^domain/gateway/(?P<pk>[0-9a-zA-Z\-]{36})/update/$', views.DomainGatewayUpdateView.as_view(), name='domain-gateway-update'),
]
......@@ -54,7 +54,8 @@ def test_gateway_connectability(gateway):
proxy.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
proxy.connect(gateway.ip, username=gateway.username,
proxy.connect(gateway.ip, gateway.port,
username=gateway.username,
password=gateway.password,
pkey=gateway.private_key_obj)
except(paramiko.AuthenticationException,
......
......@@ -140,11 +140,6 @@ class DomainGatewayUpdateView(AdminUserRequiredMixin, UpdateView):
domain = self.object.domain
return reverse('assets:domain-gateway-list', kwargs={"pk": domain.id})
def form_valid(self, form):
response = super().form_valid(form)
print(form.cleaned_data)
return response
def get_context_data(self, **kwargs):
context = {
'app': _('Assets'),
......
......@@ -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"),
......
......@@ -39,9 +39,9 @@
{% endif %}
{% csrf_token %}
<h3>{% trans "MFA setting" %}</h3>
<h3>{% trans "User login settings" %}</h3>
{% for field in form %}
{% if forloop.counter == 2 %}
{% if forloop.counter == 4 %}
<div class="hr-line-dashed"></div>
<h3>{% trans "Password check rule" %}</h3>
{% endif %}
......
This diff is collapsed.
......@@ -343,10 +343,11 @@ if AUTH_LDAP:
AUTHENTICATION_BACKENDS.insert(0, AUTH_LDAP_BACKEND)
# Celery using redis as broker
CELERY_BROKER_URL = 'redis://:%(password)s@%(host)s:%(port)s/3' % {
CELERY_BROKER_URL = 'redis://:%(password)s@%(host)s:%(port)s/%(db)s' % {
'password': CONFIG.REDIS_PASSWORD if CONFIG.REDIS_PASSWORD else '',
'host': CONFIG.REDIS_HOST or '127.0.0.1',
'port': CONFIG.REDIS_PORT or 6379,
'db':CONFIG.REDIS_DB_CELERY_BROKER or 3,
}
CELERY_TASK_SERIALIZER = 'pickle'
CELERY_RESULT_SERIALIZER = 'pickle'
......@@ -367,10 +368,11 @@ CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CACHES = {
'default': {
'BACKEND': 'redis_cache.RedisCache',
'LOCATION': 'redis://:%(password)s@%(host)s:%(port)s/4' % {
'LOCATION': 'redis://:%(password)s@%(host)s:%(port)s/%(db)s' % {
'password': CONFIG.REDIS_PASSWORD if CONFIG.REDIS_PASSWORD else '',
'host': CONFIG.REDIS_HOST or '127.0.0.1',
'port': CONFIG.REDIS_PORT or 6379,
'db':CONFIG.REDIS_DB_CACHE or 4,
}
}
}
......@@ -403,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 = {
......
......@@ -93,7 +93,7 @@ class JMSInventory(BaseInventory):
if gateway.password:
proxy_command_list.insert(
0, "sshpass -p {}".format(gateway.password)
0, "sshpass -p '{}'".format(gateway.password)
)
if gateway.private_key:
proxy_command_list.append("-i {}".format(gateway.private_key_file))
......
......@@ -77,9 +77,9 @@ class UserGrantedAssetsApi(ListAPIView):
util = AssetPermissionUtil(user)
for k, v in util.get_assets().items():
if k.is_unixlike():
system_users_granted = [s for s in v if s.protocol == 'ssh']
system_users_granted = [s for s in v if s.protocol in ['ssh', 'telnet']]
else:
system_users_granted = [s for s in v if s.protocol == 'rdp']
system_users_granted = [s for s in v if s.protocol in ['rdp', 'telnet']]
k.system_users_granted = system_users_granted
queryset.append(k)
return queryset
......@@ -128,9 +128,9 @@ class UserGrantedNodesWithAssetsApi(ListAPIView):
assets = _assets.keys()
for k, v in _assets.items():
if k.is_unixlike():
system_users_granted = [s for s in v if s.protocol == 'ssh']
system_users_granted = [s for s in v if s.protocol in ['ssh', 'telnet']]
else:
system_users_granted = [s for s in v if s.protocol == 'rdp']
system_users_granted = [s for s in v if s.protocol in ['rdp', 'telnet']]
k.system_users_granted = system_users_granted
node.assets_granted = assets
queryset.append(node)
......
......@@ -6,13 +6,11 @@ from .. import views
app_name = 'perms'
urlpatterns = [
url(r'^asset-permission$', views.AssetPermissionListView.as_view(), name='asset-permission-list'),
url(r'^asset-permission/create$', views.AssetPermissionCreateView.as_view(), name='asset-permission-create'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/update$', views.AssetPermissionUpdateView.as_view(), name='asset-permission-update'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})$', views.AssetPermissionDetailView.as_view(),name='asset-permission-detail'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/delete$', views.AssetPermissionDeleteView.as_view(), name='asset-permission-delete'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/user$', views.AssetPermissionUserView.as_view(), name='asset-permission-user-list'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/asset$', views.AssetPermissionAssetView.as_view(), name='asset-permission-asset-list'),
url(r'^asset-permission/$', views.AssetPermissionListView.as_view(), name='asset-permission-list'),
url(r'^asset-permission/create/$', views.AssetPermissionCreateView.as_view(), name='asset-permission-create'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/update/$', views.AssetPermissionUpdateView.as_view(), name='asset-permission-update'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/$', views.AssetPermissionDetailView.as_view(),name='asset-permission-detail'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/delete/$', views.AssetPermissionDeleteView.as_view(), name='asset-permission-delete'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/user/$', views.AssetPermissionUserView.as_view(), name='asset-permission-user-list'),
url(r'^asset-permission/(?P<pk>[0-9a-zA-Z\-]{36})/asset/$', views.AssetPermissionAssetView.as_view(), name='asset-permission-asset-list'),
]
......@@ -173,14 +173,14 @@ function APIUpdateAttr(props) {
}
if (typeof props.success === 'function') {
return props.success(data);
}
}
}).fail(function(jqXHR, textStatus, errorThrown) {
if (flash_message) {
toastr.error(fail_message);
}
if (typeof props.error === 'function') {
return props.error(jqXHR.responseText);
}
}
});
// return true;
}
......@@ -198,7 +198,8 @@ function objectDelete(obj, name, url, redirectTo) {
}
};
var fail = function() {
swal("错误", "删除"+"[ "+name+" ]"+"遇到错误", "error");
// swal("错误", "删除"+"[ "+name+" ]"+"遇到错误", "error");
swal("错误", "[ "+name+" ]"+"正在被资产使用中,请先解除资产绑定", "error");
};
APIUpdateAttr({
url: url,
......@@ -219,7 +220,7 @@ function objectDelete(obj, name, url, redirectTo) {
confirmButtonText: '确认',
closeOnConfirm: true,
}, function () {
doDelete()
doDelete()
});
}
......@@ -272,7 +273,7 @@ jumpserver.initDataTable = function (options) {
$(td).html('<input type="checkbox" class="text-center ipt_check" id=99991937>'.replace('99991937', cellData));
}
},
{className: 'text-center', targets: '_all'}
{className: 'text-center', render: $.fn.dataTable.render.text(), targets: '_all'}
];
columnDefs = options.columnDefs ? options.columnDefs.concat(columnDefs) : columnDefs;
var select = {
......
<div class="footer fixed">
<div class="pull-right">
Version <strong>1.3.2-{% include '_build.html' %}</strong> GPLv2.
Version <strong>1.3.3-{% include '_build.html' %}</strong> GPLv2.
<!--<img style="display: none" src="http://www.jumpserver.org/img/evaluate_avatar1.jpg">-->
</div>
<div>
......
......@@ -259,10 +259,35 @@ class SessionReplayViewSet(viewsets.ViewSet):
serializer_class = ReplaySerializer
permission_classes = (IsSuperUserOrAppUser,)
session = None
def gen_session_path(self):
upload_to = 'replay' # 仅添加到本地存储中
def get_session_path(self, version=2):
"""
获取session日志的文件路径
:param version: 原来后缀是 .gz,为了统一新版本改为 .replay.gz
:return:
"""
suffix = '.replay.gz'
if version == 1:
suffix = '.gz'
date = self.session.date_start.strftime('%Y-%m-%d')
return os.path.join(date, str(self.session.id) + '.gz')
return os.path.join(date, str(self.session.id) + suffix)
def get_local_path(self, version=2):
session_path = self.get_session_path(version=version)
if version == 2:
local_path = os.path.join(self.upload_to, session_path)
else:
local_path = session_path
return local_path
def save_to_storage(self, f):
local_path = self.get_local_path()
try:
name = default_storage.save(local_path, f)
return name, None
except OSError as e:
return None, e
def create(self, request, *args, **kwargs):
session_id = kwargs.get('pk')
......@@ -271,46 +296,49 @@ class SessionReplayViewSet(viewsets.ViewSet):
if serializer.is_valid():
file = serializer.validated_data['file']
file_path = self.gen_session_path()
try:
default_storage.save(file_path, file)
return Response({'url': default_storage.url(file_path)},
status=201)
except IOError:
return Response("Save error", status=500)
name, err = self.save_to_storage(file)
if not name:
msg = "Failed save replay `{}`: {}".format(session_id, err)
logger.error(msg)
return Response({'msg': str(err)}, status=400)
url = default_storage.url(name)
return Response({'url': url}, status=201)
else:
logger.error(
'Update load data invalid: {}'.format(serializer.errors))
msg = 'Upload data invalid: {}'.format(serializer.errors)
logger.error(msg)
return Response({'msg': serializer.errors}, status=401)
def retrieve(self, request, *args, **kwargs):
session_id = kwargs.get('pk')
self.session = get_object_or_404(Session, id=session_id)
path = self.gen_session_path()
if default_storage.exists(path):
url = default_storage.url(path)
return redirect(url)
else:
config = settings.TERMINAL_REPLAY_STORAGE
configs = copy.deepcopy(config)
for cfg in config:
if config[cfg]['TYPE'] == 'server':
configs.__delitem__(cfg)
if not configs:
return HttpResponseNotFound()
date = self.session.date_start.strftime('%Y-%m-%d')
file_path = os.path.join(date, str(self.session.id) + '.replay.gz')
target_path = default_storage.base_location + '/' + path
storage = jms_storage.get_multi_object_storage(configs)
ok, err = storage.download(file_path, target_path)
if ok:
return redirect(default_storage.url(path))
else:
logger.error("Failed download replay file: {}".format(err))
return HttpResponseNotFound()
# 新版本和老版本的文件后缀不同
session_path = self.get_session_path() # 存在外部存储上的路径
local_path = self.get_local_path()
local_path_v1 = self.get_local_path(version=1)
# 去default storage中查找
for _local_path in (local_path, local_path_v1, session_path):
if default_storage.exists(_local_path):
url = default_storage.url(_local_path)
return redirect(url)
# 去定义的外部storage查找
configs = settings.TERMINAL_REPLAY_STORAGE
configs = {k: v for k, v in configs.items() if v['TYPE'] != 'server'}
if not configs:
return HttpResponseNotFound()
target_path = os.path.join(default_storage.base_location, local_path) # 保存到storage的路径
target_dir = os.path.dirname(target_path)
if not os.path.isdir(target_dir):
os.makedirs(target_dir, exist_ok=True)
storage = jms_storage.get_multi_object_storage(configs)
ok, err = storage.download(session_path, target_path)
if not ok:
logger.error("Failed download replay file: {}".format(err))
return HttpResponseNotFound()
return redirect(default_storage.url(local_path))
class SessionReplayV2ViewSet(SessionReplayViewSet):
......
......@@ -73,6 +73,7 @@
<th class="text-center">{% trans 'System user' %}</th>
<th class="text-center">{% trans 'Remote addr' %}</th>
<th class="text-center">{% trans 'Protocol' %}</th>
<th class="text-center">{% trans 'Login from' %}</th>
<th class="text-center">{% trans 'Command' %}</th>
<th class="text-center">{% trans 'Date start' %}</th>
{# <th class="text-center">{% trans 'Date last active' %}</th>#}
......@@ -92,6 +93,7 @@
<td class="text-center">{{ session.system_user }}</td>
<td class="text-center">{{ session.remote_addr|default:"" }}</td>
<td class="text-center">{{ session.protocol }}</td>
<td class="text-center">{{ session.get_login_from_display }}</td>
<td class="text-center">{{ session.id | get_session_command_amount }}</td>
<td class="text-center">{{ session.date_start }}</td>
......
......@@ -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
......@@ -93,6 +95,22 @@ class UserUpdatePKApi(generics.UpdateAPIView):
user.save()
class UserUnblockPKApi(generics.UpdateAPIView):
queryset = User.objects.all()
permission_classes = (IsSuperUser,)
serializer_class = UserSerializer
key_prefix_limit = "_LOGIN_LIMIT_{}_{}"
key_prefix_block = "_LOGIN_BLOCK_{}"
def perform_update(self, serializer):
user = self.get_object()
username = user.username if user else ''
key_limit = self.key_prefix_limit.format(username, '*')
key_block = self.key_prefix_block.format(username)
cache.delete_pattern(key_limit)
cache.delete(key_block)
class UserGroupViewSet(IDInFilterMixin, BulkModelViewSet):
queryset = UserGroup.objects.all()
serializer_class = UserGroupSerializer
......@@ -128,16 +146,12 @@ class UserToken(APIView):
return Response({'error': msg}, status=406)
class UserProfile(APIView):
permission_classes = (IsValidUser,)
class UserProfile(generics.RetrieveAPIView):
permission_classes = (IsAuthenticated,)
serializer_class = UserSerializer
def get(self, request):
# return Response(request.user.to_json())
return Response(self.serializer_class(request.user).data)
def post(self, request):
return Response(self.serializer_class(request.user).data)
def get_object(self):
return self.request.user
class UserOtpAuthApi(APIView):
......@@ -153,10 +167,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,
......@@ -165,7 +192,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', '')
......@@ -173,25 +200,54 @@ 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_{}_{}"
key_prefix_block = "_LOGIN_BLOCK_{}"
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(username, ip)
key_block = self.key_prefix_block.format(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, key_block)
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,
......@@ -208,7 +264,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):
......@@ -222,7 +279,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', '')
......@@ -230,10 +287,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):
......
......@@ -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:
......
......@@ -45,13 +45,17 @@
</div>
<form class="m-t" role="form" method="post" action="">
{% csrf_token %}
{% if form.errors %}
{% if block_login %}
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p>
{% elif form.errors %}
{% if 'captcha' in form.errors %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
{% else %}
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
{% endif %}
{% endif %}
<div class="form-group">
<input type="text" class="form-control" name="{{ form.username.html_name }}" placeholder="{% trans 'Username' %}" required="" value="{% if form.username.value %}{{ form.username.value }}{% endif %}">
</div>
......
......@@ -51,6 +51,9 @@
<th class="text-center">{% trans 'UA' %}</th>
<th class="text-center">{% trans 'IP' %}</th>
<th class="text-center">{% trans 'City' %}</th>
<th class="text-center">{% trans 'MFA' %}</th>
<th class="text-center">{% trans 'Reason' %}</th>
<th class="text-center">{% trans 'Status' %}</th>
<th class="text-center">{% trans 'Date' %}</th>
{% endblock %}
......@@ -65,6 +68,9 @@
</td>
<td class="text-center">{{ login_log.ip }}</td>
<td class="text-center">{{ login_log.city }}</td>
<td class="text-center">{{ login_log.get_mfa_display }}</td>
<td class="text-center">{{ login_log.get_reason_display }}</td>
<td class="text-center">{{ login_log.get_status_display }}</td>
<td class="text-center">{{ login_log.datetime }}</td>
</tr>
{% endfor %}
......
......@@ -182,6 +182,14 @@
</span>
</td>
</tr>
<tr style="{% if not unblock %}display:none{% endif %}">
<td>{% trans 'Unblock user' %}</td>
<td>
<span class="pull-right">
<button type="button" class="btn btn-primary btn-xs" id="btn-unblock-user" style="width: 54px">{% trans 'Unblock' %}</button>
</span>
</td>
</tr>
</tbody>
</table>
</div>
......@@ -275,7 +283,7 @@ $(document).ready(function() {
.on('select2:unselect', function(evt) {
var data = evt.params.data;
delete jumpserver.nodes_selected[data.id];
})
});
})
.on('click', '#is_active', function() {
var the_url = "{% url 'api-users:user-detail' pk=user_object.id %}";
......@@ -293,7 +301,7 @@ $(document).ready(function() {
.on('click', '#force_enable_otp', function() {
{% if request.user == user_object %}
toastr.error("{% trans 'Goto profile page enable MFA' %}");
return
return;
{% endif %}
var the_url = "{% url 'api-users:user-detail' pk=user_object.id %}";
......@@ -421,11 +429,45 @@ $(document).ready(function() {
APIUpdateAttr({ url: the_url, body: JSON.stringify(body), success: success, error: fail});
}).on('click', '.btn-delete-user', function () {
var $this = $(this);
var name = "{{ user.name }}";
var uid = "{{ user.id }}";
var name = "{{ user_object.name }}";
var uid = "{{ user_object.id }}";
var the_url = '{% url "api-users:user-detail" pk=DEFAULT_PK %}'.replace('{{ DEFAULT_PK }}', uid);
var redirect_url = "{% url 'users:user-list' %}";
objectDelete($this, name, the_url, redirect_url);
}).on('click', '#btn-unblock-user', function () {
function doReset() {
{#var the_url = '{% url "api-users:user-reset-password" pk=user_object.id %}';#}
var the_url = '{% url "api-users:user-unblock" pk=user_object.id %}';
var body = {};
var success = function() {
var msg = "{% trans "Success" %}";
{#swal("{% trans 'Unblock user' %}", msg, "success");#}
swal({
title: "{% trans 'Unblock user' %}",
text: msg,
type: "success"
}, function() {
location.reload()
}
);
};
APIUpdateAttr({
url: the_url,
body: JSON.stringify(body),
success: success
});
}
swal({
title: "{% trans 'Are you sure?' %}",
text: "{% trans "After unlocking the user, the user can log in normally."%}",
type: "warning",
showCancelButton: true,
confirmButtonColor: "#DD6B55",
confirmButtonText: "{% trans 'Confirm' %}",
closeOnConfirm: false
}, function() {
doReset();
});
})
</script>
{% endblock %}
......@@ -59,7 +59,7 @@ function initTable() {
ele: $('#user_list_table'),
columnDefs: [
{targets: 1, createdCell: function (td, cellData, rowData) {
var detail_btn = '<a href="{% url "users:user-detail" pk=DEFAULT_PK %}">' + cellData + '</a>';
var detail_btn = '<a href="{% url "users:user-detail" pk=DEFAULT_PK %}">' + escape(cellData) + '</a>';
$(td).html(detail_btn.replace("{{ DEFAULT_PK }}", rowData.id));
}},
{targets: 4, createdCell: function (td, cellData) {
......
......@@ -29,6 +29,8 @@ urlpatterns = [
api.UserResetPKApi.as_view(), name='user-public-key-reset'),
url(r'^v1/users/(?P<pk>[0-9a-zA-Z\-]{36})/pubkey/update/$',
api.UserUpdatePKApi.as_view(), name='user-public-key-update'),
url(r'^v1/users/(?P<pk>[0-9a-zA-Z\-]{36})/unblock/$',
api.UserUnblockPKApi.as_view(), name='user-unblock'),
url(r'^v1/users/(?P<pk>[0-9a-zA-Z\-]{36})/groups/$',
api.UserUpdateGroupApi.as_view(), name='user-update-group'),
url(r'^v1/groups/(?P<pk>[0-9a-zA-Z\-]{36})/users/$',
......
......@@ -8,13 +8,13 @@ app_name = 'users'
urlpatterns = [
# Login view
url(r'^login$', views.UserLoginView.as_view(), name='login'),
url(r'^logout$', views.UserLogoutView.as_view(), name='logout'),
url(r'^login/otp$', views.UserLoginOtpView.as_view(), name='login-otp'),
url(r'^password/forgot$', views.UserForgotPasswordView.as_view(), name='forgot-password'),
url(r'^password/forgot/sendmail-success$', views.UserForgotPasswordSendmailSuccessView.as_view(), name='forgot-password-sendmail-success'),
url(r'^password/reset$', views.UserResetPasswordView.as_view(), name='reset-password'),
url(r'^password/reset/success$', views.UserResetPasswordSuccessView.as_view(), name='reset-password-success'),
url(r'^login/$', views.UserLoginView.as_view(), name='login'),
url(r'^logout/$', views.UserLogoutView.as_view(), name='logout'),
url(r'^login/otp/$', views.UserLoginOtpView.as_view(), name='login-otp'),
url(r'^password/forgot/$', views.UserForgotPasswordView.as_view(), name='forgot-password'),
url(r'^password/forgot/sendmail-success/$', views.UserForgotPasswordSendmailSuccessView.as_view(), name='forgot-password-sendmail-success'),
url(r'^password/reset/$', views.UserResetPasswordView.as_view(), name='reset-password'),
url(r'^password/reset/success/$', views.UserResetPasswordSuccessView.as_view(), name='reset-password-success'),
# Profile
url(r'^profile/$', views.UserProfileView.as_view(), name='user-profile'),
......@@ -29,23 +29,23 @@ urlpatterns = [
url(r'^profile/otp/settings-success/$', views.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'),
# User view
url(r'^user$', views.UserListView.as_view(), name='user-list'),
url(r'^user/export/', views.UserExportView.as_view(), name='user-export'),
url(r'^user/$', views.UserListView.as_view(), name='user-list'),
url(r'^user/export/$', views.UserExportView.as_view(), name='user-export'),
url(r'^first-login/$', views.UserFirstLoginView.as_view(), name='user-first-login'),
url(r'^user/import/$', views.UserBulkImportView.as_view(), name='user-import'),
url(r'^user/create$', views.UserCreateView.as_view(), name='user-create'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/update$', views.UserUpdateView.as_view(), name='user-update'),
url(r'^user/update$', views.UserBulkUpdateView.as_view(), name='user-bulk-update'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})$', views.UserDetailView.as_view(), name='user-detail'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/assets', views.UserGrantedAssetView.as_view(), name='user-granted-asset'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/login-history', views.UserDetailView.as_view(), name='user-login-history'),
url(r'^user/create/$', views.UserCreateView.as_view(), name='user-create'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/update/$', views.UserUpdateView.as_view(), name='user-update'),
url(r'^user/update/$', views.UserBulkUpdateView.as_view(), name='user-bulk-update'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/$', views.UserDetailView.as_view(), name='user-detail'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/assets/$', views.UserGrantedAssetView.as_view(), name='user-granted-asset'),
url(r'^user/(?P<pk>[0-9a-zA-Z\-]{36})/login-history/$', views.UserDetailView.as_view(), name='user-login-history'),
# User group view
url(r'^user-group$', views.UserGroupListView.as_view(), name='user-group-list'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})$', views.UserGroupDetailView.as_view(), name='user-group-detail'),
url(r'^user-group/create$', views.UserGroupCreateView.as_view(), name='user-group-create'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})/update$', views.UserGroupUpdateView.as_view(), name='user-group-update'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})/assets', views.UserGroupGrantedAssetView.as_view(), name='user-group-granted-asset'),
url(r'^user-group/$', views.UserGroupListView.as_view(), name='user-group-list'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})/$', views.UserGroupDetailView.as_view(), name='user-group-detail'),
url(r'^user-group/create/$', views.UserGroupCreateView.as_view(), name='user-group-create'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})/update/$', views.UserGroupUpdateView.as_view(), name='user-group-update'),
url(r'^user-group/(?P<pk>[0-9a-zA-Z\-]{36})/assets/$', views.UserGroupGrantedAssetView.as_view(), name='user-group-granted-asset'),
# Login log
url(r'^login-log/$', views.LoginLogListView.as_view(), name='login-log-list'),
......
......@@ -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,44 @@ 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, key_block):
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
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 >= limit_count:
cache.set(key_block, 1, int(limit_time)*60)
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
def is_need_unblock(key_block):
if not cache.get(key_block):
return False
return True
......@@ -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,9 @@ 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_{}_{}"
key_prefix_block = "_LOGIN_BLOCK_{}"
def get(self, request, *args, **kwargs):
if request.user.is_staff:
......@@ -57,6 +61,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(username, ip)
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 +79,24 @@ 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(username, ip)
key_block = self.key_prefix_block.format(username)
set_user_login_failed_count_to_cache(key_limit, key_block)
# 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 +104,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 +121,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 +137,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 +161,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')
......
......@@ -36,7 +36,9 @@ from common.utils import get_logger, get_object_or_none, is_uuid, ssh_key_gen
from common.models import Setting
from .. import forms
from ..models import User, UserGroup
from ..utils import AdminUserRequiredMixin, generate_otp_uri, check_otp_code, get_user_or_tmp_user, get_password_check_rules, check_password_rules
from ..utils import AdminUserRequiredMixin, generate_otp_uri, check_otp_code, \
get_user_or_tmp_user, get_password_check_rules, check_password_rules, \
is_need_unblock
from ..signals import post_user_create
from ..tasks import write_login_log_async
......@@ -168,13 +170,17 @@ class UserDetailView(AdminUserRequiredMixin, DetailView):
model = User
template_name = 'users/user_detail.html'
context_object_name = "user_object"
key_prefix_block = "_LOGIN_BLOCK_{}"
def get_context_data(self, **kwargs):
user = self.get_object()
key_block = self.key_prefix_block.format(user.username)
groups = UserGroup.objects.exclude(id__in=self.object.groups.all())
context = {
'app': _('Users'),
'action': _('User detail'),
'groups': groups
'groups': groups,
'unblock': is_need_unblock(key_block),
}
kwargs.update(context)
return super().get_context_data(**kwargs)
......
......@@ -21,10 +21,10 @@ class Config:
ALLOWED_HOSTS = ['*']
# Development env open this, when error occur display the full process track, Production disable it
DEBUG = True
DEBUG = os.environ.get("DEBUG") or True
# DEBUG, INFO, WARNING, ERROR, CRITICAL can set. See https://docs.djangoproject.com/en/1.10/topics/logging/
LOG_LEVEL = 'DEBUG'
LOG_LEVEL = os.environ.get("LOG_LEVEL") or 'DEBUG'
LOG_DIR = os.path.join(BASE_DIR, 'logs')
# Database setting, Support sqlite3, mysql, postgres ....
......@@ -35,12 +35,12 @@ class Config:
DB_NAME = os.path.join(BASE_DIR, 'data', 'db.sqlite3')
# MySQL or postgres setting like:
# DB_ENGINE = 'mysql'
# DB_HOST = '127.0.0.1'
# DB_PORT = 3306
# DB_USER = 'root'
# DB_PASSWORD = ''
# DB_NAME = 'jumpserver'
# DB_ENGINE = os.environ.get("DB_ENGINE") or 'mysql'
# DB_HOST = os.environ.get("DB_HOST") or '127.0.0.1'
# DB_PORT = os.environ.get("DB_PORT") or 3306
# DB_USER = os.environ.get("DB_USER") or 'jumpserver'
# DB_PASSWORD = os.environ.get("DB_PASSWORD") or 'weakPassword'
# DB_NAME = os.environ.get("DB_NAME") or 'jumpserver'
# When Django start it will bind this host and port
# ./manage.py runserver 127.0.0.1:8080
......@@ -48,9 +48,11 @@ class Config:
HTTP_LISTEN_PORT = 8080
# Use Redis as broker for celery and web socket
REDIS_HOST = '127.0.0.1'
REDIS_PORT = 6379
REDIS_PASSWORD = ''
REDIS_HOST = os.environ.get("REDIS_HOST") or '127.0.0.1'
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD") or ''
REDIS_DB_CELERY = os.environ.get('REDIS_DB') or 3
REDIS_DB_CACHE = os.environ.get('REDIS_DB') or 4
def __init__(self):
pass
......
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
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
......@@ -61,7 +61,7 @@ pytz==2018.3
PyYAML==3.12
redis==2.10.6
requests==2.18.4
jms-storage==0.0.17
jms-storage==0.0.18
s3transfer==0.1.13
simplejson==3.13.2
six==1.11.0
......
......@@ -4,3 +4,5 @@
python3 ../apps/manage.py makemigrations
python3 ../apps/manage.py migrate
python3 ../apps/manage.py makemigrations --merge
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