Commit 45dcb261 authored by ibuler's avatar ibuler

Merge branch 'audits'

parents 4e1f9c97 bb9a0672
...@@ -19,7 +19,7 @@ class IDC(models.Model): ...@@ -19,7 +19,7 @@ class IDC(models.Model):
address = models.CharField(max_length=128, blank=True, verbose_name=_("Address")) address = models.CharField(max_length=128, blank=True, verbose_name=_("Address"))
intranet = models.TextField(blank=True, verbose_name=_('Intranet')) intranet = models.TextField(blank=True, verbose_name=_('Intranet'))
extranet = models.TextField(blank=True, verbose_name=_('Extranet')) extranet = models.TextField(blank=True, verbose_name=_('Extranet'))
date_created = models.DateTimeField(auto_now=True, null=True, verbose_name=_('Date added')) date_created = models.DateTimeField(auto_now_add=True, null=True, verbose_name=_('Date added'))
operator = models.CharField(max_length=32, blank=True, verbose_name=_('Operator')) operator = models.CharField(max_length=32, blank=True, verbose_name=_('Operator'))
created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by'))
comment = models.TextField(blank=True, verbose_name=_('Comment')) comment = models.TextField(blank=True, verbose_name=_('Comment'))
...@@ -62,7 +62,7 @@ class AssetExtend(models.Model): ...@@ -62,7 +62,7 @@ class AssetExtend(models.Model):
key = models.CharField(max_length=64, verbose_name=_('KEY')) key = models.CharField(max_length=64, verbose_name=_('KEY'))
value = models.CharField(max_length=64, verbose_name=_('VALUE')) value = models.CharField(max_length=64, verbose_name=_('VALUE'))
created_by = models.CharField(max_length=32, blank=True, verbose_name=_("Created by")) created_by = models.CharField(max_length=32, blank=True, verbose_name=_("Created by"))
date_created = models.DateTimeField(auto_now=True, null=True) date_created = models.DateTimeField(auto_now_add=True, null=True)
comment = models.TextField(blank=True, verbose_name=_('Comment')) comment = models.TextField(blank=True, verbose_name=_('Comment'))
def __unicode__(self): def __unicode__(self):
...@@ -98,7 +98,7 @@ class AdminUser(models.Model): ...@@ -98,7 +98,7 @@ class AdminUser(models.Model):
_public_key = models.CharField(max_length=4096, blank=True, verbose_name=_('SSH public key')) _public_key = models.CharField(max_length=4096, blank=True, verbose_name=_('SSH public key'))
as_default = models.BooleanField(default=False, verbose_name=_('As default')) as_default = models.BooleanField(default=False, verbose_name=_('As default'))
comment = models.TextField(blank=True, verbose_name=_('Comment')) comment = models.TextField(blank=True, verbose_name=_('Comment'))
date_created = models.DateTimeField(auto_now=True, null=True) date_created = models.DateTimeField(auto_now_add=True, null=True)
created_by = models.CharField(max_length=32, null=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, null=True, verbose_name=_('Created by'))
def __unicode__(self): def __unicode__(self):
...@@ -169,7 +169,7 @@ class SystemUser(models.Model): ...@@ -169,7 +169,7 @@ class SystemUser(models.Model):
shell = models.CharField(max_length=64, default='/bin/bash', verbose_name=_('Shell')) shell = models.CharField(max_length=64, default='/bin/bash', verbose_name=_('Shell'))
home = models.CharField(max_length=64, blank=True, verbose_name=_('Home')) home = models.CharField(max_length=64, blank=True, verbose_name=_('Home'))
uid = models.IntegerField(null=True, blank=True, verbose_name=_('Uid')) uid = models.IntegerField(null=True, blank=True, verbose_name=_('Uid'))
date_created = models.DateTimeField(auto_now=True) date_created = models.DateTimeField(auto_now_add=True)
created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by'))
comment = models.TextField(max_length=128, blank=True, verbose_name=_('Comment')) comment = models.TextField(max_length=128, blank=True, verbose_name=_('Comment'))
...@@ -243,7 +243,7 @@ class AssetGroup(models.Model): ...@@ -243,7 +243,7 @@ class AssetGroup(models.Model):
name = models.CharField(max_length=64, unique=True, verbose_name=_('Name')) name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'))
system_users = models.ManyToManyField(SystemUser, related_name='asset_groups', blank=True) system_users = models.ManyToManyField(SystemUser, related_name='asset_groups', blank=True)
created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, blank=True, verbose_name=_('Created by'))
date_created = models.DateTimeField(auto_now=True, null=True, verbose_name=_('Date added')) date_created = models.DateTimeField(auto_now_add=True, null=True, verbose_name=_('Date added'))
comment = models.TextField(blank=True, verbose_name=_('Comment')) comment = models.TextField(blank=True, verbose_name=_('Comment'))
def __unicode__(self): def __unicode__(self):
...@@ -321,7 +321,7 @@ class Asset(models.Model): ...@@ -321,7 +321,7 @@ class Asset(models.Model):
sn = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Serial number')) sn = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Serial number'))
created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
is_active = models.BooleanField(default=True, verbose_name=_('Is active')) is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
date_created = models.DateTimeField(auto_now=True, null=True, blank=True, verbose_name=_('Date added')) date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date added'))
comment = models.TextField(max_length=128, null=True, blank=True, verbose_name=_('Comment')) comment = models.TextField(max_length=128, null=True, blank=True, verbose_name=_('Comment'))
tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True) tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True)
...@@ -365,15 +365,15 @@ class Asset(models.Model): ...@@ -365,15 +365,15 @@ class Asset(models.Model):
class Tag(models.Model): class Tag(models.Model):
name = models.CharField('标签名', max_length=64,unique=True) name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'))
created_time = models.DateTimeField('创建时间', auto_now_add=True) created_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Create time'))
created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
def __str__(self):
return self.name
def __unicode__(self): def __unicode__(self):
return self.name return self.name
__str__ = __unicode__
class Meta: class Meta:
db_table = 'tag' db_table = 'tag'
......
...@@ -6,18 +6,46 @@ from rest_framework import generics ...@@ -6,18 +6,46 @@ from rest_framework import generics
import serializers import serializers
from .models import ProxyLog from .models import ProxyLog, CommandLog
from .hands import IsSuperUserOrTerminalUser, Terminal
class ProxyLogListCreateApi(generics.ListCreateAPIView): class ProxyLogListCreateApi(generics.ListCreateAPIView):
"""User proxy to backend server need call this api.
params: {
"username": "",
"name": "",
"hostname": "",
"ip": "",
"terminal", "",
"login_type": "",
"system_user": "",
"was_failed": "",
"date_start": ""
}
some params we need generate: {
"log_file", "", # No use now, may be think more about monitor and record
}
"""
queryset = ProxyLog.objects.all() queryset = ProxyLog.objects.all()
serializer_class = serializers.ProxyLogSerializer serializer_class = serializers.ProxyLogSerializer
permission_classes = (IsSuperUserOrTerminalUser,)
def perform_create(self, serializer):
# Todo: May be save log_file
super(ProxyLogListCreateApi, self).perform_create(serializer)
class ProxyLogDetailApi(generics.RetrieveUpdateDestroyAPIView): class ProxyLogDetailApi(generics.RetrieveUpdateDestroyAPIView):
queryset = ProxyLog.objects.all() queryset = ProxyLog.objects.all()
serializer_class = serializers.ProxyLogSerializer serializer_class = serializers.ProxyLogSerializer
permission_classes = (IsSuperUserOrTerminalUser,)
class CommandLogCreateApi(generics.CreateAPIView): class CommandLogCreateApi(generics.ListCreateAPIView):
queryset = CommandLog.objects.all()
serializer_class = serializers.CommandLogSerializer serializer_class = serializers.CommandLogSerializer
permission_classes = (IsSuperUserOrTerminalUser,)
# ~*~ coding: utf-8 ~*~
#
from users.backends import IsSuperUserOrTerminalUser
from terminal.models import Terminal
...@@ -9,17 +9,20 @@ from django.utils.translation import ugettext_lazy as _ ...@@ -9,17 +9,20 @@ from django.utils.translation import ugettext_lazy as _
class LoginLog(models.Model): class LoginLog(models.Model):
LOGIN_TYPE_CHOICE = ( LOGIN_TYPE_CHOICE = (
('S', 'ssh'), ('W', 'Web'),
('W', 'web'), ('S', 'SSH Terminal'),
('WT', 'Web Terminal')
) )
username = models.CharField(max_length=20, verbose_name=_('Username')) username = models.CharField(max_length=20, verbose_name=_('Username'))
name = models.CharField(max_length=20, blank=True, verbose_name=_('Name')) name = models.CharField(max_length=20, blank=True, verbose_name=_('Name'))
login_type = models.CharField(choices=LOGIN_TYPE_CHOICE, max_length=1, verbose_name=_('Login type')) login_type = models.CharField(choices=LOGIN_TYPE_CHOICE, max_length=2, verbose_name=_('Login type'))
terminal = models.CharField(max_length=32, verbose_name=_('Terminal'))
login_ip = models.GenericIPAddressField(verbose_name=_('Login ip')) login_ip = models.GenericIPAddressField(verbose_name=_('Login ip'))
login_city = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('Login city')) login_city = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('Login city'))
user_agent = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('User agent')) user_agent = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('User agent'))
date_login = models.DateTimeField(auto_now=True, verbose_name=_('Date login')) from_terminal = models.ForeignKey
date_login = models.DateTimeField(auto_now_add=True, verbose_name=_('Date login'))
date_logout = models.DateTimeField(null=True, verbose_name=_('Date logout')) date_logout = models.DateTimeField(null=True, verbose_name=_('Date logout'))
class Meta: class Meta:
...@@ -29,8 +32,8 @@ class LoginLog(models.Model): ...@@ -29,8 +32,8 @@ class LoginLog(models.Model):
class ProxyLog(models.Model): class ProxyLog(models.Model):
LOGIN_TYPE_CHOICE = ( LOGIN_TYPE_CHOICE = (
('S', 'ssh'), ('S', 'SSH Terminal'),
('W', 'web'), ('WT', 'Web Terminal'),
) )
username = models.CharField(max_length=20, verbose_name=_('Username')) username = models.CharField(max_length=20, verbose_name=_('Username'))
...@@ -38,11 +41,13 @@ class ProxyLog(models.Model): ...@@ -38,11 +41,13 @@ class ProxyLog(models.Model):
hostname = models.CharField(max_length=128, blank=True, verbose_name=_('Hostname')) hostname = models.CharField(max_length=128, blank=True, verbose_name=_('Hostname'))
ip = models.GenericIPAddressField(max_length=32, verbose_name=_('IP')) ip = models.GenericIPAddressField(max_length=32, verbose_name=_('IP'))
system_user = models.CharField(max_length=20, verbose_name=_('System user')) system_user = models.CharField(max_length=20, verbose_name=_('System user'))
login_type = models.CharField(choices=LOGIN_TYPE_CHOICE, max_length=1, verbose_name=_('Login type')) login_type = models.CharField(choices=LOGIN_TYPE_CHOICE, max_length=2, blank=True,
null=True, verbose_name=_('Login type'))
terminal = models.CharField(max_length=32, blank=True, null=True, verbose_name=_('Terminal'))
log_file = models.CharField(max_length=1000, blank=True, null=True) log_file = models.CharField(max_length=1000, blank=True, null=True)
was_failed = models.BooleanField(default=False, verbose_name=_('Did connect failed')) was_failed = models.BooleanField(default=False, verbose_name=_('Did connect failed'))
is_finished = models.BooleanField(default=False, verbose_name=_('Is finished')) is_finished = models.BooleanField(default=False, verbose_name=_('Is finished'))
date_start = models.DateTimeField(auto_now=True, verbose_name=_('Date start')) date_start = models.DateTimeField(verbose_name=_('Date start'))
date_finished = models.DateTimeField(null=True, verbose_name=_('Date finished')) date_finished = models.DateTimeField(null=True, verbose_name=_('Date finished'))
def __unicode__(self): def __unicode__(self):
...@@ -55,14 +60,14 @@ class ProxyLog(models.Model): ...@@ -55,14 +60,14 @@ class ProxyLog(models.Model):
class CommandLog(models.Model): class CommandLog(models.Model):
proxy_log = models.ForeignKey(ProxyLog, on_delete=models.CASCADE, related_name='command_log') proxy_log = models.ForeignKey(ProxyLog, on_delete=models.CASCADE, related_name='command_log')
command_no = models.IntegerField()
command = models.CharField(max_length=1000, blank=True) command = models.CharField(max_length=1000, blank=True)
output = models.TextField(blank=True) output = models.TextField(blank=True)
date_start = models.DateTimeField(null=True) datetime = models.DateTimeField(null=True)
date_finished = models.DateTimeField(null=True)
def __unicode__(self): def __unicode__(self):
return '%s: %s' % (self.id, self.command) return '%s: %s' % (self.id, self.command)
class Meta: class Meta:
db_table = 'command_log' db_table = 'command_log'
ordering = ['-date_start', 'command'] ordering = ['command_no', 'command']
...@@ -16,5 +16,4 @@ class CommandLogSerializer(serializers.ModelSerializer): ...@@ -16,5 +16,4 @@ class CommandLogSerializer(serializers.ModelSerializer):
model = models.CommandLog model = models.CommandLog
if __name__ == '__main__':
pass
...@@ -7,14 +7,18 @@ from itertools import chain ...@@ -7,14 +7,18 @@ from itertools import chain
import string import string
import logging import logging
from itsdangerous import Signer, TimedJSONWebSignatureSerializer, JSONWebSignatureSerializer, TimestampSigner, \
BadSignature, SignatureExpired
from django.shortcuts import reverse as dj_reverse from django.shortcuts import reverse as dj_reverse
from django.conf import settings from django.conf import settings
from django.core import signing from django.core import signing
from django.utils import timezone from django.utils import timezone
SECRET_KEY = settings.SECRET_KEY
def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None, external=False):
url = dj_reverse(viewname, urlconf=urlconf, args=args, kwargs=kwargs, current_app=current_app) def reverse(view_name, urlconf=None, args=None, kwargs=None, current_app=None, external=False):
url = dj_reverse(view_name, urlconf=urlconf, args=args, kwargs=kwargs, current_app=current_app)
if external: if external:
url = settings.SITE_URL.strip('/') + url url = settings.SITE_URL.strip('/') + url
...@@ -43,13 +47,29 @@ def decrypt(*args, **kwargs): ...@@ -43,13 +47,29 @@ def decrypt(*args, **kwargs):
return '' return ''
def sign(value, secret_key=SECRET_KEY):
signer = TimestampSigner(secret_key)
return signer.sign(value)
def unsign(value, max_age=3600, secret_key=SECRET_KEY):
signer = TimestampSigner(secret_key)
try:
return signer.unsign(value, max_age=max_age)
except (BadSignature, SignatureExpired):
return ''
def date_expired_default(): def date_expired_default():
try: try:
years = int(settings.CONFIG.DEFAULT_EXPIRED_YEARS) years = int(settings.CONFIG.DEFAULT_EXPIRED_YEARS)
except TypeError: except TypeError:
years = 70 years = 70
return timezone.now() + timezone.timedelta(days=365*years)
return timezone.now() + timezone.timedelta(days=365 * years) def sign(value):
return SIGNER.sign(value)
def combine_seq(s1, s2, callback=None): def combine_seq(s1, s2, callback=None):
......
...@@ -33,7 +33,7 @@ except ImportError: ...@@ -33,7 +33,7 @@ except ImportError:
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = CONFIG.SECRET_KEY or '2vym+ky!997d5kkcc64mnz06y1mmui3lut#(^wd=%s_qj$1%x' SECRET_KEY = CONFIG.SECRET_KEY
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = CONFIG.DEBUG or False DEBUG = CONFIG.DEBUG or False
...@@ -54,10 +54,10 @@ INSTALLED_APPS = [ ...@@ -54,10 +54,10 @@ INSTALLED_APPS = [
'users.apps.UsersConfig', 'users.apps.UsersConfig',
'assets.apps.AssetsConfig', 'assets.apps.AssetsConfig',
'perms.apps.PermsConfig', 'perms.apps.PermsConfig',
# 'terminal.apps.TerminalConfig',
'ops.apps.OpsConfig', 'ops.apps.OpsConfig',
'audits.apps.AuditsConfig', 'audits.apps.AuditsConfig',
'common.apps.CommonConfig', 'common.apps.CommonConfig',
'terminal.apps.TerminalConfig',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'bootstrapform', 'bootstrapform',
...@@ -68,7 +68,6 @@ INSTALLED_APPS = [ ...@@ -68,7 +68,6 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'ws4redis',
] ]
...@@ -264,12 +263,14 @@ REST_FRAMEWORK = { ...@@ -264,12 +263,14 @@ REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions, # Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAdminUser', # 'rest_framework.permissions.IsAuthenticated',
'users.backends.IsValidUser',
), ),
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.TokenAuthentication',
'users.backends.TerminalAuthentication',
), ),
} }
# This setting is required to override the Django's main loop, when running in # This setting is required to override the Django's main loop, when running in
......
...@@ -26,6 +26,7 @@ urlpatterns = [ ...@@ -26,6 +26,7 @@ urlpatterns = [
url(r'^assets/', include('assets.urls')), url(r'^assets/', include('assets.urls')),
url(r'^perms/', include('perms.urls')), url(r'^perms/', include('perms.urls')),
url(r'^(api/)?audits/', include('audits.urls')), url(r'^(api/)?audits/', include('audits.urls')),
url(r'^(api/)?terminal/', include('terminal.urls')),
] ]
......
...@@ -27,7 +27,7 @@ class AssetPermission(models.Model): ...@@ -27,7 +27,7 @@ class AssetPermission(models.Model):
is_active = models.BooleanField(default=True, verbose_name=_('Active')) is_active = models.BooleanField(default=True, verbose_name=_('Active'))
date_expired = models.DateTimeField(default=date_expired_default, verbose_name=_('Date expired')) date_expired = models.DateTimeField(default=date_expired_default, verbose_name=_('Date expired'))
created_by = models.CharField(max_length=128, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=128, blank=True, verbose_name=_('Created by'))
date_created = models.DateTimeField(auto_now=True, verbose_name=_('Date created')) date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created'))
comment = models.TextField(verbose_name=_('Comment'), blank=True) comment = models.TextField(verbose_name=_('Comment'), blank=True)
def __unicode__(self): def __unicode__(self):
......
...@@ -4447,7 +4447,7 @@ extend(SVGRenderer.prototype, { ...@@ -4447,7 +4447,7 @@ extend(SVGRenderer.prototype, {
* START OF INTERNET EXPLORER <= 8 SPECIFIC CODE * * START OF INTERNET EXPLORER <= 8 SPECIFIC CODE *
* * * *
* For applications and websites that don't need IE support, like platform * * For applications and websites that don't need IE support, like platform *
* targeted mobile apps and web apps, this code can be removed. * * targeted mobile terminal and web terminal, this code can be removed. *
* * * *
*****************************************************************************/ *****************************************************************************/
......
...@@ -4217,19 +4217,19 @@ Terminal.prototype.setMode = function(params) { ...@@ -4217,19 +4217,19 @@ Terminal.prototype.setMode = function(params) {
// focusout: ^[[O // focusout: ^[[O
this.sendFocus = true; this.sendFocus = true;
break; break;
case 1005: // utf8 ext mode mouse case 1005: // utf8 terminal mode mouse
this.utfMouse = true; this.utfMouse = true;
// for wide terminals // for wide terminals
// simply encodes large values as utf8 characters // simply encodes large values as utf8 characters
break; break;
case 1006: // sgr ext mode mouse case 1006: // sgr terminal mode mouse
this.sgrMouse = true; this.sgrMouse = true;
// for wide terminals // for wide terminals
// does not add 32 to fields // does not add 32 to fields
// press: ^[[<b;x;yM // press: ^[[<b;x;yM
// release: ^[[<b;x;ym // release: ^[[<b;x;ym
break; break;
case 1015: // urxvt ext mode mouse case 1015: // urxvt terminal mode mouse
this.urxvtMouse = true; this.urxvtMouse = true;
// for wide terminals // for wide terminals
// numbers for fields // numbers for fields
...@@ -4406,13 +4406,13 @@ Terminal.prototype.resetMode = function(params) { ...@@ -4406,13 +4406,13 @@ Terminal.prototype.resetMode = function(params) {
case 1004: // send focusin/focusout events case 1004: // send focusin/focusout events
this.sendFocus = false; this.sendFocus = false;
break; break;
case 1005: // utf8 ext mode mouse case 1005: // utf8 terminal mode mouse
this.utfMouse = false; this.utfMouse = false;
break; break;
case 1006: // sgr ext mode mouse case 1006: // sgr terminal mode mouse
this.sgrMouse = false; this.sgrMouse = false;
break; break;
case 1015: // urxvt ext mode mouse case 1015: // urxvt terminal mode mouse
this.urxvtMouse = false; this.urxvtMouse = false;
break; break;
case 25: // hide cursor case 25: // hide cursor
...@@ -6030,7 +6030,7 @@ Terminal.charsets = {}; ...@@ -6030,7 +6030,7 @@ Terminal.charsets = {};
// DEC Special Character and Line Drawing Set. // DEC Special Character and Line Drawing Set.
// http://vt100.net/docs/vt102-ug/table5-13.html // http://vt100.net/docs/vt102-ug/table5-13.html
// A lot of curses apps use this if they see TERM=xterm. // A lot of curses terminal use this if they see TERM=xterm.
// testing: echo -e '\e(0a\e(B' // testing: echo -e '\e(0a\e(B'
// The xterm output sometimes seems to conflict with the // The xterm output sometimes seems to conflict with the
// reference above. xterm seems in line with the reference // reference above. xterm seems in line with the reference
......
...@@ -32,11 +32,13 @@ ...@@ -32,11 +32,13 @@
<li id="asset-permission"> <li id="asset-permission">
<a href="{% url 'perms:asset-permission-list' %}">{% trans 'Asset permission' %}</a> <a href="{% url 'perms:asset-permission-list' %}">{% trans 'Asset permission' %}</a>
</li> </li>
{# <li id="user-group">#}
{# <a href="">{% trans 'User group perm' %}</a>#}
{# </li>#}
</ul> </ul>
</li> </li>
<li id="">
<a href="{% url 'terminal:terminal-list' %}">
<i class="fa fa-desktop"></i><span class="nav-label">{% trans 'Terminal' %}</span><span class="label label-info pull-right"></span>
</a>
</li>
<li id=""> <li id="">
<a href=""> <a href="">
<i class="fa fa-files-o"></i><span class="nav-label">{% trans 'Audits' %}</span><span class="label label-info pull-right"></span> <i class="fa fa-files-o"></i><span class="nav-label">{% trans 'Audits' %}</span><span class="label label-info pull-right"></span>
......
from django.contrib import admin
# Register your models here.
# -*- coding: utf-8 -*-
#
from rest_framework.generics import ListCreateAPIView, CreateAPIView
from rest_framework.views import APIView, Response
from rest_framework.permissions import AllowAny
from common.utils import unsign, get_object_or_none
from .models import Terminal, TerminalHeatbeat
from .serializers import TerminalSerializer, TerminalHeatbeatSerializer
from .hands import IsSuperUserOrTerminalUser
class TerminalApi(ListCreateAPIView):
queryset = Terminal.objects.all()
serializer_class = TerminalSerializer
permission_classes = (AllowAny,)
def post(self, request, *args, **kwargs):
name = unsign(request.data.get('name', ''))
if name:
terminal = get_object_or_none(Terminal, name=name)
if terminal:
if terminal.is_accepted and terminal.is_active:
return Response(data={'data': {'name': name, 'id': terminal.id},
'msg': 'Success'},
status=200)
else:
return Response(data={'data': {'name': name, 'ip': terminal.ip},
'msg': 'Need admin accept or active it'},
status=203)
else:
ip = request.META.get('X-Real-IP') or request.META.get('REMOTE_ADDR')
terminal = Terminal.objects.create(name=name, ip=ip)
return Response(data={'data': {'name': name, 'ip': terminal.ip},
'msg': 'Need admin accept or active it'},
status=204)
else:
return Response(data={'msg': 'Secrete key invalid'}, status=401)
class TerminalHeatbeatApi(CreateAPIView):
model = TerminalHeatbeat
serializer_class = TerminalHeatbeatSerializer
permission_classes = (IsSuperUserOrTerminalUser,)
from __future__ import unicode_literals
from django.apps import AppConfig
class TerminalConfig(AppConfig):
name = 'terminal'
# -*- coding: utf-8 -*-
#
from users.backends import IsSuperUserOrTerminalUser
from __future__ import unicode_literals
from django.db import models
from django.utils.translation import ugettext_lazy as _
from users.models import User
class Terminal(models.Model):
TYPE_CHOICES = (
('S', 'SSH Terminal'),
('WT', 'Web Terminal')
)
name = models.CharField(max_length=30, unique=True, verbose_name=_('Name'))
ip = models.GenericIPAddressField(verbose_name=_('From ip'))
is_active = models.BooleanField(default=False, verbose_name=_('Is active'))
is_bound_ip = models.BooleanField(default=False, verbose_name=_('Is bound ip'))
heatbeat_interval = models.IntegerField(default=60, verbose_name=_('Heatbeat interval'))
type = models.CharField(choices=TYPE_CHOICES, max_length=2, verbose_name=_('Terminal type'))
url = models.CharField(max_length=100, verbose_name=_('URL to login'))
mail_to = models.ManyToManyField(User, verbose_name=_('Mail to'))
is_accepted = models.BooleanField(default=False, verbose_name=_('Is accepted'))
date_created = models.DateTimeField(auto_now_add=True)
comment = models.TextField(verbose_name=_('Comment'))
def is_valid(self):
return self.is_active and self.is_accepted
@property
def is_superuser(self):
return False
@property
def is_terminal(self):
return True
class Meta:
db_table = 'terminal'
ordering = ['name']
class TerminalHeatbeat(models.Model):
terminal = models.ForeignKey(Terminal, on_delete=models.CASCADE)
date_created = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'terminal_heatbeat'
# -*- coding: utf-8 -*-
#
from rest_framework import serializers
from .models import Terminal, TerminalHeatbeat
class TerminalSerializer(serializers.ModelSerializer):
class Meta:
model = Terminal
fields = ['name', 'ip', 'type', 'url', 'comment', 'is_active', 'is_accepted',
'get_type_display']
class TerminalHeatbeatSerializer(serializers.ModelSerializer):
class Meta:
model = TerminalHeatbeat
fields = ['terminal']
if __name__ == '__main__':
pass
{% extends '_base_list.html' %}
{% load i18n static %}
{% block custom_head_css_js %}
{{ block.super }}
<style>
div.dataTables_wrapper div.dataTables_filter,
.dataTables_length {
float: right !important;
}
div.dataTables_wrapper div.dataTables_filter {
margin-left: 15px;
}
</style>
{% endblock %}
{% block table_search %}{% endblock %}
{% block table_container %}
{#<div class="uc pull-left m-l-5 m-r-5"><a href="{% url "users:user-create" %}" class="btn btn-sm btn-primary"> {% trans "Create user" %} </a></div>#}
<table class="table table-striped table-bordered table-hover " id="terminal_list_table" >
<thead>
<tr>
<th class="text-center">
<div class="checkbox checkbox-default">
<input type="checkbox" class="ipt_check_all">
</div>
</th>
<th class="text-center">{% trans 'Name' %}</th>
<th class="text-center">{% trans 'IP' %}</th>
<th class="text-center">{% trans 'Type' %}</th>
<th class="text-center">{% trans 'url' %}</th>
<th class="text-center">{% trans 'Active' %}</th>
<th class="text-center">{% trans 'Action' %}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
{% endblock %}
{% block content_bottom_left %}{% endblock %}
{% block custom_foot_js %}
<script src="{% static 'js/jquery.form.min.js' %}"></script>
<script>
$(document).ready(function(){
var options = {
ele: $('#terminal_list_table'),
{# columnDefs: [#}
{# {targets: 1, createdCell: function (td, cellData, rowData) {#}
{# var detail_btn = '<a href="{% url "users:user-detail" pk=99991937 %}">' + cellData + '</a>';#}
{# $(td).html(detail_btn.replace('99991937', rowData.id));#}
{# }}#}
{# {targets: 4, createdCell: function (td, cellData) {#}
{# var innerHtml = cellData.length > 8 ? cellData.substring(0, 8) + '...': cellData;#}
{# $(td).html('<a href="javascript:void(0);" data-toggle="tooltip" title="' + cellData + '">' + innerHtml + '</a>');#}
{# }},#}
{# {targets: 6, createdCell: function (td, cellData) {#}
{# if (!cellData) {#}
{# $(td).html('<i class="fa fa-times text-danger"></i>')#}
{# } else {#}
{# $(td).html('<i class="fa fa-check text-navy"></i>')#}
{# }#}
{# }},#}
{# {targets: 7, createdCell: function (td, cellData, rowData) {#}
{# var update_btn = '<a href="{% url "users:user-update" pk=99991937 %}" class="btn btn-xs btn-info">{% trans "Update" %}</a>'.replace('99991937', cellData);#}
{# var del_btn = '<a class="btn btn-xs btn-danger m-l-xs btn_user_delete" data-uid="99991937">{% trans "Delete" %}</a>'.replace('99991937', cellData);#}
{# if (rowData.id === 1) {#}
{# $(td).html(update_btn)#}
{# } else {#}
{# $(td).html(update_btn + del_btn)#}
{# }}],#}
{# ],#}
ajax_url: '{% url "terminal:terminal-list-create-api" %}',
columns: [{data: function(){return ""}}, {data: "name" }, {data: "ip" }, {data: "get_type_display" }, {data: "url" },
{data: "is_active" }, {data: "ip"}],
op_html: $('#actions').html()
};
jumpserver.initDataTable(options);
})
</script>
{% endblock %}
from django.test import TestCase
# Create your tests here.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
from django.conf.urls import url
import views
import api
app_name = 'terminal'
urlpatterns = [
url(r'^terminal$', views.TerminalListView.as_view(), name='terminal-list'),
]
urlpatterns += [
url(r'^v1/terminal/$', api.TerminalApi.as_view(), name='terminal-list-create-api'),
url(r'^v1/terminal-heatbeat/$', api.TerminalHeatbeatApi.as_view(), name='terminal-heatbeat-api'),
]
# ~*~ coding: utf-8 ~*~
#
from django.views.generic import ListView
from django.utils.translation import ugettext as _
from .models import Terminal
class TerminalListView(ListView):
model = Terminal
template_name = 'terminal/terminal_list.html'
def get_context_data(self, **kwargs):
context = super(TerminalListView, self).get_context_data(**kwargs)
context.update({'app': _('Terminal'), 'action': _('Terminal list')})
return context
...@@ -7,11 +7,12 @@ from rest_framework import generics, status ...@@ -7,11 +7,12 @@ from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_bulk import ListBulkCreateUpdateDestroyAPIView from rest_framework_bulk import ListBulkCreateUpdateDestroyAPIView
from common.mixins import BulkDeleteApiMixin
from common.utils import get_logger
from .models import User, UserGroup from .models import User, UserGroup
from .serializers import UserDetailSerializer, UserAndGroupSerializer, \ from .serializers import UserDetailSerializer, UserAndGroupSerializer, \
GroupDetailSerializer, UserPKUpdateSerializer, UserBulkUpdateSerializer, GroupBulkUpdateSerializer GroupDetailSerializer, UserPKUpdateSerializer, UserBulkUpdateSerializer, GroupBulkUpdateSerializer
from common.mixins import BulkDeleteApiMixin from .backends import IsSuperUser, IsTerminalUser, IsValidUser, IsSuperUserOrTerminalUser
from common.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
...@@ -20,11 +21,13 @@ logger = get_logger(__name__) ...@@ -20,11 +21,13 @@ logger = get_logger(__name__)
class UserDetailApi(generics.RetrieveUpdateDestroyAPIView): class UserDetailApi(generics.RetrieveUpdateDestroyAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserDetailSerializer serializer_class = UserDetailSerializer
permission_classes = (IsSuperUser,)
class UserAndGroupEditApi(generics.RetrieveUpdateAPIView): class UserAndGroupEditApi(generics.RetrieveUpdateAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserAndGroupSerializer serializer_class = UserAndGroupSerializer
permission_classes = (IsSuperUser,)
class UserResetPasswordApi(generics.UpdateAPIView): class UserResetPasswordApi(generics.UpdateAPIView):
...@@ -84,6 +87,10 @@ class GroupDetailApi(generics.RetrieveUpdateDestroyAPIView): ...@@ -84,6 +87,10 @@ class GroupDetailApi(generics.RetrieveUpdateDestroyAPIView):
class UserListUpdateApi(BulkDeleteApiMixin, ListBulkCreateUpdateDestroyAPIView): class UserListUpdateApi(BulkDeleteApiMixin, ListBulkCreateUpdateDestroyAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserBulkUpdateSerializer serializer_class = UserBulkUpdateSerializer
permission_classes = (IsSuperUserOrTerminalUser,)
# def get(self, request, *args, **kwargs):
# return super(UserListUpdateApi, self).get(request, *args, **kwargs)
class GroupListUpdateApi(BulkDeleteApiMixin, ListBulkCreateUpdateDestroyAPIView): class GroupListUpdateApi(BulkDeleteApiMixin, ListBulkCreateUpdateDestroyAPIView):
...@@ -104,3 +111,23 @@ class DeleteUserFromGroupApi(generics.DestroyAPIView): ...@@ -104,3 +111,23 @@ class DeleteUserFromGroupApi(generics.DestroyAPIView):
user_id = kwargs.get('uid') user_id = kwargs.get('uid')
user = get_object_or_404(User, id=user_id) user = get_object_or_404(User, id=user_id)
instance.users.remove(user) instance.users.remove(user)
class AppUserRegisterApi(generics.CreateAPIView):
"""App send a post request to register a app user
request params contains `username_signed`, You can unsign it,
username = unsign(username_signed), if you get the username,
It's present it's a valid request, or return (401, Invalid request),
then your should check if the user exist or not. If exist,
return (200, register success), If not, you should be save it, and
notice admin user, The user default is not active before admin user
unblock it.
Save fields:
username:
name: name + request.ip
email: username + '@app.org'
role: App
"""
pass
# -*- coding: utf-8 -*-
#
from rest_framework import authentication, exceptions, permissions
from rest_framework.compat import is_authenticated
from django.utils.translation import ugettext as _
from common.utils import unsign, get_object_or_none
from .hands import Terminal
class TerminalAuthentication(authentication.BaseAuthentication):
keyword = 'Sign'
model = Terminal
def authenticate(self, request):
auth = authentication.get_authorization_header(request).split()
if not auth or auth[0].lower() != self.keyword.lower().encode():
return None
if len(auth) == 1:
msg = _('Invalid sign header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid sign header. Sign string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
try:
sign = auth[1].decode()
except UnicodeError:
msg = _('Invalid token header. Sign string should not contain invalid characters.')
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(sign)
def authenticate_credentials(self, sign):
name = unsign(sign, max_age=300)
if name:
terminal = get_object_or_none(self.model, name=name)
else:
raise exceptions.AuthenticationFailed(_('Invalid sign.'))
if not terminal.is_active:
raise exceptions.AuthenticationFailed(_('Terminal inactive or deleted.'))
terminal.is_authenticated = True
return terminal, None
class IsValidUser(permissions.IsAuthenticated, permissions.BasePermission):
"""Allows access to valid user, is active and not expired"""
def has_permission(self, request, view):
return super(IsValidUser, self).has_permission(request, view) \
and request.user.is_valid
class IsTerminalUser(IsValidUser, permissions.BasePermission):
"""Allows access only to app user """
def has_permission(self, request, view):
return super(IsTerminalUser, self).has_permission(request, view) \
and isinstance(request.user, Terminal)
class IsSuperUser(IsValidUser, permissions.BasePermission):
"""Allows access only to superuser"""
def has_permission(self, request, view):
return super(IsSuperUser, self).has_permission(request, view) \
and request.user.is_superuser
class IsSuperUserOrTerminalUser(IsValidUser, permissions.BasePermission):
"""Allows access between superuser and app user"""
def has_permission(self, request, view):
return super(IsSuperUserOrTerminalUser, self).has_permission(request, view) \
and (request.user.is_superuser or request.user.is_terminal)
if __name__ == '__main__':
pass
...@@ -12,3 +12,4 @@ ...@@ -12,3 +12,4 @@
from perms.models import AssetPermission from perms.models import AssetPermission
from perms.utils import get_user_granted_assets, get_user_granted_asset_groups from perms.utils import get_user_granted_assets, get_user_granted_asset_groups
from terminal.models import Terminal
\ No newline at end of file
...@@ -141,6 +141,10 @@ class User(AbstractUser): ...@@ -141,6 +141,10 @@ class User(AbstractUser):
else: else:
return False return False
@property
def is_terminal(self):
return False
@is_superuser.setter @is_superuser.setter
def is_superuser(self, value): def is_superuser(self, value):
if value is True: if value is True:
...@@ -150,7 +154,7 @@ class User(AbstractUser): ...@@ -150,7 +154,7 @@ class User(AbstractUser):
@property @property
def is_staff(self): def is_staff(self):
if self.is_authenticated and self.is_active and not self.is_expired and self.is_superuser: if self.is_authenticated and self.is_valid:
return True return True
else: else:
return False return False
...@@ -178,21 +182,20 @@ class User(AbstractUser): ...@@ -178,21 +182,20 @@ class User(AbstractUser):
token = Token.objects.get(user=self) token = Token.objects.get(user=self)
except Token.DoesNotExist: except Token.DoesNotExist:
token = Token.objects.create(user=self) token = Token.objects.create(user=self)
return token.key return token.key
def refresh_private_token(self): def refresh_private_token(self):
Token.objects.filter(user=self).delete() Token.objects.filter(user=self).delete()
return Token.objects.create(user=self) return Token.objects.create(user=self)
def generate_reset_token(self):
return signing.dumps({'reset': self.id, 'email': self.email})
def is_member_of(self, user_group): def is_member_of(self, user_group):
if user_group in self.groups.all(): if user_group in self.groups.all():
return True return True
return False return False
def generate_reset_token(self):
return signing.dumps({'reset': self.id, 'email': self.email})
@classmethod @classmethod
def validate_reset_token(cls, token, max_age=3600): def validate_reset_token(cls, token, max_age=3600):
try: try:
......
...@@ -5,23 +5,23 @@ from django.utils.translation import ugettext_lazy as _ ...@@ -5,23 +5,23 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework_bulk import BulkListSerializer, BulkSerializerMixin from rest_framework_bulk import BulkListSerializer, BulkSerializerMixin
from common.utils import unsign
from .models import User, UserGroup from .models import User, UserGroup
class UserDetailSerializer(serializers.ModelSerializer): class UserDetailSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ['avatar', 'wechat', 'phone', 'enable_otp', 'comment', 'is_active', 'name'] fields = ['avatar', 'wechat', 'phone', 'enable_otp', 'comment', 'is_active', 'name']
class UserPKUpdateSerializer(serializers.ModelSerializer): class UserPKUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ['id', '_public_key'] fields = ['id', '_public_key']
def validate__public_key(self, value): @staticmethod
def validate__public_key(value):
from sshpubkeys import SSHKey from sshpubkeys import SSHKey
from sshpubkeys.exceptions import InvalidKeyException from sshpubkeys.exceptions import InvalidKeyException
ssh = SSHKey(value) ssh = SSHKey(value)
...@@ -45,7 +45,6 @@ class UserAndGroupSerializer(serializers.ModelSerializer): ...@@ -45,7 +45,6 @@ class UserAndGroupSerializer(serializers.ModelSerializer):
class GroupDetailSerializer(serializers.ModelSerializer): class GroupDetailSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = UserGroup model = UserGroup
fields = ['id', 'name', 'comment', 'date_created', 'created_by', 'users'] fields = ['id', 'name', 'comment', 'date_created', 'created_by', 'users']
...@@ -63,16 +62,17 @@ class UserBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer) ...@@ -63,16 +62,17 @@ class UserBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer)
'enable_otp', 'comment', 'groups', 'get_role_display', 'enable_otp', 'comment', 'groups', 'get_role_display',
'group_display', 'active_display'] 'group_display', 'active_display']
def get_group_display(self, obj): @staticmethod
def get_group_display(obj):
return " ".join([group.name for group in obj.groups.all()]) return " ".join([group.name for group in obj.groups.all()])
def get_active_display(self, obj): @staticmethod
# TODO: user ative state def get_active_display(obj):
# TODO: user active state
return not (obj.is_expired and obj.is_active) return not (obj.is_expired and obj.is_active)
class GroupBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer): class GroupBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer):
user_amount = serializers.SerializerMethodField() user_amount = serializers.SerializerMethodField()
class Meta: class Meta:
...@@ -80,5 +80,18 @@ class GroupBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer ...@@ -80,5 +80,18 @@ class GroupBulkUpdateSerializer(BulkSerializerMixin, serializers.ModelSerializer
list_serializer_class = BulkListSerializer list_serializer_class = BulkListSerializer
fields = ['id', 'name', 'comment', 'user_amount'] fields = ['id', 'name', 'comment', 'user_amount']
def get_user_amount(self, obj): @staticmethod
def get_user_amount(obj):
return obj.users.count() return obj.users.count()
class AppUserRegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=20)
def create(self, validated_data):
sign = validated_data('username', '')
username = unsign(sign)
pass
def update(self, instance, validated_data):
pass
...@@ -23,12 +23,12 @@ div.dataTables_wrapper div.dataTables_filter { ...@@ -23,12 +23,12 @@ div.dataTables_wrapper div.dataTables_filter {
<th class="text-center"> <th class="text-center">
<div class="checkbox checkbox-default"><input id="" type="checkbox" class="ipt_check_all"><label></label></div> <div class="checkbox checkbox-default"><input id="" type="checkbox" class="ipt_check_all"><label></label></div>
</th> </th>
<th class="text-center">{% trans 'Name' %}</a></th> <th class="text-center">{% trans 'Name' %}</th>
<th class="text-center">{% trans 'Username' %}</a></th> <th class="text-center">{% trans 'Username' %}</th>
<th class="text-center">{% trans 'Role' %}</th> <th class="text-center">{% trans 'Role' %}</th>
<th class="text-center">{% trans 'User group' %}</th> <th class="text-center">{% trans 'User group' %}</th>
<th class="text-center">{% trans 'Asset num' %}</th> <th class="text-center">{% trans 'Asset num' %}</th>
<th class="text-center">{% trans 'Active' %}</a></th> <th class="text-center">{% trans 'Active' %}</th>
<th class="text-center">{% trans 'Action' %}</th> <th class="text-center">{% trans 'Action' %}</th>
</tr> </tr>
</thead> </thead>
...@@ -165,7 +165,7 @@ $(document).ready(function(){ ...@@ -165,7 +165,7 @@ $(document).ready(function(){
var fail = function() { var fail = function() {
var msg = "{% trans 'User Deleting failed.' %}"; var msg = "{% trans 'User Deleting failed.' %}";
swal("{% trans 'User Delete' %}", msg, "error"); swal("{% trans 'User Delete' %}", msg, "error");
} };
APIUpdateAttr({ APIUpdateAttr({
url: the_url, url: the_url,
body: JSON.stringify(body), body: JSON.stringify(body),
...@@ -208,15 +208,15 @@ $(document).ready(function(){ ...@@ -208,15 +208,15 @@ $(document).ready(function(){
post_list.push(content); post_list.push(content);
}); });
if (post_list === []) { if (post_list === []) {
return false; return false
}; }
var the_url = "{% url 'users:user-bulk-update-api' %}"; var the_url = "{% url 'users:user-bulk-update-api' %}";
var success = function() { var success = function() {
var msg = "{% trans 'The selected users has been updated successfully.' %}"; var msg = "{% trans 'The selected users has been updated successfully.' %}";
swal("{% trans 'User Updated' %}", msg, "success"); swal("{% trans 'User Updated' %}", msg, "success");
$('#user_list_table').DataTable().ajax.reload(); $('#user_list_table').DataTable().ajax.reload();
jumpserver.checked = false; jumpserver.checked = false;
} };
APIUpdateAttr({url: the_url, method: 'PATCH', body: JSON.stringify(post_list), success: success}); APIUpdateAttr({url: the_url, method: 'PATCH', body: JSON.stringify(post_list), success: success});
$('#user_bulk_update_modal').modal('hide'); $('#user_bulk_update_modal').modal('hide');
}).on('click', '#btn_user_import', function() { }).on('click', '#btn_user_import', function() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment