diff --git a/apps/audits/api.py b/apps/audits/api.py index 490608e22..e0d7dbdec 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -6,12 +6,16 @@ from importlib import import_module from django.conf import settings from django.db.models import F, Value, CharField, Q from django.http import HttpResponse, FileResponse +from django.utils import timezone from django.utils.encoding import escape_uri_path from rest_framework import generics +from rest_framework import status +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from common.api import CommonApiMixin from common.const.http import GET, POST from common.drf.filters import DatetimeRangeFilterBackend from common.permissions import IsServiceAccount @@ -28,13 +32,13 @@ from .backends import TYPE_ENGINE_MAPPING from .const import ActivityChoices from .models import ( FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, - ActivityLog, JobLog, + ActivityLog, JobLog, UserSession ) from .serializers import ( FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer, OperateLogSerializer, OperateLogActionDetailSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer, - FileSerializer + FileSerializer, UserSessionSerializer ) logger = get_logger(__name__) @@ -246,3 +250,41 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): user__in=[str(user) for user in users] ) return queryset + + +class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): + http_method_names = ('get', 'post', 'head', 'options', 'trace') + serializer_class = UserSessionSerializer + filterset_fields = ['id', 'ip', 'city', 'type'] + search_fields = ['id', 'ip', 'city'] + + rbac_perms = { + 'offline': ['users.offline_usersession'] + } + + @property + def org_user_ids(self): + user_ids = current_org.get_members().values_list('id', flat=True) + return user_ids + + def get_queryset(self): + queryset = UserSession.objects.filter(date_expired__gt=timezone.now()) + if current_org.is_root(): + return queryset + user_ids = self.org_user_ids + queryset = queryset.filter(user_id__in=user_ids) + return queryset + + @action(['POST'], detail=False, url_path='offline') + def offline(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids) + if not queryset.exists(): + return Response(status=status.HTTP_200_OK) + + keys = queryset.values_list('key', flat=True) + session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore + for key in keys: + session_store_cls(key).delete() + queryset.delete() + return Response(status=status.HTTP_200_OK) diff --git a/apps/audits/migrations/0024_usersession.py b/apps/audits/migrations/0024_usersession.py new file mode 100644 index 000000000..3cca28f75 --- /dev/null +++ b/apps/audits/migrations/0024_usersession.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.10 on 2023-09-15 08:58 + +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), + ('audits', '0023_auto_20230906_1322'), + ] + + operations = [ + migrations.CreateModel( + name='UserSession', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('ip', models.GenericIPAddressField(verbose_name='Login IP')), + ('key', models.CharField(max_length=128, verbose_name='Session key')), + ('city', models.CharField(blank=True, max_length=254, null=True, verbose_name='Login city')), + ('user_agent', models.CharField(blank=True, max_length=254, null=True, verbose_name='User agent')), + ('type', models.CharField(choices=[('W', 'Web'), ('T', 'Terminal'), ('U', 'Unknown')], max_length=2, verbose_name='Login type')), + ('backend', models.CharField(default='', max_length=32, verbose_name='Authentication backend')), + ('date_created', models.DateTimeField(blank=True, null=True, verbose_name='Date created')), + ('date_expired', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date expired')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'User session', + 'ordering': ['-date_created'], + 'permissions': [('offline_usersession', 'Offline ussr session')], + }, + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index 7d7ebaa7e..0ec6f2809 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -28,7 +28,8 @@ __all__ = [ "ActivityLog", "PasswordChangeLog", "UserLoginLog", - "JobLog" + "JobLog", + "UserSession" ] @@ -245,3 +246,36 @@ class UserLoginLog(models.Model): class Meta: ordering = ["-datetime", "username"] verbose_name = _("User login log") + + +class UserSession(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + ip = models.GenericIPAddressField(verbose_name=_("Login IP")) + key = models.CharField(max_length=128, verbose_name=_("Session key")) + city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("Login city")) + user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("User agent")) + type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type")) + backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend")) + date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created')) + date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True) + user = models.ForeignKey( + 'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE + ) + + def __str__(self): + return '%s(%s)' % (self.user, self.ip) + + @property + def backend_display(self): + return gettext(self.backend) + + @classmethod + def clear_expired_sessions(cls): + cls.objects.filter(date_expired__lt=timezone.now()).delete() + + class Meta: + ordering = ['-date_created'] + verbose_name = _('User session') + permissions = [ + ('offline_usersession', _('Offline ussr session')), + ] diff --git a/apps/audits/serializers.py b/apps/audits/serializers.py index a90651bc9..829986297 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -4,12 +4,13 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from audits.backends.db import OperateLogStore -from common.serializers.fields import LabeledChoiceField +from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from common.utils import reverse, i18n_trans from common.utils.timezone import as_current_tz from ops.serializers.job import JobExecutionSerializer from orgs.mixins.serializers import BulkOrgResourceModelSerializer from terminal.models import Session +from users.models import User from . import models from .const import ( ActionChoices, OperateChoices, @@ -163,3 +164,27 @@ class ActivityUnionLogSerializer(serializers.Serializer): class FileSerializer(serializers.Serializer): file = serializers.FileField(allow_empty_file=True) + + +class UserSessionSerializer(serializers.ModelSerializer): + type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type")) + user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User')) + is_current_user_session = serializers.SerializerMethodField() + + class Meta: + model = models.UserSession + fields_mini = ['id'] + fields_small = fields_mini + [ + 'type', 'ip', 'city', 'user_agent', 'user', 'is_current_user_session', + 'backend', 'backend_display', 'date_created', 'date_expired' + ] + fields = fields_small + extra_kwargs = { + "backend_display": {"label": _("Authentication backend")}, + } + + def get_is_current_user_session(self, obj): + request = self.context.get('request') + if not request: + return False + return request.session.session_key == obj.key diff --git a/apps/audits/signal_handlers/login_log.py b/apps/audits/signal_handlers/login_log.py index 148196699..d1bd6b58b 100644 --- a/apps/audits/signal_handlers/login_log.py +++ b/apps/audits/signal_handlers/login_log.py @@ -16,8 +16,9 @@ from audits.models import UserLoginLog from authentication.signals import post_auth_failed, post_auth_success from authentication.utils import check_different_city_login_if_need from common.utils import get_request_ip, get_logger -from users.models import User, UserSession +from users.models import User from ..const import LoginTypeChoices +from ..models import UserSession from ..utils import write_login_log logger = get_logger(__name__) diff --git a/apps/audits/urls/api_urls.py b/apps/audits/urls/api_urls.py index 4e1771170..765470afb 100644 --- a/apps/audits/urls/api_urls.py +++ b/apps/audits/urls/api_urls.py @@ -15,6 +15,7 @@ router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log') router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log') router.register(r'job-logs', api.JobAuditViewSet, 'job-log') router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log') +router.register(r'user-sessions', api.UserSessionViewSet, 'user-session') urlpatterns = [ path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'), diff --git a/apps/rbac/builtin.py b/apps/rbac/builtin.py index 71cd01ff2..8498f7776 100644 --- a/apps/rbac/builtin.py +++ b/apps/rbac/builtin.py @@ -36,7 +36,6 @@ system_user_perms += (user_perms + _view_all_joined_org_perms) _auditor_perms = ( ('rbac', 'menupermission', 'view', 'audit'), - ('users', 'usersession', '*', '*'), ('audits', '*', '*', '*'), ('audits', 'joblog', '*', '*'), ('terminal', 'commandstorage', 'view', 'commandstorage'), diff --git a/apps/rbac/const.py b/apps/rbac/const.py index a63e013e1..fac97b5db 100644 --- a/apps/rbac/const.py +++ b/apps/rbac/const.py @@ -28,7 +28,6 @@ exclude_permissions = ( ('authentication', 'superconnectiontoken', 'change,delete', 'superconnectiontoken'), ('authentication', 'temptoken', 'delete', 'temptoken'), ('users', 'userpasswordhistory', '*', '*'), - ('users', 'usersession', 'add,delete,change', 'usersession'), ('assets', 'adminuser', '*', '*'), ('assets', 'assetgroup', '*', '*'), ('assets', 'cluster', '*', '*'), @@ -93,6 +92,7 @@ exclude_permissions = ( ('audits', 'activitylog', 'add,delete,change', 'activitylog'), ('audits', 'passwordchangelog', 'add,change,delete', 'passwordchangelog'), ('audits', 'userloginlog', 'add,change,delete,change', 'userloginlog'), + ('audits', 'usersession', 'add,delete,change', 'usersession'), ('audits', 'ftplog', 'delete', 'ftplog'), ('tickets', 'ticketassignee', '*', 'ticketassignee'), ('tickets', 'ticketflow', 'add,delete', 'ticketflow'), diff --git a/apps/rbac/tree.py b/apps/rbac/tree.py index 998f3540f..d608a869f 100644 --- a/apps/rbac/tree.py +++ b/apps/rbac/tree.py @@ -98,7 +98,6 @@ special_pid_mapper = { 'terminal.endpoint': 'terminal_node', 'terminal.endpointrule': 'terminal_node', 'audits.ftplog': 'terminal', - 'users.usersession': 'terminal', 'perms.view_myassets': 'my_assets', 'ops.celerytask': 'task_center', 'ops.view_celerytaskexecution': 'task_center', @@ -113,6 +112,7 @@ special_pid_mapper = { "settings.view_setting": "view_setting", "rbac.view_console": "view_console", "rbac.view_audit": "view_audit", + 'audits.usersession': 'view_audit', "rbac.view_workbench": "view_workbench", "rbac.view_webterminal": "view_workbench", "rbac.view_filemanager": "view_workbench", diff --git a/apps/users/api/__init__.py b/apps/users/api/__init__.py index 464c857a7..8553b4bc6 100644 --- a/apps/users/api/__init__.py +++ b/apps/users/api/__init__.py @@ -6,5 +6,4 @@ from .preference import * from .profile import * from .relation import * from .service import * -from .session import * from .user import * diff --git a/apps/users/api/session.py b/apps/users/api/session.py deleted file mode 100644 index 6fb90ea01..000000000 --- a/apps/users/api/session.py +++ /dev/null @@ -1,51 +0,0 @@ -from importlib import import_module - -from django.conf import settings -from django.utils import timezone -from rest_framework import status -from rest_framework import viewsets -from rest_framework.decorators import action -from rest_framework.response import Response - -from common.api import CommonApiMixin -from orgs.utils import current_org -from users import serializers -from ..models import UserSession - - -class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): - http_method_names = ('get', 'post', 'head', 'options', 'trace') - serializer_class = serializers.UserSessionSerializer - filterset_fields = ['id', 'ip', 'city', 'type'] - search_fields = ['id', 'ip', 'city'] - - rbac_perms = { - 'offline': ['users.offline_usersession'] - } - - @property - def org_user_ids(self): - user_ids = current_org.get_members().values_list('id', flat=True) - return user_ids - - def get_queryset(self): - queryset = UserSession.objects.filter(date_expired__gt=timezone.now()) - if current_org.is_root(): - return queryset - user_ids = self.org_user_ids - queryset = queryset.filter(user_id__in=user_ids) - return queryset - - @action(['POST'], detail=False, url_path='offline') - def offline(self, request, *args, **kwargs): - ids = request.data.get('ids', []) - queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids) - if not queryset.exists(): - return Response(status=status.HTTP_200_OK) - - keys = queryset.values_list('key', flat=True) - session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore - for key in keys: - session_store_cls(key).delete() - queryset.delete() - return Response(status=status.HTTP_200_OK) diff --git a/apps/users/migrations/0045_delete_usersession.py b/apps/users/migrations/0045_delete_usersession.py new file mode 100644 index 000000000..226637adc --- /dev/null +++ b/apps/users/migrations/0045_delete_usersession.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.10 on 2023-09-15 08:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0044_usersession'), + ] + + operations = [ + migrations.DeleteModel( + name='UserSession', + ), + ] diff --git a/apps/users/models/__init__.py b/apps/users/models/__init__.py index 7f6c9dda6..34e081c5a 100644 --- a/apps/users/models/__init__.py +++ b/apps/users/models/__init__.py @@ -4,6 +4,5 @@ from .group import * from .preference import * -from .session import * from .user import * from .utils import * diff --git a/apps/users/models/session.py b/apps/users/models/session.py deleted file mode 100644 index eab35ea8e..000000000 --- a/apps/users/models/session.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid - -from django.db import models -from django.utils import timezone -from django.utils.translation import gettext, gettext_lazy as _ - -from audits.const import LoginTypeChoices - - -class UserSession(models.Model): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - ip = models.GenericIPAddressField(verbose_name=_("Login IP")) - key = models.CharField(max_length=128, verbose_name=_("Session key")) - city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("Login city")) - user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("User agent")) - type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type")) - backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend")) - date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created')) - date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True) - user = models.ForeignKey( - 'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE - ) - - def __str__(self): - return '%s(%s)' % (self.user, self.ip) - - @property - def backend_display(self): - return gettext(self.backend) - - @classmethod - def clear_expired_sessions(cls): - cls.objects.filter(date_expired__lt=timezone.now()).delete() - - class Meta: - ordering = ['-date_created'] - verbose_name = _('User session') - permissions = [ - ('offline_usersession', _('Offline ussr session')), - ] diff --git a/apps/users/serializers/__init__.py b/apps/users/serializers/__init__.py index 82d92d907..58191b569 100644 --- a/apps/users/serializers/__init__.py +++ b/apps/users/serializers/__init__.py @@ -4,5 +4,4 @@ from .group import * from .preference import * from .profile import * from .realtion import * -from .session import * from .user import * diff --git a/apps/users/serializers/session.py b/apps/users/serializers/session.py deleted file mode 100644 index 8ff3bbec5..000000000 --- a/apps/users/serializers/session.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers - -from audits.const import LoginTypeChoices -from common.serializers.fields import LabeledChoiceField, ObjectRelatedField -from users.models import User -from ..models import UserSession - - -class UserSessionSerializer(serializers.ModelSerializer): - type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type")) - user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User')) - is_current_user_session = serializers.SerializerMethodField() - - class Meta: - model = UserSession - fields_mini = ['id'] - fields_small = fields_mini + [ - 'type', 'ip', 'city', 'user_agent', 'user', 'is_current_user_session', - 'backend', 'backend_display', 'date_created', 'date_expired' - ] - fields = fields_small - extra_kwargs = { - "backend_display": {"label": _("Authentication backend")}, - } - - def get_is_current_user_session(self, obj): - request = self.context.get('request') - if not request: - return False - return request.session.session_key == obj.key - diff --git a/apps/users/signal_handlers.py b/apps/users/signal_handlers.py index f84340128..6e53bb87b 100644 --- a/apps/users/signal_handlers.py +++ b/apps/users/signal_handlers.py @@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _ from django_auth_ldap.backend import populate_user from django_cas_ng.signals import cas_user_authenticated +from audits.models import UserSession from authentication.backends.oauth2.signals import oauth2_create_or_update_user from authentication.backends.oidc.signals import openid_create_or_update_user from authentication.backends.saml2.signals import saml2_create_or_update_user @@ -17,7 +18,7 @@ from common.decorators import on_transaction_commit from common.utils import get_logger from jumpserver.utils import get_current_request from ops.celery.decorator import register_as_period_task -from .models import User, UserPasswordHistory, UserSession +from .models import User, UserPasswordHistory from .signals import post_user_create logger = get_logger(__file__) diff --git a/apps/users/urls/api_urls.py b/apps/users/urls/api_urls.py index 9f8247a3f..fa44e67e4 100644 --- a/apps/users/urls/api_urls.py +++ b/apps/users/urls/api_urls.py @@ -17,7 +17,6 @@ router.register(r'groups', api.UserGroupViewSet, 'user-group') router.register(r'users-groups-relations', api.UserUserGroupRelationViewSet, 'users-groups-relation') router.register(r'service-account-registrations', api.ServiceAccountRegistrationViewSet, 'service-account-registration') router.register(r'connection-token', auth_api.ConnectionTokenViewSet, 'connection-token') -router.register(r'user-sessions', api.UserSessionViewSet, 'user-session') urlpatterns = [