mirror of https://github.com/jumpserver/jumpserver
commit
b3e2b30e71
|
@ -1,10 +1,8 @@
|
|||
{% extends '_base_list.html' %}
|
||||
{% load i18n static %}
|
||||
{% 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' %}
|
||||
<b><a href="https://github.com/jumpserver/Jmservisor/releases" target="view_window" >{% trans 'Download application loader' %}</a></b>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block table_search %}{% endblock %}
|
||||
{% block table_container %}
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -12,13 +12,12 @@ __all__ = ['GatheredUser']
|
|||
class GatheredUser(OrgModelMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_("Asset"))
|
||||
username = models.CharField(max_length=32, blank=True, db_index=True,
|
||||
verbose_name=_('Username'))
|
||||
username = models.CharField(max_length=32, blank=True, db_index=True, verbose_name=_('Username'))
|
||||
present = models.BooleanField(default=True, verbose_name=_("Present"))
|
||||
date_created = models.DateTimeField(auto_now_add=True,
|
||||
verbose_name=_("Date created"))
|
||||
date_updated = models.DateTimeField(auto_now=True,
|
||||
verbose_name=_("Date updated"))
|
||||
date_last_login = models.DateTimeField(null=True, verbose_name=_("Date last login"))
|
||||
ip_last_login = models.CharField(max_length=39, default='', verbose_name=_("IP last login"))
|
||||
date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created"))
|
||||
date_updated = models.DateTimeField(auto_now=True, verbose_name=_("Date updated"))
|
||||
|
||||
@property
|
||||
def hostname(self):
|
||||
|
|
|
@ -12,6 +12,7 @@ class GatheredUserSerializer(OrgResourceModelSerializerMixin):
|
|||
model = GatheredUser
|
||||
fields = [
|
||||
'id', 'asset', 'hostname', 'ip', 'username',
|
||||
'date_last_login', 'ip_last_login',
|
||||
'present', 'date_created', 'date_updated'
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
|
|
@ -94,6 +94,13 @@ GATHER_ASSET_USERS_TASKS = [
|
|||
"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 = [
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from celery import shared_task
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils import timezone
|
||||
|
||||
from orgs.utils import tmp_to_org
|
||||
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):
|
||||
task_result = {}
|
||||
for task_name, raw in result.items():
|
||||
res = raw.get('ansible_facts', {}).get('getent_passwd')
|
||||
if res:
|
||||
task_result = res
|
||||
break
|
||||
if not task_result or not isinstance(task_result, dict):
|
||||
return []
|
||||
users = []
|
||||
for username, attr in task_result.items():
|
||||
users = defaultdict(dict)
|
||||
users_result = result.get('gather host users', {})\
|
||||
.get('ansible_facts', {})\
|
||||
.get('getent_passwd')
|
||||
if not isinstance(users_result, dict):
|
||||
users_result = {}
|
||||
for username, attr in users_result.items():
|
||||
if ignore_login_shell.search(attr[-1]):
|
||||
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
|
||||
|
||||
|
||||
|
@ -45,7 +52,7 @@ def parse_windows_result_to_users(result):
|
|||
if not task_result:
|
||||
return []
|
||||
|
||||
users = []
|
||||
users = {}
|
||||
|
||||
for i in range(4):
|
||||
task_result.pop(0)
|
||||
|
@ -55,7 +62,7 @@ def parse_windows_result_to_users(result):
|
|||
for line in task_result:
|
||||
user = space.split(line)
|
||||
if user[0]:
|
||||
users.append(user[0])
|
||||
users[user[0]] = {}
|
||||
return users
|
||||
|
||||
|
||||
|
@ -82,8 +89,12 @@ def add_asset_users(assets, results):
|
|||
with tmp_to_org(asset.org_id):
|
||||
GatheredUser.objects.filter(asset=asset, present=True)\
|
||||
.update(present=False)
|
||||
for username in users:
|
||||
for username, data in users.items():
|
||||
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(
|
||||
defaults=defaults, asset=asset, username=username,
|
||||
)
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<div class="form-group">
|
||||
<div class="col-sm-9 col-lg-9 col-sm-offset-2">
|
||||
<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>
|
||||
|
|
|
@ -135,7 +135,8 @@ function initAssetModalTable() {
|
|||
],
|
||||
lengthMenu: [[10, 25, 50], [10, 25, 50]],
|
||||
pageLength: 10,
|
||||
select_style: assetModalOption.selectStyle
|
||||
select_style: assetModalOption.selectStyle,
|
||||
paging_numbers_length: 3
|
||||
};
|
||||
assetModalTable = jumpserver.initServerSideDataTable(options);
|
||||
if (assetModalOption.onModalTableDone) {
|
||||
|
|
|
@ -303,9 +303,24 @@ function defaultCallback(action) {
|
|||
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 () {
|
||||
$('.treebox').css('height', window.innerHeight - 180);
|
||||
$('.treebox').css('height', window.innerHeight - 60);
|
||||
})
|
||||
.on('click', '.btn-show-current-asset', function(){
|
||||
hideRMenu();
|
||||
|
@ -320,6 +335,9 @@ $(document).ready(function () {
|
|||
$('#show_current_asset').css('display', 'inline-block');
|
||||
setCookie('show_current_asset', '');
|
||||
location.reload();
|
||||
}).on('click', '.tree-toggle-btn', function (e) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
{% extends '_base_list.html' %}
|
||||
{% load i18n static %}
|
||||
{% 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 'Jumpserver users of the system using the user to `push system user`, `get assets hardware information`, etc. '%}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block table_search %}
|
||||
<div class="" style="float: right">
|
||||
|
|
|
@ -3,16 +3,16 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% 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' %}
|
||||
</div>
|
||||
{# </div>#}
|
||||
{% endblock %}
|
||||
|
||||
{% block custom_head_css_js %}
|
||||
<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>
|
||||
{# <link href="{% static 'css/plugins/ztree/awesomeStyle/awesome.css' %}" rel="stylesheet">#}
|
||||
{# <script type="text/javascript" src="{% static 'js/plugins/ztree/jquery.ztree.all.min.js' %}"></script>#}
|
||||
<script src="{% static 'js/jquery.form.min.js' %}"></script>
|
||||
<style type="text/css">
|
||||
div#rMenu {
|
||||
|
@ -48,12 +48,12 @@
|
|||
{% block content %}
|
||||
<div class="wrapper wrapper-content">
|
||||
<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' %}
|
||||
</div>
|
||||
<div class="col-lg-9 animated fadeInRight" id="split-right">
|
||||
<div class="tree-toggle">
|
||||
<div class="btn btn-sm btn-primary tree-toggle-btn" onclick="toggle()">
|
||||
<div class="tree-toggle" style="z-index: 9999">
|
||||
<div class="btn btn-sm btn-primary tree-toggle-btn">
|
||||
<i class="fa fa-angle-left fa-x" id="toggle-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -151,9 +151,9 @@ function initTable() {
|
|||
}},
|
||||
{targets: 4, createdCell: function (td, cellData, rowData) {
|
||||
var innerHtml = "";
|
||||
if (cellData.status == 1) {
|
||||
if (cellData.status === 1) {
|
||||
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>'
|
||||
} else {
|
||||
innerHtml = '<i class="fa fa-circle text-warning"></i>'
|
||||
|
@ -386,6 +386,10 @@ $(document).ready(function(){
|
|||
setTimeout( function () {window.location.reload();}, 300);
|
||||
}
|
||||
|
||||
function reloadTable() {
|
||||
asset_table.ajax.reload();
|
||||
}
|
||||
|
||||
function doDeactive() {
|
||||
var data = [];
|
||||
$.each(id_list, function(index, object_id) {
|
||||
|
@ -396,7 +400,7 @@ $(document).ready(function(){
|
|||
url: the_url,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
success: refreshPage
|
||||
success: reloadTable
|
||||
});
|
||||
}
|
||||
function doActive() {
|
||||
|
@ -409,7 +413,7 @@ $(document).ready(function(){
|
|||
url: the_url,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
success: refreshPage
|
||||
success: reloadTable
|
||||
});
|
||||
}
|
||||
function doDelete() {
|
||||
|
@ -431,7 +435,7 @@ $(document).ready(function(){
|
|||
success: function () {
|
||||
var msg = "{% trans 'Asset Deleted.' %}";
|
||||
swal("{% trans 'Asset Delete' %}", msg, "success");
|
||||
refreshPage();
|
||||
reloadTable();
|
||||
},
|
||||
flash_message: false,
|
||||
});
|
||||
|
@ -478,16 +482,12 @@ $(document).ready(function(){
|
|||
'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);
|
||||
|
||||
requestApi({
|
||||
'url': url,
|
||||
'method': 'PUT',
|
||||
'body': JSON.stringify(data),
|
||||
'success': success
|
||||
'success': reloadTable
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -2,14 +2,12 @@
|
|||
{% load i18n static %}
|
||||
{% block table_search %}{% endblock %}
|
||||
{% block help_message %}
|
||||
<div class="alert alert-info help-message">
|
||||
{% 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 '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 'else if action is deny, then command with be deny,' %}
|
||||
{% trans 'else match next rule, if none matched, allowed' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block table_container %}
|
||||
<div class="uc pull-left m-r-5">
|
||||
|
|
|
@ -3,13 +3,9 @@
|
|||
{% block table_search %}{% endblock %}
|
||||
|
||||
{% 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.' %}
|
||||
<br>
|
||||
{% trans 'JMS => Domain gateway => Target assets' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block table_container %}
|
||||
|
|
|
@ -2,11 +2,9 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% 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 '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.' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block table_search %}
|
||||
|
|
|
@ -110,5 +110,14 @@ class UserLoginLog(models.Model):
|
|||
login_logs = login_logs.filter(username__in=username_list)
|
||||
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:
|
||||
ordering = ['-datetime', 'username']
|
||||
|
|
|
@ -4,15 +4,18 @@
|
|||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.request import Request
|
||||
|
||||
from jumpserver.utils import current_request
|
||||
from common.utils import get_request_ip, get_logger, get_syslogger
|
||||
from users.models import User
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from terminal.models import Session, Command
|
||||
from terminal.backends.command.serializers import SessionCommandSerializer
|
||||
from . import models
|
||||
from . import serializers
|
||||
from . import models, serializers
|
||||
from .tasks import write_login_log_async
|
||||
|
||||
logger = get_logger(__name__)
|
||||
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')
|
||||
msg = "{} - {}".format(category, data)
|
||||
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)
|
||||
|
|
|
@ -7,6 +7,7 @@ from celery import shared_task
|
|||
|
||||
from ops.celery.decorator import register_as_period_task
|
||||
from .models import UserLoginLog
|
||||
from .utils import write_login_log
|
||||
|
||||
|
||||
@register_as_period_task(interval=3600*24)
|
||||
|
@ -19,3 +20,8 @@ def clean_login_log_period():
|
|||
days = 90
|
||||
expired_day = now - datetime.timedelta(days=days)
|
||||
UserLoginLog.objects.filter(datetime__lt=expired_day).delete()
|
||||
|
||||
|
||||
@shared_task
|
||||
def write_login_log_async(*args, **kwargs):
|
||||
write_login_log(*args, **kwargs)
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
<td class="text-center">{{ login_log.ip }}</td>
|
||||
<td class="text-center">{{ login_log.city }}</td>
|
||||
<td class="text-center">{{ login_log.get_mfa_display }}</td>
|
||||
<td class="text-center">{% 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.datetime }}</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import csv
|
||||
import codecs
|
||||
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):
|
||||
|
@ -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]
|
||||
writer.writerow(data)
|
||||
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)
|
||||
|
|
|
@ -5,3 +5,4 @@ from .auth import *
|
|||
from .token import *
|
||||
from .mfa import *
|
||||
from .access_key import *
|
||||
from .login_confirm import *
|
||||
|
|
|
@ -1,117 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
import uuid
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.utils import get_logger, get_request_ip
|
||||
from common.permissions import IsOrgAdminOrAppUser, IsValidUser
|
||||
from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdminOrAppUser
|
||||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from users.serializers import UserSerializer
|
||||
from users.models import User
|
||||
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__)
|
||||
__all__ = [
|
||||
'UserAuthApi', 'UserConnectionTokenApi', 'UserOtpAuthApi',
|
||||
'UserOtpVerifyApi',
|
||||
'UserConnectionTokenApi',
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
|
||||
|
@ -153,59 +61,5 @@ class UserConnectionTokenApi(RootOrgViewMixin, APIView):
|
|||
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)
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -1,11 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import time
|
||||
from rest_framework.permissions import AllowAny
|
||||
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 errors
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
class MFAChallengeApi(CreateAPIView):
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
|
||||
|
||||
|
||||
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
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)
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
# -*- 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.response import Response
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
|
||||
from common.utils import get_request_ip, get_logger
|
||||
from users.utils import (
|
||||
check_otp_code, increase_login_failed_count,
|
||||
is_block_login, clean_failed_count
|
||||
)
|
||||
from ..utils import check_user_valid
|
||||
from ..signals import post_auth_success, post_auth_failed
|
||||
from .. import serializers
|
||||
from common.utils import get_logger
|
||||
|
||||
from .. import serializers, errors
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
@ -25,71 +16,26 @@ logger = get_logger(__name__)
|
|||
__all__ = ['TokenCreateApi']
|
||||
|
||||
|
||||
class AuthFailedError(Exception):
|
||||
def __init__(self, msg, reason=None):
|
||||
self.msg = msg
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class MFARequiredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TokenCreateApi(CreateAPIView):
|
||||
class TokenCreateApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = serializers.BearerTokenSerializer
|
||||
|
||||
@staticmethod
|
||||
def check_is_block(username, ip):
|
||||
if is_block_login(username, ip):
|
||||
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_session_if_need(self):
|
||||
if self.request.session.is_empty():
|
||||
self.request.session.create()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
username = self.request.data.get('username')
|
||||
ip = self.request.data.get('remote_addr', None)
|
||||
ip = ip or get_request_ip(self.request)
|
||||
user = None
|
||||
self.create_session_if_need()
|
||||
# 如果认证没有过,检查账号密码
|
||||
try:
|
||||
self.check_is_block(username, ip)
|
||||
user = self.check_user_valid()
|
||||
if user.otp_enabled:
|
||||
raise MFARequiredError()
|
||||
user = self.check_user_auth_if_need()
|
||||
self.check_user_mfa_if_need(user)
|
||||
self.check_user_login_confirm_if_need(user)
|
||||
self.send_auth_signal(success=True, user=user)
|
||||
clean_failed_count(username, ip)
|
||||
return super().create(request, *args, **kwargs)
|
||||
except AuthFailedError as e:
|
||||
increase_login_failed_count(username, ip)
|
||||
self.send_auth_signal(success=False, user=user, username=username, reason=str(e))
|
||||
return Response({'msg': str(e)}, status=401)
|
||||
except MFARequiredError:
|
||||
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
|
||||
)
|
||||
self.clear_auth_mark()
|
||||
resp = super().create(request, *args, **kwargs)
|
||||
return resp
|
||||
except errors.AuthFailedError as e:
|
||||
return Response(e.as_data(), status=400)
|
||||
except errors.NeedMoreInfoError as e:
|
||||
return Response(e.as_data(), status=200)
|
||||
|
|
|
@ -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
|
|
@ -5,6 +5,8 @@ from django.contrib.auth import get_user_model
|
|||
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
|
||||
from django.conf import settings
|
||||
|
||||
from pyrad.packet import AccessRequest
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
|
@ -9,53 +9,19 @@ from django.conf import settings
|
|||
from users.utils import get_login_failed_count
|
||||
|
||||
|
||||
class UserLoginForm(AuthenticationForm):
|
||||
class UserLoginForm(forms.Form):
|
||||
username = forms.CharField(label=_('Username'), max_length=100)
|
||||
password = forms.CharField(
|
||||
label=_('Password'), widget=forms.PasswordInput,
|
||||
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):
|
||||
if not user.is_staff:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['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,
|
||||
code='inactive',
|
||||
)
|
||||
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):
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
)
|
|
@ -1,9 +1,13 @@
|
|||
import uuid
|
||||
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 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):
|
||||
id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True,
|
||||
|
@ -33,3 +37,42 @@ class PrivateToken(Token):
|
|||
|
||||
class Meta:
|
||||
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)
|
||||
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.core.cache import cache
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.utils import get_object_or_none
|
||||
from users.models import User
|
||||
from .models import AccessKey
|
||||
from users.serializers import UserProfileSerializer
|
||||
from .models import AccessKey, LoginConfirmSetting
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer',
|
||||
]
|
||||
|
||||
|
||||
class AccessKeySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = AccessKey
|
||||
fields = ['id', 'secret', 'is_active', 'date_created']
|
||||
|
@ -25,65 +25,54 @@ class OtpVerifySerializer(serializers.Serializer):
|
|||
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)
|
||||
keyword = serializers.SerializerMethodField()
|
||||
date_expired = serializers.DateTimeField(read_only=True)
|
||||
user = UserProfileSerializer(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def get_keyword(obj):
|
||||
return 'Bearer'
|
||||
|
||||
def create_response(self, username):
|
||||
request = self.context.get("request")
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("username %s not exist" % username)
|
||||
def create(self, validated_data):
|
||||
request = self.context.get('request')
|
||||
if request.user and not request.user.is_anonymous:
|
||||
user = request.user
|
||||
else:
|
||||
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)
|
||||
instance = {
|
||||
"username": username,
|
||||
"token": token,
|
||||
"date_expired": date_expired,
|
||||
"user": user
|
||||
}
|
||||
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):
|
||||
pass
|
||||
|
||||
|
||||
class BearerTokenSerializer(BearerTokenMixin, serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
password = serializers.CharField(write_only=True, allow_null=True,
|
||||
required=False)
|
||||
public_key = serializers.CharField(write_only=True, allow_null=True,
|
||||
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)
|
||||
|
||||
class LoginConfirmSettingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = LoginConfirmSetting
|
||||
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
|
||||
read_only_fields = ['date_created', 'date_updated']
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
from rest_framework.request import Request
|
||||
from django.http.request import QueryDict
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.utils import timezone
|
||||
from django_auth_ldap.backend import populate_user
|
||||
|
||||
from common.utils import get_request_ip
|
||||
from .backends.openid import new_client
|
||||
from .backends.openid.signals import (
|
||||
post_create_openid_user, post_openid_login_success
|
||||
)
|
||||
from .tasks import write_login_log_async
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
from .signals import post_auth_success
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
|
@ -52,35 +48,4 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs):
|
|||
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)
|
||||
|
|
|
@ -6,17 +6,8 @@ from ops.celery.decorator import register_as_period_task
|
|||
from django.contrib.sessions.models import Session
|
||||
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)
|
||||
@shared_task
|
||||
def clean_django_sessions():
|
||||
Session.objects.filter(expire_date__lt=timezone.now()).delete()
|
||||
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@
|
|||
<p>
|
||||
{% trans "Changes the world, starting with a little bit." %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="ibox-content">
|
||||
|
@ -47,25 +46,29 @@
|
|||
</div>
|
||||
<form class="m-t" role="form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if block_login %}
|
||||
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p>
|
||||
{% elif password_expired %}
|
||||
<p class="red-fonts">{% trans 'The user password has expired' %}</p>
|
||||
{% elif form.errors %}
|
||||
{% if 'captcha' in form.errors %}
|
||||
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
|
||||
{% else %}
|
||||
{% if form.non_field_errors %}
|
||||
<div style="line-height: 17px;">
|
||||
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
|
||||
{% endif %}
|
||||
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
|
||||
</div>
|
||||
{% elif form.errors.captcha %}
|
||||
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="{{ form.username.html_name }}" placeholder="{% trans 'Username' %}" required="" value="{% if form.username.value %}{{ form.username.value }}{% endif %}">
|
||||
{% if form.errors.username %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.username.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
{{ form.captcha }}
|
||||
|
|
|
@ -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>
|
|
@ -48,6 +48,13 @@
|
|||
float: right;
|
||||
}
|
||||
|
||||
.red-fonts {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
@ -69,30 +76,34 @@
|
|||
<div style="margin-bottom: 10px">
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div style="height: 70px;color: red;line-height: 17px;">
|
||||
{% if block_login %}
|
||||
<p class="red-fonts">{% trans 'Log in frequently and try again later' %}</p>
|
||||
<p class="red-fonts">{{ form.errors.password.as_text }}</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">{{ form.non_field_errors.as_text }}</p>
|
||||
</div>
|
||||
{% elif form.errors.captcha %}
|
||||
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
|
||||
{% else %}
|
||||
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
|
||||
<div style="height: 50px"></div>
|
||||
{% endif %}
|
||||
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{% if form.errors.username %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.username.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
|
||||
{{ form.captcha }}
|
|
@ -1,29 +1,25 @@
|
|||
# coding:utf-8
|
||||
#
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .. import api
|
||||
|
||||
app_name = 'authentication'
|
||||
router = DefaultRouter()
|
||||
router.register('access-keys', api.AccessKeyViewSet, 'access-key')
|
||||
|
||||
|
||||
app_name = 'authentication'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# 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('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||
path('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('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
|
||||
|
|
|
@ -16,5 +16,7 @@ urlpatterns = [
|
|||
# login
|
||||
path('login/', views.UserLoginView.as_view(), name='login'),
|
||||
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'),
|
||||
]
|
||||
|
|
|
@ -3,22 +3,11 @@
|
|||
from django.utils.translation import ugettext as _
|
||||
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 . import const
|
||||
|
||||
|
||||
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)
|
||||
from . import errors
|
||||
|
||||
|
||||
def check_user_valid(**kwargs):
|
||||
|
@ -26,6 +15,7 @@ def check_user_valid(**kwargs):
|
|||
public_key = kwargs.pop('public_key', None)
|
||||
email = kwargs.pop('email', None)
|
||||
username = kwargs.pop('username', None)
|
||||
request = kwargs.get('request')
|
||||
|
||||
if username:
|
||||
user = get_object_or_none(User, username=username)
|
||||
|
@ -35,21 +25,17 @@ def check_user_valid(**kwargs):
|
|||
user = None
|
||||
|
||||
if user is None:
|
||||
return None, const.user_not_exist
|
||||
elif not user.is_valid:
|
||||
return None, const.user_invalid
|
||||
return None, errors.reason_user_not_exist
|
||||
elif user.is_expired:
|
||||
return None, errors.reason_user_inactive
|
||||
elif not user.is_active:
|
||||
return None, errors.reason_user_inactive
|
||||
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, ''
|
||||
|
||||
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
|
||||
return None, errors.reason_password_failed
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from .login import *
|
||||
from .mfa import *
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
import datetime
|
||||
from django.core.cache import cache
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
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.csrf import csrf_protect
|
||||
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.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from common.utils import get_request_ip
|
||||
from users.models import User
|
||||
from audits.models import UserLoginLog as LoginLog
|
||||
from common.utils import get_request_ip, get_object_or_none
|
||||
from users.utils import (
|
||||
check_otp_code, is_block_login, clean_failed_count, get_user_or_tmp_user,
|
||||
set_tmp_user_to_cache, increase_login_failed_count,
|
||||
redirect_user_first_login_or_index,
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
from ..signals import post_auth_success, post_auth_failed
|
||||
from .. import forms
|
||||
from .. import const
|
||||
from .. import forms, mixins, errors
|
||||
|
||||
|
||||
__all__ = [
|
||||
'UserLoginView', 'UserLoginOtpView', 'UserLogoutView',
|
||||
'UserLoginView', 'UserLogoutView',
|
||||
'UserLoginGuardView', 'UserLoginWaitConfirmView',
|
||||
]
|
||||
|
||||
|
||||
@method_decorator(sensitive_post_parameters(), name='dispatch')
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class UserLoginView(FormView):
|
||||
class UserLoginView(mixins.AuthMixin, FormView):
|
||||
form_class = forms.UserLoginForm
|
||||
form_class_captcha = forms.UserLoginCaptchaForm
|
||||
redirect_field_name = 'next'
|
||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||
redirect_field_name = 'next'
|
||||
|
||||
def get_template_names(self):
|
||||
template_name = 'authentication/login.html'
|
||||
|
@ -52,7 +49,7 @@ class UserLoginView(FormView):
|
|||
if not License.has_valid_license():
|
||||
return template_name
|
||||
|
||||
template_name = 'authentication/new_login.html'
|
||||
template_name = 'authentication/xpack_login.html'
|
||||
return template_name
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
@ -68,48 +65,27 @@ class UserLoginView(FormView):
|
|||
request.session.set_test_cookie()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# limit login authentication
|
||||
ip = get_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):
|
||||
if not self.request.session.test_cookie_worked():
|
||||
return HttpResponse(_("Please enable cookies and try again."))
|
||||
user = form.get_user()
|
||||
# user password expired
|
||||
if user.password_has_expired:
|
||||
reason = const.password_expired
|
||||
self.send_auth_signal(success=False, username=user.username, reason=reason)
|
||||
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
|
||||
try:
|
||||
self.check_user_auth()
|
||||
except errors.AuthFailedError as e:
|
||||
form.add_error(None, e.msg)
|
||||
ip = self.get_request_ip()
|
||||
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
|
||||
form = self.form_class_captcha(data=form.data)
|
||||
form._errors = old_form.errors
|
||||
return super().form_invalid(form)
|
||||
def redirect_to_guard_view(self):
|
||||
guard_url = reverse('authentication:login-guard')
|
||||
args = self.request.META.get('QUERY_STRING', '')
|
||||
if args:
|
||||
guard_url = "%s?%s" % (guard_url, args)
|
||||
return redirect(guard_url)
|
||||
|
||||
def get_form_class(self):
|
||||
ip = get_request_ip(self.request)
|
||||
|
@ -118,21 +94,6 @@ class UserLoginView(FormView):
|
|||
else:
|
||||
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):
|
||||
context = {
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
|
@ -141,51 +102,70 @@ class UserLoginView(FormView):
|
|||
kwargs.update(context)
|
||||
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 UserLoginOtpView(FormView):
|
||||
template_name = 'authentication/login_otp.html'
|
||||
form_class = forms.UserCheckOtpCodeForm
|
||||
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
||||
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):
|
||||
user = get_user_or_tmp_user(self.request)
|
||||
otp_code = form.cleaned_data.get('otp_code')
|
||||
otp_secret_key = user.otp_secret_key
|
||||
def format_redirect_url(self, url):
|
||||
args = self.request.META.get('QUERY_STRING', '')
|
||||
if args and self.query_string:
|
||||
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)
|
||||
self.send_auth_signal(success=True, user=user)
|
||||
return redirect(self.get_success_url())
|
||||
else:
|
||||
self.send_auth_signal(
|
||||
success=False, username=user.username,
|
||||
reason=const.mfa_failed
|
||||
self.clear_auth_mark()
|
||||
# 启用但是没有设置otp, 排除radius
|
||||
if user.mfa_enabled_but_not_set():
|
||||
# 1,2,mfa_setting & F
|
||||
return reverse('users:user-otp-enable-authentication')
|
||||
url = redirect_user_first_login_or_index(
|
||||
self.request, self.redirect_field_name
|
||||
)
|
||||
form.add_error(
|
||||
'otp_code', _('MFA code invalid, or ntp sync server time')
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
return url
|
||||
|
||||
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=''):
|
||||
if success:
|
||||
post_auth_success.send(sender=self.__class__, user=user, request=self.request)
|
||||
class UserLoginWaitConfirmView(TemplateView):
|
||||
template_name = 'authentication/login_wait_confirm.html'
|
||||
|
||||
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:
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username=username,
|
||||
request=self.request, reason=reason
|
||||
)
|
||||
ticket = get_object_or_none(Ticket, pk=ticket_id)
|
||||
context = super().get_context_data(**kwargs)
|
||||
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')
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -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)
|
|
@ -25,6 +25,15 @@ class IDSpmFilterMixin:
|
|||
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:
|
||||
default_added_filters = [CustomFilter, IDSpmFilter]
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
|
@ -44,5 +53,5 @@ class ExtraFilterFieldsMixin:
|
|||
return queryset
|
||||
|
||||
|
||||
class CommonApiMixin(ExtraFilterFieldsMixin):
|
||||
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin):
|
||||
pass
|
||||
|
|
|
@ -53,3 +53,15 @@ class CommonModelMixin(models.Model):
|
|||
|
||||
class Meta:
|
||||
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
|
||||
|
|
|
@ -153,6 +153,14 @@ def get_request_ip(request):
|
|||
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):
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
|
|
|
@ -375,6 +375,7 @@ defaults = {
|
|||
'RADIUS_SERVER': 'localhost',
|
||||
'RADIUS_PORT': 1812,
|
||||
'RADIUS_SECRET': '',
|
||||
'RADIUS_ENCRYPT_PASSWORD': True,
|
||||
'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000,
|
||||
'AUTH_LDAP_SYNC_IS_PERIODIC': False,
|
||||
'AUTH_LDAP_SYNC_INTERVAL': None,
|
||||
|
@ -394,8 +395,11 @@ defaults = {
|
|||
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
|
||||
'FLOWER_URL': "127.0.0.1:5555",
|
||||
'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,
|
||||
'OTP_IN_RADIUS': False,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -18,7 +18,9 @@ def jumpserver_processor(request):
|
|||
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2019',
|
||||
'SECURITY_COMMAND_EXECUTION': settings.SECURITY_COMMAND_EXECUTION,
|
||||
'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,
|
||||
'LOGIN_CONFIRM_ENABLE': settings.CONFIG.LOGIN_CONFIRM_ENABLE,
|
||||
}
|
||||
return context
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ INSTALLED_APPS = [
|
|||
'audits.apps.AuditsConfig',
|
||||
'authentication.apps.AuthenticationConfig', # authentication
|
||||
'applications.apps.ApplicationsConfig',
|
||||
'tickets.apps.TicketsConfig',
|
||||
'rest_framework',
|
||||
'rest_framework_swagger',
|
||||
'drf_yasg',
|
||||
|
@ -331,7 +332,7 @@ LOCALE_PATHS = [
|
|||
# Static files (CSS, JavaScript, Images)
|
||||
# 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_DIR = os.path.join(BASE_DIR, "static")
|
||||
|
||||
|
@ -410,6 +411,7 @@ REST_FRAMEWORK = {
|
|||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'authentication.backends.pubkey.PublicKeyAuthBackend',
|
||||
]
|
||||
|
||||
# Custom User Auth model
|
||||
|
@ -655,3 +657,4 @@ CHANNEL_LAYERS = {
|
|||
|
||||
# Enable internal period task
|
||||
PERIOD_TASK_ENABLED = CONFIG.PERIOD_TASK_ENABLED
|
||||
FORCE_SCRIPT_NAME = CONFIG.FORCE_SCRIPT_NAME
|
||||
|
|
|
@ -24,6 +24,7 @@ api_v1 = [
|
|||
path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')),
|
||||
path('common/', include('common.urls.api_urls', namespace='api-common')),
|
||||
path('applications/', include('applications.urls.api_urls', namespace='api-applications')),
|
||||
path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')),
|
||||
]
|
||||
|
||||
api_v2 = [
|
||||
|
@ -42,6 +43,7 @@ app_view_patterns = [
|
|||
path('orgs/', include('orgs.urls.views_urls', namespace='orgs')),
|
||||
path('auth/', include('authentication.urls.view_urls'), name='auth'),
|
||||
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'),
|
||||
]
|
||||
|
||||
|
@ -65,7 +67,8 @@ urlpatterns = [
|
|||
path('api/v2/', include(api_v2)),
|
||||
re_path('api/(?P<app>\w+)/(?P<version>v\d)/.*', views.redirect_format_api),
|
||||
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'),
|
||||
path('i18n/<str:lang>/', views.I18NView.as_view(), name='i18n-switch'),
|
||||
path('settings/', include('settings.urls.view_urls', namespace='settings')),
|
||||
|
|
|
@ -234,3 +234,10 @@ class WsView(APIView):
|
|||
.format(self.ws_port))
|
||||
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
|
@ -246,30 +246,35 @@ class AdHoc(models.Model):
|
|||
time_start = time.time()
|
||||
date_start = timezone.now()
|
||||
is_success = False
|
||||
summary = {}
|
||||
raw = ''
|
||||
|
||||
try:
|
||||
date_start_s = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(_("{} Start task: {}").format(date_start_s, self.task.name))
|
||||
raw, summary = self._run_only()
|
||||
is_success = summary.get('success', False)
|
||||
return raw, summary
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
summary = {}
|
||||
raw = {"dark": {"all": str(e)}, "contacted": []}
|
||||
return raw, summary
|
||||
finally:
|
||||
date_end = timezone.now()
|
||||
date_end_s = date_end.strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(_("{} Task finish").format(date_end_s))
|
||||
print('.\n\n.')
|
||||
try:
|
||||
summary_text = json.dumps(summary)
|
||||
except json.JSONDecodeError:
|
||||
summary_text = '{}'
|
||||
AdHocRunHistory.objects.filter(id=history.id).update(
|
||||
date_start=date_start,
|
||||
is_finished=True,
|
||||
is_success=is_success,
|
||||
date_finished=timezone.now(),
|
||||
timedelta=time.time() - time_start
|
||||
timedelta=time.time() - time_start,
|
||||
_summary=summary_text
|
||||
)
|
||||
return raw, summary
|
||||
|
||||
def _run_only(self):
|
||||
Task.objects.filter(id=self.task.id).update(date_updated=timezone.now())
|
||||
|
@ -321,10 +326,9 @@ class AdHoc(models.Model):
|
|||
except AdHocRunHistory.DoesNotExist:
|
||||
return None
|
||||
|
||||
def save(self, force_insert=False, force_update=False, using=None,
|
||||
update_fields=None):
|
||||
super().save(force_insert=force_insert, force_update=force_update,
|
||||
using=using, update_fields=update_fields)
|
||||
def save(self, **kwargs):
|
||||
instance = super().save(**kwargs)
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return "{} of {}".format(self.task.name, self.short_id)
|
||||
|
@ -393,7 +397,10 @@ class AdHocRunHistory(models.Model):
|
|||
|
||||
@summary.setter
|
||||
def summary(self, item):
|
||||
try:
|
||||
self._summary = json.dumps(item)
|
||||
except json.JSONDecodeError:
|
||||
self._summary = json.dumps({})
|
||||
|
||||
@property
|
||||
def success_hosts(self):
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import uuid
|
||||
import json
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext
|
||||
|
@ -64,6 +65,9 @@ class CommandExecution(models.Model):
|
|||
try:
|
||||
result = runner.execute(self.command, 'all')
|
||||
self.result = result.results_command
|
||||
except SoftTimeLimitExceeded as e:
|
||||
print("Run timeout than 60s")
|
||||
self.result = {"error": str(e)}
|
||||
except Exception as e:
|
||||
print("Error occur: {}".format(e))
|
||||
self.result = {"error": str(e)}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
import traceback
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
|
@ -9,7 +8,7 @@ from django.core.exceptions import ValidationError
|
|||
from common.utils import get_logger
|
||||
from ..utils import (
|
||||
set_current_org, get_current_org, current_org,
|
||||
get_org_filters
|
||||
filter_org_queryset
|
||||
)
|
||||
from ..models import Organization
|
||||
|
||||
|
@ -20,17 +19,18 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
class OrgQuerySet(models.QuerySet):
|
||||
pass
|
||||
|
||||
|
||||
class OrgManager(models.Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
kwargs = get_org_filters()
|
||||
if kwargs:
|
||||
return queryset.filter(**kwargs)
|
||||
return queryset
|
||||
queryset = super(OrgManager, self).get_queryset()
|
||||
return filter_org_queryset(queryset)
|
||||
|
||||
def all(self):
|
||||
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):
|
||||
if isinstance(org, str):
|
||||
|
@ -38,20 +38,6 @@ class OrgManager(models.Manager):
|
|||
set_current_org(org)
|
||||
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):
|
||||
org_id = models.CharField(max_length=36, blank=True, default='',
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.conf import settings
|
|||
from django.db import models
|
||||
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):
|
||||
|
@ -72,7 +72,8 @@ class Organization(models.Model):
|
|||
org = cls.default() if default else None
|
||||
return org
|
||||
|
||||
def get_org_users(self):
|
||||
@lazyproperty
|
||||
def org_users(self):
|
||||
from users.models import User
|
||||
if self.is_real():
|
||||
return self.users.all()
|
||||
|
@ -81,18 +82,29 @@ class Organization(models.Model):
|
|||
users = users.filter(related_user_orgs__isnull=True)
|
||||
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
|
||||
if self.is_real():
|
||||
return self.admins.all()
|
||||
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
|
||||
if self.is_real():
|
||||
return self.auditors.all()
|
||||
return User.objects.filter(role=User.ROLE_AUDITOR)
|
||||
|
||||
def get_org_auditors(self):
|
||||
return self.org_auditors
|
||||
|
||||
def get_org_members(self, exclude=()):
|
||||
from users.models import User
|
||||
members = User.objects.none()
|
||||
|
|
|
@ -100,16 +100,6 @@
|
|||
<link rel="stylesheet" type="text/css" href={% static "css/plugins/daterangepicker/daterangepicker.css" %} />
|
||||
|
||||
<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 }}";
|
||||
|
||||
$(document).ready(function () {
|
||||
|
@ -119,8 +109,8 @@ $(document).ready(function () {
|
|||
nodesSelect2Init(".nodes-select2");
|
||||
usersSelect2Init(".users-select2");
|
||||
|
||||
$('#date_start').daterangepicker(dateOptions);
|
||||
$('#date_expired').daterangepicker(dateOptions);
|
||||
initDateRangePicker('#date_start');
|
||||
initDateRangePicker('#date_expired');
|
||||
|
||||
$("#id_assets").parent().find(".select2-selection").on('click', function (e) {
|
||||
if ($(e.target).attr('class') !== 'select2-selection__choice__remove'){
|
||||
|
|
|
@ -23,12 +23,12 @@
|
|||
{% block content %}
|
||||
<div class="wrapper wrapper-content">
|
||||
<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' %}
|
||||
</div>
|
||||
<div class="col-lg-9 animated fadeInRight" id="split-right">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -64,16 +64,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu search-help">
|
||||
<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>
|
||||
{% include '_filter_dropdown.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% 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(){
|
||||
initTable();
|
||||
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 () {
|
||||
var $this = $(this);
|
||||
|
@ -284,27 +272,8 @@ $(document).ready(function(){
|
|||
detailRows.push(tr.attr('id'));
|
||||
}
|
||||
}
|
||||
}).on('click', '#permission_list_table_filter input', function (e) {
|
||||
e.preventDefault();
|
||||
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) {
|
||||
})
|
||||
.on('click', 'body', function (e) {
|
||||
$('.dropdown-menu.search-help').hide()
|
||||
})
|
||||
|
||||
|
|
|
@ -115,8 +115,8 @@ $(document).ready(function () {
|
|||
closeOnSelect: false
|
||||
});
|
||||
usersSelect2Init('.users-select2');
|
||||
$('#date_start').daterangepicker(dateOptions);
|
||||
$('#date_expired').daterangepicker(dateOptions);
|
||||
initDateRangePicker('#date_start');
|
||||
initDateRangePicker('#date_expired');
|
||||
})
|
||||
.on("submit", "form", function (evt) {
|
||||
evt.preventDefault();
|
||||
|
|
|
@ -334,7 +334,8 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
|||
c = settings.CONFIG
|
||||
instance = {
|
||||
"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
|
||||
|
|
|
@ -219,7 +219,7 @@ class SecuritySettingForm(BaseForm):
|
|||
min_value=1, max_value=99999, required=False,
|
||||
label=_("Connection max idle time"),
|
||||
help_text=_(
|
||||
'If idle time more than it, disconnect connection(only ssh now) '
|
||||
'If idle time more than it, disconnect connection '
|
||||
'Unit: minute'
|
||||
),
|
||||
)
|
||||
|
|
|
@ -474,3 +474,87 @@ span.select2-selection__placeholder {
|
|||
.p-r-5 {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -1588,9 +1588,9 @@ table.dataTable thead .sorting_desc_disabled {
|
|||
/*.dataTables_length {*/
|
||||
/*float: left;*/
|
||||
/*}*/
|
||||
.dataTables_filter label {
|
||||
margin-right: 5px;
|
||||
}
|
||||
/*.dataTables_filter label {*/
|
||||
/* margin-right: 5px;*/
|
||||
/*}*/
|
||||
.html5buttons {
|
||||
float: right;
|
||||
}
|
||||
|
|
|
@ -137,14 +137,19 @@ function setAjaxCSRFToken() {
|
|||
});
|
||||
}
|
||||
|
||||
function activeNav() {
|
||||
var url_array = document.location.pathname.split("/");
|
||||
var app = url_array[1];
|
||||
var resource = url_array[2];
|
||||
function activeNav(prefix) {
|
||||
var path = document.location.pathname;
|
||||
if (prefix) {
|
||||
path = path.replace(prefix, '');
|
||||
console.log(path);
|
||||
}
|
||||
var urlArray = path.split("/");
|
||||
var app = urlArray[1];
|
||||
var resource = urlArray[2];
|
||||
if (app === '') {
|
||||
$('#index').addClass('active');
|
||||
} else if (app === 'xpack' && resource === 'cloud') {
|
||||
var item = url_array[3];
|
||||
var item = urlArray[3];
|
||||
$("#" + app).addClass('active');
|
||||
$('#' + app + ' #' + resource).addClass('active');
|
||||
$('#' + app + ' #' + resource + ' #' + item + ' a').css('color', '#ffffff');
|
||||
|
@ -294,6 +299,8 @@ function requestApi(props) {
|
|||
msg = jqXHR.responseJSON.error
|
||||
} else if (jqXHR.responseJSON.msg) {
|
||||
msg = jqXHR.responseJSON.msg
|
||||
} else if (jqXHR.responseJSON.detail) {
|
||||
msg = jqXHR.responseJSON.detail
|
||||
}
|
||||
}
|
||||
if (msg === "") {
|
||||
|
@ -302,7 +309,7 @@ function requestApi(props) {
|
|||
toastr.error(msg);
|
||||
}
|
||||
if (typeof props.error === 'function') {
|
||||
return props.error(jqXHR.responseText, jqXHR.status);
|
||||
return props.error(jqXHR.responseText, jqXHR.responseJSON, jqXHR.status);
|
||||
}
|
||||
});
|
||||
// return true;
|
||||
|
@ -411,6 +418,9 @@ function makeLabel(data) {
|
|||
|
||||
function parseTableFilter(value) {
|
||||
var cleanValues = [];
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
var valuesArray = value.split(':');
|
||||
for (var i=0; i<valuesArray.length; i++) {
|
||||
var v = valuesArray[i].trim();
|
||||
|
@ -472,6 +482,11 @@ jumpserver.language = {
|
|||
last: "»"
|
||||
}
|
||||
};
|
||||
|
||||
function setDataTablePagerLength(num) {
|
||||
$.fn.DataTable.ext.pager.numbers_length = num;
|
||||
}
|
||||
|
||||
jumpserver.initDataTable = function (options) {
|
||||
// options = {
|
||||
// ele *: $('#dataTable_id'),
|
||||
|
@ -486,6 +501,7 @@ jumpserver.initDataTable = function (options) {
|
|||
// op_html: 'div.btn-group?',
|
||||
// paging: true
|
||||
// }
|
||||
setDataTablePagerLength(5);
|
||||
var ele = options.ele || $('.dataTable');
|
||||
var columnDefs = [
|
||||
{
|
||||
|
@ -582,8 +598,14 @@ jumpserver.initServerSideDataTable = function (options) {
|
|||
// columnDefs: [{target: 0, createdCell: ()=>{}}, ...],
|
||||
// uc_html: '<a>header button</a>',
|
||||
// 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 columnDefs = [
|
||||
{
|
||||
|
@ -606,16 +628,21 @@ jumpserver.initServerSideDataTable = function (options) {
|
|||
style: select_style,
|
||||
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({
|
||||
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"><"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 || [],
|
||||
buttons: [],
|
||||
columnDefs: columnDefs,
|
||||
serverSide: true,
|
||||
processing: true,
|
||||
searchDelay: 800,
|
||||
oSearch: options.oSearch,
|
||||
ajax: {
|
||||
url: options.ajax_url,
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
|
@ -1276,3 +1303,35 @@ function showCeleryTaskLog(taskId) {
|
|||
var url = '/ops/celery/task/taskId/log/'.replace('taskId', taskId);
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -9,7 +9,7 @@
|
|||
<script type="text/javascript" src="{% url 'javascript-catalog' %}"></script>
|
||||
<script src="{% static "js/jumpserver.js" %}?v=5"></script>
|
||||
<script>
|
||||
activeNav();
|
||||
activeNav("{{ FORCE_SCRIPT_NAME }}");
|
||||
$(document).ready(function(){
|
||||
setAjaxCSRFToken();
|
||||
$('textarea').attr('rows', 5);
|
||||
|
|
|
@ -121,6 +121,14 @@
|
|||
</li>
|
||||
{% 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 #}
|
||||
{% if request.user.can_admin_or_audit_current_org %}
|
||||
|
|
|
@ -19,7 +19,8 @@
|
|||
</span>
|
||||
<span class="fa fa-sort-desc pull-right"></span>
|
||||
</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 %}
|
||||
<li>
|
||||
<a class="org-dropdown" href="{% url 'orgs:org-switch' pk=org.id %}" data-id="{{ org.id }}">
|
||||
|
@ -35,3 +36,28 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
</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>
|
||||
|
|
|
@ -11,13 +11,16 @@
|
|||
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
|
||||
{% block custom_head_css_js %} {% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
{% include '_left_side_bar.html' %}
|
||||
<div id="page-wrapper" class="gray-bg">
|
||||
{% 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' %}
|
||||
{% block content %}{% endblock %}
|
||||
{% include '_footer.html' %}
|
||||
|
@ -27,4 +30,23 @@
|
|||
</body>
|
||||
{% include '_foot_js.html' %}
|
||||
{% 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>
|
||||
|
|
|
@ -55,9 +55,6 @@
|
|||
<div class="col-md-6">
|
||||
{% include '_copyright.html' %}
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
<small>2014-2019</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from .ticket import *
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TicketsConfig(AppConfig):
|
||||
name = 'tickets'
|
||||
|
||||
def ready(self):
|
||||
from . import signals_handler
|
||||
return super().ready()
|
|
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from .ticket import *
|
|
@ -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', )
|
|
@ -0,0 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from .ticket import *
|
|
@ -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'
|
||||
]
|
|
@ -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)
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
|
@ -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
|
|
@ -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'),
|
||||
]
|
|
@ -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)
|
|
@ -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
|
|
@ -3,3 +3,4 @@
|
|||
|
||||
from .user import *
|
||||
from .group import *
|
||||
from .relation import *
|
||||
|
|
|
@ -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
|
|
@ -40,6 +40,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet):
|
|||
filter_fields = ('username', 'email', 'name', 'id')
|
||||
search_fields = filter_fields
|
||||
serializer_class = serializers.UserSerializer
|
||||
serializer_display_class = serializers.UserDisplaySerializer
|
||||
permission_classes = (IsOrgAdmin, CanUpdateDeleteUser)
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -172,8 +173,8 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
|
|||
if user == request.user:
|
||||
msg = _("Could not reset self otp, use profile reset instead")
|
||||
return Response({"error": msg}, status=401)
|
||||
if user.otp_enabled and user.otp_secret_key:
|
||||
user.otp_secret_key = ''
|
||||
if user.mfa_enabled:
|
||||
user.reset_mfa()
|
||||
user.save()
|
||||
logout(request)
|
||||
return Response({"msg": "success"})
|
||||
|
|
|
@ -61,10 +61,10 @@ class UserCreateUpdateFormMixin(OrgModelForm):
|
|||
fields = [
|
||||
'username', 'name', 'email', 'groups', 'wechat',
|
||||
'source', 'phone', 'role', 'date_expired',
|
||||
'comment', 'otp_level'
|
||||
'comment', 'mfa_level'
|
||||
]
|
||||
widgets = {
|
||||
'otp_level': forms.RadioSelect(),
|
||||
'mfa_level': forms.RadioSelect(),
|
||||
'groups': forms.SelectMultiple(
|
||||
attrs={
|
||||
'class': 'select2',
|
||||
|
@ -126,13 +126,13 @@ class UserCreateUpdateFormMixin(OrgModelForm):
|
|||
|
||||
def save(self, commit=True):
|
||||
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')
|
||||
user = super().save(commit=commit)
|
||||
if password:
|
||||
user.reset_password(password)
|
||||
if otp_level:
|
||||
user.otp_level = otp_level
|
||||
if mfa_level:
|
||||
user.mfa_level = mfa_level
|
||||
user.save()
|
||||
if public_key:
|
||||
user.public_key = public_key
|
||||
|
@ -158,8 +158,8 @@ class UserUpdateForm(UserCreateUpdateFormMixin):
|
|||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
username = forms.CharField(disabled=True)
|
||||
name = forms.CharField(disabled=True)
|
||||
username = forms.CharField(disabled=True, label=_("Username"))
|
||||
name = forms.CharField(disabled=True, label=_("Name"))
|
||||
email = forms.CharField(disabled=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -183,10 +183,10 @@ class UserMFAForm(forms.ModelForm):
|
|||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['otp_level']
|
||||
widgets = {'otp_level': forms.RadioSelect()}
|
||||
fields = ['mfa_level']
|
||||
widgets = {'mfa_level': forms.RadioSelect()}
|
||||
help_texts = {
|
||||
'otp_level': _('* Enable MFA authentication '
|
||||
'mfa_level': _('* Enable MFA authentication '
|
||||
'to make the account more secure.'),
|
||||
}
|
||||
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue