Merge pull request #3488 from jumpserver/1.5.5

1.5.5
pull/3491/head
BaiJiangJie 2019-12-04 12:33:03 +08:00 committed by GitHub
commit b3e2b30e71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 3533 additions and 1265 deletions

View File

@ -1,10 +1,8 @@
{% extends '_base_list.html' %} {% extends '_base_list.html' %}
{% load i18n static %} {% load i18n static %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message">
{% trans 'Before using this feature, make sure that the application loader has been uploaded to the application server and successfully published as a RemoteApp application' %} {% trans 'Before using this feature, make sure that the application loader has been uploaded to the application server and successfully published as a RemoteApp application' %}
<b><a href="https://github.com/jumpserver/Jmservisor/releases" target="view_window" >{% trans 'Download application loader' %}</a></b> <b><a href="https://github.com/jumpserver/Jmservisor/releases" target="view_window" >{% trans 'Download application loader' %}</a></b>
</div>
{% endblock %} {% endblock %}
{% block table_search %}{% endblock %} {% block table_search %}{% endblock %}
{% block table_container %} {% block table_container %}

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.5 on 2019-11-14 03:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0042_favoriteasset'),
]
operations = [
migrations.AddField(
model_name='gathereduser',
name='date_last_login',
field=models.DateTimeField(null=True, verbose_name='Date last login'),
),
migrations.AddField(
model_name='gathereduser',
name='ip_last_login',
field=models.CharField(default='', max_length=39, verbose_name='IP last login'),
),
]

View File

@ -12,13 +12,12 @@ __all__ = ['GatheredUser']
class GatheredUser(OrgModelMixin): class GatheredUser(OrgModelMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True) id = models.UUIDField(default=uuid.uuid4, primary_key=True)
asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_("Asset")) asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_("Asset"))
username = models.CharField(max_length=32, blank=True, db_index=True, username = models.CharField(max_length=32, blank=True, db_index=True, verbose_name=_('Username'))
verbose_name=_('Username'))
present = models.BooleanField(default=True, verbose_name=_("Present")) present = models.BooleanField(default=True, verbose_name=_("Present"))
date_created = models.DateTimeField(auto_now_add=True, date_last_login = models.DateTimeField(null=True, verbose_name=_("Date last login"))
verbose_name=_("Date created")) ip_last_login = models.CharField(max_length=39, default='', verbose_name=_("IP last login"))
date_updated = models.DateTimeField(auto_now=True, date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created"))
verbose_name=_("Date updated")) date_updated = models.DateTimeField(auto_now=True, verbose_name=_("Date updated"))
@property @property
def hostname(self): def hostname(self):

View File

@ -12,6 +12,7 @@ class GatheredUserSerializer(OrgResourceModelSerializerMixin):
model = GatheredUser model = GatheredUser
fields = [ fields = [
'id', 'asset', 'hostname', 'ip', 'username', 'id', 'asset', 'hostname', 'ip', 'username',
'date_last_login', 'ip_last_login',
'present', 'date_created', 'date_updated' 'present', 'date_created', 'date_updated'
] ]
read_only_fields = fields read_only_fields = fields

View File

@ -94,6 +94,13 @@ GATHER_ASSET_USERS_TASKS = [
"args": "database=passwd" "args": "database=passwd"
}, },
}, },
{
"name": "get last login",
"action": {
"module": "shell",
"args": "users=$(getent passwd | grep -v 'nologin' | grep -v 'shudown' | awk -F: '{ print $1 }');for i in $users;do last -F $i -1 | head -1 | grep -v '^$' | awk '{ print $1\"@\"$3\"@\"$5,$6,$7,$8 }';done"
}
}
] ]
GATHER_ASSET_USERS_TASKS_WINDOWS = [ GATHER_ASSET_USERS_TASKS_WINDOWS = [

View File

@ -2,9 +2,10 @@
import re import re
from collections import defaultdict from collections import defaultdict
from celery import shared_task
from celery import shared_task
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils import timezone
from orgs.utils import tmp_to_org from orgs.utils import tmp_to_org
from common.utils import get_logger from common.utils import get_logger
@ -19,19 +20,25 @@ ignore_login_shell = re.compile(r'nologin$|sync$|shutdown$|halt$')
def parse_linux_result_to_users(result): def parse_linux_result_to_users(result):
task_result = {} users = defaultdict(dict)
for task_name, raw in result.items(): users_result = result.get('gather host users', {})\
res = raw.get('ansible_facts', {}).get('getent_passwd') .get('ansible_facts', {})\
if res: .get('getent_passwd')
task_result = res if not isinstance(users_result, dict):
break users_result = {}
if not task_result or not isinstance(task_result, dict): for username, attr in users_result.items():
return []
users = []
for username, attr in task_result.items():
if ignore_login_shell.search(attr[-1]): if ignore_login_shell.search(attr[-1]):
continue continue
users.append(username) users[username] = {}
last_login_result = result.get('get last login', {}).get('stdout_lines', [])
for line in last_login_result:
data = line.split('@')
if len(data) != 3:
continue
username, ip, dt = data
dt += ' +0800'
date = timezone.datetime.strptime(dt, '%b %d %H:%M:%S %Y %z')
users[username] = {"ip": ip, "date": date}
return users return users
@ -45,7 +52,7 @@ def parse_windows_result_to_users(result):
if not task_result: if not task_result:
return [] return []
users = [] users = {}
for i in range(4): for i in range(4):
task_result.pop(0) task_result.pop(0)
@ -55,7 +62,7 @@ def parse_windows_result_to_users(result):
for line in task_result: for line in task_result:
user = space.split(line) user = space.split(line)
if user[0]: if user[0]:
users.append(user[0]) users[user[0]] = {}
return users return users
@ -82,8 +89,12 @@ def add_asset_users(assets, results):
with tmp_to_org(asset.org_id): with tmp_to_org(asset.org_id):
GatheredUser.objects.filter(asset=asset, present=True)\ GatheredUser.objects.filter(asset=asset, present=True)\
.update(present=False) .update(present=False)
for username in users: for username, data in users.items():
defaults = {'asset': asset, 'username': username, 'present': True} defaults = {'asset': asset, 'username': username, 'present': True}
if data.get("ip"):
defaults["ip_last_login"] = data["ip"]
if data.get("date"):
defaults["date_last_login"] = data["date"]
GatheredUser.objects.update_or_create( GatheredUser.objects.update_or_create(
defaults=defaults, asset=asset, username=username, defaults=defaults, asset=asset, username=username,
) )

View File

@ -31,7 +31,7 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-9 col-lg-9 col-sm-offset-2"> <div class="col-sm-9 col-lg-9 col-sm-offset-2">
<div class="checkbox checkbox-success"> <div class="checkbox checkbox-success">
<input type="checkbox" name="enable_otp" checked id="id_enable_otp"><label for="id_enable_otp">{% trans 'Enable-MFA' %}</label> <input type="checkbox" name="enable_mfa" checked id="id_enable_mfa"><label for="id_enable_mfa">{% trans 'Enable-MFA' %}</label>
</div> </div>
</div> </div>
</div> </div>

View File

@ -135,7 +135,8 @@ function initAssetModalTable() {
], ],
lengthMenu: [[10, 25, 50], [10, 25, 50]], lengthMenu: [[10, 25, 50], [10, 25, 50]],
pageLength: 10, pageLength: 10,
select_style: assetModalOption.selectStyle select_style: assetModalOption.selectStyle,
paging_numbers_length: 3
}; };
assetModalTable = jumpserver.initServerSideDataTable(options); assetModalTable = jumpserver.initServerSideDataTable(options);
if (assetModalOption.onModalTableDone) { if (assetModalOption.onModalTableDone) {

View File

@ -303,9 +303,24 @@ function defaultCallback(action) {
return logging return logging
} }
function toggle() {
if (show === 0) {
$("#split-left").hide(500, function () {
$("#split-right").attr("class", "col-lg-12");
$("#toggle-icon").attr("class", "fa fa-angle-right fa-x");
show = 1;
});
} else {
$("#split-right").attr("class", "col-lg-9");
$("#toggle-icon").attr("class", "fa fa-angle-left fa-x");
$("#split-left").show(500);
show = 0;
}
}
$(document).ready(function () { $(document).ready(function () {
$('.treebox').css('height', window.innerHeight - 180); $('.treebox').css('height', window.innerHeight - 60);
}) })
.on('click', '.btn-show-current-asset', function(){ .on('click', '.btn-show-current-asset', function(){
hideRMenu(); hideRMenu();
@ -320,6 +335,9 @@ $(document).ready(function () {
$('#show_current_asset').css('display', 'inline-block'); $('#show_current_asset').css('display', 'inline-block');
setCookie('show_current_asset', ''); setCookie('show_current_asset', '');
location.reload(); location.reload();
}).on('click', '.tree-toggle-btn', function (e) {
e.preventDefault();
toggle();
}) })
</script> </script>

View File

@ -1,10 +1,8 @@
{% extends '_base_list.html' %} {% extends '_base_list.html' %}
{% load i18n static %} {% load i18n static %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message">
{% trans 'Admin users are asset (charged server) on the root, or have NOPASSWD: ALL sudo permissions users, '%} {% trans 'Admin users are asset (charged server) on the root, or have NOPASSWD: ALL sudo permissions users, '%}
{% trans 'Jumpserver users of the system using the user to `push system user`, `get assets hardware information`, etc. '%} {% trans 'Jumpserver users of the system using the user to `push system user`, `get assets hardware information`, etc. '%}
</div>
{% endblock %} {% endblock %}
{% block table_search %} {% block table_search %}
<div class="" style="float: right"> <div class="" style="float: right">

View File

@ -3,16 +3,16 @@
{% load i18n %} {% load i18n %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message"> {# <div class="alert alert-info help-message">#}
{# <button aria-hidden="true" data-dismiss="alert" class="close" type="button">×</button>#}
{# 左侧是资产树,右击可以新建、删除、更改树节点,授权资产也是以节点方式组织的,右侧是属于该节点下的资产#} {# 左侧是资产树,右击可以新建、删除、更改树节点,授权资产也是以节点方式组织的,右侧是属于该节点下的资产#}
{% trans 'The left side is the asset tree, right click to create, delete, and change the tree node, authorization asset is also organized as a node, and the right side is the asset under that node' %} {% trans 'The left side is the asset tree, right click to create, delete, and change the tree node, authorization asset is also organized as a node, and the right side is the asset under that node' %}
</div> {# </div>#}
{% endblock %} {% endblock %}
{% block custom_head_css_js %} {% block custom_head_css_js %}
<link href="{% static 'css/plugins/ztree/awesomeStyle/awesome.css' %}" rel="stylesheet"> {# <link href="{% static 'css/plugins/ztree/awesomeStyle/awesome.css' %}" rel="stylesheet">#}
{# <link href="https://cdn.datatables.net/1.10.19/css/jquery.dataTables.min.css" rel="stylesheet">#} {# <script type="text/javascript" src="{% static 'js/plugins/ztree/jquery.ztree.all.min.js' %}"></script>#}
<script type="text/javascript" src="{% static 'js/plugins/ztree/jquery.ztree.all.min.js' %}"></script>
<script src="{% static 'js/jquery.form.min.js' %}"></script> <script src="{% static 'js/jquery.form.min.js' %}"></script>
<style type="text/css"> <style type="text/css">
div#rMenu { div#rMenu {
@ -48,12 +48,12 @@
{% block content %} {% block content %}
<div class="wrapper wrapper-content"> <div class="wrapper wrapper-content">
<div class="row"> <div class="row">
<div class="col-lg-3" id="split-left" style="padding-left: 3px"> <div class="col-lg-3" id="split-left" style="padding-left: 3px;padding-right: 0">
{% include 'assets/_node_tree.html' %} {% include 'assets/_node_tree.html' %}
</div> </div>
<div class="col-lg-9 animated fadeInRight" id="split-right"> <div class="col-lg-9 animated fadeInRight" id="split-right">
<div class="tree-toggle"> <div class="tree-toggle" style="z-index: 9999">
<div class="btn btn-sm btn-primary tree-toggle-btn" onclick="toggle()"> <div class="btn btn-sm btn-primary tree-toggle-btn">
<i class="fa fa-angle-left fa-x" id="toggle-icon"></i> <i class="fa fa-angle-left fa-x" id="toggle-icon"></i>
</div> </div>
</div> </div>
@ -151,9 +151,9 @@ function initTable() {
}}, }},
{targets: 4, createdCell: function (td, cellData, rowData) { {targets: 4, createdCell: function (td, cellData, rowData) {
var innerHtml = ""; var innerHtml = "";
if (cellData.status == 1) { if (cellData.status === 1) {
innerHtml = '<i class="fa fa-circle text-navy"></i>' innerHtml = '<i class="fa fa-circle text-navy"></i>'
} else if (cellData.status == 0) { } else if (cellData.status === 0) {
innerHtml = '<i class="fa fa-circle text-danger"></i>' innerHtml = '<i class="fa fa-circle text-danger"></i>'
} else { } else {
innerHtml = '<i class="fa fa-circle text-warning"></i>' innerHtml = '<i class="fa fa-circle text-warning"></i>'
@ -386,6 +386,10 @@ $(document).ready(function(){
setTimeout( function () {window.location.reload();}, 300); setTimeout( function () {window.location.reload();}, 300);
} }
function reloadTable() {
asset_table.ajax.reload();
}
function doDeactive() { function doDeactive() {
var data = []; var data = [];
$.each(id_list, function(index, object_id) { $.each(id_list, function(index, object_id) {
@ -396,7 +400,7 @@ $(document).ready(function(){
url: the_url, url: the_url,
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
success: refreshPage success: reloadTable
}); });
} }
function doActive() { function doActive() {
@ -409,7 +413,7 @@ $(document).ready(function(){
url: the_url, url: the_url,
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
success: refreshPage success: reloadTable
}); });
} }
function doDelete() { function doDelete() {
@ -431,7 +435,7 @@ $(document).ready(function(){
success: function () { success: function () {
var msg = "{% trans 'Asset Deleted.' %}"; var msg = "{% trans 'Asset Deleted.' %}";
swal("{% trans 'Asset Delete' %}", msg, "success"); swal("{% trans 'Asset Delete' %}", msg, "success");
refreshPage(); reloadTable();
}, },
flash_message: false, flash_message: false,
}); });
@ -478,16 +482,12 @@ $(document).ready(function(){
'assets': id_list 'assets': id_list
}; };
var success = function () {
asset_table.ajax.reload()
};
var url = "{% url 'api-assets:node-remove-assets' pk=DEFAULT_PK %}".replace("{{ DEFAULT_PK }}", current_node_id); var url = "{% url 'api-assets:node-remove-assets' pk=DEFAULT_PK %}".replace("{{ DEFAULT_PK }}", current_node_id);
requestApi({ requestApi({
'url': url, 'url': url,
'method': 'PUT', 'method': 'PUT',
'body': JSON.stringify(data), 'body': JSON.stringify(data),
'success': success 'success': reloadTable
}) })
} }

View File

@ -2,14 +2,12 @@
{% load i18n static %} {% load i18n static %}
{% block table_search %}{% endblock %} {% block table_search %}{% endblock %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message">
{% trans 'System user bound some command filter, each command filter has some rules,'%} {% trans 'System user bound some command filter, each command filter has some rules,'%}
{% trans 'When user login asset with this system user, then run a command,' %} {% trans 'When user login asset with this system user, then run a command,' %}
{% trans 'The command will be filter by rules, higher priority rule run first,' %} {% trans 'The command will be filter by rules, higher priority rule run first,' %}
{% trans 'When a rule matched, if rule action is allow, then allow command execute,' %} {% trans 'When a rule matched, if rule action is allow, then allow command execute,' %}
{% trans 'else if action is deny, then command with be deny,' %} {% trans 'else if action is deny, then command with be deny,' %}
{% trans 'else match next rule, if none matched, allowed' %} {% trans 'else match next rule, if none matched, allowed' %}
</div>
{% endblock %} {% endblock %}
{% block table_container %} {% block table_container %}
<div class="uc pull-left m-r-5"> <div class="uc pull-left m-r-5">

View File

@ -3,13 +3,9 @@
{% block table_search %}{% endblock %} {% block table_search %}{% endblock %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message">
{# 网域功能是为了解决部分环境(如:混合云)无法直接连接而新增的功能,原理是通过网关服务器进行跳转登录。<br>#}
{# JMS => 网域网关 => 目标资产#}
{% trans 'The domain function is added to address the fact that some environments (such as the hybrid cloud) cannot be connected directly by jumping on the gateway server.' %} {% trans 'The domain function is added to address the fact that some environments (such as the hybrid cloud) cannot be connected directly by jumping on the gateway server.' %}
<br> <br>
{% trans 'JMS => Domain gateway => Target assets' %} {% trans 'JMS => Domain gateway => Target assets' %}
</div>
{% endblock %} {% endblock %}
{% block table_container %} {% block table_container %}

View File

@ -2,11 +2,9 @@
{% load i18n %} {% load i18n %}
{% block help_message %} {% block help_message %}
<div class="alert alert-info help-message">
{% trans 'System user is Jumpserver jump login assets used by the users, can be understood as the user login assets, such as web, sa, the dba (` ssh web@some-host `), rather than using a user the username login server jump (` ssh xiaoming@some-host `); '%} {% trans 'System user is Jumpserver jump login assets used by the users, can be understood as the user login assets, such as web, sa, the dba (` ssh web@some-host `), rather than using a user the username login server jump (` ssh xiaoming@some-host `); '%}
{% trans 'In simple terms, users log into Jumpserver using their own username, and Jumpserver uses system users to log into assets. '%} {% trans 'In simple terms, users log into Jumpserver using their own username, and Jumpserver uses system users to log into assets. '%}
{% trans 'When system users are created, if you choose auto push Jumpserver to use Ansible push system users into the asset, if the asset (Switch) does not support ansible, please manually fill in the account password.' %} {% trans 'When system users are created, if you choose auto push Jumpserver to use Ansible push system users into the asset, if the asset (Switch) does not support ansible, please manually fill in the account password.' %}
</div>
{% endblock %} {% endblock %}
{% block table_search %} {% block table_search %}

View File

@ -110,5 +110,14 @@ class UserLoginLog(models.Model):
login_logs = login_logs.filter(username__in=username_list) login_logs = login_logs.filter(username__in=username_list)
return login_logs return login_logs
@property
def reason_display(self):
from authentication.errors import reason_choices, old_reason_choices
reason = reason_choices.get(self.reason)
if reason:
return reason
reason = old_reason_choices.get(self.reason, self.reason)
return reason
class Meta: class Meta:
ordering = ['-datetime', 'username'] ordering = ['-datetime', 'username']

View File

@ -4,15 +4,18 @@
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.db import transaction from django.db import transaction
from django.utils import timezone
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from jumpserver.utils import current_request from jumpserver.utils import current_request
from common.utils import get_request_ip, get_logger, get_syslogger from common.utils import get_request_ip, get_logger, get_syslogger
from users.models import User from users.models import User
from authentication.signals import post_auth_failed, post_auth_success
from terminal.models import Session, Command from terminal.models import Session, Command
from terminal.backends.command.serializers import SessionCommandSerializer from terminal.backends.command.serializers import SessionCommandSerializer
from . import models from . import models, serializers
from . import serializers from .tasks import write_login_log_async
logger = get_logger(__name__) logger = get_logger(__name__)
sys_logger = get_syslogger("audits") sys_logger = get_syslogger("audits")
@ -99,3 +102,39 @@ def on_audits_log_create(sender, instance=None, **kwargs):
data = json_render.render(s.data).decode(errors='ignore') data = json_render.render(s.data).decode(errors='ignore')
msg = "{} - {}".format(category, data) msg = "{} - {}".format(category, data)
sys_logger.info(msg) sys_logger.info(msg)
def generate_data(username, request):
user_agent = request.META.get('HTTP_USER_AGENT', '')
if isinstance(request, Request):
login_ip = request.data.get('remote_addr', '0.0.0.0')
login_type = request.data.get('login_type', '')
else:
login_ip = get_request_ip(request) or '0.0.0.0'
login_type = 'W'
data = {
'username': username,
'ip': login_ip,
'type': login_type,
'user_agent': user_agent,
'datetime': timezone.now()
}
return data
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, **kwargs):
logger.debug('User login success: {}'.format(user.username))
data = generate_data(user.username, request)
data.update({'mfa': int(user.mfa_enabled), 'status': True})
write_login_log_async.delay(**data)
@receiver(post_auth_failed)
def on_user_auth_failed(sender, username, request, reason, **kwargs):
logger.debug('User login failed: {}'.format(username))
data = generate_data(username, request)
data.update({'reason': reason, 'status': False})
write_login_log_async.delay(**data)

View File

@ -7,6 +7,7 @@ from celery import shared_task
from ops.celery.decorator import register_as_period_task from ops.celery.decorator import register_as_period_task
from .models import UserLoginLog from .models import UserLoginLog
from .utils import write_login_log
@register_as_period_task(interval=3600*24) @register_as_period_task(interval=3600*24)
@ -19,3 +20,8 @@ def clean_login_log_period():
days = 90 days = 90
expired_day = now - datetime.timedelta(days=days) expired_day = now - datetime.timedelta(days=days)
UserLoginLog.objects.filter(datetime__lt=expired_day).delete() UserLoginLog.objects.filter(datetime__lt=expired_day).delete()
@shared_task
def write_login_log_async(*args, **kwargs):
write_login_log(*args, **kwargs)

View File

@ -78,7 +78,7 @@
<td class="text-center">{{ login_log.ip }}</td> <td class="text-center">{{ login_log.ip }}</td>
<td class="text-center">{{ login_log.city }}</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_mfa_display }}</td>
<td class="text-center">{% trans login_log.reason %}</td> <td class="text-center">{{ login_log.reason_display }}</td>
<td class="text-center">{{ login_log.get_status_display }}</td> <td class="text-center">{{ login_log.get_status_display }}</td>
<td class="text-center">{{ login_log.datetime }}</td> <td class="text-center">{{ login_log.datetime }}</td>
</tr> </tr>

View File

@ -1,6 +1,9 @@
import csv import csv
import codecs import codecs
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.translation import ugettext as _
from common.utils import validate_ip, get_ip_city
def get_excel_response(filename): def get_excel_response(filename):
@ -20,3 +23,16 @@ def write_content_to_excel(response, header=None, login_logs=None, fields=None):
data = [getattr(log, field.name) for field in fields] data = [getattr(log, field.name) for field in fields]
writer.writerow(data) writer.writerow(data)
return response return response
def write_login_log(*args, **kwargs):
from audits.models import UserLoginLog
default_city = _("Unknown")
ip = kwargs.get('ip') or ''
if not (ip and validate_ip(ip)):
ip = ip[:15]
city = default_city
else:
city = get_ip_city(ip) or default_city
kwargs.update({'ip': ip, 'city': city})
UserLoginLog.objects.create(**kwargs)

View File

@ -5,3 +5,4 @@ from .auth import *
from .token import * from .token import *
from .mfa import * from .mfa import *
from .access_key import * from .access_key import *
from .login_confirm import *

View File

@ -1,117 +1,25 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import uuid import uuid
import time
from django.core.cache import cache from django.core.cache import cache
from django.urls import reverse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.generics import CreateAPIView
from rest_framework.views import APIView from rest_framework.views import APIView
from common.utils import get_logger, get_request_ip from common.utils import get_logger
from common.permissions import IsOrgAdminOrAppUser, IsValidUser from common.permissions import IsOrgAdminOrAppUser
from orgs.mixins.api import RootOrgViewMixin from orgs.mixins.api import RootOrgViewMixin
from users.serializers import UserSerializer
from users.models import User from users.models import User
from assets.models import Asset, SystemUser from assets.models import Asset, SystemUser
from audits.models import UserLoginLog as LoginLog
from users.utils import (
check_otp_code, increase_login_failed_count,
is_block_login, clean_failed_count
)
from .. import const
from ..utils import check_user_valid
from ..serializers import OtpVerifySerializer
from ..signals import post_auth_success, post_auth_failed
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = [ __all__ = [
'UserAuthApi', 'UserConnectionTokenApi', 'UserOtpAuthApi', 'UserConnectionTokenApi',
'UserOtpVerifyApi',
] ]
class UserAuthApi(RootOrgViewMixin, APIView):
permission_classes = (AllowAny,)
serializer_class = UserSerializer
def get_serializer_context(self):
return {
'request': self.request,
'view': self
}
def get_serializer(self, *args, **kwargs):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def post(self, request):
# limit login
username = request.data.get('username')
ip = request.data.get('remote_addr', None)
ip = ip or get_request_ip(request)
if is_block_login(username, ip):
msg = _("Log in frequently and try again later")
logger.warn(msg + ': ' + username + ':' + ip)
return Response({'msg': msg}, status=401)
user, msg = self.check_user_valid(request)
if not user:
username = request.data.get('username', '')
self.send_auth_signal(success=False, username=username, reason=msg)
increase_login_failed_count(username, ip)
return Response({'msg': msg}, status=401)
if not user.otp_enabled:
self.send_auth_signal(success=True, user=user)
# 登陆成功,清除原来的缓存计数
clean_failed_count(username, ip)
token, expired_at = user.create_bearer_token(request)
return Response(
{'token': token, 'user': self.get_serializer(user).data}
)
seed = uuid.uuid4().hex
cache.set(seed, user, 300)
return Response(
{
'code': 101,
'msg': _('Please carry seed value and '
'conduct MFA secondary certification'),
'otp_url': reverse('api-auth:user-otp-auth'),
'seed': seed,
'user': self.get_serializer(user).data
}, status=300
)
@staticmethod
def check_user_valid(request):
username = request.data.get('username', '')
password = request.data.get('password', '')
public_key = request.data.get('public_key', '')
user, msg = check_user_valid(
username=username, password=password,
public_key=public_key
)
return user, msg
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
post_auth_success.send(sender=self.__class__, user=user, request=self.request)
else:
post_auth_failed.send(
sender=self.__class__, username=username,
request=self.request, reason=reason
)
class UserConnectionTokenApi(RootOrgViewMixin, APIView): class UserConnectionTokenApi(RootOrgViewMixin, APIView):
permission_classes = (IsOrgAdminOrAppUser,) permission_classes = (IsOrgAdminOrAppUser,)
@ -153,59 +61,5 @@ class UserConnectionTokenApi(RootOrgViewMixin, APIView):
return super().get_permissions() return super().get_permissions()
class UserOtpAuthApi(RootOrgViewMixin, APIView):
permission_classes = (AllowAny,)
serializer_class = UserSerializer
def get_serializer_context(self):
return {
'request': self.request,
'view': self
}
def get_serializer(self, *args, **kwargs):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def post(self, request):
otp_code = request.data.get('otp_code', '')
seed = request.data.get('seed', '')
user = cache.get(seed, None)
if not user:
return Response(
{'msg': _('Please verify the user name and password first')},
status=401
)
if not check_otp_code(user.otp_secret_key, otp_code):
self.send_auth_signal(success=False, username=user.username, reason=const.mfa_failed)
return Response({'msg': _('MFA certification failed')}, status=401)
self.send_auth_signal(success=True, user=user)
token, expired_at = user.create_bearer_token(request)
data = {'token': token, 'user': self.get_serializer(user).data}
return Response(data)
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
post_auth_success.send(sender=self.__class__, user=user, request=self.request)
else:
post_auth_failed.send(
sender=self.__class__, username=username,
request=self.request, reason=reason
)
class UserOtpVerifyApi(CreateAPIView):
permission_classes = (IsValidUser,)
serializer_class = OtpVerifySerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
if request.user.check_otp(code):
request.session["MFA_VERIFY_TIME"] = int(time.time())
return Response({"ok": "1"})
else:
return Response({"error": "Code not valid"}, status=400)

View File

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
#
from rest_framework.generics import UpdateAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _
from common.utils import get_logger, get_object_or_none
from common.permissions import IsOrgAdmin
from ..models import LoginConfirmSetting
from ..serializers import LoginConfirmSettingSerializer
from .. import errors, mixins
__all__ = ['LoginConfirmSettingUpdateApi', 'TicketStatusApi']
logger = get_logger(__name__)
class LoginConfirmSettingUpdateApi(UpdateAPIView):
permission_classes = (IsOrgAdmin,)
serializer_class = LoginConfirmSettingSerializer
def get_object(self):
from users.models import User
user_id = self.kwargs.get('user_id')
user = get_object_or_404(User, pk=user_id)
defaults = {'user': user}
s, created = LoginConfirmSetting.objects.get_or_create(
defaults, user=user,
)
return s
class TicketStatusApi(mixins.AuthMixin, APIView):
permission_classes = ()
def get_ticket(self):
from tickets.models import Ticket
ticket_id = self.request.session.get("auth_ticket_id")
logger.debug('Login confirm ticket id: {}'.format(ticket_id))
if not ticket_id:
ticket = None
else:
ticket = get_object_or_none(Ticket, pk=ticket_id)
return ticket
def get(self, request, *args, **kwargs):
try:
self.check_user_login_confirm()
return Response({"msg": "ok"})
except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200)
def delete(self, request, *args, **kwargs):
ticket = self.get_ticket()
if ticket:
request.session.pop('auth_ticket_id', '')
ticket.perform_status('closed', request.user)
return Response('', status=200)

View File

@ -1,11 +1,59 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import time
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.generics import CreateAPIView from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from common.permissions import IsValidUser
from ..serializers import OtpVerifySerializer
from .. import serializers from .. import serializers
from .. import errors
from ..mixins import AuthMixin
class MFAChallengeApi(CreateAPIView): __all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
class MFAChallengeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = serializers.MFAChallengeSerializer serializer_class = serializers.MFAChallengeSerializer
def perform_create(self, serializer):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
valid = user.check_mfa(code)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
username=user.username, request=self.request
)
else:
self.request.session['auth_mfa'] = '1'
except errors.AuthFailedError as e:
data = {"error": e.error, "msg": e.msg}
raise ValidationError(data)
except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200)
def create(self, request, *args, **kwargs):
super().create(request, *args, **kwargs)
return Response({'msg': 'ok'})
class UserOtpVerifyApi(CreateAPIView):
permission_classes = (IsValidUser,)
serializer_class = OtpVerifySerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
if request.user.check_mfa(code):
request.session["MFA_VERIFY_TIME"] = int(time.time())
return Response({"ok": "1"})
else:
return Response({"error": "Code not valid"}, status=400)

View File

@ -1,23 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import uuid
from django.core.cache import cache
from django.utils.translation import ugettext as _
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.generics import CreateAPIView from rest_framework.generics import CreateAPIView
from drf_yasg.utils import swagger_auto_schema
from common.utils import get_request_ip, get_logger from common.utils import get_logger
from users.utils import (
check_otp_code, increase_login_failed_count, from .. import serializers, errors
is_block_login, clean_failed_count from ..mixins import AuthMixin
)
from ..utils import check_user_valid
from ..signals import post_auth_success, post_auth_failed
from .. import serializers
logger = get_logger(__name__) logger = get_logger(__name__)
@ -25,71 +16,26 @@ logger = get_logger(__name__)
__all__ = ['TokenCreateApi'] __all__ = ['TokenCreateApi']
class AuthFailedError(Exception): class TokenCreateApi(AuthMixin, CreateAPIView):
def __init__(self, msg, reason=None):
self.msg = msg
self.reason = reason
class MFARequiredError(Exception):
pass
class TokenCreateApi(CreateAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = serializers.BearerTokenSerializer serializer_class = serializers.BearerTokenSerializer
@staticmethod def create_session_if_need(self):
def check_is_block(username, ip): if self.request.session.is_empty():
if is_block_login(username, ip): self.request.session.create()
msg = _("Log in frequently and try again later")
logger.warn(msg + ': ' + username + ':' + ip)
raise AuthFailedError(msg)
def check_user_valid(self):
request = self.request
username = request.data.get('username', '')
password = request.data.get('password', '')
public_key = request.data.get('public_key', '')
user, msg = check_user_valid(
username=username, password=password,
public_key=public_key
)
if not user:
raise AuthFailedError(msg)
return user
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
username = self.request.data.get('username') self.create_session_if_need()
ip = self.request.data.get('remote_addr', None) # 如果认证没有过,检查账号密码
ip = ip or get_request_ip(self.request)
user = None
try: try:
self.check_is_block(username, ip) user = self.check_user_auth_if_need()
user = self.check_user_valid() self.check_user_mfa_if_need(user)
if user.otp_enabled: self.check_user_login_confirm_if_need(user)
raise MFARequiredError()
self.send_auth_signal(success=True, user=user) self.send_auth_signal(success=True, user=user)
clean_failed_count(username, ip) self.clear_auth_mark()
return super().create(request, *args, **kwargs) resp = super().create(request, *args, **kwargs)
except AuthFailedError as e: return resp
increase_login_failed_count(username, ip) except errors.AuthFailedError as e:
self.send_auth_signal(success=False, user=user, username=username, reason=str(e)) return Response(e.as_data(), status=400)
return Response({'msg': str(e)}, status=401) except errors.NeedMoreInfoError as e:
except MFARequiredError: return Response(e.as_data(), status=200)
msg = _("MFA required")
seed = uuid.uuid4().hex
cache.set(seed, user.username, 300)
resp = {'msg': msg, "choices": ["otp"], "req": seed}
return Response(resp, status=300)
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
post_auth_success.send(
sender=self.__class__, user=user, request=self.request
)
else:
post_auth_failed.send(
sender=self.__class__, username=username,
request=self.request, reason=reason
)

View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
#
from django.contrib.auth import get_user_model
UserModel = get_user_model()
__all__ = ['PublicKeyAuthBackend']
class PublicKeyAuthBackend:
def authenticate(self, request, username=None, public_key=None, **kwargs):
if not public_key:
return None
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
return None
else:
if user.check_public_key(public_key) and \
self.user_can_authenticate(user):
return user
@staticmethod
def user_can_authenticate(user):
"""
Reject users with is_active=False. Custom user models that don't have
that attribute are allowed.
"""
is_active = getattr(user, 'is_active', None)
return is_active or is_active is None
def get_user(self, user_id):
try:
user = UserModel._default_manager.get(pk=user_id)
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None

View File

@ -5,6 +5,8 @@ from django.contrib.auth import get_user_model
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
from django.conf import settings from django.conf import settings
from pyrad.packet import AccessRequest
User = get_user_model() User = get_user_model()

View File

@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
#
from django.utils.translation import ugettext_lazy as _
password_failed = _('Username/password check failed')
mfa_failed = _('MFA authentication failed')
user_not_exist = _("Username does not exist")
password_expired = _("Password expired")
user_invalid = _('Disabled or expired')

View File

@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
#
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from .signals import post_auth_failed
from users.utils import (
increase_login_failed_count, get_login_failed_count
)
reason_password_failed = 'password_failed'
reason_mfa_failed = 'mfa_failed'
reason_user_not_exist = 'user_not_exist'
reason_password_expired = 'password_expired'
reason_user_invalid = 'user_invalid'
reason_user_inactive = 'user_inactive'
reason_choices = {
reason_password_failed: _('Username/password check failed'),
reason_mfa_failed: _('MFA authentication failed'),
reason_user_not_exist: _("Username does not exist"),
reason_password_expired: _("Password expired"),
reason_user_invalid: _('Disabled or expired'),
reason_user_inactive: _("This account is inactive.")
}
old_reason_choices = {
'0': '-',
'1': reason_choices[reason_password_failed],
'2': reason_choices[reason_mfa_failed],
'3': reason_choices[reason_user_not_exist],
'4': reason_choices[reason_password_expired],
}
session_empty_msg = _("No session found, check your cookie")
invalid_login_msg = _(
"The username or password you entered is incorrect, "
"please enter it again. "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
block_login_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
)
mfa_failed_msg = _("MFA code invalid, or ntp sync server time")
mfa_required_msg = _("MFA required")
login_confirm_required_msg = _("Login confirm required")
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
login_confirm_error_msg = _("Login confirm ticket was {}")
class AuthFailedNeedLogMixin:
username = ''
request = None
error = ''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
post_auth_failed.send(
sender=self.__class__, username=self.username,
request=self.request, reason=self.error
)
class AuthFailedNeedBlockMixin:
username = ''
ip = ''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
increase_login_failed_count(self.username, self.ip)
class AuthFailedError(Exception):
username = ''
msg = ''
error = ''
request = None
ip = ''
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
}
class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError):
def __init__(self, error, username, ip, request):
super().__init__(error=error, username=username, ip=ip, request=request)
times_up = settings.SECURITY_LOGIN_LIMIT_COUNT
times_failed = get_login_failed_count(username, ip)
times_try = int(times_up) - int(times_failed)
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
default_msg = invalid_login_msg.format(
times_try=times_try, block_time=block_time
)
if error == reason_password_failed:
self.msg = default_msg
else:
self.msg = reason_choices.get(error, default_msg)
class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed
msg = mfa_failed_msg
def __init__(self, username, request):
super().__init__(username=username, request=request)
class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
error = 'block_login'
msg = block_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
def __init__(self, username, ip):
super().__init__(username=username, ip=ip)
class SessionEmptyError(AuthFailedError):
msg = session_empty_msg
error = 'session_empty'
class NeedMoreInfoError(Exception):
error = ''
msg = ''
def __init__(self, error='', msg=''):
if error:
self.error = error
if msg:
self.msg = msg
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
}
class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg
error = 'mfa_required'
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
'data': {
'choices': ['otp'],
'url': reverse('api-auth:mfa-challenge')
}
}
class LoginConfirmBaseError(NeedMoreInfoError):
def __init__(self, ticket_id, **kwargs):
self.ticket_id = ticket_id
super().__init__(**kwargs)
def as_data(self):
return {
"error": self.error,
"msg": self.msg,
"data": {
"ticket_id": self.ticket_id
}
}
class LoginConfirmWaitError(LoginConfirmBaseError):
msg = login_confirm_wait_msg
error = 'login_confirm_wait'
class LoginConfirmOtherError(LoginConfirmBaseError):
error = 'login_confirm_error'
def __init__(self, ticket_id, status):
msg = login_confirm_error_msg.format(status)
super().__init__(ticket_id=ticket_id, msg=msg)

View File

@ -9,53 +9,19 @@ from django.conf import settings
from users.utils import get_login_failed_count from users.utils import get_login_failed_count
class UserLoginForm(AuthenticationForm): class UserLoginForm(forms.Form):
username = forms.CharField(label=_('Username'), max_length=100) username = forms.CharField(label=_('Username'), max_length=100)
password = forms.CharField( password = forms.CharField(
label=_('Password'), widget=forms.PasswordInput, label=_('Password'), widget=forms.PasswordInput,
max_length=128, strip=False max_length=128, strip=False
) )
error_messages = {
'invalid_login': _(
"The username or password you entered is incorrect, "
"please enter it again."
),
'inactive': _("This account is inactive."),
'limit_login': _(
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
),
'block_login': _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
)
}
def confirm_login_allowed(self, user): def confirm_login_allowed(self, user):
if not user.is_staff: if not user.is_staff:
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['inactive'], self.error_messages['inactive'],
code='inactive',) code='inactive',
def get_limit_login_error_message(self, username, ip):
times_up = settings.SECURITY_LOGIN_LIMIT_COUNT
times_failed = get_login_failed_count(username, ip)
times_try = int(times_up) - int(times_failed)
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_try <= 0:
error_message = self.error_messages['block_login']
error_message = error_message.format(block_time)
else:
error_message = self.error_messages['limit_login']
error_message = error_message.format(
times_try=times_try, block_time=block_time,
) )
return error_message
def add_limit_login_error(self, username, ip):
error = self.get_limit_login_error_message(username, ip)
self.add_error('password', error)
class UserLoginCaptchaForm(UserLoginForm): class UserLoginCaptchaForm(UserLoginForm):

View File

@ -0,0 +1,32 @@
# Generated by Django 2.2.5 on 2019-10-31 10:23
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0002_auto_20190729_1423'),
]
operations = [
migrations.CreateModel(
name='LoginConfirmSetting',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
('reviewers', models.ManyToManyField(blank=True, related_name='review_login_confirm_settings', to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='login_confirm_setting', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
#
import time
from django.conf import settings
from common.utils import get_object_or_none, get_request_ip, get_logger
from users.models import User
from users.utils import (
is_block_login, clean_failed_count, increase_login_failed_count
)
from . import errors
from .utils import check_user_valid
from .signals import post_auth_success, post_auth_failed
logger = get_logger(__name__)
class AuthMixin:
request = None
def get_user_from_session(self):
if self.request.session.is_empty():
raise errors.SessionEmptyError()
if self.request.user and not self.request.user.is_anonymous:
return self.request.user
user_id = self.request.session.get('user_id')
if not user_id:
user = None
else:
user = get_object_or_none(User, pk=user_id)
if not user:
raise errors.SessionEmptyError()
user.backend = self.request.session.get("auth_backend")
return user
def get_request_ip(self):
ip = ''
if hasattr(self.request, 'data'):
ip = self.request.data.get('remote_addr', '')
ip = ip or get_request_ip(self.request)
return ip
def check_is_block(self):
if hasattr(self.request, 'data'):
username = self.request.data.get("username")
else:
username = self.request.POST.get("username")
ip = self.get_request_ip()
if is_block_login(username, ip):
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
raise errors.BlockLoginError(username=username, ip=ip)
def check_user_auth(self):
self.check_is_block()
request = self.request
if hasattr(request, 'data'):
username = request.data.get('username', '')
password = request.data.get('password', '')
public_key = request.data.get('public_key', '')
else:
username = request.POST.get('username', '')
password = request.POST.get('password', '')
public_key = request.POST.get('public_key', '')
user, error = check_user_valid(
username=username, password=password,
public_key=public_key
)
ip = self.get_request_ip()
if not user:
raise errors.CredentialError(
username=username, error=error, ip=ip, request=request
)
clean_failed_count(username, ip)
request.session['auth_password'] = 1
request.session['user_id'] = str(user.id)
auth_backend = getattr(user, 'backend', 'django.contrib.auth.backends.ModelBackend')
request.session['auth_backend'] = auth_backend
return user
def check_user_auth_if_need(self):
request = self.request
if request.session.get('auth_password') and \
request.session.get('user_id'):
user = self.get_user_from_session()
if user:
return user
return self.check_user_auth()
def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'):
return
if not user.mfa_enabled:
return
if not user.otp_secret_key and user.mfa_is_otp():
return
raise errors.MFARequiredError()
def check_user_mfa(self, code):
user = self.get_user_from_session()
ok = user.check_mfa(code)
if ok:
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_type'] = 'otp'
return
raise errors.MFAFailedError(username=user.username, request=self.request)
def get_ticket(self):
from tickets.models import Ticket
ticket_id = self.request.session.get("auth_ticket_id")
logger.debug('Login confirm ticket id: {}'.format(ticket_id))
if not ticket_id:
ticket = None
else:
ticket = get_object_or_none(Ticket, pk=ticket_id)
return ticket
def get_ticket_or_create(self, confirm_setting):
ticket = self.get_ticket()
if not ticket or ticket.status == ticket.STATUS_CLOSED:
ticket = confirm_setting.create_confirm_ticket(self.request)
self.request.session['auth_ticket_id'] = str(ticket.id)
return ticket
def check_user_login_confirm(self):
ticket = self.get_ticket()
if not ticket:
raise errors.LoginConfirmOtherError('', "Not found")
if ticket.status == ticket.STATUS_OPEN:
raise errors.LoginConfirmWaitError(ticket.id)
elif ticket.action == ticket.ACTION_APPROVE:
self.request.session["auth_confirm"] = "1"
return
elif ticket.action == ticket.ACTION_REJECT:
raise errors.LoginConfirmOtherError(
ticket.id, ticket.get_action_display()
)
else:
raise errors.LoginConfirmOtherError(
ticket.id, ticket.get_status_display()
)
def check_user_login_confirm_if_need(self, user):
if not settings.CONFIG.LOGIN_CONFIRM_ENABLE:
return
confirm_setting = user.get_login_confirm_setting()
if self.request.session.get('auth_confirm') or not confirm_setting:
return
self.get_ticket_or_create(confirm_setting)
self.check_user_login_confirm()
def clear_auth_mark(self):
self.request.session['auth_password'] = ''
self.request.session['auth_user_id'] = ''
self.request.session['auth_mfa'] = ''
self.request.session['auth_confirm'] = ''
self.request.session['auth_ticket_id'] = ''
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
post_auth_success.send(
sender=self.__class__, user=user, request=self.request
)
else:
post_auth_failed.send(
sender=self.__class__, username=username,
request=self.request, reason=reason
)

View File

@ -1,9 +1,13 @@
import uuid import uuid
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.conf import settings from django.conf import settings
from common.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city
class AccessKey(models.Model): class AccessKey(models.Model):
id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True, id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True,
@ -33,3 +37,42 @@ class PrivateToken(Token):
class Meta: class Meta:
verbose_name = _('Private Token') verbose_name = _('Private Token')
class LoginConfirmSetting(CommonModelMixin):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name="login_confirm_setting")
reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True)
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
@classmethod
def get_user_confirm_setting(cls, user):
return get_object_or_none(cls, user=user)
def create_confirm_ticket(self, request=None):
from tickets.models import Ticket
title = _('Login confirm') + '{}'.format(self.user)
if request:
remote_addr = get_request_ip(request)
city = get_ip_city(remote_addr)
datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
body = __("{user_key}: {username}<br>"
"IP: {ip}<br>"
"{city_key}: {city}<br>"
"{date_key}: {date}<br>").format(
user_key=__("User"), username=self.user,
ip=remote_addr, city_key=_("City"), city=city,
date_key=__("Datetime"), date=datetime
)
else:
body = ''
reviewer = self.reviewers.all()
ticket = Ticket.objects.create(
user=self.user, title=title, body=body,
type=Ticket.TYPE_LOGIN_CONFIRM,
)
ticket.assignees.set(reviewer)
return ticket
def __str__(self):
return '{} confirm'.format(self.user.username)

View File

@ -1,20 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.core.cache import cache
from rest_framework import serializers from rest_framework import serializers
from common.utils import get_object_or_none
from users.models import User from users.models import User
from .models import AccessKey from users.serializers import UserProfileSerializer
from .models import AccessKey, LoginConfirmSetting
__all__ = [ __all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer',
] ]
class AccessKeySerializer(serializers.ModelSerializer): class AccessKeySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = AccessKey model = AccessKey
fields = ['id', 'secret', 'is_active', 'date_created'] fields = ['id', 'secret', 'is_active', 'date_created']
@ -25,65 +25,54 @@ class OtpVerifySerializer(serializers.Serializer):
code = serializers.CharField(max_length=6, min_length=6) code = serializers.CharField(max_length=6, min_length=6)
class BearerTokenMixin(serializers.Serializer): class BearerTokenSerializer(serializers.Serializer):
username = serializers.CharField(allow_null=True, required=False, write_only=True)
password = serializers.CharField(write_only=True, allow_null=True,
required=False, allow_blank=True)
public_key = serializers.CharField(write_only=True, allow_null=True,
allow_blank=True, required=False)
token = serializers.CharField(read_only=True) token = serializers.CharField(read_only=True)
keyword = serializers.SerializerMethodField() keyword = serializers.SerializerMethodField()
date_expired = serializers.DateTimeField(read_only=True) date_expired = serializers.DateTimeField(read_only=True)
user = UserProfileSerializer(read_only=True)
@staticmethod @staticmethod
def get_keyword(obj): def get_keyword(obj):
return 'Bearer' return 'Bearer'
def create_response(self, username): def create(self, validated_data):
request = self.context.get("request") request = self.context.get('request')
try: if request.user and not request.user.is_anonymous:
user = User.objects.get(username=username) user = request.user
except User.DoesNotExist: else:
raise serializers.ValidationError("username %s not exist" % username) user_id = request.session.get('user_id')
user = get_object_or_none(User, pk=user_id)
if not user:
raise serializers.ValidationError(
"user id {} not exist".format(user_id)
)
token, date_expired = user.create_bearer_token(request) token, date_expired = user.create_bearer_token(request)
instance = { instance = {
"username": username,
"token": token, "token": token,
"date_expired": date_expired, "date_expired": date_expired,
"user": user
} }
return instance return instance
class MFAChallengeSerializer(serializers.Serializer):
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
code = serializers.CharField(write_only=True)
def create(self, validated_data):
pass
def update(self, instance, validated_data): def update(self, instance, validated_data):
pass pass
class BearerTokenSerializer(BearerTokenMixin, serializers.Serializer): class LoginConfirmSettingSerializer(serializers.ModelSerializer):
username = serializers.CharField() class Meta:
password = serializers.CharField(write_only=True, allow_null=True, model = LoginConfirmSetting
required=False) fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
public_key = serializers.CharField(write_only=True, allow_null=True, read_only_fields = ['date_created', 'date_updated']
required=False)
def create(self, validated_data):
username = validated_data.get("username")
return self.create_response(username)
class MFAChallengeSerializer(BearerTokenMixin, serializers.Serializer):
req = serializers.CharField(write_only=True)
auth_type = serializers.CharField(write_only=True)
code = serializers.CharField(write_only=True)
def validate_req(self, attr):
username = cache.get(attr)
if not username:
raise serializers.ValidationError("Not valid, may be expired")
self.context["username"] = username
def validate_code(self, code):
username = self.context["username"]
user = User.objects.get(username=username)
ok = user.check_otp(code)
if not ok:
msg = "Otp code not valid, may be expired"
raise serializers.ValidationError(msg)
def create(self, validated_data):
username = self.context["username"]
return self.create_response(username)

View File

@ -1,18 +1,14 @@
from rest_framework.request import Request
from django.http.request import QueryDict from django.http.request import QueryDict
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.signals import user_logged_out from django.contrib.auth.signals import user_logged_out
from django.utils import timezone
from django_auth_ldap.backend import populate_user from django_auth_ldap.backend import populate_user
from common.utils import get_request_ip
from .backends.openid import new_client from .backends.openid import new_client
from .backends.openid.signals import ( from .backends.openid.signals import (
post_create_openid_user, post_openid_login_success post_create_openid_user, post_openid_login_success
) )
from .tasks import write_login_log_async from .signals import post_auth_success
from .signals import post_auth_success, post_auth_failed
@receiver(user_logged_out) @receiver(user_logged_out)
@ -52,35 +48,4 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs):
user.save() user.save()
def generate_data(username, request):
user_agent = request.META.get('HTTP_USER_AGENT', '')
if isinstance(request, Request):
login_ip = request.data.get('remote_addr', None)
login_type = request.data.get('login_type', '')
else:
login_ip = get_request_ip(request)
login_type = 'W'
data = {
'username': username,
'ip': login_ip,
'type': login_type,
'user_agent': user_agent,
'datetime': timezone.now()
}
return data
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, **kwargs):
data = generate_data(user.username, request)
data.update({'mfa': int(user.otp_enabled), 'status': True})
write_login_log_async.delay(**data)
@receiver(post_auth_failed)
def on_user_auth_failed(sender, username, request, reason, **kwargs):
data = generate_data(username, request)
data.update({'reason': reason, 'status': False})
write_login_log_async.delay(**data)

View File

@ -6,17 +6,8 @@ from ops.celery.decorator import register_as_period_task
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
from django.utils import timezone from django.utils import timezone
from .utils import write_login_log
@shared_task
def write_login_log_async(*args, **kwargs):
write_login_log(*args, **kwargs)
@register_as_period_task(interval=3600*24) @register_as_period_task(interval=3600*24)
@shared_task @shared_task
def clean_django_sessions(): def clean_django_sessions():
Session.objects.filter(expire_date__lt=timezone.now()).delete() Session.objects.filter(expire_date__lt=timezone.now()).delete()

View File

@ -37,7 +37,6 @@
<p> <p>
{% trans "Changes the world, starting with a little bit." %} {% trans "Changes the world, starting with a little bit." %}
</p> </p>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="ibox-content"> <div class="ibox-content">
@ -47,25 +46,29 @@
</div> </div>
<form class="m-t" role="form" method="post" action=""> <form class="m-t" role="form" method="post" action="">
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %}
{% if block_login %} <div style="line-height: 17px;">
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p>
{% elif password_expired %}
<p class="red-fonts">{% trans 'The user password has expired' %}</p>
{% elif form.errors %}
{% if 'captcha' in form.errors %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
{% else %}
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p> <p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
{% endif %} </div>
<p class="red-fonts">{{ form.errors.password.as_text }}</p> {% elif form.errors.captcha %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
{% endif %} {% endif %}
<div class="form-group"> <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 %}"> <input type="text" class="form-control" name="{{ form.username.html_name }}" placeholder="{% trans 'Username' %}" required="" value="{% if form.username.value %}{{ form.username.value }}{% endif %}">
{% if form.errors.username %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.username.as_text }}</p>
</div>
{% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required=""> <input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
{% if form.errors.password %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
</div>
{% endif %}
</div> </div>
<div> <div>
{{ form.captcha }} {{ form.captcha }}

View File

@ -0,0 +1,166 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon">
<title>{{ title }}</title>
{% include '_head_css_js.html' %}
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
<script type="text/javascript" src="{% url 'javascript-catalog' %}"></script>
<script src="{% static "js/jumpserver.js" %}"></script>
</head>
<body class="gray-bg">
<div class="passwordBox2 animated fadeInDown">
<div class="row">
<div class="col-md-12">
<div class="ibox-content">
<div>
<img src="{{ LOGO_URL }}" style="margin: auto" width="82" height="82">
<h2 style="display: inline">
{{ JMS_TITLE }}
</h2>
</div>
<p></p>
<div class="alert alert-success info-messages" >
{{ msg|safe }}
</div>
<div class="alert alert-danger error-messages" style="display: none">
</div>
<div class="progress progress-bar-default progress-striped active">
<div aria-valuemax="3600" aria-valuemin="0" aria-valuenow="43" role="progressbar" class="progress-bar">
</div>
</div>
<div class="row">
<div class="col-sm-3">
<a class="btn btn-primary btn-sm block btn-refresh">
<i class="fa fa-refresh"></i> {% trans 'Refresh' %}
</a>
</div>
<div class="col-sm-3">
<a class="btn btn-primary btn-sm block btn-copy" data-link="{{ ticket_detail_url }}">
<i class="fa fa-clipboard"></i> {% trans 'Copy link' %}
</a>
</div>
<div class="col-sm-3">
<a class="btn btn-default btn-sm block btn-return">
<i class="fa fa-reply"></i> {% trans 'Return' %}
</a>
</div>
</div>
</div>
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-6">
{% include '_copyright.html' %}
</div>
</div>
</div>
</body>
{% include '_foot_js.html' %}
<script src="{% static "js/plugins/clipboard/clipboard.min.js" %}"></script>
<script>
var errorMsgShow = false;
var errorMsgRef = $(".error-messages");
var infoMsgRef = $(".info-messages");
var timestamp = '{{ timestamp }}';
var progressBarRef = $(".progress-bar");
var interval, checkInterval;
var url = "{% url 'api-auth:login-confirm-ticket-status' %}";
var successUrl = "{% url 'authentication:login-guard' %}";
function doRequestAuth() {
requestApi({
url: url,
method: "GET",
success: function (data) {
if (!data.error && data.msg === 'ok') {
window.onbeforeunload = function(){};
window.location = "{% url 'authentication:login-guard' %}"
} else if (data.error !== "login_confirm_wait") {
if (!errorMsgShow) {
infoMsgRef.hide();
errorMsgRef.show();
progressBarRef.addClass('progress-bar-danger');
errorMsgShow = true;
}
clearInterval(interval);
clearInterval(checkInterval);
$(".copy-btn").attr('disabled', 'disabled');
errorMsgRef.html(data.msg)
}
},
error: function (text, data) {
},
flash_message: false
})
}
function initClipboard() {
var clipboard = new Clipboard('.btn-copy', {
text: function (trigger) {
var origin = window.location.origin;
var link = origin + $(".btn-copy").data('link');
return link
}
});
clipboard.on("success", function (e) {
toastr.success("{% trans "Copy success" %}")
})
}
function handleProgressBar() {
var now = new Date().getTime() / 1000;
var offset = now - timestamp;
var percent = offset / 3600 * 100;
if (percent > 100) {
percent = 100
}
progressBarRef.css("width", percent + '%');
progressBarRef.attr('aria-valuenow', offset);
}
function cancelTicket() {
requestApi({
url: url,
method: "DELETE",
flash_message: false
})
}
function cancelCloseConfirm() {
window.onbeforeunload = function() {};
window.onunload = function(){};
}
function setCloseConfirm() {
window.onbeforeunload = function (e) {
return 'Confirm';
};
window.onunload = function (e) {
cancelTicket();
}
}
$(document).ready(function () {
interval = setInterval(handleProgressBar, 1000);
checkInterval = setInterval(doRequestAuth, 5000);
doRequestAuth();
initClipboard();
setCloseConfirm();
}).on('click', '.btn-refresh', function () {
cancelCloseConfirm();
window.location.reload();
}).on('click', '.btn-return', function () {
cancelCloseConfirm();
window.location = "{% url 'authentication:login' %}"
})
</script>
</html>

View File

@ -48,6 +48,13 @@
float: right; float: right;
} }
.red-fonts {
color: red;
}
.field-error {
text-align: left;
}
</style> </style>
</head> </head>
@ -69,30 +76,34 @@
<div style="margin-bottom: 10px"> <div style="margin-bottom: 10px">
<div> <div>
<div class="col-md-1"></div> <div class="col-md-1"></div>
<div class="contact-form col-md-10" style="margin-top: 10px;height: 35px"> <div class="contact-form col-md-10" style="margin-top: 20px;height: 35px">
<form id="contact-form" action="" method="post" role="form" novalidate="novalidate"> <form id="contact-form" action="" method="post" role="form" novalidate="novalidate">
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %}
<div style="height: 70px;color: red;line-height: 17px;"> <div style="height: 70px;color: red;line-height: 17px;">
{% if block_login %} <p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p> </div>
<p class="red-fonts">{{ form.errors.password.as_text }}</p> {% elif form.errors.captcha %}
{% elif password_expired %}
<p class="red-fonts">{% trans 'The user password has expired' %}</p>
{% elif form.errors %}
{% if 'captcha' in form.errors %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p> <p class="red-fonts">{% trans 'Captcha invalid' %}</p>
{% else %} {% else %}
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p> <div style="height: 50px"></div>
{% endif %} {% endif %}
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
{% endif %}
</div>
<div class="form-group"> <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 %}" style="height: 35px"> <input type="text" class="form-control" name="{{ form.username.html_name }}" placeholder="{% trans 'Username' %}" required="" value="{% if form.username.value %}{{ form.username.value }}{% endif %}" style="height: 35px">
{% if form.errors.username %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.username.as_text }}</p>
</div>
{% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required=""> <input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
{% if form.errors.password %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
</div>
{% endif %}
</div> </div>
<div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px"> <div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
{{ form.captcha }} {{ form.captcha }}

View File

@ -1,29 +1,25 @@
# coding:utf-8 # coding:utf-8
# #
from __future__ import absolute_import
from django.urls import path from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .. import api from .. import api
app_name = 'authentication'
router = DefaultRouter() router = DefaultRouter()
router.register('access-keys', api.AccessKeyViewSet, 'access-key') router.register('access-keys', api.AccessKeyViewSet, 'access-key')
app_name = 'authentication'
urlpatterns = [ urlpatterns = [
# path('token/', api.UserToken.as_view(), name='user-token'), # path('token/', api.UserToken.as_view(), name='user-token'),
path('auth/', api.UserAuthApi.as_view(), name='user-auth'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('connection-token/', path('connection-token/',
api.UserConnectionTokenApi.as_view(), name='connection-token'), api.UserConnectionTokenApi.as_view(), name='connection-token'),
path('otp/auth/', api.UserOtpAuthApi.as_view(), name='user-otp-auth'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
] ]
urlpatterns += router.urls urlpatterns += router.urls

View File

@ -16,5 +16,7 @@ urlpatterns = [
# login # login
path('login/', views.UserLoginView.as_view(), name='login'), path('login/', views.UserLoginView.as_view(), name='login'),
path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'), path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'), path('logout/', views.UserLogoutView.as_view(), name='logout'),
] ]

View File

@ -3,22 +3,11 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from common.utils import get_ip_city, get_object_or_none, validate_ip from common.utils import (
get_ip_city, get_object_or_none, validate_ip
)
from users.models import User from users.models import User
from . import const from . import errors
def write_login_log(*args, **kwargs):
from audits.models import UserLoginLog
default_city = _("Unknown")
ip = kwargs.get('ip') or ''
if not (ip and validate_ip(ip)):
ip = ip[:15]
city = default_city
else:
city = get_ip_city(ip) or default_city
kwargs.update({'ip': ip, 'city': city})
UserLoginLog.objects.create(**kwargs)
def check_user_valid(**kwargs): def check_user_valid(**kwargs):
@ -26,6 +15,7 @@ def check_user_valid(**kwargs):
public_key = kwargs.pop('public_key', None) public_key = kwargs.pop('public_key', None)
email = kwargs.pop('email', None) email = kwargs.pop('email', None)
username = kwargs.pop('username', None) username = kwargs.pop('username', None)
request = kwargs.get('request')
if username: if username:
user = get_object_or_none(User, username=username) user = get_object_or_none(User, username=username)
@ -35,21 +25,17 @@ def check_user_valid(**kwargs):
user = None user = None
if user is None: if user is None:
return None, const.user_not_exist return None, errors.reason_user_not_exist
elif not user.is_valid: elif user.is_expired:
return None, const.user_invalid return None, errors.reason_user_inactive
elif not user.is_active:
return None, errors.reason_user_inactive
elif user.password_has_expired: elif user.password_has_expired:
return None, const.password_expired return None, errors.reason_password_expired
if password and authenticate(username=username, password=password): if password or public_key:
user = authenticate(request, username=username,
password=password, public_key=public_key)
if user:
return user, '' return user, ''
return None, errors.reason_password_failed
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, const.password_failed

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from .login import * from .login import *
from .mfa import *

View File

@ -3,6 +3,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import datetime
from django.core.cache import cache from django.core.cache import cache
from django.contrib.auth import login as auth_login, logout as auth_logout from django.contrib.auth import login as auth_login, logout as auth_logout
from django.http import HttpResponse from django.http import HttpResponse
@ -12,36 +13,32 @@ from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView, RedirectView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from django.conf import settings from django.conf import settings
from django.urls import reverse_lazy
from common.utils import get_request_ip from common.utils import get_request_ip, get_object_or_none
from users.models import User
from audits.models import UserLoginLog as LoginLog
from users.utils import ( from users.utils import (
check_otp_code, is_block_login, clean_failed_count, get_user_or_tmp_user, redirect_user_first_login_or_index
set_tmp_user_to_cache, increase_login_failed_count,
redirect_user_first_login_or_index,
) )
from ..signals import post_auth_success, post_auth_failed from .. import forms, mixins, errors
from .. import forms
from .. import const
__all__ = [ __all__ = [
'UserLoginView', 'UserLoginOtpView', 'UserLogoutView', 'UserLoginView', 'UserLogoutView',
'UserLoginGuardView', 'UserLoginWaitConfirmView',
] ]
@method_decorator(sensitive_post_parameters(), name='dispatch') @method_decorator(sensitive_post_parameters(), name='dispatch')
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(never_cache, name='dispatch') @method_decorator(never_cache, name='dispatch')
class UserLoginView(FormView): class UserLoginView(mixins.AuthMixin, FormView):
form_class = forms.UserLoginForm form_class = forms.UserLoginForm
form_class_captcha = forms.UserLoginCaptchaForm form_class_captcha = forms.UserLoginCaptchaForm
redirect_field_name = 'next'
key_prefix_captcha = "_LOGIN_INVALID_{}" key_prefix_captcha = "_LOGIN_INVALID_{}"
redirect_field_name = 'next'
def get_template_names(self): def get_template_names(self):
template_name = 'authentication/login.html' template_name = 'authentication/login.html'
@ -52,7 +49,7 @@ class UserLoginView(FormView):
if not License.has_valid_license(): if not License.has_valid_license():
return template_name return template_name
template_name = 'authentication/new_login.html' template_name = 'authentication/xpack_login.html'
return template_name return template_name
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@ -68,48 +65,27 @@ class UserLoginView(FormView):
request.session.set_test_cookie() request.session.set_test_cookie()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
# limit login authentication
ip = get_request_ip(request)
username = self.request.POST.get('username')
if is_block_login(username, ip):
return self.render_to_response(self.get_context_data(block_login=True))
return super().post(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
if not self.request.session.test_cookie_worked(): if not self.request.session.test_cookie_worked():
return HttpResponse(_("Please enable cookies and try again.")) return HttpResponse(_("Please enable cookies and try again."))
user = form.get_user() try:
# user password expired self.check_user_auth()
if user.password_has_expired: except errors.AuthFailedError as e:
reason = const.password_expired form.add_error(None, e.msg)
self.send_auth_signal(success=False, username=user.username, reason=reason) ip = self.get_request_ip()
return self.render_to_response(self.get_context_data(password_expired=True))
set_tmp_user_to_cache(self.request, user)
username = form.cleaned_data.get('username')
ip = get_request_ip(self.request)
# 登陆成功,清除缓存计数
clean_failed_count(username, ip)
return redirect(self.get_success_url())
def form_invalid(self, form):
# write login failed log
username = form.cleaned_data.get('username')
exist = User.objects.filter(username=username).first()
reason = const.password_failed if exist else const.user_not_exist
# limit user login failed count
ip = get_request_ip(self.request)
increase_login_failed_count(username, ip)
form.add_limit_login_error(username, ip)
# show captcha
cache.set(self.key_prefix_captcha.format(ip), 1, 3600) cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
self.send_auth_signal(success=False, username=username, reason=reason) new_form = self.form_class_captcha(data=form.data)
new_form._errors = form.errors
context = self.get_context_data(form=new_form)
return self.render_to_response(context)
return self.redirect_to_guard_view()
old_form = form def redirect_to_guard_view(self):
form = self.form_class_captcha(data=form.data) guard_url = reverse('authentication:login-guard')
form._errors = old_form.errors args = self.request.META.get('QUERY_STRING', '')
return super().form_invalid(form) if args:
guard_url = "%s?%s" % (guard_url, args)
return redirect(guard_url)
def get_form_class(self): def get_form_class(self):
ip = get_request_ip(self.request) ip = get_request_ip(self.request)
@ -118,21 +94,6 @@ class UserLoginView(FormView):
else: else:
return self.form_class return self.form_class
def get_success_url(self):
user = get_user_or_tmp_user(self.request)
if user.otp_enabled and user.otp_secret_key:
# 1,2,mfa_setting & T
return reverse('authentication:login-otp')
elif user.otp_enabled and not user.otp_secret_key:
# 1,2,mfa_setting & F
return reverse('users:user-otp-enable-authentication')
elif not user.otp_enabled:
# 0 & T,F
auth_login(self.request, user)
self.send_auth_signal(success=True, user=user)
return redirect_user_first_login_or_index(self.request, self.redirect_field_name)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = { context = {
'demo_mode': os.environ.get("DEMO_MODE"), 'demo_mode': os.environ.get("DEMO_MODE"),
@ -141,51 +102,70 @@ class UserLoginView(FormView):
kwargs.update(context) kwargs.update(context)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
post_auth_success.send(sender=self.__class__, user=user, request=self.request)
else:
post_auth_failed.send(
sender=self.__class__, username=username,
request=self.request, reason=reason
)
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
class UserLoginOtpView(FormView):
template_name = 'authentication/login_otp.html'
form_class = forms.UserCheckOtpCodeForm
redirect_field_name = 'next' redirect_field_name = 'next'
login_url = reverse_lazy('authentication:login')
login_otp_url = reverse_lazy('authentication:login-otp')
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
def form_valid(self, form): def format_redirect_url(self, url):
user = get_user_or_tmp_user(self.request) args = self.request.META.get('QUERY_STRING', '')
otp_code = form.cleaned_data.get('otp_code') if args and self.query_string:
otp_secret_key = user.otp_secret_key url = "%s?%s" % (url, args)
return url
if check_otp_code(otp_secret_key, otp_code): def get_redirect_url(self, *args, **kwargs):
try:
user = self.check_user_auth_if_need()
self.check_user_mfa_if_need(user)
self.check_user_login_confirm_if_need(user)
except errors.CredentialError:
return self.format_redirect_url(self.login_url)
except errors.MFARequiredError:
return self.format_redirect_url(self.login_otp_url)
except errors.LoginConfirmBaseError:
return self.format_redirect_url(self.login_confirm_url)
else:
auth_login(self.request, user) auth_login(self.request, user)
self.send_auth_signal(success=True, user=user) self.send_auth_signal(success=True, user=user)
return redirect(self.get_success_url()) self.clear_auth_mark()
else: # 启用但是没有设置otp, 排除radius
self.send_auth_signal( if user.mfa_enabled_but_not_set():
success=False, username=user.username, # 1,2,mfa_setting & F
reason=const.mfa_failed return reverse('users:user-otp-enable-authentication')
url = redirect_user_first_login_or_index(
self.request, self.redirect_field_name
) )
form.add_error( return url
'otp_code', _('MFA code invalid, or ntp sync server time')
)
return super().form_invalid(form)
def get_success_url(self):
return redirect_user_first_login_or_index(self.request, self.redirect_field_name)
def send_auth_signal(self, success=True, user=None, username='', reason=''): class UserLoginWaitConfirmView(TemplateView):
if success: template_name = 'authentication/login_wait_confirm.html'
post_auth_success.send(sender=self.__class__, user=user, request=self.request)
def get_context_data(self, **kwargs):
from tickets.models import Ticket
ticket_id = self.request.session.get("auth_ticket_id")
if not ticket_id:
ticket = None
else: else:
post_auth_failed.send( ticket = get_object_or_none(Ticket, pk=ticket_id)
sender=self.__class__, username=username, context = super().get_context_data(**kwargs)
request=self.request, reason=reason if ticket:
) timestamp_created = datetime.datetime.timestamp(ticket.date_created)
ticket_detail_url = reverse('tickets:ticket-detail', kwargs={'pk': ticket_id})
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
Don't close this page""").format(ticket.assignees_display)
else:
timestamp_created = 0
ticket_detail_url = ''
msg = _("No ticket found")
context.update({
"msg": msg,
"timestamp": timestamp_created,
"ticket_detail_url": ticket_detail_url
})
return context
@method_decorator(never_cache, name='dispatch') @method_decorator(never_cache, name='dispatch')

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
#
from __future__ import unicode_literals
from django.views.generic.edit import FormView
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
__all__ = ['UserLoginOtpView']
class UserLoginOtpView(mixins.AuthMixin, FormView):
template_name = 'authentication/login_otp.html'
form_class = forms.UserCheckOtpCodeForm
redirect_field_name = 'next'
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
try:
self.check_user_mfa(otp_code)
return redirect_to_guard_view()
except errors.MFAFailedError as e:
form.add_error('otp_code', e.msg)
return super().form_invalid(form)

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
#
from django.shortcuts import reverse, redirect
def redirect_to_guard_view():
continue_url = reverse('authentication:login-guard')
return redirect(continue_url)

View File

@ -25,6 +25,15 @@ class IDSpmFilterMixin:
return backends return backends
class SerializerMixin:
def get_serializer_class(self):
if self.request.method.lower() == 'get' and\
self.request.query_params.get('draw') \
and hasattr(self, 'serializer_display_class'):
return self.serializer_display_class
return super().get_serializer_class()
class ExtraFilterFieldsMixin: class ExtraFilterFieldsMixin:
default_added_filters = [CustomFilter, IDSpmFilter] default_added_filters = [CustomFilter, IDSpmFilter]
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
@ -44,5 +53,5 @@ class ExtraFilterFieldsMixin:
return queryset return queryset
class CommonApiMixin(ExtraFilterFieldsMixin): class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin):
pass pass

View File

@ -53,3 +53,15 @@ class CommonModelMixin(models.Model):
class Meta: class Meta:
abstract = True abstract = True
class DebugQueryManager(models.Manager):
def get_queryset(self):
import traceback
lines = traceback.format_stack()
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>")
for line in lines[-10:-1]:
print(line)
print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
queryset = super().get_queryset()
return queryset

View File

@ -153,6 +153,14 @@ def get_request_ip(request):
return login_ip return login_ip
def get_request_ip_or_data(request):
ip = ''
if hasattr(request, 'data'):
ip = request.data.get('remote_addr', '')
ip = ip or get_request_ip(request)
return ip
def validate_ip(ip): def validate_ip(ip):
try: try:
ipaddress.ip_address(ip) ipaddress.ip_address(ip)

View File

@ -375,6 +375,7 @@ defaults = {
'RADIUS_SERVER': 'localhost', 'RADIUS_SERVER': 'localhost',
'RADIUS_PORT': 1812, 'RADIUS_PORT': 1812,
'RADIUS_SECRET': '', 'RADIUS_SECRET': '',
'RADIUS_ENCRYPT_PASSWORD': True,
'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000, 'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000,
'AUTH_LDAP_SYNC_IS_PERIODIC': False, 'AUTH_LDAP_SYNC_IS_PERIODIC': False,
'AUTH_LDAP_SYNC_INTERVAL': None, 'AUTH_LDAP_SYNC_INTERVAL': None,
@ -394,8 +395,11 @@ defaults = {
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
'FLOWER_URL': "127.0.0.1:5555", 'FLOWER_URL': "127.0.0.1:5555",
'DEFAULT_ORG_SHOW_ALL_USERS': True, 'DEFAULT_ORG_SHOW_ALL_USERS': True,
'PERIOD_TASK_ENABLED': True, 'PERIOD_TASK_ENABLE': True,
'FORCE_SCRIPT_NAME': '',
'LOGIN_CONFIRM_ENABLE': False,
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
'OTP_IN_RADIUS': False,
} }

View File

@ -18,7 +18,9 @@ def jumpserver_processor(request):
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2019', 'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2019',
'SECURITY_COMMAND_EXECUTION': settings.SECURITY_COMMAND_EXECUTION, 'SECURITY_COMMAND_EXECUTION': settings.SECURITY_COMMAND_EXECUTION,
'SECURITY_MFA_VERIFY_TTL': settings.SECURITY_MFA_VERIFY_TTL, 'SECURITY_MFA_VERIFY_TTL': settings.SECURITY_MFA_VERIFY_TTL,
'FORCE_SCRIPT_NAME': settings.FORCE_SCRIPT_NAME,
'SECURITY_VIEW_AUTH_NEED_MFA': settings.CONFIG.SECURITY_VIEW_AUTH_NEED_MFA, 'SECURITY_VIEW_AUTH_NEED_MFA': settings.CONFIG.SECURITY_VIEW_AUTH_NEED_MFA,
'LOGIN_CONFIRM_ENABLE': settings.CONFIG.LOGIN_CONFIRM_ENABLE,
} }
return context return context

View File

@ -71,6 +71,7 @@ INSTALLED_APPS = [
'audits.apps.AuditsConfig', 'audits.apps.AuditsConfig',
'authentication.apps.AuthenticationConfig', # authentication 'authentication.apps.AuthenticationConfig', # authentication
'applications.apps.ApplicationsConfig', 'applications.apps.ApplicationsConfig',
'tickets.apps.TicketsConfig',
'rest_framework', 'rest_framework',
'rest_framework_swagger', 'rest_framework_swagger',
'drf_yasg', 'drf_yasg',
@ -331,7 +332,7 @@ LOCALE_PATHS = [
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/ # https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = '{}/static/'.format(CONFIG.FORCE_SCRIPT_NAME)
STATIC_ROOT = os.path.join(PROJECT_DIR, "data", "static") STATIC_ROOT = os.path.join(PROJECT_DIR, "data", "static")
STATIC_DIR = os.path.join(BASE_DIR, "static") STATIC_DIR = os.path.join(BASE_DIR, "static")
@ -410,6 +411,7 @@ REST_FRAMEWORK = {
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
'authentication.backends.pubkey.PublicKeyAuthBackend',
] ]
# Custom User Auth model # Custom User Auth model
@ -655,3 +657,4 @@ CHANNEL_LAYERS = {
# Enable internal period task # Enable internal period task
PERIOD_TASK_ENABLED = CONFIG.PERIOD_TASK_ENABLED PERIOD_TASK_ENABLED = CONFIG.PERIOD_TASK_ENABLED
FORCE_SCRIPT_NAME = CONFIG.FORCE_SCRIPT_NAME

View File

@ -24,6 +24,7 @@ api_v1 = [
path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')), path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')),
path('common/', include('common.urls.api_urls', namespace='api-common')), path('common/', include('common.urls.api_urls', namespace='api-common')),
path('applications/', include('applications.urls.api_urls', namespace='api-applications')), path('applications/', include('applications.urls.api_urls', namespace='api-applications')),
path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')),
] ]
api_v2 = [ api_v2 = [
@ -42,6 +43,7 @@ app_view_patterns = [
path('orgs/', include('orgs.urls.views_urls', namespace='orgs')), path('orgs/', include('orgs.urls.views_urls', namespace='orgs')),
path('auth/', include('authentication.urls.view_urls'), name='auth'), path('auth/', include('authentication.urls.view_urls'), name='auth'),
path('applications/', include('applications.urls.views_urls', namespace='applications')), path('applications/', include('applications.urls.views_urls', namespace='applications')),
path('tickets/', include('tickets.urls.views_urls', namespace='tickets')),
re_path(r'flower/(?P<path>.*)', celery_flower_view, name='flower-view'), re_path(r'flower/(?P<path>.*)', celery_flower_view, name='flower-view'),
] ]
@ -65,7 +67,8 @@ urlpatterns = [
path('api/v2/', include(api_v2)), path('api/v2/', include(api_v2)),
re_path('api/(?P<app>\w+)/(?P<version>v\d)/.*', views.redirect_format_api), re_path('api/(?P<app>\w+)/(?P<version>v\d)/.*', views.redirect_format_api),
path('api/health/', views.HealthCheckView.as_view(), name="health"), path('api/health/', views.HealthCheckView.as_view(), name="health"),
path('luna/', views.LunaView.as_view(), name='luna-view'), re_path('luna/.*', views.LunaView.as_view(), name='luna-view'),
re_path('koko/.*', views.KokoView.as_view(), name='koko-view'),
re_path('ws/.*', views.WsView.as_view(), name='ws-view'), re_path('ws/.*', views.WsView.as_view(), name='ws-view'),
path('i18n/<str:lang>/', views.I18NView.as_view(), name='i18n-switch'), path('i18n/<str:lang>/', views.I18NView.as_view(), name='i18n-switch'),
path('settings/', include('settings.urls.view_urls', namespace='settings')), path('settings/', include('settings.urls.view_urls', namespace='settings')),

View File

@ -234,3 +234,10 @@ class WsView(APIView):
.format(self.ws_port)) .format(self.ws_port))
return JsonResponse({"msg": msg}) return JsonResponse({"msg": msg})
class KokoView(View):
def get(self, request):
msg = _(
"<div>Koko is a separately deployed program, you need to deploy Koko, configure nginx for url distribution,</div> "
"</div>If you see this page, prove that you are not accessing the nginx listening port. Good luck.</div>")
return HttpResponse(msg)

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -246,30 +246,35 @@ class AdHoc(models.Model):
time_start = time.time() time_start = time.time()
date_start = timezone.now() date_start = timezone.now()
is_success = False is_success = False
summary = {}
raw = ''
try: try:
date_start_s = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') date_start_s = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(_("{} Start task: {}").format(date_start_s, self.task.name)) print(_("{} Start task: {}").format(date_start_s, self.task.name))
raw, summary = self._run_only() raw, summary = self._run_only()
is_success = summary.get('success', False) is_success = summary.get('success', False)
return raw, summary
except Exception as e: except Exception as e:
logger.error(e, exc_info=True) logger.error(e, exc_info=True)
summary = {}
raw = {"dark": {"all": str(e)}, "contacted": []} raw = {"dark": {"all": str(e)}, "contacted": []}
return raw, summary
finally: finally:
date_end = timezone.now() date_end = timezone.now()
date_end_s = date_end.strftime('%Y-%m-%d %H:%M:%S') date_end_s = date_end.strftime('%Y-%m-%d %H:%M:%S')
print(_("{} Task finish").format(date_end_s)) print(_("{} Task finish").format(date_end_s))
print('.\n\n.') print('.\n\n.')
try:
summary_text = json.dumps(summary)
except json.JSONDecodeError:
summary_text = '{}'
AdHocRunHistory.objects.filter(id=history.id).update( AdHocRunHistory.objects.filter(id=history.id).update(
date_start=date_start, date_start=date_start,
is_finished=True, is_finished=True,
is_success=is_success, is_success=is_success,
date_finished=timezone.now(), date_finished=timezone.now(),
timedelta=time.time() - time_start timedelta=time.time() - time_start,
_summary=summary_text
) )
return raw, summary
def _run_only(self): def _run_only(self):
Task.objects.filter(id=self.task.id).update(date_updated=timezone.now()) Task.objects.filter(id=self.task.id).update(date_updated=timezone.now())
@ -321,10 +326,9 @@ class AdHoc(models.Model):
except AdHocRunHistory.DoesNotExist: except AdHocRunHistory.DoesNotExist:
return None return None
def save(self, force_insert=False, force_update=False, using=None, def save(self, **kwargs):
update_fields=None): instance = super().save(**kwargs)
super().save(force_insert=force_insert, force_update=force_update, return instance
using=using, update_fields=update_fields)
def __str__(self): def __str__(self):
return "{} of {}".format(self.task.name, self.short_id) return "{} of {}".format(self.task.name, self.short_id)
@ -393,7 +397,10 @@ class AdHocRunHistory(models.Model):
@summary.setter @summary.setter
def summary(self, item): def summary(self, item):
try:
self._summary = json.dumps(item) self._summary = json.dumps(item)
except json.JSONDecodeError:
self._summary = json.dumps({})
@property @property
def success_hosts(self): def success_hosts(self):

View File

@ -3,6 +3,7 @@
import uuid import uuid
import json import json
from celery.exceptions import SoftTimeLimitExceeded
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext from django.utils.translation import ugettext
@ -64,6 +65,9 @@ class CommandExecution(models.Model):
try: try:
result = runner.execute(self.command, 'all') result = runner.execute(self.command, 'all')
self.result = result.results_command self.result = result.results_command
except SoftTimeLimitExceeded as e:
print("Run timeout than 60s")
self.result = {"error": str(e)}
except Exception as e: except Exception as e:
print("Error occur: {}".format(e)) print("Error occur: {}".format(e))
self.result = {"error": str(e)} self.result = {"error": str(e)}

View File

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import traceback
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -9,7 +8,7 @@ from django.core.exceptions import ValidationError
from common.utils import get_logger from common.utils import get_logger
from ..utils import ( from ..utils import (
set_current_org, get_current_org, current_org, set_current_org, get_current_org, current_org,
get_org_filters filter_org_queryset
) )
from ..models import Organization from ..models import Organization
@ -20,17 +19,18 @@ __all__ = [
] ]
class OrgQuerySet(models.QuerySet):
pass
class OrgManager(models.Manager): class OrgManager(models.Manager):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super(OrgManager, self).get_queryset()
kwargs = get_org_filters() return filter_org_queryset(queryset)
if kwargs:
return queryset.filter(**kwargs) def all(self):
return queryset if not current_org:
msg = 'You can `objects.set_current_org(org).all()` then run it'
return self
else:
return super(OrgManager, self).all()
def set_current_org(self, org): def set_current_org(self, org):
if isinstance(org, str): if isinstance(org, str):
@ -38,20 +38,6 @@ class OrgManager(models.Manager):
set_current_org(org) set_current_org(org)
return self return self
def all(self):
# print("Call all: {}".format(current_org))
#
# lines = traceback.format_stack()
# print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>")
# for line in lines[-10:-1]:
# print(line)
# print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
if not current_org:
msg = 'You can `objects.set_current_org(org).all()` then run it'
return self
else:
return super().all()
class OrgModelMixin(models.Model): class OrgModelMixin(models.Model):
org_id = models.CharField(max_length=36, blank=True, default='', org_id = models.CharField(max_length=36, blank=True, default='',

View File

@ -4,7 +4,7 @@ from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from common.utils import is_uuid from common.utils import is_uuid, lazyproperty
class Organization(models.Model): class Organization(models.Model):
@ -72,7 +72,8 @@ class Organization(models.Model):
org = cls.default() if default else None org = cls.default() if default else None
return org return org
def get_org_users(self): @lazyproperty
def org_users(self):
from users.models import User from users.models import User
if self.is_real(): if self.is_real():
return self.users.all() return self.users.all()
@ -81,18 +82,29 @@ class Organization(models.Model):
users = users.filter(related_user_orgs__isnull=True) users = users.filter(related_user_orgs__isnull=True)
return users return users
def get_org_admins(self): def get_org_users(self):
return self.org_users
@lazyproperty
def org_admins(self):
from users.models import User from users.models import User
if self.is_real(): if self.is_real():
return self.admins.all() return self.admins.all()
return User.objects.filter(role=User.ROLE_ADMIN) return User.objects.filter(role=User.ROLE_ADMIN)
def get_org_auditors(self): def get_org_admins(self):
return self.org_admins
@lazyproperty
def org_auditors(self):
from users.models import User from users.models import User
if self.is_real(): if self.is_real():
return self.auditors.all() return self.auditors.all()
return User.objects.filter(role=User.ROLE_AUDITOR) return User.objects.filter(role=User.ROLE_AUDITOR)
def get_org_auditors(self):
return self.org_auditors
def get_org_members(self, exclude=()): def get_org_members(self, exclude=()):
from users.models import User from users.models import User
members = User.objects.none() members = User.objects.none()

View File

@ -100,16 +100,6 @@
<link rel="stylesheet" type="text/css" href={% static "css/plugins/daterangepicker/daterangepicker.css" %} /> <link rel="stylesheet" type="text/css" href={% static "css/plugins/daterangepicker/daterangepicker.css" %} />
<script> <script>
var dateOptions = {
singleDatePicker: true,
showDropdowns: true,
timePicker: true,
timePicker24Hour: true,
autoApply: true,
locale: {
format: 'YYYY-MM-DD HH:mm'
}
};
var api_action = "{{ api_action }}"; var api_action = "{{ api_action }}";
$(document).ready(function () { $(document).ready(function () {
@ -119,8 +109,8 @@ $(document).ready(function () {
nodesSelect2Init(".nodes-select2"); nodesSelect2Init(".nodes-select2");
usersSelect2Init(".users-select2"); usersSelect2Init(".users-select2");
$('#date_start').daterangepicker(dateOptions); initDateRangePicker('#date_start');
$('#date_expired').daterangepicker(dateOptions); initDateRangePicker('#date_expired');
$("#id_assets").parent().find(".select2-selection").on('click', function (e) { $("#id_assets").parent().find(".select2-selection").on('click', function (e) {
if ($(e.target).attr('class') !== 'select2-selection__choice__remove'){ if ($(e.target).attr('class') !== 'select2-selection__choice__remove'){

View File

@ -23,12 +23,12 @@
{% block content %} {% block content %}
<div class="wrapper wrapper-content"> <div class="wrapper wrapper-content">
<div class="row"> <div class="row">
<div class="col-lg-3" id="split-left" style="padding-left: 3px"> <div class="col-lg-3" id="split-left" style="padding-left: 3px;padding-right: 0">
{% include 'assets/_node_tree.html' %} {% include 'assets/_node_tree.html' %}
</div> </div>
<div class="col-lg-9 animated fadeInRight" id="split-right"> <div class="col-lg-9 animated fadeInRight" id="split-right">
<div class="tree-toggle"> <div class="tree-toggle">
<div class="btn btn-sm btn-primary tree-toggle-btn" onclick="toggle()"> <div class="btn btn-sm btn-primary tree-toggle-btn">
<i class="fa fa-angle-left fa-x" id="toggle-icon"></i> <i class="fa fa-angle-left fa-x" id="toggle-icon"></i>
</div> </div>
</div> </div>
@ -64,16 +64,7 @@
</div> </div>
</div> </div>
<ul class="dropdown-menu search-help"> {% include '_filter_dropdown.html' %}
<li><a class="search-item" data-value="name">{% trans 'Name' %}</a></li>
<li><a class="search-item" data-value="is_valid">{% trans 'Validity' %}</a></li>
<li><a class="search-item" data-value="username">{% trans 'Username' %}</a></li>
<li><a class="search-item" data-value="user_group">{% trans 'User group' %}</a></li>
<li><a class="search-item" data-value="ip">IP</a></li>
<li><a class="search-item" data-value="hostname">{% trans 'Hostname' %}</a></li>
<li><a class="search-item" data-value="node">{% trans 'Node' %}</a></li>
<li><a class="search-item" data-value="system_user">{% trans 'System user' %}</a></li>
</ul>
{% endblock %} {% endblock %}
{% block custom_foot_js %} {% block custom_foot_js %}
@ -209,24 +200,21 @@ function initTree() {
}) })
} }
function toggle() {
if (show === 0) {
$("#split-left").hide(500, function () {
$("#split-right").attr("class", "col-lg-12");
$("#toggle-icon").attr("class", "fa fa-angle-right fa-x");
show = 1;
});
} else {
$("#split-right").attr("class", "col-lg-9");
$("#toggle-icon").attr("class", "fa fa-angle-left fa-x");
$("#split-left").show(500);
show = 0;
}
}
$(document).ready(function(){ $(document).ready(function(){
initTable(); initTable();
initTree(); initTree();
var filterMenu = [
{title: "{% trans 'Name' %}", value: "name"},
{title: "{% trans 'Validity' %}", value: "is_valid"},
{title: "{% trans 'Username' %}", value: "username"},
{title: "{% trans 'User group' %}", value: "user_group"},
{title: "{% trans 'IP' %}", value: "ip"},
{title: "{% trans 'Hostname' %}", value: "hostname"},
{title: "{% trans 'Node' %}", value: "node"},
{title: "{% trans 'System user' %}", value: "system_user"},
];
initTableFilterDropdown('#permission_list_table_filter input', filterMenu)
}) })
.on('click', '.btn-del', function () { .on('click', '.btn-del', function () {
var $this = $(this); var $this = $(this);
@ -284,27 +272,8 @@ $(document).ready(function(){
detailRows.push(tr.attr('id')); detailRows.push(tr.attr('id'));
} }
} }
}).on('click', '#permission_list_table_filter input', function (e) { })
e.preventDefault(); .on('click', 'body', function (e) {
e.stopPropagation();
var position = $('#permission_list_table_filter input').offset();
var y = position['top'];
var x = position['left'];
x -= 220;
y += 30;
$('.search-help').css({"top":y+"px", "left":x+"px", "position": "absolute"});
$('.dropdown-menu.search-help').show();
}).on('click', '.search-item', function (e) {
e.preventDefault();
e.stopPropagation();
var value = $(this).data('value');
var old_value = $('#permission_list_table_filter input').val();
var new_value = old_value + ' ' + value + ':';
$('#permission_list_table_filter input').val(new_value.trim());
$('.dropdown-menu.search-help').hide();
$('#permission_list_table_filter input').focus()
}).on('click', 'body', function (e) {
$('.dropdown-menu.search-help').hide() $('.dropdown-menu.search-help').hide()
}) })

View File

@ -115,8 +115,8 @@ $(document).ready(function () {
closeOnSelect: false closeOnSelect: false
}); });
usersSelect2Init('.users-select2'); usersSelect2Init('.users-select2');
$('#date_start').daterangepicker(dateOptions); initDateRangePicker('#date_start');
$('#date_expired').daterangepicker(dateOptions); initDateRangePicker('#date_expired');
}) })
.on("submit", "form", function (evt) { .on("submit", "form", function (evt) {
evt.preventDefault(); evt.preventDefault();

View File

@ -334,7 +334,8 @@ class PublicSettingApi(generics.RetrieveAPIView):
c = settings.CONFIG c = settings.CONFIG
instance = { instance = {
"data": { "data": {
"WINDOWS_SKIP_ALL_MANUAL_PASSWORD": c.WINDOWS_SKIP_ALL_MANUAL_PASSWORD "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": c.WINDOWS_SKIP_ALL_MANUAL_PASSWORD,
"SECURITY_MAX_IDLE_TIME": c.SECURITY_MAX_IDLE_TIME,
} }
} }
return instance return instance

View File

@ -219,7 +219,7 @@ class SecuritySettingForm(BaseForm):
min_value=1, max_value=99999, required=False, min_value=1, max_value=99999, required=False,
label=_("Connection max idle time"), label=_("Connection max idle time"),
help_text=_( help_text=_(
'If idle time more than it, disconnect connection(only ssh now) ' 'If idle time more than it, disconnect connection '
'Unit: minute' 'Unit: minute'
), ),
) )

View File

@ -474,3 +474,87 @@ span.select2-selection__placeholder {
.p-r-5 { .p-r-5 {
padding-right: 5px; padding-right: 5px;
} }
.dropdown-submenu {
position: relative;
}
.dropdown-submenu>.dropdown-menu {
top: 0;
left: 100%;
margin-top: -6px;
margin-left: -1px;
-webkit-border-radius: 0 6px 6px 6px;
-moz-border-radius: 0 6px 6px;
border-radius: 0 6px 6px 6px;
}
.dropdown-submenu:hover>.dropdown-menu {
display: block;
}
.dropdown-submenu>a:after {
display: block;
content: " ";
float: right;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
border-width: 5px 0 5px 5px;
border-left-color: #ccc;
margin-top: 5px;
margin-right: -10px;
}
.dropdown-submenu:hover>a:after {
border-left-color: #fff;
}
.dropdown-submenu.pull-left {
float: none;
}
.dropdown-submenu.pull-left>.dropdown-menu {
left: -100px;
margin-left: 10px;
-webkit-border-radius: 6px 0 6px 6px;
-moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px;
}
.bootstrap-tagsinput {
border: 1px solid #e5e6e7;
box-shadow: none;
padding: 4px 6px;
cursor: text;
}
/*.bootstrap-tagsinput {*/
/* background-color: #fff;*/
/* border: 1px solid #ccc;*/
/* box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);*/
/* display: inline-block;*/
/* color: #555;*/
/* vertical-align: middle;*/
/* border-radius: 4px;*/
/* max-width: 100%;*/
/* line-height: 22px;*/
/*}*/
.bootstrap-tagsinput input {
border: none;
box-shadow: none;
outline: none;
background-color: transparent;
padding: 0 6px;
margin: 0;
width: auto;
height: 22px;
max-width: inherit;
}
table.table-striped.table-bordered {
width: 100% !important;
}

View File

@ -1588,9 +1588,9 @@ table.dataTable thead .sorting_desc_disabled {
/*.dataTables_length {*/ /*.dataTables_length {*/
/*float: left;*/ /*float: left;*/
/*}*/ /*}*/
.dataTables_filter label { /*.dataTables_filter label {*/
margin-right: 5px; /* margin-right: 5px;*/
} /*}*/
.html5buttons { .html5buttons {
float: right; float: right;
} }

View File

@ -137,14 +137,19 @@ function setAjaxCSRFToken() {
}); });
} }
function activeNav() { function activeNav(prefix) {
var url_array = document.location.pathname.split("/"); var path = document.location.pathname;
var app = url_array[1]; if (prefix) {
var resource = url_array[2]; path = path.replace(prefix, '');
console.log(path);
}
var urlArray = path.split("/");
var app = urlArray[1];
var resource = urlArray[2];
if (app === '') { if (app === '') {
$('#index').addClass('active'); $('#index').addClass('active');
} else if (app === 'xpack' && resource === 'cloud') { } else if (app === 'xpack' && resource === 'cloud') {
var item = url_array[3]; var item = urlArray[3];
$("#" + app).addClass('active'); $("#" + app).addClass('active');
$('#' + app + ' #' + resource).addClass('active'); $('#' + app + ' #' + resource).addClass('active');
$('#' + app + ' #' + resource + ' #' + item + ' a').css('color', '#ffffff'); $('#' + app + ' #' + resource + ' #' + item + ' a').css('color', '#ffffff');
@ -294,6 +299,8 @@ function requestApi(props) {
msg = jqXHR.responseJSON.error msg = jqXHR.responseJSON.error
} else if (jqXHR.responseJSON.msg) { } else if (jqXHR.responseJSON.msg) {
msg = jqXHR.responseJSON.msg msg = jqXHR.responseJSON.msg
} else if (jqXHR.responseJSON.detail) {
msg = jqXHR.responseJSON.detail
} }
} }
if (msg === "") { if (msg === "") {
@ -302,7 +309,7 @@ function requestApi(props) {
toastr.error(msg); toastr.error(msg);
} }
if (typeof props.error === 'function') { if (typeof props.error === 'function') {
return props.error(jqXHR.responseText, jqXHR.status); return props.error(jqXHR.responseText, jqXHR.responseJSON, jqXHR.status);
} }
}); });
// return true; // return true;
@ -411,6 +418,9 @@ function makeLabel(data) {
function parseTableFilter(value) { function parseTableFilter(value) {
var cleanValues = []; var cleanValues = [];
if (!value) {
return {}
}
var valuesArray = value.split(':'); var valuesArray = value.split(':');
for (var i=0; i<valuesArray.length; i++) { for (var i=0; i<valuesArray.length; i++) {
var v = valuesArray[i].trim(); var v = valuesArray[i].trim();
@ -472,6 +482,11 @@ jumpserver.language = {
last: "»" last: "»"
} }
}; };
function setDataTablePagerLength(num) {
$.fn.DataTable.ext.pager.numbers_length = num;
}
jumpserver.initDataTable = function (options) { jumpserver.initDataTable = function (options) {
// options = { // options = {
// ele *: $('#dataTable_id'), // ele *: $('#dataTable_id'),
@ -486,6 +501,7 @@ jumpserver.initDataTable = function (options) {
// op_html: 'div.btn-group?', // op_html: 'div.btn-group?',
// paging: true // paging: true
// } // }
setDataTablePagerLength(5);
var ele = options.ele || $('.dataTable'); var ele = options.ele || $('.dataTable');
var columnDefs = [ var columnDefs = [
{ {
@ -582,8 +598,14 @@ jumpserver.initServerSideDataTable = function (options) {
// columnDefs: [{target: 0, createdCell: ()=>{}}, ...], // columnDefs: [{target: 0, createdCell: ()=>{}}, ...],
// uc_html: '<a>header button</a>', // uc_html: '<a>header button</a>',
// op_html: 'div.btn-group?', // op_html: 'div.btn-group?',
// paging: true // paging: true,
// paging_numbers_length: 5;
// } // }
var pagingNumbersLength = 5;
if (options.paging_numbers_length){
pagingNumbersLength = options.paging_numbers_length;
}
setDataTablePagerLength(pagingNumbersLength);
var ele = options.ele || $('.dataTable'); var ele = options.ele || $('.dataTable');
var columnDefs = [ var columnDefs = [
{ {
@ -606,16 +628,21 @@ jumpserver.initServerSideDataTable = function (options) {
style: select_style, style: select_style,
selector: 'td:first-child' selector: 'td:first-child'
}; };
var dom = '<"#uc.pull-left"> <"pull-right"<"inline"l> <"#fb.inline"> <"inline"f><"#fa.inline">>' +
'tr' +
'<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>';
var table = ele.DataTable({ var table = ele.DataTable({
pageLength: options.pageLength || 15, pageLength: options.pageLength || 15,
// dom: options.dom || '<"#uc.pull-left">fltr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>', // dom: options.dom || '<"#uc.pull-left">fltr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>',
dom: options.dom || '<"#uc.pull-left"><"pull-right"<"inline"l><"#fb.inline"><"inline"f><"#fa.inline">>tr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>', // dom: options.dom || '<"#uc.pull-left"><"pull-right"<"inline"l><"#fb.inline"><"inline"<"table-filter"f>><"#fa.inline">>tr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>',
dom: dom,
order: options.order || [], order: options.order || [],
buttons: [], buttons: [],
columnDefs: columnDefs, columnDefs: columnDefs,
serverSide: true, serverSide: true,
processing: true, processing: true,
searchDelay: 800, searchDelay: 800,
oSearch: options.oSearch,
ajax: { ajax: {
url: options.ajax_url, url: options.ajax_url,
error: function (jqXHR, textStatus, errorThrown) { error: function (jqXHR, textStatus, errorThrown) {
@ -1276,3 +1303,35 @@ function showCeleryTaskLog(taskId) {
var url = '/ops/celery/task/taskId/log/'.replace('taskId', taskId); var url = '/ops/celery/task/taskId/log/'.replace('taskId', taskId);
window.open(url, '', 'width=900,height=600') window.open(url, '', 'width=900,height=600')
} }
function initDateRangePicker(selector, options) {
if (!options) {
options = {}
}
var zhLocale = {
format: 'YYYY-MM-DD HH:mm',
separator: ' ~ ',
applyLabel: "应用",
cancelLabel: "取消",
resetLabel: "重置",
daysOfWeek: ["日", "一", "二", "三", "四", "五", "六"],//汉化处理
monthNames: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
};
var defaultOption = {
singleDatePicker: true,
showDropdowns: true,
timePicker: true,
timePicker24Hour: true,
autoApply: true,
};
var userLang = navigator.language || navigator.userLanguage;;
if (userLang.indexOf('zh') !== -1) {
defaultOption.locale = zhLocale;
}
options = Object.assign(defaultOption, options);
return $(selector).daterangepicker(options);
}
function reloadPage() {
setTimeout( function () {window.location.reload();}, 300);
}

View File

@ -0,0 +1,86 @@
<style>
li.dropdown-submenu {
width: 100%;
}
</style>
<ul class="dropdown-menu multi-level search-help" role="menu" aria-labelledby="dropdownMenu">
</ul>
<script>
function addItem(menuRef, menuItem, parent) {
menuItem.forEach(function (item) {
if (item.submenu) {
var subItemData = "<li class='dropdown-submenu pull-left'>" +
" <a tabindex='-1' class='search-select' href='#'>VALUE</a>" +
"</li>";
var subItem = $(subItemData.replace('VALUE', item.title));
var subMenu = $('<ul class="dropdown-menu"></ul>');
addItem(subMenu, item.submenu, item.value);
subItem.append(subMenu);
menuRef.append(subItem);
} else {
var itemRef = $('<li><a class="search-item" data-value="VALUE">TITLE</a></li>'
.replace('VALUE', item.value)
.replace('TITLE', item.title)
);
if (parent){
itemRef.find('a').data('parent', parent)
}
menuRef.append(itemRef)
}
})
}
function initTableFilterDropdown(selector, menu) {
/*
menu = [
{title: "Title", value: "title"},
{title: "Status", value: "status", submenu: [
{"title": "xxx", value: "xxxx"}
]},
]
*/
var dropdownRef = $(".search-help");
addItem(dropdownRef, menu);
$(selector).on("click", function (e) {
e.preventDefault();
e.stopPropagation();
var offset1 = $(selector).offset();
var x = offset1.left;
var y = offset1.top;
var offset = $(".search-help").parent().offset();
x -= offset.left;
y -= offset.top;
{#x += 18;#}
y += 30;
$('.search-help').css({"top":y+"px", "left":x+"px", "position": "absolute"});
$('.dropdown-menu.search-help').show();
});
$('.search-item').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
var keyword = $(selector);
var value = $(this).data('value');
var oldValue = keyword.val();
var newValue = '';
var parentValue = $(this).data("parent");
var re;
if (parentValue) {
re = new RegExp(parentValue + '\\s*:\\s*\\w+');
oldValue = oldValue.replace(re, '');
newValue = oldValue + ' ' + parentValue + ':' + value;
} else {
re = new RegExp(value + '\\s*:\\s*\\w+');
oldValue = oldValue.replace(re, '');
newValue = oldValue + ' ' + value + ':';
}
keyword.val(newValue.trim());
$('.dropdown-menu.search-help').hide();
keyword.trigger('input');
keyword.focus()
});
$(window).on('click', function (e) {
dropdownRef.hide();
})
}
</script>

View File

@ -9,7 +9,7 @@
<script type="text/javascript" src="{% url 'javascript-catalog' %}"></script> <script type="text/javascript" src="{% url 'javascript-catalog' %}"></script>
<script src="{% static "js/jumpserver.js" %}?v=5"></script> <script src="{% static "js/jumpserver.js" %}?v=5"></script>
<script> <script>
activeNav(); activeNav("{{ FORCE_SCRIPT_NAME }}");
$(document).ready(function(){ $(document).ready(function(){
setAjaxCSRFToken(); setAjaxCSRFToken();
$('textarea').attr('rows', 5); $('textarea').attr('rows', 5);

View File

@ -121,6 +121,14 @@
</li> </li>
{% endif %} {% endif %}
{% if request.user.can_admin_current_org and LOGIN_CONFIRM_ENABLE and LICENSE_VALID %}
<li id="tickets">
<a href="{% url 'tickets:ticket-list' %}">
<i class="fa fa-check-square-o" style="width: 14px"></i>
<span class="nav-label">{% trans 'Tickets' %}</span>
</a>
</li>
{% endif %}
{# Audits #} {# Audits #}
{% if request.user.can_admin_or_audit_current_org %} {% if request.user.can_admin_or_audit_current_org %}

View File

@ -19,7 +19,8 @@
</span> </span>
<span class="fa fa-sort-desc pull-right"></span> <span class="fa fa-sort-desc pull-right"></span>
</a> </a>
<ul class="dropdown-menu" style="min-width: 220px"> <ul class="dropdown-menu" style="min-width: 220px;max-width: 400px;max-height: 400px; overflow: auto">
<input type="text" id="left-side-org-filter" placeholder="{% trans 'Search' %}" class="form-control">
{% for org in ADMIN_OR_AUDIT_ORGS %} {% for org in ADMIN_OR_AUDIT_ORGS %}
<li> <li>
<a class="org-dropdown" href="{% url 'orgs:org-switch' pk=org.id %}" data-id="{{ org.id }}"> <a class="org-dropdown" href="{% url 'orgs:org-switch' pk=org.id %}" data-id="{{ org.id }}">
@ -35,3 +36,28 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</li> </li>
<script>
var orgsRef;
$(document).ready(function () {
orgsRef = $(".org-dropdown");
}).on('click', '#left-side-org-filter', function (e) {
e.preventDefault();
e.stopPropagation();
}).on('keyup', '#left-side-org-filter', function () {
var input = $("#left-side-org-filter").val();
if (!input) {
orgsRef.show();
return
}
orgsRef.each(function (i, v) {
var itemRef = $(v);
var orgItemText = itemRef.text().trim();
var findIndex = orgItemText.indexOf(input);
if (findIndex === -1) {
itemRef.hide();
} else {
itemRef.show();
}
});
})
</script>

View File

@ -11,13 +11,16 @@
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet"> <link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
{% block custom_head_css_js %} {% endblock %} {% block custom_head_css_js %} {% endblock %}
</head> </head>
<body> <body>
<div id="wrapper"> <div id="wrapper">
{% include '_left_side_bar.html' %} {% include '_left_side_bar.html' %}
<div id="page-wrapper" class="gray-bg"> <div id="page-wrapper" class="gray-bg">
{% include '_header_bar.html' %} {% include '_header_bar.html' %}
{% block help_message %} {% endblock %} <div class="alert alert-info help-message alert-dismissable page-message" style="display: none">
<button aria-hidden="true" data-dismiss="alert" class="close hide-btn" type="button">×</button>
{% block help_message %}
{% endblock %}
</div>
{% include '_message.html' %} {% include '_message.html' %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% include '_footer.html' %} {% include '_footer.html' %}
@ -27,4 +30,23 @@
</body> </body>
{% include '_foot_js.html' %} {% include '_foot_js.html' %}
{% block custom_foot_js %} {% endblock %} {% block custom_foot_js %} {% endblock %}
<script>
function getMessagePathKey() {
var path = window.location.pathname;
var key = 'message_' + btoa(path);
return key
}
$(document).ready(function () {
var pathKey = getMessagePathKey();
var hidden = window.localStorage.getItem(pathKey);
var hasMessage = $('.page-message').text().trim().length > 5;
if (!hidden && hasMessage) {
$(".help-message").show();
}
}).on('click', '.hide-btn', function () {
var pathKey = getMessagePathKey();
window.localStorage.setItem(pathKey, '1')
})
</script>
</html> </html>

View File

@ -55,9 +55,6 @@
<div class="col-md-6"> <div class="col-md-6">
{% include '_copyright.html' %} {% include '_copyright.html' %}
</div> </div>
<div class="col-md-6 text-right">
<small>2014-2019</small>
</div>
</div> </div>
</div> </div>
</body> </body>

0
apps/tickets/__init__.py Normal file
View File

3
apps/tickets/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
#
from .ticket import *

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
#
from rest_framework import viewsets
from django.shortcuts import get_object_or_404
from common.permissions import IsValidUser
from common.utils import lazyproperty
from .. import serializers, models, mixins
class TicketViewSet(mixins.TicketMixin, viewsets.ModelViewSet):
serializer_class = serializers.TicketSerializer
queryset = models.Ticket.objects.all()
permission_classes = (IsValidUser,)
filter_fields = ['status', 'title', 'action']
search_fields = ['user_display', 'title']
class TicketCommentViewSet(viewsets.ModelViewSet):
serializer_class = serializers.CommentSerializer
http_method_names = ['get', 'post']
def check_permissions(self, request):
ticket = self.ticket
if request.user == ticket.user or \
request.user in ticket.assignees.all():
return True
return False
def get_serializer_context(self):
context = super().get_serializer_context()
context['ticket'] = self.ticket
return context
@lazyproperty
def ticket(self):
ticket_id = self.kwargs.get('ticket_id')
ticket = get_object_or_404(models.Ticket, pk=ticket_id)
return ticket
def get_queryset(self):
queryset = self.ticket.comments.all()
return queryset

9
apps/tickets/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class TicketsConfig(AppConfig):
name = 'tickets'
def ready(self):
from . import signals_handler
return super().ready()

View File

@ -0,0 +1,59 @@
# Generated by Django 2.2.5 on 2019-11-15 06:57
import common.fields.model
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('user_display', models.CharField(max_length=128, verbose_name='User display name')),
('title', models.CharField(max_length=256, verbose_name='Title')),
('body', models.TextField(verbose_name='Body')),
('meta', common.fields.model.JsonDictTextField(default='{}', verbose_name='Meta')),
('assignee_display', models.CharField(blank=True, max_length=128, null=True, verbose_name='Assignee display name')),
('assignees_display', models.CharField(blank=True, max_length=128, verbose_name='Assignees display name')),
('type', models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm')], default='general', max_length=16, verbose_name='Type')),
('status', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', max_length=16)),
('action', models.CharField(blank=True, choices=[('approve', 'Approve'), ('reject', 'Reject')], default='', max_length=16)),
('assignee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_handled', to=settings.AUTH_USER_MODEL, verbose_name='Assignee')),
('assignees', models.ManyToManyField(related_name='ticket_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Assignees')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_requested', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'ordering': ('-date_created',),
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('user_display', models.CharField(max_length=128, verbose_name='User display name')),
('body', models.TextField(verbose_name='Body')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tickets.Ticket')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'ordering': ('date_created',),
},
),
]

View File

16
apps/tickets/mixins.py Normal file
View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
#
from django.db.models import Q
from .models import Ticket
class TicketMixin:
def get_queryset(self):
assign = self.request.GET.get('assign', None)
if assign is None:
queryset = Ticket.get_related_tickets(self.request.user)
elif assign in ['1']:
queryset = Ticket.get_assigned_tickets(self.request.user)
else:
queryset = Ticket.get_my_tickets(self.request.user)
return queryset

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
#
from .ticket import *

View File

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
#
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from common.mixins.models import CommonModelMixin
from common.fields.model import JsonDictTextField
__all__ = ['Ticket', 'Comment']
class Ticket(CommonModelMixin):
STATUS_OPEN = 'open'
STATUS_CLOSED = 'closed'
STATUS_CHOICES = (
(STATUS_OPEN, _("Open")),
(STATUS_CLOSED, _("Closed"))
)
TYPE_GENERAL = 'general'
TYPE_LOGIN_CONFIRM = 'login_confirm'
TYPE_CHOICES = (
(TYPE_GENERAL, _("General")),
(TYPE_LOGIN_CONFIRM, _("Login confirm"))
)
ACTION_APPROVE = 'approve'
ACTION_REJECT = 'reject'
ACTION_CHOICES = (
(ACTION_APPROVE, _('Approve')),
(ACTION_REJECT, _('Reject')),
)
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_requested', verbose_name=_("User"))
user_display = models.CharField(max_length=128, verbose_name=_("User display name"))
title = models.CharField(max_length=256, verbose_name=_("Title"))
body = models.TextField(verbose_name=_("Body"))
meta = JsonDictTextField(verbose_name=_("Meta"), default='{}')
assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_handled', verbose_name=_("Assignee"))
assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name"))
assignees = models.ManyToManyField('users.User', related_name='%(class)s_assigned', verbose_name=_("Assignees"))
assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True)
type = models.CharField(max_length=16, choices=TYPE_CHOICES, default=TYPE_GENERAL, verbose_name=_("Type"))
status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='open')
action = models.CharField(choices=ACTION_CHOICES, max_length=16, default='', blank=True)
def __str__(self):
return '{}: {}'.format(self.user_display, self.title)
@property
def body_as_html(self):
return self.body.replace('\n', '<br/>')
@property
def status_display(self):
return self.get_status_display()
@property
def type_display(self):
return self.get_type_display()
@property
def action_display(self):
return self.get_action_display()
def create_status_comment(self, status, user):
if status == self.STATUS_CLOSED:
action = _("Close")
else:
action = _("Open")
body = _('{} {} this ticket').format(self.user, action)
self.comments.create(user=user, body=body)
def perform_status(self, status, user):
if self.status == status:
return
self.status = status
self.save()
def create_action_comment(self, action, user):
action_display = dict(self.ACTION_CHOICES).get(action)
body = '{} {} {}'.format(user, action_display, _("this ticket"))
self.comments.create(body=body, user=user, user_display=str(user))
def perform_action(self, action, user):
self.create_action_comment(action, user)
self.action = action
self.status = self.STATUS_CLOSED
self.assignee = user
self.assignees_display = str(user)
self.save()
def is_assignee(self, user):
return self.assignees.filter(id=user.id).exists()
def is_user(self, user):
return self.user == user
@classmethod
def get_related_tickets(cls, user, queryset=None):
if queryset is None:
queryset = cls.objects.all()
queryset = queryset.filter(
Q(assignees=user) | Q(user=user)
).distinct()
return queryset
@classmethod
def get_assigned_tickets(cls, user, queryset=None):
if queryset is None:
queryset = cls.objects.all()
queryset = queryset.filter(assignees=user)
return queryset
@classmethod
def get_my_tickets(cls, user, queryset=None):
if queryset is None:
queryset = cls.objects.all()
queryset = queryset.filter(user=user)
return queryset
class Meta:
ordering = ('-date_created',)
class Comment(CommonModelMixin):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name='comments')
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, verbose_name=_("User"), related_name='comments')
user_display = models.CharField(max_length=128, verbose_name=_("User display name"))
body = models.TextField(verbose_name=_("Body"))
class Meta:
ordering = ('date_created', )

View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
#
from rest_framework.permissions import BasePermission

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
#
from .ticket import *

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
#
from rest_framework import serializers
from .. import models
__all__ = ['TicketSerializer', 'CommentSerializer']
class TicketSerializer(serializers.ModelSerializer):
class Meta:
model = models.Ticket
fields = [
'id', 'user', 'user_display', 'title', 'body',
'assignees', 'assignees_display',
'status', 'action', 'date_created', 'date_updated',
'type', 'type_display', 'action_display',
]
read_only_fields = [
'user_display', 'assignees_display',
'date_created', 'date_updated',
]
def create(self, validated_data):
validated_data.pop('action')
return super().create(validated_data)
def update(self, instance, validated_data):
action = validated_data.get("action")
user = self.context["request"].user
if action and user not in instance.assignees.all():
error = {"action": "Only assignees can update"}
raise serializers.ValidationError(error)
print(validated_data)
print(instance.status)
if instance.status == instance.STATUS_CLOSED:
validated_data.pop('action')
instance = super().update(instance, validated_data)
if not instance.status == instance.STATUS_CLOSED and action:
instance.perform_action(action, user)
return instance
class CurrentTicket(object):
ticket = None
def set_context(self, serializer_field):
self.ticket = serializer_field.context['ticket']
def __call__(self):
return self.ticket
class CommentSerializer(serializers.ModelSerializer):
user = serializers.HiddenField(
default=serializers.CurrentUserDefault(),
)
ticket = serializers.HiddenField(
default=CurrentTicket()
)
class Meta:
model = models.Comment
fields = [
'id', 'ticket', 'body', 'user', 'user_display',
'date_created', 'date_updated'
]
read_only_fields = [
'user_display', 'date_created', 'date_updated'
]

View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
#
from django.dispatch import receiver
from django.db.models.signals import m2m_changed, post_save, pre_save
from common.utils import get_logger
from .models import Ticket, Comment
from .utils import (
send_new_ticket_mail_to_assignees,
send_ticket_action_mail_to_user
)
logger = get_logger(__name__)
@receiver(m2m_changed, sender=Ticket.assignees.through)
def on_ticket_assignees_set(sender, instance=None, action=None,
reverse=False, model=None,
pk_set=None, **kwargs):
if action == 'post_add':
logger.debug('New ticket create, send mail: {}'.format(instance.id))
assignees = model.objects.filter(pk__in=pk_set)
send_new_ticket_mail_to_assignees(instance, assignees)
if action.startswith('post') and not reverse:
instance.assignees_display = ', '.join([
str(u) for u in instance.assignees.all()
])
instance.save()
@receiver(post_save, sender=Ticket)
def on_ticket_status_change(sender, instance=None, created=False, **kwargs):
if created or instance.status == "open":
return
logger.debug('Ticket changed, send mail: {}'.format(instance.id))
send_ticket_action_mail_to_user(instance)
@receiver(pre_save, sender=Ticket)
def on_ticket_create(sender, instance=None, **kwargs):
instance.user_display = str(instance.user)
if instance.assignee:
instance.assignee_display = str(instance.assignee)
@receiver(pre_save, sender=Comment)
def on_comment_create(sender, instance=None, **kwargs):
instance.user_display = str(instance.user)

View File

@ -0,0 +1,181 @@
{% extends 'base.html' %}
{% load static %}
{% load i18n %}
{% block content %}
<div class="wrapper wrapper-content animated fadeInRight">
<div class="row">
<div class="col-sm-12">
<div class="ibox float-e-margins">
<div class="ibox-title">
<h5>
{{ object.title }}
</h5>
<div class="ibox-tools">
<a class="collapse-link">
<i class="fa fa-chevron-up"></i>
</a>
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
<i class="fa fa-wrench"></i>
</a>
<a class="close-link">
<i class="fa fa-times"></i>
</a>
</div>
</div>
<div class="ibox-content">
<div class="row">
<div class="col-lg-11">
<div class="row">
<div class="col-lg-6">
<dl class="dl-horizontal">
<dt>{% trans 'User' %}:</dt> <dd>{{ object.user_display }}</dd>
<dt>{% trans 'Type' %}:</dt> <dd>{{ object.get_type_display | default_if_none:"" }}</dd>
<dt>{% trans 'Status' %}:</dt>
<dd>
{% if object.status == "open" %}
<span class="label label-primary">
{{ object.get_status_display }}
</span>
{% elif object.status == "closed" %}
<span class="label label-danger">
{{ object.get_status_display }}
</span>
{% endif %}
</dd>
</dl>
</div>
<div class="col-lg-6">
<dl class="dl-horizontal">
<dt>{% trans 'Assignees' %}:</dt> <dd> {{ object.assignees_display }}</dd>
<dt>{% trans 'Assignee' %}:</dt> <dd>{{ object.assignee_display | default_if_none:"" }}</dd>
<dt>{% trans 'Date created' %}:</dt> <dd> {{ object.date_created }}</dd>
</dl>
</div>
</div>
<div class="row m-t-sm">
<div class="col-lg-12">
<div class="panel blank-panel">
<div class="panel-body">
<div class="feed-activity-list">
<div class="feed-element">
<a href="#" class="pull-left">
<img alt="image" class="img-circle" src="{% static 'img/avatar/user.png'%}" >
</a>
<div class="media-body ">
<strong>{{ object.user_display }}</strong> <small class="text-muted"> {{ object.date_created|timesince}} {% trans 'ago' %}</small>
<br/>
<small class="text-muted">{{ object.date_created }} </small>
<div style="padding-top: 10px">
{{ object.body_as_html | safe }}
</div>
</div>
</div>
{% for comment in object.comments.all %}
<div class="feed-element">
<a href="#" class="pull-left">
<img alt="image" class="img-circle" src="{% static 'img/avatar/user.png'%}" >
</a>
<div class="media-body ">
<strong>{{ comment.user_display }}</strong> <small class="text-muted"> {{ comment.date_created|timesince}} {% trans 'ago' %}</small>
<br/>
<small class="text-muted">{{ comment.date_created }} </small>
<div style="padding-top: 10px">
{{ comment.body }}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="feed-element">
<a href="" class="pull-left">
<img alt="image" class="img-circle" src="{% static 'img/avatar/user.png'%}" >
</a>
<div class="media-body">
<textarea class="form-control" placeholder="" id="comment"></textarea>
</div>
</div>
<div class="text-right">
{% if has_action_perm %}
<a class="btn btn-sm btn-primary btn-update btn-action" data-action="approve"><i class="fa fa-check"></i> {% trans 'Approve' %}</a>
<a class="btn btn-sm btn-warning btn-update btn-action" data-action="reject"><i class="fa fa-ban"></i> {% trans 'Reject' %}</a>
{% endif %}
<a class="btn btn-sm btn-danger btn-update btn-status" data-uid="closed"><i class="fa fa-times"></i> {% trans 'Close' %}</a>
<a class="btn btn-sm btn-info btn-update btn-comment" data-uid="comment"><i class="fa fa-pencil"></i> {% trans 'Comment' %}</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-1">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block custom_foot_js %}
<script>
var ticketId = "{{ object.id }}";
var status = "{{ object.status }}";
var commentUrl = "{% url 'api-tickets:ticket-comment-list' ticket_id=object.id %}";
var ticketDetailUrl = "{% url 'api-tickets:ticket-detail' pk=object.id %}";
function createComment(successCallback) {
var commentText = $("#comment").val();
if (!commentText) {
return
}
var body = {
body: commentText,
ticket: ticketId,
};
var success = function () {
window.location.reload();
};
if (successCallback){
success = successCallback;
}
requestApi({
url: commentUrl,
data: JSON.stringify(body),
method: "POST",
success: success
})
}
$(document).ready(function () {
if (status !== "open") {
$('.btn-update').attr('disabled', '1')
}
})
.on('click', '.btn-comment', function () {
createComment();
})
.on('click', '.btn-action', function () {
createComment(function () {});
var action = $(this).data('action');
var data = {
url: ticketDetailUrl,
body: JSON.stringify({action: action}),
method: "PATCH",
success: reloadPage
};
requestApi(data);
})
.on('click', '.btn-status', function () {
var status = $(this).data('uid');
var data = {
url: ticketDetailUrl,
body: JSON.stringify({status: status}),
method: "PATCH",
success: reloadPage
};
requestApi(data);
})
</script>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% load i18n static %}
{% block content %}
<div class="wrapper wrapper-content animated fadeIn">
<div class="col-lg-12">
<div class="tabs-container">
<ul class="nav nav-tabs">
<li {% if not assign %} class="active" {% endif %}><a href="{% url 'tickets:ticket-list' %}"> {% trans 'My tickets' %}</a></li>
<li {% if assign %}class="active" {% endif %}><a href="{% url 'tickets:ticket-list' %}?assign=1" >{% trans 'Assigned me' %} <span class="label label-primary">{{ assigned_open_count }}</span></a></li>
</ul>
<div class="tab-content">
<div id="my-tickets" class="tab-pane active">
<div class="panel-body">
{% if False %}
<div class="uc pull-left m-r-5">
<div class="btn-group">
<button data-toggle="dropdown" class="btn btn-primary btn-sm dropdown-toggle" aria-expanded="false">
{% trans 'Create ticket' %} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#">{% trans 'Asset permission' %}</a></li>
</ul>
</div>
</div>
{% endif %}
<table class="table table-striped table-bordered table-hover" id="ticket-list-table" >
<thead>
<tr>
<th class="text-center">
<input id="" type="checkbox" class="ipt_check_all">
</th>
<th class="text-center">{% trans 'Title' %}</th>
<th class="text-center">{% trans 'User' %}</th>
<th class="text-center">{% trans 'Type' %}</th>
<th class="text-center">{% trans 'Status' %}</th>
<th class="text-center">{% trans 'Datetime' %}</th>
</tr>
</thead>
{% include '_filter_dropdown.html' %}
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block content_bottom_left %}{% endblock %}
{% block custom_foot_js %}
<script>
var assignedTable, myTable, listUrl;
{% if assign %}
listUrl = '{% url "api-tickets:ticket-list" %}?assign=1';
{% else %}
listUrl = '{% url "api-tickets:ticket-list" %}?assign=0';
{% endif %}
function initTable() {
var options = {
ele: $('#ticket-list-table'),
oSearch: {sSearch: "status:open"},
columnDefs: [
{targets: 1, createdCell: function (td, cellData, rowData) {
cellData = htmlEscape(cellData);
var detailBtn = '<a href="{% url "tickets:ticket-detail" pk=DEFAULT_PK %}">' + cellData + '</a>';
$(td).html(detailBtn.replace("{{ DEFAULT_PK }}", rowData.id));
}},
{targets: 3, createdCell: function (td, cellData, rowData) {
$(td).html(rowData.type_display)
}},
{targets: 4, createdCell: function (td, cellData) {
if (cellData === "open") {
$(td).html('<i class="fa fa-check-circle-o text-navy"></i>');
} else {
$(td).html('<i class="fa fa-times-circle-o text-danger"></i>')
}
}},
{targets: 5, createdCell: function (td, cellData) {
var d = toSafeLocalDateStr(cellData);
$(td).html(d)
}}
],
ajax_url: listUrl,
columns: [
{data: "id"}, {data: "title"},
{data: "user_display"}, {data: "type"},
{data: "status", width: "40px"},
{data: "date_created"}
],
op_html: $('#actions').html()
};
myTable = jumpserver.initServerSideDataTable(options);
return myTable
}
$(document).ready(function(){
initTable();
var menu = [
{title: "{% trans 'Title' %}", value: "title"},
{title: "{% trans 'User' %}", value: "user_display"},
{title: "{% trans 'Status' %}", value: "status", submenu: [
{title: "{% trans 'Open' %}", value: "open"},
{title: "{% trans 'Closed' %}", value: "closed"},
]},
{title: "{% trans 'Action' %}", value: "action", submenu: [
{title: "{% trans 'Approve' %}", value: "approve"},
{title: "{% trans 'Reject' %}", value: "reject"},
]},
];
initTableFilterDropdown('#ticket-list-table_filter input', menu)
})
</script>
{% endblock %}

3
apps/tickets/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
#

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
#
from rest_framework_bulk.routes import BulkRouter
from .. import api
app_name = 'tickets'
router = BulkRouter()
router.register('tickets', api.TicketViewSet, 'ticket')
router.register('tickets/(?P<ticket_id>[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment')
urlpatterns = [
]
urlpatterns += router.urls

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
#
from django.urls import path
from .. import views
app_name = 'tickets'
urlpatterns = [
path('tickets/', views.TicketListView.as_view(), name='ticket-list'),
path('tickets/<uuid:pk>/', views.TicketDetailView.as_view(), name='ticket-detail'),
]

54
apps/tickets/utils.py Normal file
View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
#
from django.conf import settings
from django.utils.translation import ugettext as _
from common.utils import get_logger, reverse
from common.tasks import send_mail_async
logger = get_logger(__name__)
def send_new_ticket_mail_to_assignees(ticket, assignees):
recipient_list = [user.email for user in assignees]
user = ticket.user
if not recipient_list:
logger.error("Ticket not has assignees: {}".format(ticket.id))
return
subject = '{}: {}'.format(_("New ticket"), ticket.title)
detail_url = reverse('tickets:ticket-detail',
kwargs={'pk': ticket.id}, external=True)
message = _("""
<div>
<p>Your has a new ticket</p>
<div>
{body}
<br/>
<a href={url}>click here to review</a>
</div>
</div>
""").format(body=ticket.body, user=user, url=detail_url)
send_mail_async.delay(subject, message, recipient_list, html_message=message)
def send_ticket_action_mail_to_user(ticket):
if not ticket.user:
logger.error("Ticket not has user: {}".format(ticket.id))
return
user = ticket.user
recipient_list = [user.email]
subject = '{}: {}'.format(_("Ticket has been reply"), ticket.title)
message = _("""
<div>
<p>Your ticket has been replay</p>
<div>
<b>Title:</b> {ticket.title}
<br/>
<b>Assignee:</b> {ticket.assignee_display}
<br/>
<b>Status:</b> {ticket.status_display}
<br/>
</div>
</div>
""").format(ticket=ticket)
send_mail_async.delay(subject, message, recipient_list, html_message=message)

41
apps/tickets/views.py Normal file
View File

@ -0,0 +1,41 @@
from django.views.generic import TemplateView, DetailView
from django.utils.translation import ugettext as _
from common.permissions import PermissionsMixin, IsValidUser
from .models import Ticket
from . import mixins
class TicketListView(PermissionsMixin, TemplateView):
template_name = 'tickets/ticket_list.html'
permission_classes = (IsValidUser,)
def get_context_data(self, **kwargs):
assign = self.request.GET.get('assign', '0') == '1'
context = super().get_context_data(**kwargs)
assigned_open_count = Ticket.get_assigned_tickets(self.request.user)\
.filter(status=Ticket.STATUS_OPEN).count()
context.update({
'app': _("Tickets"),
'action': _("Ticket list"),
'assign': assign,
'assigned_open_count': assigned_open_count
})
return context
class TicketDetailView(PermissionsMixin, mixins.TicketMixin, DetailView):
template_name = 'tickets/ticket_detail.html'
permission_classes = (IsValidUser,)
queryset = Ticket.objects.all()
def get_context_data(self, **kwargs):
ticket = self.get_object()
has_action_perm = ticket.is_assignee(self.request.user)
context = super().get_context_data(**kwargs)
context.update({
'app': _("Tickets"),
'action': _("Ticket detail"),
'has_action_perm': has_action_perm,
})
return context

View File

@ -3,3 +3,4 @@
from .user import * from .user import *
from .group import * from .group import *
from .relation import *

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
#
from rest_framework_bulk import BulkModelViewSet
from django.db.models import F
from common.permissions import IsOrgAdmin
from .. import serializers
from ..models import User
__all__ = ['UserUserGroupRelationViewSet']
class UserUserGroupRelationViewSet(BulkModelViewSet):
filter_fields = ('user', 'usergroup')
search_fields = filter_fields
serializer_class = serializers.UserUserGroupRelationSerializer
permission_classes = (IsOrgAdmin,)
def get_queryset(self):
queryset = User.groups.through.objects.all()\
.annotate(user_name=F('user__name'))\
.annotate(usergroup_name=F('usergroup__name'))
return queryset
def allow_bulk_destroy(self, qs, filtered):
if filtered.count() != 1:
return False
else:
return True

View File

@ -40,6 +40,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet):
filter_fields = ('username', 'email', 'name', 'id') filter_fields = ('username', 'email', 'name', 'id')
search_fields = filter_fields search_fields = filter_fields
serializer_class = serializers.UserSerializer serializer_class = serializers.UserSerializer
serializer_display_class = serializers.UserDisplaySerializer
permission_classes = (IsOrgAdmin, CanUpdateDeleteUser) permission_classes = (IsOrgAdmin, CanUpdateDeleteUser)
def get_queryset(self): def get_queryset(self):
@ -172,8 +173,8 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
if user == request.user: if user == request.user:
msg = _("Could not reset self otp, use profile reset instead") msg = _("Could not reset self otp, use profile reset instead")
return Response({"error": msg}, status=401) return Response({"error": msg}, status=401)
if user.otp_enabled and user.otp_secret_key: if user.mfa_enabled:
user.otp_secret_key = '' user.reset_mfa()
user.save() user.save()
logout(request) logout(request)
return Response({"msg": "success"}) return Response({"msg": "success"})

View File

@ -61,10 +61,10 @@ class UserCreateUpdateFormMixin(OrgModelForm):
fields = [ fields = [
'username', 'name', 'email', 'groups', 'wechat', 'username', 'name', 'email', 'groups', 'wechat',
'source', 'phone', 'role', 'date_expired', 'source', 'phone', 'role', 'date_expired',
'comment', 'otp_level' 'comment', 'mfa_level'
] ]
widgets = { widgets = {
'otp_level': forms.RadioSelect(), 'mfa_level': forms.RadioSelect(),
'groups': forms.SelectMultiple( 'groups': forms.SelectMultiple(
attrs={ attrs={
'class': 'select2', 'class': 'select2',
@ -126,13 +126,13 @@ class UserCreateUpdateFormMixin(OrgModelForm):
def save(self, commit=True): def save(self, commit=True):
password = self.cleaned_data.get('password') password = self.cleaned_data.get('password')
otp_level = self.cleaned_data.get('otp_level') mfa_level = self.cleaned_data.get('mfa_level')
public_key = self.cleaned_data.get('public_key') public_key = self.cleaned_data.get('public_key')
user = super().save(commit=commit) user = super().save(commit=commit)
if password: if password:
user.reset_password(password) user.reset_password(password)
if otp_level: if mfa_level:
user.otp_level = otp_level user.mfa_level = mfa_level
user.save() user.save()
if public_key: if public_key:
user.public_key = public_key user.public_key = public_key
@ -158,8 +158,8 @@ class UserUpdateForm(UserCreateUpdateFormMixin):
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
username = forms.CharField(disabled=True) username = forms.CharField(disabled=True, label=_("Username"))
name = forms.CharField(disabled=True) name = forms.CharField(disabled=True, label=_("Name"))
email = forms.CharField(disabled=True) email = forms.CharField(disabled=True)
class Meta: class Meta:
@ -183,10 +183,10 @@ class UserMFAForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ['otp_level'] fields = ['mfa_level']
widgets = {'otp_level': forms.RadioSelect()} widgets = {'mfa_level': forms.RadioSelect()}
help_texts = { help_texts = {
'otp_level': _('* Enable MFA authentication ' 'mfa_level': _('* Enable MFA authentication '
'to make the account more secure.'), 'to make the account more secure.'),
} }

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.5 on 2019-11-18 08:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0023_auto_20190724_1525'),
]
operations = [
migrations.RenameField(
model_name='user',
old_name='otp_level',
new_name='mfa_level',
),
]

Some files were not shown because too many files have changed in this diff Show More