diff --git a/.gitattributes b/.gitattributes index 35ca802a3..24d9e1cd5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.mmdb filter=lfs diff=lfs merge=lfs -text *.mo filter=lfs diff=lfs merge=lfs -text +*.ipdb filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 70ca32392..372644811 100644 --- a/.gitignore +++ b/.gitignore @@ -34,10 +34,11 @@ celerybeat-schedule.db data/static docs/_build/ xpack +xpack.bak logs/* ### Vagrant ### .vagrant/ release/* releashe /apps/script.py -xpack.bak +data/* diff --git a/Dockerfile b/Dockerfile index 421f95b20..de4022338 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,5 @@ -# 编译代码 -FROM python:3.8-slim as stage-build -MAINTAINER JumpServer Team -ARG VERSION -ENV VERSION=$VERSION - -WORKDIR /opt/jumpserver -ADD . . -RUN cd utils && bash -ixeu build.sh - FROM python:3.8-slim -ARG PIP_MIRROR=https://pypi.douban.com/simple -ENV PIP_MIRROR=$PIP_MIRROR -ARG PIP_JMS_MIRROR=https://pypi.douban.com/simple -ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR - -WORKDIR /opt/jumpserver +MAINTAINER JumpServer Team ARG BUILD_DEPENDENCIES=" \ g++ \ @@ -44,11 +29,12 @@ ARG TOOLS=" \ redis-tools \ telnet \ vim \ + unzip \ wget" RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ - && apt update \ + && apt update && sleep 1 && apt update \ && apt -y install ${BUILD_DEPENDENCIES} \ && apt -y install ${DEPENDENCIES} \ && apt -y install ${TOOLS} \ @@ -62,21 +48,44 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && mv /bin/sh /bin/sh.bak \ && ln -s /bin/bash /bin/sh -RUN mkdir -p /opt/jumpserver/oracle/ \ - && wget https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar \ - && tar xf instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ \ - && echo "/opt/jumpserver/oracle/instantclient_21_1" > /etc/ld.so.conf.d/oracle-instantclient.conf \ +ARG TARGETARCH +ARG ORACLE_LIB_MAJOR=19 +ARG ORACLE_LIB_MINOR=10 +ENV ORACLE_FILE="instantclient-basiclite-linux.${TARGETARCH:-amd64}-${ORACLE_LIB_MAJOR}.${ORACLE_LIB_MINOR}.0.0.0dbru.zip" + +RUN mkdir -p /opt/oracle/ \ + && cd /opt/oracle/ \ + && wget https://download.jumpserver.org/files/oracle/${ORACLE_FILE} \ + && unzip instantclient-basiclite-linux.${TARGETARCH-amd64}-19.10.0.0.0dbru.zip \ + && mv instantclient_${ORACLE_LIB_MAJOR}_${ORACLE_LIB_MINOR} instantclient \ + && echo "/opt/oracle/instantclient" > /etc/ld.so.conf.d/oracle-instantclient.conf \ && ldconfig \ - && rm -f instantclient-basiclite-linux.x64-21.1.0.0.0.tar + && rm -f ${ORACLE_FILE} -COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver +WORKDIR /tmp/build +COPY ./requirements ./requirements -RUN echo > config.yml \ - && pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \ +ARG PIP_MIRROR=https://mirrors.aliyun.com/pypi/simple/ +ENV PIP_MIRROR=$PIP_MIRROR +ARG PIP_JMS_MIRROR=https://mirrors.aliyun.com/pypi/simple/ +ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR +# 因为以 jms 或者 jumpserver 开头的 mirror 上可能没有 +RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \ && pip install --no-cache-dir $(grep -E 'jms|jumpserver' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \ && pip install --no-cache-dir -r requirements/requirements.txt -i ${PIP_MIRROR} \ && rm -rf ~/.cache/pip +ARG VERSION +ENV VERSION=$VERSION + +ADD . . +RUN cd utils \ + && bash -ixeu build.sh \ + && mv ../release/jumpserver /opt/jumpserver \ + && rm -rf /tmp/build \ + && echo > /opt/jumpserver/config.yml + +WORKDIR /opt/jumpserver VOLUME /opt/jumpserver/data VOLUME /opt/jumpserver/logs diff --git a/README.md b/README.md index 3fdcb7fde..70dac07d1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ -

JumpServer

+

+ JumpServer +

多云环境下更好用的堡垒机

License: GPLv3  release Codacy + GitHub last commit Stars

@@ -15,7 +18,7 @@ JumpServer 是全球首款开源的堡垒机,使用 GPLv3 开源协议,是符合 4A 规范的运维安全审计系统。 -JumpServer 使用 Python 开发,遵循 Web 2.0 规范,配备了业界领先的 Web Terminal 方案,交互界面美观、用户体验好。 +JumpServer 使用 Python 开发,配备了业界领先的 Web Terminal 方案,交互界面美观、用户体验好。 JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向扩展,无资产数量及并发限制。 @@ -28,9 +31,9 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 - 开源: 零门槛,线上快速获取和安装; - 分布式: 轻松支持大规模并发访问; - 无插件: 仅需浏览器,极致的 Web Terminal 使用体验; +- 多租户: 一套系统,多个子公司或部门同时使用; - 多云支持: 一套系统,同时管理不同云上面的资产; - 云端存储: 审计录像云端存储,永不丢失; -- 多租户: 一套系统,多个子公司和部门同时使用; - 多应用支持: 数据库,Windows远程应用,Kubernetes。 ### UI 展示 @@ -55,12 +58,15 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 - [手动安装](https://github.com/jumpserver/installer) ### 组件项目 -- [Lina](https://github.com/jumpserver/lina) JumpServer Web UI 项目 -- [Luna](https://github.com/jumpserver/luna) JumpServer Web Terminal 项目 -- [KoKo](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco) -- [Lion](https://github.com/jumpserver/lion-release) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/) -- [Clients](https://github.com/jumpserver/clients) JumpServer 客户端 项目 -- [Installer](https://github.com/jumpserver/installer) JumpServer 安装包 项目 +| 项目 | 状态 | 描述 | +| --------------------------------------------------------------------------- | ------------------- | ---------------------------------------- | +| [Lina](https://github.com/jumpserver/lina) | Lina release | JumpServer Web UI 项目 | +| [Luna](https://github.com/jumpserver/luna) | Luna release | JumpServer Web Terminal 项目 | +| [KoKo](https://github.com/jumpserver/koko) | Koko release | JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco) | +| [Lion](https://github.com/jumpserver/lion-release) | Lion release | JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/) | +| [Magnus](https://github.com/jumpserver/magnus-release) | Magnus release | JumpServer 数据库代理 Connector 项目 | +| [Clients](https://github.com/jumpserver/clients) | Clients release | JumpServer 客户端 项目 | +| [Installer](https://github.com/jumpserver/installer)| Installer release | JumpServer 安装包 项目 | ### 社区 @@ -75,27 +81,13 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 感谢以下贡献者,让 JumpServer 更加完善 - - - - - - - - - - - - - - - + ### 致谢 -- [Apache Guacamole](https://guacamole.apache.org/) Web页面连接 RDP, SSH, VNC协议设备,JumpServer 图形化组件 Lion 依赖 -- [OmniDB](https://omnidb.org/) Web页面连接使用数据库,JumpServer Web数据库依赖 +- [Apache Guacamole](https://guacamole.apache.org/) Web页面连接 RDP, SSH, VNC 协议设备,JumpServer 图形化组件 Lion 依赖 +- [OmniDB](https://omnidb.org/) Web 页面连接使用数据库,JumpServer Web 数据库依赖 ### JumpServer 企业版 @@ -103,14 +95,14 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 ### 案例研究 -- [JumpServer 堡垒机护航顺丰科技超大规模资产安全运维](https://blog.fit2cloud.com/?p=1147); -- [JumpServer 堡垒机让“大智慧”的混合 IT 运维更智慧](https://blog.fit2cloud.com/?p=882); -- [携程 JumpServer 堡垒机部署与运营实战](https://blog.fit2cloud.com/?p=851); -- [小红书的JumpServer堡垒机大规模资产跨版本迁移之路](https://blog.fit2cloud.com/?p=516); -- [JumpServer堡垒机助力中手游提升多云环境下安全运维能力](https://blog.fit2cloud.com/?p=732); -- [中通快递:JumpServer主机安全运维实践](https://blog.fit2cloud.com/?p=708); -- [东方明珠:JumpServer高效管控异构化、分布式云端资产](https://blog.fit2cloud.com/?p=687); -- [江苏农信:JumpServer堡垒机助力行业云安全运维](https://blog.fit2cloud.com/?p=666)。 +- [JumpServer 堡垒机护航顺丰科技超大规模资产安全运维](https://blog.fit2cloud.com/?p=1147) +- [JumpServer 堡垒机让“大智慧”的混合 IT 运维更智慧](https://blog.fit2cloud.com/?p=882) +- [携程 JumpServer 堡垒机部署与运营实战](https://blog.fit2cloud.com/?p=851) +- [小红书的JumpServer堡垒机大规模资产跨版本迁移之路](https://blog.fit2cloud.com/?p=516) +- [JumpServer堡垒机助力中手游提升多云环境下安全运维能力](https://blog.fit2cloud.com/?p=732) +- [中通快递:JumpServer主机安全运维实践](https://blog.fit2cloud.com/?p=708) +- [东方明珠:JumpServer高效管控异构化、分布式云端资产](https://blog.fit2cloud.com/?p=687) +- [江苏农信:JumpServer堡垒机助力行业云安全运维](https://blog.fit2cloud.com/?p=666) ### 安全说明 @@ -131,4 +123,3 @@ Licensed under The GNU General Public License version 3 (GPLv3) (the "License") https://www.gnu.org/licenses/gpl-3.0.html Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - diff --git a/apps/acls/api/login_asset_check.py b/apps/acls/api/login_asset_check.py index fc5f4157f..a7e8990c7 100644 --- a/apps/acls/api/login_asset_check.py +++ b/apps/acls/api/login_asset_check.py @@ -47,7 +47,7 @@ class LoginAssetCheckAPI(CreateAPIView): asset=self.serializer.asset, system_user=self.serializer.system_user, assignees=acl.reviewers.all(), - org_id=self.serializer.org.id + org_id=self.serializer.org.id, ) confirm_status_url = reverse( view_name='api-tickets:super-ticket-status', @@ -59,7 +59,7 @@ class LoginAssetCheckAPI(CreateAPIView): external=True, api_to_ui=True ) ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type) - ticket_assignees = ticket.current_node.first().ticket_assignees.all() + ticket_assignees = ticket.current_step.ticket_assignees.all() data = { 'check_confirm_status': {'method': 'GET', 'url': confirm_status_url}, 'close_confirm': {'method': 'DELETE', 'url': confirm_status_url}, diff --git a/apps/acls/models/login_acl.py b/apps/acls/models/login_acl.py index 6e1fbff73..1887ecd33 100644 --- a/apps/acls/models/login_acl.py +++ b/apps/acls/models/login_acl.py @@ -97,32 +97,25 @@ class LoginACL(BaseACL): return allow, reject_type - @staticmethod - def construct_confirm_ticket_meta(request=None): + def create_confirm_ticket(self, request): + from tickets import const + from tickets.models import ApplyLoginTicket + from orgs.models import Organization + title = _('Login confirm') + ' {}'.format(self.user) login_ip = get_request_ip(request) if request else '' login_ip = login_ip or '0.0.0.0' login_city = get_ip_city(login_ip) login_datetime = local_now_display() - ticket_meta = { - 'apply_login_ip': login_ip, - 'apply_login_city': login_city, - 'apply_login_datetime': login_datetime, - } - return ticket_meta - - def create_confirm_ticket(self, request=None): - from tickets import const - from tickets.models import Ticket - from orgs.models import Organization - ticket_title = _('Login confirm') + ' {}'.format(self.user) - ticket_meta = self.construct_confirm_ticket_meta(request) data = { - 'title': ticket_title, - 'type': const.TicketType.login_confirm.value, - 'meta': ticket_meta, + 'title': title, + 'type': const.TicketType.login_confirm, + 'applicant': self.user, + 'apply_login_city': login_city, + 'apply_login_ip': login_ip, + 'apply_login_datetime': login_datetime, 'org_id': Organization.ROOT_ID, } - ticket = Ticket.objects.create(**data) - ticket.create_process_map_and_node(self.reviewers.all()) - ticket.open(self.user) + ticket = ApplyLoginTicket.objects.create(**data) + assignees = self.reviewers.all() + ticket.open_by_system(assignees) return ticket diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index 0bde3c14f..9cf7989f9 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -85,19 +85,18 @@ class LoginAssetACL(BaseACL, OrgModelMixin): @classmethod def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): from tickets.const import TicketType - from tickets.models import Ticket + from tickets.models import ApplyLoginAssetTicket + title = _('Login asset confirm') + ' ({})'.format(user) data = { - 'title': _('Login asset confirm') + ' ({})'.format(user), + 'title': title, 'type': TicketType.login_asset_confirm, - 'meta': { - 'apply_login_user': str(user), - 'apply_login_asset': str(asset), - 'apply_login_system_user': str(system_user), - }, + 'applicant': user, + 'apply_login_user': user, + 'apply_login_asset': asset, + 'apply_login_system_user': system_user, 'org_id': org_id, } - ticket = Ticket.objects.create(**data) - ticket.create_process_map_and_node(assignees) - ticket.open(applicant=user) + ticket = ApplyLoginAssetTicket.objects.create(**data) + ticket.open_by_system(assignees) return ticket diff --git a/apps/applications/api/account.py b/apps/applications/api/account.py index 4e66a730c..bad9a02bd 100644 --- a/apps/applications/api/account.py +++ b/apps/applications/api/account.py @@ -2,14 +2,16 @@ # from django_filters import rest_framework as filters -from django.db.models import F, Q +from django.db.models import Q from common.drf.filters import BaseFilterSet from common.drf.api import JMSBulkModelViewSet +from common.mixins import RecordViewLogMixin +from common.permissions import UserConfirmation +from authentication.const import ConfirmType from rbac.permissions import RBACPermission from assets.models import SystemUser from ..models import Account -from ..hands import NeedMFAVerify from .. import serializers @@ -54,9 +56,9 @@ class SystemUserAppRelationViewSet(ApplicationAccountViewSet): perm_model = SystemUser -class ApplicationAccountSecretViewSet(ApplicationAccountViewSet): +class ApplicationAccountSecretViewSet(RecordViewLogMixin, ApplicationAccountViewSet): serializer_class = serializers.AppAccountSecretSerializer - permission_classes = [RBACPermission, NeedMFAVerify] + permission_classes = [RBACPermission, UserConfirmation.require(ConfirmType.MFA)] http_method_names = ['get', 'options'] rbac_perms = { 'retrieve': 'applications.view_applicationaccountsecret', diff --git a/apps/applications/api/application.py b/apps/applications/api/application.py index 9d1b130ce..71315cdcf 100644 --- a/apps/applications/api/application.py +++ b/apps/applications/api/application.py @@ -1,6 +1,5 @@ # coding: utf-8 # -from django.shortcuts import get_object_or_404 from orgs.mixins.api import OrgBulkModelViewSet from rest_framework.decorators import action from rest_framework.response import Response diff --git a/apps/applications/hands.py b/apps/applications/hands.py index 74d6bf9cb..c2bf4fd20 100644 --- a/apps/applications/hands.py +++ b/apps/applications/hands.py @@ -11,5 +11,4 @@ """ -from common.permissions import NeedMFAVerify from users.models import User, UserGroup diff --git a/apps/applications/migrations/0001_initial.py b/apps/applications/migrations/0001_initial.py index 221fd5e22..26948fc73 100644 --- a/apps/applications/migrations/0001_initial.py +++ b/apps/applications/migrations/0001_initial.py @@ -1,6 +1,6 @@ # Generated by Django 2.1.7 on 2019-05-20 11:04 -import common.fields.model +import common.db.fields from django.db import migrations, models import django.db.models.deletion import uuid @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=128, verbose_name='Name')), ('type', models.CharField(choices=[('Browser', (('chrome', 'Chrome'),)), ('Database tools', (('mysql_workbench', 'MySQL Workbench'),)), ('Virtualization tools', (('vmware_client', 'vSphere Client'),)), ('custom', 'Custom')], default='chrome', max_length=128, verbose_name='App type')), ('path', models.CharField(max_length=128, verbose_name='App path')), - ('params', common.fields.model.EncryptJsonDictTextField(blank=True, default={}, max_length=4096, null=True, verbose_name='Parameters')), + ('params', common.db.fields.EncryptJsonDictTextField(blank=True, default={}, max_length=4096, null=True, verbose_name='Parameters')), ('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')), ('comment', models.TextField(blank=True, default='', max_length=128, verbose_name='Comment')), diff --git a/apps/applications/migrations/0010_appaccount_historicalappaccount.py b/apps/applications/migrations/0010_appaccount_historicalappaccount.py index fc2cf2ab9..f79fd2475 100644 --- a/apps/applications/migrations/0010_appaccount_historicalappaccount.py +++ b/apps/applications/migrations/0010_appaccount_historicalappaccount.py @@ -1,7 +1,7 @@ # Generated by Django 3.1.12 on 2021-08-26 09:07 import assets.models.base -import common.fields.model +import common.db.fields from django.conf import settings import django.core.validators from django.db import migrations, models @@ -26,9 +26,9 @@ class Migration(migrations.Migration): ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), ('name', models.CharField(max_length=128, verbose_name='Name')), ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), - ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), - ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), - ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('password', common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), ('comment', models.TextField(blank=True, verbose_name='Comment')), ('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')), ('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')), @@ -56,9 +56,9 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('name', models.CharField(max_length=128, verbose_name='Name')), ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), - ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), - ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), - ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('password', common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), ('comment', models.TextField(blank=True, verbose_name='Comment')), ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), diff --git a/apps/applications/migrations/0021_auto_20220629_1826.py b/apps/applications/migrations/0021_auto_20220629_1826.py new file mode 100644 index 000000000..b74977b7d --- /dev/null +++ b/apps/applications/migrations/0021_auto_20220629_1826.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.14 on 2022-06-29 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0020_auto_20220316_2028'), + ] + + operations = [ + migrations.AlterModelOptions( + name='historicalaccount', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Application account', 'verbose_name_plural': 'historical Application accounts'}, + ), + migrations.AlterField( + model_name='historicalaccount', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/apps/applications/models/application.py b/apps/applications/models/application.py index d1859747c..ca45b043a 100644 --- a/apps/applications/models/application.py +++ b/apps/applications/models/application.py @@ -44,6 +44,14 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin): def category_remote_app(self): return self.category == const.AppCategory.remote_app.value + @property + def category_cloud(self): + return self.category == const.AppCategory.cloud.value + + @property + def category_db(self): + return self.category == const.AppCategory.db.value + def get_rdp_remote_app_setting(self): from applications.serializers.attrs import get_serializer_class_by_application_type if not self.category_remote_app: @@ -76,3 +84,14 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin): if raise_exception: raise ValueError("Remote App not has asset attr") + def get_target_ip(self): + target_ip = '' + if self.category_remote_app: + asset = self.get_remote_app_asset() + target_ip = asset.ip if asset else target_ip + elif self.category_cloud: + target_ip = self.attrs.get('cluster') + elif self.category_db: + target_ip = self.attrs.get('host') + return target_ip + diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index 9b62d1dc1..35a07e262 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from orgs.mixins.serializers import BulkOrgResourceModelSerializer from assets.serializers.base import AuthSerializerMixin -from common.drf.serializers import MethodSerializer +from common.drf.serializers import MethodSerializer, SecretReadableMixin from .attrs import ( category_serializer_classes_mapping, type_serializer_classes_mapping, @@ -152,7 +152,7 @@ class AppAccountSerializer(AppSerializerMixin, AuthSerializerMixin, BulkOrgResou return super().to_representation(instance) -class AppAccountSecretSerializer(AppAccountSerializer): +class AppAccountSecretSerializer(SecretReadableMixin, AppAccountSerializer): class Meta(AppAccountSerializer.Meta): fields_backup = [ 'id', 'app_display', 'attrs', 'username', 'password', 'private_key', diff --git a/apps/applications/serializers/attrs/application_type/chrome.py b/apps/applications/serializers/attrs/application_type/chrome.py index 96b4587e7..08035bc31 100644 --- a/apps/applications/serializers/attrs/application_type/chrome.py +++ b/apps/applications/serializers/attrs/application_type/chrome.py @@ -1,6 +1,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from common.drf.fields import EncryptedField from ..application_category import RemoteAppSerializer __all__ = ['ChromeSerializer', 'ChromeSecretSerializer'] @@ -13,19 +14,21 @@ class ChromeSerializer(RemoteAppSerializer): max_length=128, label=_('Application path'), default=CHROME_PATH, allow_null=True, ) chrome_target = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Target URL'), allow_null=True, + max_length=128, allow_blank=True, required=False, + label=_('Target URL'), allow_null=True, ) chrome_username = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Chrome username'), allow_null=True, + max_length=128, allow_blank=True, required=False, + label=_('Chrome username'), allow_null=True, ) - chrome_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, write_only=True, label=_('Chrome password'), - allow_null=True + chrome_password = EncryptedField( + max_length=128, allow_blank=True, required=False, + label=_('Chrome password'), allow_null=True ) class ChromeSecretSerializer(ChromeSerializer): - chrome_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, read_only=True, label=_('Chrome password'), - allow_null=True + chrome_password = EncryptedField( + max_length=128, allow_blank=True, required=False, + label=_('Chrome password'), allow_null=True, write_only=False ) diff --git a/apps/applications/serializers/attrs/application_type/custom.py b/apps/applications/serializers/attrs/application_type/custom.py index 0a58c28b1..cfef59d5f 100644 --- a/apps/applications/serializers/attrs/application_type/custom.py +++ b/apps/applications/serializers/attrs/application_type/custom.py @@ -1,6 +1,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from common.drf.fields import EncryptedField from ..application_category import RemoteAppSerializer __all__ = ['CustomSerializer', 'CustomSecretSerializer'] @@ -19,14 +20,14 @@ class CustomSerializer(RemoteAppSerializer): max_length=128, allow_blank=True, required=False, label=_('Custom Username'), allow_null=True, ) - custom_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, write_only=True, label=_('Custom password'), - allow_null=True, + custom_password = EncryptedField( + max_length=128, allow_blank=True, required=False, + label=_('Custom password'), allow_null=True, ) class CustomSecretSerializer(RemoteAppSerializer): - custom_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, read_only=True, label=_('Custom password'), - allow_null=True, + custom_password = EncryptedField( + max_length=128, allow_blank=True, required=False, write_only=False, + label=_('Custom password'), allow_null=True, ) diff --git a/apps/applications/serializers/attrs/application_type/mysql_workbench.py b/apps/applications/serializers/attrs/application_type/mysql_workbench.py index bc41a689b..6092b2ed1 100644 --- a/apps/applications/serializers/attrs/application_type/mysql_workbench.py +++ b/apps/applications/serializers/attrs/application_type/mysql_workbench.py @@ -1,6 +1,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from common.drf.fields import EncryptedField from ..application_category import RemoteAppSerializer __all__ = ['MySQLWorkbenchSerializer', 'MySQLWorkbenchSecretSerializer'] @@ -29,14 +30,14 @@ class MySQLWorkbenchSerializer(RemoteAppSerializer): max_length=128, allow_blank=True, required=False, label=_('Mysql workbench username'), allow_null=True, ) - mysql_workbench_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, write_only=True, label=_('Mysql workbench password'), - allow_null=True, + mysql_workbench_password = EncryptedField( + max_length=128, allow_blank=True, required=False, + label=_('Mysql workbench password'), allow_null=True, ) class MySQLWorkbenchSecretSerializer(RemoteAppSerializer): - mysql_workbench_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, read_only=True, label=_('Mysql workbench password'), - allow_null=True, + mysql_workbench_password = EncryptedField( + max_length=128, allow_blank=True, required=False, write_only=False, + label=_('Mysql workbench password'), allow_null=True, ) diff --git a/apps/applications/serializers/attrs/application_type/vmware_client.py b/apps/applications/serializers/attrs/application_type/vmware_client.py index 6ec3975cb..d6b9cef0b 100644 --- a/apps/applications/serializers/attrs/application_type/vmware_client.py +++ b/apps/applications/serializers/attrs/application_type/vmware_client.py @@ -1,6 +1,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from common.drf.fields import EncryptedField from ..application_category import RemoteAppSerializer __all__ = ['VMwareClientSerializer', 'VMwareClientSecretSerializer'] @@ -25,14 +26,14 @@ class VMwareClientSerializer(RemoteAppSerializer): max_length=128, allow_blank=True, required=False, label=_('Vmware username'), allow_null=True ) - vmware_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, write_only=True, label=_('Vmware password'), - allow_null=True + vmware_password = EncryptedField( + max_length=128, allow_blank=True, required=False, + label=_('Vmware password'), allow_null=True ) class VMwareClientSecretSerializer(RemoteAppSerializer): - vmware_password = serializers.CharField( - max_length=128, allow_blank=True, required=False, read_only=True, label=_('Vmware password'), - allow_null=True + vmware_password = EncryptedField( + max_length=128, allow_blank=True, required=False, write_only=False, + label=_('Vmware password'), allow_null=True ) diff --git a/apps/assets/api/__init__.py b/apps/assets/api/__init__.py index 0fe3f480b..aafc98988 100644 --- a/apps/assets/api/__init__.py +++ b/apps/assets/api/__init__.py @@ -12,3 +12,4 @@ from .cmd_filter import * from .gathered_user import * from .favorite_asset import * from .account_backup import * +from .account_history import * diff --git a/apps/assets/api/account_history.py b/apps/assets/api/account_history.py new file mode 100644 index 000000000..6ca4fd349 --- /dev/null +++ b/apps/assets/api/account_history.py @@ -0,0 +1,39 @@ +from assets.api.accounts import ( + AccountFilterSet, AccountViewSet, AccountSecretsViewSet +) +from common.mixins import RecordViewLogMixin +from .. import serializers +from ..models import Account + +__all__ = ['AccountHistoryViewSet', 'AccountHistorySecretsViewSet'] + + +class AccountHistoryFilterSet(AccountFilterSet): + class Meta: + model = Account.history.model + fields = AccountFilterSet.Meta.fields + + +class AccountHistoryViewSet(AccountViewSet): + model = Account.history.model + filterset_class = AccountHistoryFilterSet + serializer_classes = { + 'default': serializers.AccountHistorySerializer, + } + rbac_perms = { + 'list': 'assets.view_assethistoryaccount', + 'retrieve': 'assets.view_assethistoryaccount', + } + http_method_names = ['get', 'options'] + + +class AccountHistorySecretsViewSet(RecordViewLogMixin, AccountHistoryViewSet): + serializer_classes = { + 'default': serializers.AccountHistorySecretSerializer + } + http_method_names = ['get'] + permission_classes = AccountSecretsViewSet.permission_classes + rbac_perms = { + 'list': 'assets.view_assethistoryaccountsecret', + 'retrieve': 'assets.view_assethistoryaccountsecret', + } diff --git a/apps/assets/api/accounts.py b/apps/assets/api/accounts.py index 9989599b8..be6833e7b 100644 --- a/apps/assets/api/accounts.py +++ b/apps/assets/api/accounts.py @@ -1,4 +1,4 @@ -from django.db.models import F, Q +from django.db.models import Q from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from rest_framework.decorators import action @@ -8,12 +8,14 @@ from rest_framework.generics import CreateAPIView from orgs.mixins.api import OrgBulkModelViewSet from rbac.permissions import RBACPermission from common.drf.filters import BaseFilterSet -from common.permissions import NeedMFAVerify +from common.mixins import RecordViewLogMixin +from common.permissions import UserConfirmation +from authentication.const import ConfirmType from ..tasks.account_connectivity import test_accounts_connectivity_manual -from ..models import AuthBook, Node +from ..models import Node, Account from .. import serializers -__all__ = ['AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI'] +__all__ = ['AccountFilterSet', 'AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI'] class AccountFilterSet(BaseFilterSet): @@ -48,16 +50,16 @@ class AccountFilterSet(BaseFilterSet): return qs class Meta: - model = AuthBook + model = Account fields = [ - 'asset', 'systemuser', 'id', + 'asset', 'id', ] class AccountViewSet(OrgBulkModelViewSet): - model = AuthBook - filterset_fields = ("username", "asset", "systemuser", 'ip', 'hostname') - search_fields = ('username', 'ip', 'hostname', 'systemuser__username') + model = Account + filterset_fields = ("username", "asset", 'ip', 'hostname') + search_fields = ('username', 'ip', 'hostname') filterset_class = AccountFilterSet serializer_classes = { 'default': serializers.AccountSerializer, @@ -68,10 +70,6 @@ class AccountViewSet(OrgBulkModelViewSet): 'partial_update': 'assets.change_assetaccountsecret', } - def get_queryset(self): - queryset = AuthBook.get_queryset() - return queryset - @action(methods=['post'], detail=True, url_path='verify') def verify_account(self, request, *args, **kwargs): account = super().get_object() @@ -79,7 +77,7 @@ class AccountViewSet(OrgBulkModelViewSet): return Response(data={'task': task.id}) -class AccountSecretsViewSet(AccountViewSet): +class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet): """ 因为可能要导出所有账号,所以单独建立了一个 viewset """ @@ -87,7 +85,7 @@ class AccountSecretsViewSet(AccountViewSet): 'default': serializers.AccountSecretSerializer } http_method_names = ['get'] - permission_classes = [RBACPermission, NeedMFAVerify] + permission_classes = [RBACPermission, UserConfirmation.require(ConfirmType.MFA)] rbac_perms = { 'list': 'assets.view_assetaccountsecret', 'retrieve': 'assets.view_assetaccountsecret', @@ -104,7 +102,7 @@ class AccountTaskCreateAPI(CreateAPIView): return request.user.has_perm('assets.test_assetconnectivity') def get_accounts(self): - queryset = AuthBook.objects.all() + queryset = Account.objects.all() queryset = self.filter_queryset(queryset) return queryset diff --git a/apps/assets/api/cmd_filter.py b/apps/assets/api/cmd_filter.py index dcb2d77c9..0e09d5c73 100644 --- a/apps/assets/api/cmd_filter.py +++ b/apps/assets/api/cmd_filter.py @@ -69,7 +69,7 @@ class CommandConfirmAPI(CreateAPIView): external=True, api_to_ui=True ) ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type) - ticket_assignees = ticket.current_node.first().ticket_assignees.all() + ticket_assignees = ticket.current_step.ticket_assignees.all() return { 'check_confirm_status': {'method': 'GET', 'url': confirm_status_url}, 'close_confirm': {'method': 'DELETE', 'url': confirm_status_url}, diff --git a/apps/assets/api/system_user.py b/apps/assets/api/system_user.py index 2739044de..96141d561 100644 --- a/apps/assets/api/system_user.py +++ b/apps/assets/api/system_user.py @@ -4,7 +4,6 @@ from rest_framework.response import Response from rest_framework.decorators import action from common.utils import get_logger, get_object_or_none -from common.utils.crypto import get_aes_crypto from common.permissions import IsValidUser from common.mixins.api import SuggestionMixin from orgs.mixins.api import OrgBulkModelViewSet @@ -103,27 +102,17 @@ class SystemUserTempAuthInfoApi(generics.CreateAPIView): permission_classes = (IsValidUser,) serializer_class = SystemUserTempAuthSerializer - def decrypt_data_if_need(self, data): - csrf_token = self.request.META.get('CSRF_COOKIE') - aes = get_aes_crypto(csrf_token, 'ECB') - password = data.get('password', '') - try: - data['password'] = aes.decrypt(password) - except: - pass - return data - def create(self, request, *args, **kwargs): serializer = super().get_serializer(data=request.data) serializer.is_valid(raise_exception=True) pk = kwargs.get('pk') - data = self.decrypt_data_if_need(serializer.validated_data) - instance_id = data.get('instance_id') + data = serializer.validated_data + asset_or_app_id = data.get('instance_id') with tmp_to_root_org(): instance = get_object_or_404(SystemUser, pk=pk) - instance.set_temp_auth(instance_id, self.request.user.id, data) + instance.set_temp_auth(asset_or_app_id, self.request.user.id, data) return Response(serializer.data, status=201) diff --git a/apps/assets/api/system_user_relation.py b/apps/assets/api/system_user_relation.py index 2d8018e4d..36c16a09b 100644 --- a/apps/assets/api/system_user_relation.py +++ b/apps/assets/api/system_user_relation.py @@ -68,7 +68,6 @@ class BaseRelationViewSet(RelationMixin, OrgBulkModelViewSet): class SystemUserAssetRelationViewSet(BaseRelationViewSet): - perm_model = models.AuthBook serializer_class = serializers.SystemUserAssetRelationSerializer model = models.SystemUser.assets.through filterset_fields = [ diff --git a/apps/assets/migrations/0032_auto_20190624_2108.py b/apps/assets/migrations/0032_auto_20190624_2108.py index 441f13cdb..275308e99 100644 --- a/apps/assets/migrations/0032_auto_20190624_2108.py +++ b/apps/assets/migrations/0032_auto_20190624_2108.py @@ -1,7 +1,7 @@ # Generated by Django 2.1.7 on 2019-06-24 13:08 import assets.models.utils -import common.fields.model +import common.db.fields from django.db import migrations @@ -15,61 +15,61 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='adminuser', name='_password', - field=common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), + field=common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), ), migrations.AlterField( model_name='adminuser', name='_private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), ), migrations.AlterField( model_name='adminuser', name='_public_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), ), migrations.AlterField( model_name='authbook', name='_password', - field=common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), + field=common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), ), migrations.AlterField( model_name='authbook', name='_private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), ), migrations.AlterField( model_name='authbook', name='_public_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), ), migrations.AlterField( model_name='gateway', name='_password', - field=common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), + field=common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), ), migrations.AlterField( model_name='gateway', name='_private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), ), migrations.AlterField( model_name='gateway', name='_public_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), ), migrations.AlterField( model_name='systemuser', name='_password', - field=common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), + field=common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password'), ), migrations.AlterField( model_name='systemuser', name='_private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key'), ), migrations.AlterField( model_name='systemuser', name='_public_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key'), ), ] diff --git a/apps/assets/migrations/0035_auto_20190711_2018.py b/apps/assets/migrations/0035_auto_20190711_2018.py index 9dcbad1db..00eb41fe5 100644 --- a/apps/assets/migrations/0035_auto_20190711_2018.py +++ b/apps/assets/migrations/0035_auto_20190711_2018.py @@ -1,6 +1,6 @@ # Generated by Django 2.1.7 on 2019-07-11 12:18 -import common.fields.model +import common.db.fields from django.db import migrations @@ -14,21 +14,21 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='adminuser', name='private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), ), migrations.AlterField( model_name='authbook', name='private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), ), migrations.AlterField( model_name='gateway', name='private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), ), migrations.AlterField( model_name='systemuser', name='private_key', - field=common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), + field=common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key'), ), ] diff --git a/apps/assets/migrations/0044_platform.py b/apps/assets/migrations/0044_platform.py index 8d45a8ee3..2e1ab723e 100644 --- a/apps/assets/migrations/0044_platform.py +++ b/apps/assets/migrations/0044_platform.py @@ -1,6 +1,6 @@ # Generated by Django 2.2.7 on 2019-12-06 07:26 -import common.fields.model +import common.db.fields from django.db import migrations, models @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ('name', models.SlugField(allow_unicode=True, unique=True, verbose_name='Name')), ('base', models.CharField(choices=[('Linux', 'Linux'), ('Unix', 'Unix'), ('MacOS', 'MacOS'), ('BSD', 'BSD'), ('Windows', 'Windows'), ('Other', 'Other')], default='Linux', max_length=16, verbose_name='Base')), ('charset', models.CharField(choices=[('utf8', 'UTF-8'), ('gbk', 'GBK')], default='utf8', max_length=8, verbose_name='Charset')), - ('meta', common.fields.model.JsonDictTextField(blank=True, null=True, verbose_name='Meta')), + ('meta', common.db.fields.JsonDictTextField(blank=True, null=True, verbose_name='Meta')), ('internal', models.BooleanField(default=False, verbose_name='Internal')), ('comment', models.TextField(blank=True, null=True, verbose_name='Comment')), ], diff --git a/apps/assets/migrations/0072_historicalauthbook.py b/apps/assets/migrations/0072_historicalauthbook.py index 978584824..e28949c1f 100644 --- a/apps/assets/migrations/0072_historicalauthbook.py +++ b/apps/assets/migrations/0072_historicalauthbook.py @@ -1,6 +1,6 @@ # Generated by Django 3.1.6 on 2021-06-05 16:10 -import common.fields.model +import common.db.fields from django.conf import settings import django.core.validators from django.db import migrations, models @@ -58,9 +58,9 @@ class Migration(migrations.Migration): ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), ('name', models.CharField(max_length=128, verbose_name='Name')), ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), - ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), - ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), - ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('password', common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), ('comment', models.TextField(blank=True, verbose_name='Comment')), ('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')), ('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')), diff --git a/apps/assets/migrations/0090_auto_20220412_1145.py b/apps/assets/migrations/0090_auto_20220412_1145.py new file mode 100644 index 000000000..3259cd37b --- /dev/null +++ b/apps/assets/migrations/0090_auto_20220412_1145.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.14 on 2022-04-12 03:45 + +from django.db import migrations, models + + +def create_internal_platform(apps, schema_editor): + model = apps.get_model("assets", "Platform") + db_alias = schema_editor.connection.alias + type_platforms = ( + ('AIX', 'Unix', None), + ) + for name, base, meta in type_platforms: + defaults = {'name': name, 'base': base, 'meta': meta, 'internal': True} + model.objects.using(db_alias).update_or_create( + name=name, defaults=defaults + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0089_auto_20220310_0616'), + ] + + operations = [ + migrations.AlterField( + model_name='asset', + name='number', + field=models.CharField(blank=True, max_length=128, null=True, verbose_name='Asset number'), + ), + migrations.RunPython(create_internal_platform) + ] diff --git a/apps/assets/migrations/0091_auto_20220629_1826.py b/apps/assets/migrations/0091_auto_20220629_1826.py new file mode 100644 index 000000000..589dce2b2 --- /dev/null +++ b/apps/assets/migrations/0091_auto_20220629_1826.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.14 on 2022-06-29 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0090_auto_20220412_1145'), + ] + + operations = [ + migrations.AlterModelOptions( + name='authbook', + options={'permissions': [('test_authbook', 'Can test asset account connectivity'), ('view_assetaccountsecret', 'Can view asset account secret'), ('change_assetaccountsecret', 'Can change asset account secret'), ('view_assethistoryaccount', 'Can view asset history account'), ('view_assethistoryaccountsecret', 'Can view asset history account secret')], 'verbose_name': 'AuthBook'}, + ), + migrations.AlterModelOptions( + name='historicalauthbook', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical AuthBook', 'verbose_name_plural': 'historical AuthBooks'}, + ), + migrations.AlterField( + model_name='historicalauthbook', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/apps/assets/migrations/0092_auto_20220711_1409.py b/apps/assets/migrations/0092_auto_20220711_1409.py new file mode 100644 index 000000000..8036cc09f --- /dev/null +++ b/apps/assets/migrations/0092_auto_20220711_1409.py @@ -0,0 +1,83 @@ +# Generated by Django 3.2.12 on 2022-07-11 08:59 + +import assets.models.base +import assets.models.user +import common.db.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('assets', '0091_auto_20220629_1826'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalAccount', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('connectivity', models.CharField(choices=[('unknown', 'Unknown'), ('ok', 'Ok'), ('failed', 'Failed')], default='unknown', max_length=16, verbose_name='Connectivity')), + ('date_verified', models.DateTimeField(null=True, verbose_name='Date verified')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username')), + ('password', common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')), + ('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('protocol', models.CharField(choices=[('ssh', 'SSH'), ('rdp', 'RDP'), ('telnet', 'Telnet'), ('vnc', 'VNC'), ('mysql', 'MySQL'), ('oracle', 'Oracle'), ('mariadb', 'MariaDB'), ('postgresql', 'PostgreSQL'), ('sqlserver', 'SQLServer'), ('redis', 'Redis'), ('mongodb', 'MongoDB'), ('k8s', 'K8S')], default='ssh', max_length=16, verbose_name='Protocol')), + ('type', models.CharField(choices=[('common', 'Common user'), ('admin', 'Admin user')], default='common', max_length=16, verbose_name='Type')), + ('version', models.IntegerField(default=1, verbose_name='Version')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('asset', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='assets.asset', verbose_name='Asset')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical Account', + 'verbose_name_plural': 'historical Accounts', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Account', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('connectivity', models.CharField(choices=[('unknown', 'Unknown'), ('ok', 'Ok'), ('failed', 'Failed')], default='unknown', max_length=16, verbose_name='Connectivity')), + ('date_verified', models.DateTimeField(null=True, verbose_name='Date verified')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username')), + ('password', common.db.fields.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('protocol', models.CharField(choices=[('ssh', 'SSH'), ('rdp', 'RDP'), ('telnet', 'Telnet'), ('vnc', 'VNC'), ('mysql', 'MySQL'), ('oracle', 'Oracle'), ('mariadb', 'MariaDB'), ('postgresql', 'PostgreSQL'), ('sqlserver', 'SQLServer'), ('redis', 'Redis'), ('mongodb', 'MongoDB'), ('k8s', 'K8S')], default='ssh', max_length=16, verbose_name='Protocol')), + ('type', models.CharField(choices=[('common', 'Common user'), ('admin', 'Admin user')], default='common', max_length=16, verbose_name='Type')), + ('version', models.IntegerField(default=0, verbose_name='Version')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='assets.asset', verbose_name='Asset')), + ], + options={ + 'verbose_name': 'Account', + 'permissions': [('view_assetaccountsecret', 'Can view asset account secret'), ('change_assetaccountsecret', 'Can change asset account secret'), ('view_assethistoryaccount', 'Can view asset history account'), ('view_assethistoryaccountsecret', 'Can view asset history account secret')], + 'unique_together': {('username', 'asset')}, + }, + bases=(models.Model, assets.models.base.AuthMixin, assets.models.user.ProtocolMixin), + ), + ] diff --git a/apps/assets/migrations/0093_auto_20220711_1413.py b/apps/assets/migrations/0093_auto_20220711_1413.py new file mode 100644 index 000000000..ba7d7e0c0 --- /dev/null +++ b/apps/assets/migrations/0093_auto_20220711_1413.py @@ -0,0 +1,65 @@ +# Generated by Django 3.2.12 on 2022-07-11 06:13 + +import time +from django.db import migrations + + +def migrate_accounts(apps, schema_editor): + auth_book_model = apps.get_model('assets', 'AuthBook') + account_model = apps.get_model('assets', 'Account') + + count = 0 + bulk_size = 1000 + print("\nStart migrate accounts") + while True: + start = time.time() + auth_books = auth_book_model.objects \ + .prefetch_related('systemuser') \ + .all()[count:count+bulk_size] + count += len(auth_books) + if not auth_books: + break + + accounts = [] + # auth book 和 account 相同的属性 + same_attrs = [ + 'id', 'comment', 'date_created', 'date_updated', + 'created_by', 'asset_id', 'org_id', + ] + # 认证的属性,可能是 authbook 的,可能是 systemuser 的 + auth_attrs = ['username', 'password', 'private_key', 'public_key'] + + for auth_book in auth_books: + values = {attr: getattr(auth_book, attr) for attr in same_attrs} + values['protocol'] = 'ssh' + values['version'] = 1 + + system_user = auth_book.systemuser + if auth_book.systemuser: + values.update({attr: getattr(system_user, attr) for attr in auth_attrs}) + values['protocol'] = system_user.protocol + values['created_by'] = str(system_user.id) + values['type'] = system_user.type + + auth_book_auth = {attr: getattr(auth_book, attr) for attr in auth_attrs} + auth_book_auth = {attr: value for attr, value in auth_book_auth.items() if value} + values.update(auth_book_auth) + + account = account_model(**values) + accounts.append(account) + + account_model.objects.bulk_create(accounts, ignore_conflicts=True) + print("Create accounts: {}-{} using: {:.2f}s".format( + count - bulk_size, count, time.time()-start + )) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0092_auto_20220711_1409'), + ] + + operations = [ + migrations.RunPython(migrate_accounts) + ] diff --git a/apps/assets/migrations/0094_alter_systemuser_assets.py b/apps/assets/migrations/0094_alter_systemuser_assets.py new file mode 100644 index 000000000..c3f2f2cfc --- /dev/null +++ b/apps/assets/migrations/0094_alter_systemuser_assets.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.12 on 2022-07-13 09:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0093_auto_20220711_1413'), + ] + + operations = [ + migrations.RemoveField( + model_name='systemuser', + name='assets', + ), + migrations.AddField( + model_name='systemuser', + name='assets', + field=models.ManyToManyField(blank=True, related_name='system_users', to='assets.Asset', verbose_name='Assets'), + ), + ] diff --git a/apps/assets/migrations/0095_auto_20220713_1746.py b/apps/assets/migrations/0095_auto_20220713_1746.py new file mode 100644 index 000000000..b803dae51 --- /dev/null +++ b/apps/assets/migrations/0095_auto_20220713_1746.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.12 on 2022-07-13 09:46 + +import time +from django.db import migrations + + +def migrate_asset_system_user_relations(apps, schema_editor): + system_user_model = apps.get_model('assets', 'SystemUser') + old_model = apps.get_model('assets', 'AuthBook') + new_model = system_user_model.assets.through + + count = 0 + bulk_size = 1000 + print("\nStart migrate asset system user relations") + while True: + start = time.time() + auth_books = old_model.objects.only('asset_id', 'systemuser_id')[count:count+bulk_size] + auth_books = list(auth_books) + count += len(auth_books) + if not auth_books: + break + asset_system_users = [] + for auth_book in auth_books: + if not auth_book.asset_id or not auth_book.systemuser_id: + continue + asset_system_user = new_model( + asset_id=auth_book.asset_id, + systemuser_id=auth_book.systemuser_id + ) + asset_system_users.append(asset_system_user) + new_model.objects.bulk_create(asset_system_users, ignore_conflicts=True) + print("Create asset system user relations: {}-{} using: {:.2f}s".format( + count - bulk_size, count, time.time()-start + )) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0094_alter_systemuser_assets'), + ] + + operations = [ + migrations.RunPython(migrate_asset_system_user_relations) + ] diff --git a/apps/assets/migrations/0096_auto_20220714_1627.py b/apps/assets/migrations/0096_auto_20220714_1627.py new file mode 100644 index 000000000..274b2d8ef --- /dev/null +++ b/apps/assets/migrations/0096_auto_20220714_1627.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-07-14 08:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0095_auto_20220713_1746'), + ] + + operations = [ + migrations.RenameField( + model_name='systemuser', + old_name='auto_push', + new_name='auto_push_account', + ), + migrations.AddField( + model_name='systemuser', + name='account_template_enabled', + field=models.BooleanField(default=False, verbose_name='Auto account if not exist'), + ), + ] diff --git a/apps/assets/models/__init__.py b/apps/assets/models/__init__.py index b4ea60bb1..308202190 100644 --- a/apps/assets/models/__init__.py +++ b/apps/assets/models/__init__.py @@ -14,3 +14,4 @@ from .authbook import * from .gathered_user import * from .favorite_asset import * from .backup import * +from .account import * diff --git a/apps/assets/models/account.py b/apps/assets/models/account.py new file mode 100644 index 000000000..7660ff682 --- /dev/null +++ b/apps/assets/models/account.py @@ -0,0 +1,35 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from simple_history.models import HistoricalRecords + +from .user import ProtocolMixin +from .base import BaseUser, AbsConnectivity + + +__all__ = ['Account'] + + +class Account(BaseUser, AbsConnectivity, ProtocolMixin): + class Type(models.TextChoices): + common = 'common', _('Common user') + admin = 'admin', _('Admin user') + + protocol = models.CharField(max_length=16, choices=ProtocolMixin.Protocol.choices, + default='ssh', verbose_name=_('Protocol')) + type = models.CharField(max_length=16, choices=Type.choices, default=Type.common, verbose_name=_("Type")) + asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_('Asset')) + version = models.IntegerField(default=0, verbose_name=_('Version')) + history = HistoricalRecords() + + class Meta: + verbose_name = _('Account') + unique_together = [('username', 'asset')] + permissions = [ + ('view_assetaccountsecret', _('Can view asset account secret')), + ('change_assetaccountsecret', _('Can change asset account secret')), + ('view_assethistoryaccount', _('Can view asset history account')), + ('view_assethistoryaccountsecret', _('Can view asset history account secret')), + ] + + def __str__(self): + return '{}://{}@{}'.format(self.protocol, self.username, self.asset.hostname) diff --git a/apps/assets/models/asset/common.py b/apps/assets/models/asset/common.py index 04abf1f25..0e3f743f0 100644 --- a/apps/assets/models/asset/common.py +++ b/apps/assets/models/asset/common.py @@ -11,6 +11,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import ValidationError +from common.db.fields import JsonDictTextField from common.utils import lazyproperty from orgs.mixins.models import OrgModelMixin, OrgManager from assets.const import Category, AllTypes @@ -147,7 +148,7 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): # Some information public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP')) - number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number')) + number = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Asset number')) labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels")) created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by')) @@ -159,15 +160,8 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): def __str__(self): return '{0.hostname}({0.ip})'.format(self) - def set_admin_user_relation(self): - from assets.models import AuthBook - if not self.admin_user: - return - if self.admin_user.type != 'admin': - raise ValidationError('System user should be type admin') - - defaults = {'asset': self, 'systemuser': self.admin_user, 'org_id': self.org_id} - AuthBook.objects.get_or_create(defaults=defaults, asset=self, systemuser=self.admin_user) + def get_target_ip(self): + return self.ip @property def admin_user_display(self): @@ -222,7 +216,7 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): 'private_key': auth_user.private_key_file } - if not with_become: + if not with_become or self.is_windows(): return info if become_user: diff --git a/apps/assets/models/authbook.py b/apps/assets/models/authbook.py index 53766d2b7..f5d9e457d 100644 --- a/apps/assets/models/authbook.py +++ b/apps/assets/models/authbook.py @@ -29,7 +29,9 @@ class AuthBook(BaseUser, AbsConnectivity): permissions = [ ('test_authbook', _('Can test asset account connectivity')), ('view_assetaccountsecret', _('Can view asset account secret')), - ('change_assetaccountsecret', _('Can change asset account secret')) + ('change_assetaccountsecret', _('Can change asset account secret')), + ('view_assethistoryaccount', _('Can view asset history account')), + ('view_assethistoryaccountsecret', _('Can view asset history account secret')), ] def __init__(self, *args, **kwargs): @@ -123,8 +125,9 @@ class AuthBook(BaseUser, AbsConnectivity): logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser)) @classmethod - def get_queryset(cls): - queryset = cls.objects.all() \ + def get_queryset(cls, is_history_model=False): + model = cls.history.model if is_history_model else cls + queryset = model.objects.all() \ .annotate(ip=F('asset__ip')) \ .annotate(hostname=F('asset__hostname')) \ .annotate(platform=F('asset__platform__name')) \ @@ -134,3 +137,4 @@ class AuthBook(BaseUser, AbsConnectivity): def __str__(self): return self.smart_name + diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index 38b7218c4..493036efc 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -19,7 +19,7 @@ from common.utils import ( ) from common.utils.encode import ssh_pubkey_gen from common.validators import alphanumeric -from common import fields +from common.db import fields from orgs.mixins.models import OrgModelMixin diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index 82fd50e89..b17a4263d 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -165,24 +165,23 @@ class CommandFilterRule(OrgModelMixin): def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id): from tickets.const import TicketType - from tickets.models import Ticket + from tickets.models import ApplyCommandTicket data = { 'title': _('Command confirm') + ' ({})'.format(session.user), 'type': TicketType.command_confirm, - 'meta': { - 'apply_run_user': session.user, - 'apply_run_asset': session.asset, - 'apply_run_system_user': session.system_user, - 'apply_run_command': run_command, - 'apply_from_session_id': str(session.id), - 'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id), - 'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id) - }, + 'applicant': session.user_obj, + 'apply_run_user_id': session.user_id, + 'apply_run_asset': str(session.asset), + 'apply_run_system_user_id': session.system_user_id, + 'apply_run_command': run_command[:4090], + 'apply_from_session_id': str(session.id), + 'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id), + 'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id), 'org_id': org_id, } - ticket = Ticket.objects.create(**data) - ticket.create_process_map_and_node(self.reviewers.all()) - ticket.open(applicant=session.user_obj) + ticket = ApplyCommandTicket.objects.create(**data) + assignees = self.reviewers.all() + ticket.open_by_system(assignees) return ticket @classmethod diff --git a/apps/assets/models/domain.py b/apps/assets/models/domain.py index ba3b069f5..a57cbdffc 100644 --- a/apps/assets/models/domain.py +++ b/apps/assets/models/domain.py @@ -9,7 +9,7 @@ import paramiko from django.db import models from django.utils.translation import ugettext_lazy as _ -from common.utils import get_logger +from common.utils import get_logger, lazyproperty from orgs.mixins.models import OrgModelMixin from .base import BaseUser @@ -36,7 +36,7 @@ class Domain(OrgModelMixin): def has_gateway(self): return self.gateway_set.filter(is_active=True).exists() - @property + @lazyproperty def gateways(self): return self.gateway_set.filter(is_active=True) @@ -44,8 +44,9 @@ class Domain(OrgModelMixin): gateways = [gw for gw in self.gateways if gw.is_connective] if gateways: return random.choice(gateways) - else: - logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.') + + logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.') + if self.gateways: return random.choice(self.gateways) diff --git a/apps/assets/models/platform.py b/apps/assets/models/platform.py index 4a7ed2ce4..878940e9e 100644 --- a/apps/assets/models/platform.py +++ b/apps/assets/models/platform.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from assets.const import Category, AllTypes -from common.fields.model import JsonDictTextField +from common.db.fields import JsonDictTextField __all__ = ['Platform'] diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index e179c9b22..0aecfe665 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -7,16 +7,14 @@ import logging from django.db import models from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator, MaxValueValidator -from django.core.cache import cache -from common.utils import signer, get_object_or_none from assets.const import Protocol +from common.utils import signer from .base import BaseUser from .asset import Asset -from .authbook import AuthBook -__all__ = ['AdminUser', 'SystemUser'] +__all__ = ['AdminUser', 'SystemUser', 'ProtocolMixin'] logger = logging.getLogger(__name__) @@ -70,151 +68,11 @@ class ProtocolMixin: return self.protocol in self.ASSET_CATEGORY_PROTOCOLS -class AuthMixin: - username_same_with_user: bool - protocol: str - ASSET_CATEGORY_PROTOCOLS: list - login_mode: str - LOGIN_MANUAL: str - id: str - username: str - password: str - private_key: str - public_key: str - - def set_temp_auth(self, asset_or_app_id, user_id, auth, ttl=300): - if not auth: - raise ValueError('Auth not set') - key = 'TEMP_PASSWORD_{}_{}_{}'.format(self.id, asset_or_app_id, user_id) - logger.debug(f'Set system user temp auth: {key}') - cache.set(key, auth, ttl) - - def get_temp_auth(self, asset_or_app_id, user_id): - key = 'TEMP_PASSWORD_{}_{}_{}'.format(self.id, asset_or_app_id, user_id) - logger.debug(f'Get system user temp auth: {key}') - password = cache.get(key) - return password - - def _clean_auth_info_if_manual_login_mode(self): - if self.login_mode == self.LOGIN_MANUAL: - self.password = '' - self.private_key = '' - self.public_key = '' - - def _load_tmp_auth_if_has(self, asset_or_app_id, user_id): - if self.login_mode != self.LOGIN_MANUAL: - return - - if not asset_or_app_id or not user_id: - return - - auth = self.get_temp_auth(asset_or_app_id, user_id) - if not auth: - return - - username = auth.get('username') - password = auth.get('password') - - if username: - self.username = username - if password: - self.password = password - - def load_app_more_auth(self, app_id=None, username=None, user_id=None): - from applications.models import Application - app = get_object_or_none(Application, pk=app_id) - if app and app.category_remote_app: - # Remote app - self._load_remoteapp_more_auth(app, username, user_id) - return - - # Other app - self._clean_auth_info_if_manual_login_mode() - # 加载临时认证信息 - if self.login_mode == self.LOGIN_MANUAL: - self._load_tmp_auth_if_has(app_id, user_id) - return - # 更新用户名 - from users.models import User - user = get_object_or_none(User, pk=user_id) if user_id else None - if self.username_same_with_user: - if user and not username: - _username = user.username - else: - _username = username - self.username = _username - - def _load_remoteapp_more_auth(self, app, username, user_id): - asset = app.get_remote_app_asset(raise_exception=False) - if asset: - self.load_asset_more_auth(asset_id=asset.id, username=username, user_id=user_id) - - def load_asset_special_auth(self, asset, username=''): - """ - AuthBook 的数据状态 - | asset | systemuser | username | - 1 | * | * | x | - 2 | * | x | * | - - 当前 AuthBook 只有以上两种状态,systemuser 与 username 不会并存。 - 正常的资产与系统用户关联产生的是第1种状态,改密则产生第2种状态。改密之后 - 只有 username 而没有 systemuser 。 - - Freq: 关联同一资产的多个系统用户指定同一用户名时,修改用户密码会影响所有系统用户 - - 这里有一个不对称的行为,同名系统用户密码覆盖 - 当有相同 username 的多个系统用户时,有改密动作之后,所有的同名系统用户都使用最后 - 一次改动,但如果没有发生过改密,同名系统用户使用的密码还是各自的。 - - """ - if username == '': - username = self.username - - authbook = AuthBook.objects.filter( - asset=asset, username=username, systemuser__isnull=True - ).order_by('-date_created').first() - - if not authbook: - authbook = AuthBook.objects.filter( - asset=asset, systemuser=self - ).order_by('-date_created').first() - - if not authbook: - return None - - authbook.load_auth() - self.password = authbook.password - self.private_key = authbook.private_key - self.public_key = authbook.public_key - - def load_asset_more_auth(self, asset_id=None, username=None, user_id=None): - from users.models import User - self._clean_auth_info_if_manual_login_mode() - # 加载临时认证信息 - if self.login_mode == self.LOGIN_MANUAL: - self._load_tmp_auth_if_has(asset_id, user_id) - return - # 更新用户名 - user = get_object_or_none(User, pk=user_id) if user_id else None - if self.username_same_with_user: - if user and not username: - _username = user.username - else: - _username = username - self.username = _username - # 加载某个资产的特殊配置认证信息 - asset = get_object_or_none(Asset, pk=asset_id) if asset_id else None - if not asset: - logger.debug('Asset not found, pass') - return - self.load_asset_special_auth(asset, self.username) - - -class SystemUser(ProtocolMixin, AuthMixin, BaseUser): +class SystemUser(ProtocolMixin, BaseUser): LOGIN_AUTO = 'auto' LOGIN_MANUAL = 'manual' LOGIN_MODE_CHOICES = ( - (LOGIN_AUTO, _('Automatic managed')), + (LOGIN_AUTO, _('使用账号')), (LOGIN_MANUAL, _('Manually input')) ) @@ -226,24 +84,34 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser): nodes = models.ManyToManyField('assets.Node', blank=True, verbose_name=_("Nodes")) assets = models.ManyToManyField( 'assets.Asset', blank=True, verbose_name=_("Assets"), - through='assets.AuthBook', through_fields=['systemuser', 'asset'], related_name='system_users' ) users = models.ManyToManyField('users.User', blank=True, verbose_name=_("Users")) groups = models.ManyToManyField('users.UserGroup', blank=True, verbose_name=_("User groups")) - type = models.CharField(max_length=16, choices=Type.choices, default=Type.common, verbose_name=_('Type')) - priority = models.IntegerField(default=81, verbose_name=_("Priority"), help_text=_("1-100, the lower the value will be match first"), validators=[MinValueValidator(1), MaxValueValidator(100)]) + priority = models.IntegerField( + default=81, verbose_name=_("Priority"), + help_text=_("1-100, the lower the value will be match first"), + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) protocol = models.CharField(max_length=16, choices=Protocol.choices, default='ssh', verbose_name=_('Protocol')) + login_mode = models.CharField(choices=LOGIN_MODE_CHOICES, default=LOGIN_AUTO, max_length=10, verbose_name=_('Login mode')) + + # Todo: 重构平台后或许这里也得变化 + # 账号模版 + account_template_enabled = models.BooleanField(default=False, verbose_name=_("启用账号模版")) + auto_push_account = models.BooleanField(default=True, verbose_name=_('自动推送账号')) + type = models.CharField(max_length=16, choices=Type.choices, default=Type.common, verbose_name=_('Type')) auto_push = models.BooleanField(default=True, verbose_name=_('Auto push')) sudo = models.TextField(default='/bin/whoami', verbose_name=_('Sudo')) shell = models.CharField(max_length=64, default='/bin/bash', verbose_name=_('Shell')) - login_mode = models.CharField(choices=LOGIN_MODE_CHOICES, default=LOGIN_AUTO, max_length=10, verbose_name=_('Login mode')) sftp_root = models.CharField(default='tmp', max_length=128, verbose_name=_("SFTP Root")) token = models.TextField(default='', verbose_name=_('Token')) home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True) system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True) ad_domain = models.CharField(default='', max_length=256) + # linux su 命令 (switch user) + # Todo: 修改为 username, 不必系统用户了 su_enabled = models.BooleanField(default=False, verbose_name=_('User switch')) su_from = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='su_to', null=True, verbose_name=_("Switch from")) @@ -262,7 +130,7 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser): return self.get_login_mode_display() def is_need_push(self): - if self.auto_push and self.is_protocol_support_push: + if self.auto_push_account and self.is_protocol_support_push: return True else: return False diff --git a/apps/assets/serializers/__init__.py b/apps/assets/serializers/__init__.py index f6d166db9..33835d832 100644 --- a/apps/assets/serializers/__init__.py +++ b/apps/assets/serializers/__init__.py @@ -11,5 +11,6 @@ from .cmd_filter import * from .gathered_user import * from .favorite_asset import * from .account import * -from .platform import * +from .account_history import * from .backup import * +from .platform import * diff --git a/apps/assets/serializers/account.py b/apps/assets/serializers/account.py index daa7f38f8..33632d0c9 100644 --- a/apps/assets/serializers/account.py +++ b/apps/assets/serializers/account.py @@ -1,19 +1,19 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ +from django.db.models import F -from assets.models import AuthBook +from assets.models import Account from orgs.mixins.serializers import BulkOrgResourceModelSerializer from .base import AuthSerializerMixin -from .utils import validate_password_contains_left_double_curly_bracket from common.utils.encode import ssh_pubkey_gen +from common.drf.serializers import SecretReadableMixin class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): ip = serializers.ReadOnlyField(label=_("IP")) hostname = serializers.ReadOnlyField(label=_("Hostname")) platform = serializers.ReadOnlyField(label=_("Platform")) - protocols = serializers.SerializerMethodField(label=_("Protocols")) date_created = serializers.DateTimeField( label=_('Date created'), format="%Y/%m/%d %H:%M:%S", read_only=True ) @@ -22,22 +22,20 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): ) class Meta: - model = AuthBook - fields_mini = ['id', 'username', 'ip', 'hostname', 'platform', 'protocols', 'version'] - fields_write_only = ['password', 'private_key', "public_key", 'passphrase'] + model = Account + fields_mini = [ + 'id', 'type', 'username', 'ip', 'hostname', + 'platform', 'protocol', 'version' + ] + fields_write_only = ['password', 'private_key', 'public_key', 'passphrase'] fields_other = ['date_created', 'date_updated', 'connectivity', 'date_verified', 'comment'] fields_small = fields_mini + fields_write_only + fields_other - fields_fk = ['asset', 'systemuser', 'systemuser_display'] + fields_fk = ['asset'] fields = fields_small + fields_fk extra_kwargs = { 'username': {'required': True}, - 'password': { - 'write_only': True, - "validators": [validate_password_contains_left_double_curly_bracket] - }, 'private_key': {'write_only': True}, 'public_key': {'write_only': True}, - 'systemuser_display': {'label': _('System user display')} } ref_name = 'AssetAccountSerializer' @@ -56,21 +54,16 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): attrs = self._validate_gen_key(attrs) return attrs - def get_protocols(self, v): - return v.protocols.replace(' ', ', ') - @classmethod def setup_eager_loading(cls, queryset): """ Perform necessary eager loading of data. """ - queryset = queryset.prefetch_related('systemuser', 'asset') + queryset = queryset.prefetch_related('asset')\ + .annotate(ip=F('asset__ip')) \ + .annotate(hostname=F('asset__hostname')) return queryset - def to_representation(self, instance): - instance.load_auth() - return super().to_representation(instance) - -class AccountSecretSerializer(AccountSerializer): +class AccountSecretSerializer(SecretReadableMixin, AccountSerializer): class Meta(AccountSerializer.Meta): fields_backup = [ 'hostname', 'ip', 'platform', 'protocols', 'username', 'password', diff --git a/apps/assets/serializers/account_history.py b/apps/assets/serializers/account_history.py new file mode 100644 index 000000000..b0846c04d --- /dev/null +++ b/apps/assets/serializers/account_history.py @@ -0,0 +1,29 @@ + +from assets.models import Account +from common.drf.serializers import SecretReadableMixin +from .account import AccountSerializer, AccountSecretSerializer + + +class AccountHistorySerializer(AccountSerializer): + + class Meta: + model = Account.history.model + fields = AccountSerializer.Meta.fields_mini + \ + AccountSerializer.Meta.fields_write_only + \ + AccountSerializer.Meta.fields_fk + \ + ['history_id', 'date_created', 'date_updated'] + read_only_fields = fields + ref_name = 'AccountHistorySerializer' + + def get_field_names(self, declared_fields, info): + fields = super().get_field_names(declared_fields, info) + fields = list(set(fields) - {'org_name'}) + return fields + + def to_representation(self, instance): + return super(AccountSerializer, self).to_representation(instance) + + +class AccountHistorySecretSerializer(SecretReadableMixin, AccountHistorySerializer): + class Meta(AccountHistorySerializer.Meta): + extra_kwargs = AccountSecretSerializer.Meta.extra_kwargs diff --git a/apps/assets/serializers/asset/common.py b/apps/assets/serializers/asset/common.py index 74f54ed07..5284168f5 100644 --- a/apps/assets/serializers/asset/common.py +++ b/apps/assets/serializers/asset/common.py @@ -3,9 +3,11 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.serializers import BulkOrgResourceModelSerializer from orgs.mixins.serializers import OrgResourceModelSerializerMixin from ...models import Asset, Node, Platform, SystemUser from ..mixin import CategoryDisplayMixin +from ..account import AccountSerializer __all__ = [ 'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer', @@ -76,6 +78,7 @@ class AssetSerializer(CategoryDisplayMixin, OrgResourceModelSerializerMixin): platform_display = serializers.SlugField( source='platform.name', label=_("Platform display"), read_only=True ) + accounts = AccountSerializer(many=True, write_only=True, required=False) """ 资产的数据结构 @@ -95,7 +98,7 @@ class AssetSerializer(CategoryDisplayMixin, OrgResourceModelSerializerMixin): 'admin_user', 'admin_user_display' ] fields_m2m = [ - 'nodes', 'nodes_display', 'labels', 'labels_display', + 'nodes', 'nodes_display', 'labels', 'labels_display', 'accounts' ] read_only_fields = [ 'category', 'category_display', 'type', 'type_display', @@ -110,6 +113,11 @@ class AssetSerializer(CategoryDisplayMixin, OrgResourceModelSerializerMixin): 'admin_user_display': {'label': _('Admin user display'), 'read_only': True}, } + def __init__(self, *args, **kwargs): + data = kwargs.get('data', {}) + self.accounts_data = data.pop('accounts', []) + super().__init__(*args, **kwargs) + def get_fields(self): fields = super().get_fields() @@ -161,10 +169,24 @@ class AssetSerializer(CategoryDisplayMixin, OrgResourceModelSerializerMixin): nodes_to_set.append(node) instance.nodes.set(nodes_to_set) + @staticmethod + def add_accounts(instance, accounts_data): + for data in accounts_data: + data['asset'] = instance.id + print("Data: ", accounts_data) + serializer = AccountSerializer(data=accounts_data, many=True) + try: + serializer.is_valid(raise_exception=True) + except Exception as e: + raise serializers.ValidationError({'accounts': e}) + serializer.save() + def create(self, validated_data): self.compatible_with_old_protocol(validated_data) nodes_display = validated_data.pop('nodes_display', '') instance = super().create(validated_data) + if self.accounts_data: + self.add_accounts(instance, self.accounts_data) self.perform_nodes_display_create(instance, nodes_display) return instance diff --git a/apps/assets/serializers/base.py b/apps/assets/serializers/base.py index 2f3486d01..92249705d 100644 --- a/apps/assets/serializers/base.py +++ b/apps/assets/serializers/base.py @@ -6,12 +6,14 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from common.utils import ssh_pubkey_gen, ssh_private_key_gen, validate_ssh_private_key +from common.drf.fields import EncryptedField from assets.models import Type +from .utils import validate_password_for_ansible class AuthSerializer(serializers.ModelSerializer): - password = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=1024) - private_key = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=4096) + password = EncryptedField(required=False, allow_blank=True, allow_null=True, max_length=1024, label=_('Password')) + private_key = EncryptedField(required=False, allow_blank=True, allow_null=True, max_length=16384, label=_('Private key')) def gen_keys(self, private_key=None, password=None): if private_key is None: @@ -31,6 +33,13 @@ class AuthSerializer(serializers.ModelSerializer): class AuthSerializerMixin(serializers.ModelSerializer): + password = EncryptedField( + label=_('Password'), required=False, allow_blank=True, allow_null=True, max_length=1024, + validators=[validate_password_for_ansible] + ) + private_key = EncryptedField( + label=_('SSH private key'), required=False, allow_blank=True, allow_null=True, max_length=16384 + ) passphrase = serializers.CharField( allow_blank=True, allow_null=True, required=False, max_length=512, write_only=True, label=_('Key password') diff --git a/apps/assets/serializers/cmd_filter.py b/apps/assets/serializers/cmd_filter.py index a57fab72a..9a33dd6fa 100644 --- a/apps/assets/serializers/cmd_filter.py +++ b/apps/assets/serializers/cmd_filter.py @@ -31,24 +31,24 @@ class CommandFilterSerializer(BulkOrgResourceModelSerializer): class CommandFilterRuleSerializer(BulkOrgResourceModelSerializer): - type_display = serializers.ReadOnlyField(source='get_type_display') - action_display = serializers.ReadOnlyField(source='get_action_display') + type_display = serializers.ReadOnlyField(source='get_type_display', label=_("Type display")) + action_display = serializers.ReadOnlyField(source='get_action_display', label=_("Action display")) class Meta: model = CommandFilterRule fields_mini = ['id'] fields_small = fields_mini + [ - 'type', 'type_display', 'content', 'ignore_case', 'pattern', 'priority', - 'action', 'action_display', 'reviewers', - 'date_created', 'date_updated', - 'comment', 'created_by', + 'type', 'type_display', 'content', 'ignore_case', 'pattern', + 'priority', 'action', 'action_display', 'reviewers', + 'date_created', 'date_updated', 'comment', 'created_by', ] fields_fk = ['filter'] fields = fields_small + fields_fk extra_kwargs = { 'date_created': {'label': _("Date created")}, 'date_updated': {'label': _("Date updated")}, - 'action_display': {'label': _("Action display")} + 'action_display': {'label': _("Action display")}, + 'pattern': {'label': _("Pattern")} } def __init__(self, *args, **kwargs): diff --git a/apps/assets/serializers/domain.py b/apps/assets/serializers/domain.py index b86938c43..fa1a39574 100644 --- a/apps/assets/serializers/domain.py +++ b/apps/assets/serializers/domain.py @@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from common.validators import alphanumeric from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from common.drf.serializers import SecretReadableMixin from ..models import Domain, Gateway from .base import AuthSerializerMixin @@ -43,7 +44,7 @@ class DomainSerializer(BulkOrgResourceModelSerializer): class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): - is_connective = serializers.BooleanField(required=False) + is_connective = serializers.BooleanField(required=False, label=_('Connectivity')) class Meta: model = Gateway @@ -67,7 +68,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): } -class GatewayWithAuthSerializer(GatewaySerializer): +class GatewayWithAuthSerializer(SecretReadableMixin, GatewaySerializer): class Meta(GatewaySerializer.Meta): extra_kwargs = { 'password': {'write_only': False}, diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index 4ede6ac65..9d5b454c0 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -4,11 +4,13 @@ from django.db.models import Count from common.mixins.serializers import BulkSerializerMixin from common.utils import ssh_pubkey_gen +from common.drf.fields import EncryptedField +from common.drf.serializers import SecretReadableMixin from common.validators import alphanumeric_re, alphanumeric_cn_re, alphanumeric_win_re from orgs.mixins.serializers import BulkOrgResourceModelSerializer from assets.const import Protocol from ..models import SystemUser, Asset -from .utils import validate_password_contains_left_double_curly_bracket +from .utils import validate_password_for_ansible from .base import AuthSerializerMixin __all__ = [ @@ -24,9 +26,17 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): """ 系统用户 """ + password = EncryptedField( + label=_('Password'), required=False, allow_blank=True, allow_null=True, max_length=1024, + trim_whitespace=False, validators=[validate_password_for_ansible], + write_only=True + ) auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True) type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint')) + token = EncryptedField( + label=_('Token'), required=False, write_only=True, style={'base_template': 'textarea.html'} + ) applications_amount = serializers.IntegerField( source='apps_amount', read_only=True, label=_('Apps amount') ) @@ -38,24 +48,18 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): fields_small = fields_mini + fields_write_only + [ 'token', 'ssh_key_fingerprint', 'type', 'type_display', 'protocol', 'is_asset_protocol', - 'login_mode', 'login_mode_display', 'priority', + 'account_template_enabled', 'login_mode', 'login_mode_display', 'priority', 'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain', - 'username_same_with_user', 'auto_push', 'auto_generate_key', + 'username_same_with_user', 'auto_push_account', 'auto_generate_key', 'su_enabled', 'su_from', 'date_created', 'date_updated', 'comment', 'created_by', ] fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes'] fields = fields_small + fields_m2m extra_kwargs = { - 'password': { - "write_only": True, - 'trim_whitespace': False, - "validators": [validate_password_contains_left_double_curly_bracket] - }, 'cmd_filters': {"required": False, 'label': _('Command filter')}, 'public_key': {"write_only": True}, 'private_key': {"write_only": True}, - 'token': {"write_only": True}, 'nodes_amount': {'label': _('Nodes amount')}, 'assets_amount': {'label': _('Assets amount')}, 'login_mode_display': {'label': _('Login mode display')}, @@ -188,7 +192,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): attrs['protocol'] = Protocol.ssh attrs['login_mode'] = SystemUser.LOGIN_AUTO attrs['username_same_with_user'] = False - attrs['auto_push'] = False + attrs['auto_push_account'] = False return attrs def _validate_gen_key(self, attrs): @@ -249,14 +253,14 @@ class MiniSystemUserSerializer(serializers.ModelSerializer): fields = SystemUserSerializer.Meta.fields_mini -class SystemUserWithAuthInfoSerializer(SystemUserSerializer): +class SystemUserWithAuthInfoSerializer(SecretReadableMixin, SystemUserSerializer): class Meta(SystemUserSerializer.Meta): fields_mini = ['id', 'name', 'username'] fields_write_only = ['password', 'public_key', 'private_key'] fields_small = fields_mini + fields_write_only + [ 'protocol', 'login_mode', 'login_mode_display', 'priority', 'sudo', 'shell', 'ad_domain', 'sftp_root', 'token', - "username_same_with_user", 'auto_push', 'auto_generate_key', + "username_same_with_user", 'auto_push_account', 'auto_generate_key', 'comment', ] fields = fields_small @@ -265,6 +269,9 @@ class SystemUserWithAuthInfoSerializer(SystemUserSerializer): 'assets_amount': {'label': _('Asset')}, 'login_mode_display': {'label': _('Login mode display')}, 'created_by': {'read_only': True}, + 'password': {'write_only': False}, + 'private_key': {'write_only': False}, + 'token': {'write_only': False} } @@ -292,7 +299,7 @@ class SystemUserAssetRelationSerializer(RelationMixin, serializers.ModelSerializ asset_display = serializers.ReadOnlyField(label=_('Asset hostname')) class Meta: - model = SystemUser.assets.through + model = SystemUser fields = [ "id", "asset", "asset_display", 'systemuser', 'systemuser_display', "connectivity", 'date_verified', 'org_id' diff --git a/apps/assets/serializers/utils.py b/apps/assets/serializers/utils.py index 9110a9978..52527e723 100644 --- a/apps/assets/serializers/utils.py +++ b/apps/assets/serializers/utils.py @@ -2,8 +2,16 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -def validate_password_contains_left_double_curly_bracket(password): +def validate_password_for_ansible(password): + """ 校验 Ansible 不支持的特殊字符 """ # validate password contains left double curly bracket # check password not contains `{{` + # Ansible 推送的时候不支持 if '{{' in password: raise serializers.ValidationError(_('Password can not contains `{{` ')) + # Ansible Windows 推送的时候不支持 + if "'" in password: + raise serializers.ValidationError(_("Password can not contains `'` ")) + if '"' in password: + raise serializers.ValidationError(_('Password can not contains `"` ')) + diff --git a/apps/assets/signal_handlers/asset.py b/apps/assets/signal_handlers/asset.py index 97f727a46..a7e466c9b 100644 --- a/apps/assets/signal_handlers/asset.py +++ b/apps/assets/signal_handlers/asset.py @@ -52,8 +52,6 @@ def on_asset_created_or_update(sender, instance=None, created=False, **kwargs): if not has_node: instance.nodes.add(Node.org_root()) - instance.set_admin_user_relation() - @receiver(m2m_changed, sender=Asset.nodes.through) def on_asset_nodes_add(instance, action, reverse, pk_set, **kwargs): @@ -89,22 +87,22 @@ def on_asset_nodes_add(instance, action, reverse, pk_set, **kwargs): systemuser_id__in=system_user_ids, asset_id__in=asset_ids ).values_list('systemuser_id', 'asset_id')) # TODO 优化 - to_create = [] - for system_user_id in system_user_ids: - asset_ids_to_push = [] - for asset_id in asset_ids: - if (system_user_id, asset_id) in exist: - continue - asset_ids_to_push.append(asset_id) - to_create.append(m2m_model( - systemuser_id=system_user_id, - asset_id=asset_id, - org_id=instance.org_id - )) - if asset_ids_to_push: - push_system_user_to_assets.delay(system_user_id, asset_ids_to_push) - m2m_model.objects.bulk_create(to_create) - + # to_create = [] + # for system_user_id in system_user_ids: + # asset_ids_to_push = [] + # for asset_id in asset_ids: + # if (system_user_id, asset_id) in exist: + # continue + # asset_ids_to_push.append(asset_id) + # to_create.append(m2m_model( + # systemuser_id=system_user_id, + # asset_id=asset_id, + # org_id=instance.org_id + # )) + # if asset_ids_to_push: + # push_system_user_to_assets.delay(system_user_id, asset_ids_to_push) + # m2m_model.objects.bulk_create(to_create) + # RELATED_NODE_IDS = '_related_node_ids' diff --git a/apps/assets/signal_handlers/authbook.py b/apps/assets/signal_handlers/authbook.py index 513488763..8020e4087 100644 --- a/apps/assets/signal_handlers/authbook.py +++ b/apps/assets/signal_handlers/authbook.py @@ -1,50 +1,14 @@ from django.dispatch import receiver -from django.apps import apps -from simple_history.signals import pre_create_historical_record -from django.db.models.signals import post_save, pre_save, pre_delete +from django.db.models.signals import pre_save from common.utils import get_logger -from ..models import AuthBook, SystemUser +from ..models import Account -AuthBookHistory = apps.get_model('assets', 'HistoricalAuthBook') logger = get_logger(__name__) -@receiver(pre_create_historical_record, sender=AuthBookHistory) -def pre_create_historical_record_callback(sender, history_instance=None, **kwargs): - attrs_to_copy = ['username', 'password', 'private_key'] - - for attr in attrs_to_copy: - if getattr(history_instance, attr): - continue - try: - system_user = history_instance.systemuser - except SystemUser.DoesNotExist: - continue - if not system_user: - continue - system_user_attr_value = getattr(history_instance.systemuser, attr) - if system_user_attr_value: - setattr(history_instance, attr, system_user_attr_value) - - -@receiver(pre_delete, sender=AuthBook) -def on_authbook_post_delete(sender, instance, **kwargs): - instance.remove_asset_admin_user_if_need() - - -@receiver(post_save, sender=AuthBook) -def on_authbook_post_create(sender, instance, created, **kwargs): - instance.sync_to_system_user_account() - if created: - pass - # # 不再自动更新资产管理用户,只允许用户手动指定。 - # 只在创建时进行更新资产的管理用户 - # instance.update_asset_admin_user_if_need() - - -@receiver(pre_save, sender=AuthBook) -def on_authbook_pre_create(sender, instance, **kwargs): +@receiver(pre_save, sender=Account) +def on_account_pre_create(sender, instance, **kwargs): # 升级版本号 instance.version += 1 # 即使在 root 组织也不怕 diff --git a/apps/assets/signal_handlers/system_user.py b/apps/assets/signal_handlers/system_user.py index 00b19e110..64a656c29 100644 --- a/apps/assets/signal_handlers/system_user.py +++ b/apps/assets/signal_handlers/system_user.py @@ -9,9 +9,8 @@ from common.exceptions import M2MReverseNotAllowed from common.const.signals import POST_ADD from common.utils import get_logger from common.decorator import on_transaction_commit -from assets.models import Asset, SystemUser, Node, AuthBook +from assets.models import Asset, SystemUser, Node from users.models import User -from orgs.utils import tmp_to_root_org from assets.tasks import ( push_system_user_to_assets_manual, push_system_user_to_assets, @@ -39,35 +38,7 @@ def on_system_user_assets_change(instance, action, model, pk_set, **kwargs): else: system_user_ids = pk_set asset_ids = [instance.id] - - org_id = instance.org_id - - # 关联创建的 authbook 没有系统用户id - with tmp_to_root_org(): - authbooks = AuthBook.objects.filter( - asset_id__in=asset_ids, - systemuser_id__in=system_user_ids - ) - if action == POST_ADD: - authbooks.update(org_id=org_id) - - save_action_mapper = { - 'pre_add': pre_save, - 'post_add': post_save, - 'pre_remove': pre_delete, - 'post_remove': post_delete - } - - for ab in authbooks: - ab.org_id = org_id - - save_action = save_action_mapper[action] - logger.debug('Send AuthBook post save signal: {} -> {}'.format(action, ab.id)) - save_action.send(sender=AuthBook, instance=ab, created=True) - - if action == POST_ADD: - for system_user_id in system_user_ids: - push_system_user_to_assets.delay(system_user_id, asset_ids) + # todo: Auto create account if need @receiver(m2m_changed, sender=SystemUser.users.through) diff --git a/apps/assets/task_handlers/backup/handlers.py b/apps/assets/task_handlers/backup/handlers.py index a73bced59..969ed5433 100644 --- a/apps/assets/task_handlers/backup/handlers.py +++ b/apps/assets/task_handlers/backup/handlers.py @@ -1,13 +1,13 @@ import os import time -import pandas as pd +from openpyxl import Workbook from collections import defaultdict, OrderedDict from django.conf import settings from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -from assets.models import AuthBook +from assets.models import Account from assets.serializers import AccountSecretSerializer from assets.notifications import AccountBackupExecutionTaskMsg from applications.models import Account @@ -48,7 +48,7 @@ class BaseAccountHandler: _fields = cls.get_header_fields(v) header_fields.update(_fields) else: - header_fields[field] = v.label + header_fields[field] = str(v.label) return header_fields @classmethod @@ -59,7 +59,7 @@ class BaseAccountHandler: data = cls.unpack_data(serializer.data) row_dict = {} for field, header_name in header_fields.items(): - row_dict[header_name] = data[field] + row_dict[header_name] = str(data[field]) return row_dict @@ -72,24 +72,24 @@ class AssetAccountHandler(BaseAccountHandler): return filename @classmethod - def create_df(cls): - df_dict = defaultdict(list) - sheet_name = AuthBook._meta.verbose_name + def create_data_map(cls): + data_map = defaultdict(list) + sheet_name = Account._meta.verbose_name - accounts = AuthBook.get_queryset().select_related('systemuser') + accounts = Account.get_queryset() if not accounts.first(): - return df_dict + return data_map header_fields = cls.get_header_fields(AccountSecretSerializer(accounts.first())) for account in accounts: account.load_auth() row = cls.create_row(account, AccountSecretSerializer, header_fields) - df_dict[sheet_name].append(row) - for k, v in df_dict.items(): - df_dict[k] = pd.DataFrame(v) + if sheet_name not in data_map: + data_map[sheet_name].append(list(row.keys())) + data_map[sheet_name].append(list(row.values())) logger.info('\n\033[33m- 共收集 {} 条资产账号\033[0m'.format(accounts.count())) - return df_dict + return data_map class AppAccountHandler(BaseAccountHandler): @@ -101,19 +101,19 @@ class AppAccountHandler(BaseAccountHandler): return filename @classmethod - def create_df(cls): - df_dict = defaultdict(list) + def create_data_map(cls): + data_map = defaultdict(list) accounts = Account.get_queryset().select_related('systemuser') for account in accounts: account.load_auth() app_type = account.type sheet_name = AppType.get_label(app_type) row = cls.create_row(account, AppAccountSecretSerializer) - df_dict[sheet_name].append(row) - for k, v in df_dict.items(): - df_dict[k] = pd.DataFrame(v) + if sheet_name not in data_map: + data_map[sheet_name].append(list(row.keys())) + data_map[sheet_name].append(list(row.values())) logger.info('\n\033[33m- 共收集{}条应用账号\033[0m'.format(accounts.count())) - return df_dict + return data_map handler_map = { @@ -142,24 +142,24 @@ class AccountBackupHandler: if not handler: continue - df_dict = handler.create_df() - if not df_dict: + data_map = handler.create_data_map() + if not data_map: continue filename = handler.get_filename(self.plan_name) - with pd.ExcelWriter(filename) as w: - for sheet, df in df_dict.items(): - sheet = sheet.replace(' ', '-') - getattr(df, 'to_excel')(w, sheet_name=sheet, index=False) + + wb = Workbook(filename) + for sheet, data in data_map.items(): + ws = wb.create_sheet(str(sheet)) + for row in data: + ws.append(row) + wb.save(filename) files.append(filename) timedelta = round((time.time() - time_start), 2) logger.info('步骤完成: 用时 {}s'.format(timedelta)) return files - def send_backup_mail(self, files): - recipients = self.execution.plan_snapshot.get('recipients') - if not recipients: - return + def send_backup_mail(self, files, recipients): if not files: return recipients = User.objects.filter(id__in=list(recipients)) @@ -198,8 +198,16 @@ class AccountBackupHandler: is_success = False error = '-' try: - files = self.create_excel() - self.send_backup_mail(files) + recipients = self.execution.plan_snapshot.get('recipients') + if not recipients: + logger.info( + '\n' + '\033[32m>>> 该备份任务未分配收件人\033[0m' + '' + ) + else: + files = self.create_excel() + self.send_backup_mail(files, recipients) except Exception as e: self.is_frozen = True logger.error('任务执行被异常中断') diff --git a/apps/assets/tasks/asset_connectivity.py b/apps/assets/tasks/asset_connectivity.py index 0038c38d3..491792095 100644 --- a/apps/assets/tasks/asset_connectivity.py +++ b/apps/assets/tasks/asset_connectivity.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext_noop from common.utils import get_logger from orgs.utils import org_aware_func -from ..models import Asset, Connectivity, AuthBook +from ..models import Asset, Connectivity, Account from . import const from .utils import clean_ansible_task_hosts, group_asset_by_platform @@ -18,6 +18,7 @@ __all__ = [ ] +# Todo: 这里可能有问题了 def set_assets_accounts_connectivity(assets, results_summary): asset_ids_ok = set() asset_ids_failed = set() @@ -33,11 +34,11 @@ def set_assets_accounts_connectivity(assets, results_summary): Asset.bulk_set_connectivity(asset_ids_ok, Connectivity.ok) Asset.bulk_set_connectivity(asset_ids_failed, Connectivity.failed) - accounts_ok = AuthBook.objects.filter(asset_id__in=asset_ids_ok, systemuser__type='admin') - accounts_failed = AuthBook.objects.filter(asset_id__in=asset_ids_failed, systemuser__type='admin') + accounts_ok = Account.objects.filter(asset_id__in=asset_ids_ok,) + accounts_failed = Account.objects.filter(asset_id__in=asset_ids_faile) - AuthBook.bulk_set_connectivity(accounts_ok, Connectivity.ok) - AuthBook.bulk_set_connectivity(accounts_failed, Connectivity.failed) + Account.bulk_set_connectivity(accounts_ok, Connectivity.ok) + Account.bulk_set_connectivity(accounts_failed, Connectivity.failed) @shared_task(queue="ansible") diff --git a/apps/assets/tasks/common.py b/apps/assets/tasks/common.py index b6c2326e9..6ad17b4c3 100644 --- a/apps/assets/tasks/common.py +++ b/apps/assets/tasks/common.py @@ -9,6 +9,7 @@ from assets.models import AuthBook __all__ = ['add_nodes_assets_to_system_users'] +# Todo: 等待优化 @shared_task @tmp_to_root_org() def add_nodes_assets_to_system_users(nodes_keys, system_users): @@ -17,6 +18,7 @@ def add_nodes_assets_to_system_users(nodes_keys, system_users): nodes = Node.objects.filter(key__in=nodes_keys) assets = Node.get_nodes_all_assets(*nodes) + for system_user in system_users: """ 解决资产和节点进行关联时,已经关联过的节点不会触发 authbook post_save 信号, 无法更新节点下所有资产的管理用户的问题 """ @@ -28,7 +30,7 @@ def add_nodes_assets_to_system_users(nodes_keys, system_users): ) if created: need_push_asset_ids.append(asset.id) - # # 不再自动更新资产管理用户,只允许用户手动指定。 + # 不再自动更新资产管理用户,只允许用户手动指定。 # 只要关联都需要更新资产的管理用户 # instance.update_asset_admin_user_if_need() diff --git a/apps/assets/tasks/push_system_user.py b/apps/assets/tasks/push_system_user.py index 4270f5e0f..8834a29e9 100644 --- a/apps/assets/tasks/push_system_user.py +++ b/apps/assets/tasks/push_system_user.py @@ -32,17 +32,18 @@ def _dump_args(args: dict): return ' '.join([f'{k}={v}' for k, v in args.items() if v is not Empty]) -def get_push_unixlike_system_user_tasks(system_user, username=None): - comment = system_user.name - +def get_push_unixlike_system_user_tasks(system_user, username=None, **kwargs): + algorithm = kwargs.get('algorithm') if username is None: username = system_user.username + comment = system_user.name if system_user.username_same_with_user: from users.models import User user = User.objects.filter(username=username).only('name', 'username').first() if user: comment = f'{system_user.name}[{str(user)}]' + comment = comment.replace(' ', '') password = system_user.password public_key = system_user.public_key @@ -104,7 +105,7 @@ def get_push_unixlike_system_user_tasks(system_user, username=None): 'module': 'user', 'args': 'name={} shell={} state=present password={}'.format( username, system_user.shell, - encrypt_password(password, salt="K3mIlKK"), + encrypt_password(password, salt="K3mIlKK", algorithm=algorithm), ), } }) @@ -138,7 +139,7 @@ def get_push_unixlike_system_user_tasks(system_user, username=None): return tasks -def get_push_windows_system_user_tasks(system_user: SystemUser, username=None): +def get_push_windows_system_user_tasks(system_user: SystemUser, username=None, **kwargs): if username is None: username = system_user.username password = system_user.password @@ -176,7 +177,7 @@ def get_push_windows_system_user_tasks(system_user: SystemUser, username=None): return tasks -def get_push_system_user_tasks(system_user, platform="unixlike", username=None): +def get_push_system_user_tasks(system_user, platform="unixlike", username=None, algorithm=None): """ 获取推送系统用户的 ansible 命令,跟资产无关 :param system_user: @@ -190,16 +191,16 @@ def get_push_system_user_tasks(system_user, platform="unixlike", username=None): } get_tasks = get_task_map.get(platform, get_push_unixlike_system_user_tasks) if not system_user.username_same_with_user: - return get_tasks(system_user) + return get_tasks(system_user, algorithm=algorithm) tasks = [] # 仅推送这个username if username is not None: - tasks.extend(get_tasks(system_user, username)) + tasks.extend(get_tasks(system_user, username, algorithm=algorithm)) return tasks users = system_user.users.all().values_list('username', flat=True) print(_("System user is dynamic: {}").format(list(users))) for _username in users: - tasks.extend(get_tasks(system_user, _username)) + tasks.extend(get_tasks(system_user, _username, algorithm=algorithm)) return tasks @@ -244,7 +245,11 @@ def push_system_user_util(system_user, assets, task_name, username=None): for u in usernames: for a in _assets: system_user.load_asset_special_auth(a, u) - tasks = get_push_system_user_tasks(system_user, platform, username=u) + algorithm = 'des' if a.platform.name == 'AIX' else 'sha512' + tasks = get_push_system_user_tasks( + system_user, platform, username=u, + algorithm=algorithm + ) run_task(tasks, [a]) @@ -269,7 +274,7 @@ def push_system_user_a_asset_manual(system_user, asset, username=None): # if username is None: # username = system_user.username task_name = gettext_noop("Push system users to asset: ") + "{}({}) => {}".format( - system_user.name, username, asset + system_user.name, username or system_user.username, asset ) return push_system_user_util(system_user, [asset], task_name=task_name, username=username) diff --git a/apps/assets/tasks/system_user_connectivity.py b/apps/assets/tasks/system_user_connectivity.py index 893081163..2213bfa26 100644 --- a/apps/assets/tasks/system_user_connectivity.py +++ b/apps/assets/tasks/system_user_connectivity.py @@ -8,7 +8,7 @@ from django.utils.translation import ugettext as _, gettext_noop from assets.models import Asset from common.utils import get_logger from orgs.utils import tmp_to_org, org_aware_func -from ..models import SystemUser, Connectivity, AuthBook +from ..models import SystemUser, Connectivity, Account from . import const from .utils import ( clean_ansible_task_hosts, group_asset_by_platform @@ -34,11 +34,11 @@ def set_assets_accounts_connectivity(system_user, assets, results_summary): else: asset_ids_failed.add(asset.id) - accounts_ok = AuthBook.objects.filter(asset_id__in=asset_ids_ok, systemuser=system_user) - accounts_failed = AuthBook.objects.filter(asset_id__in=asset_ids_failed, systemuser=system_user) + accounts_ok = Account.objects.filter(asset_id__in=asset_ids_ok, systemuser=system_user) + accounts_failed = Account.objects.filter(asset_id__in=asset_ids_failed, systemuser=system_user) - AuthBook.bulk_set_connectivity(accounts_ok, Connectivity.ok) - AuthBook.bulk_set_connectivity(accounts_failed, Connectivity.failed) + Account.bulk_set_connectivity(accounts_ok, Connectivity.ok) + Account.bulk_set_connectivity(accounts_failed, Connectivity.failed) @org_aware_func("system_user") diff --git a/apps/assets/tasks/utils.py b/apps/assets/tasks/utils.py index 93aaa4bfc..a60f1b494 100644 --- a/apps/assets/tasks/utils.py +++ b/apps/assets/tasks/utils.py @@ -25,7 +25,7 @@ def check_asset_can_run_ansible(asset): def check_system_user_can_run_ansible(system_user): - if not system_user.auto_push: + if not system_user.auto_push_account: logger.warn(f'Push system user task skip, auto push not enable: system_user={system_user.name}') return False if not system_user.is_protocol_support_push: diff --git a/apps/assets/urls/api_urls.py b/apps/assets/urls/api_urls.py index 826e16d4e..c464649b1 100644 --- a/apps/assets/urls/api_urls.py +++ b/apps/assets/urls/api_urls.py @@ -15,6 +15,8 @@ router.register(r'hosts', api.HostViewSet, 'host') router.register(r'databases', api.DatabaseViewSet, 'database') router.register(r'accounts', api.AccountViewSet, 'account') router.register(r'account-secrets', api.AccountSecretsViewSet, 'account-secret') +router.register(r'accounts-history', api.AccountHistoryViewSet, 'account-history') +router.register(r'account-history-secrets', api.AccountHistorySecretsViewSet, 'account-history-secret') router.register(r'platforms', api.AssetPlatformViewSet, 'platform') router.register(r'system-users', api.SystemUserViewSet, 'system-user') router.register(r'admin-users', api.AdminUserViewSet, 'admin-user') diff --git a/apps/audits/const.py b/apps/audits/const.py index 97f6cc6b1..eaed75fc0 100644 --- a/apps/audits/const.py +++ b/apps/audits/const.py @@ -3,3 +3,23 @@ from django.utils.translation import ugettext_lazy as _ DEFAULT_CITY = _("Unknown") + +MODELS_NEED_RECORD = ( + # users + 'User', 'UserGroup', + # acls + 'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting', + # assets + 'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule', + 'CommandFilter', 'Platform', 'Account', + # applications + 'Application', + # orgs + 'Organization', + # settings + 'Setting', + # perms + 'AssetPermission', 'ApplicationPermission', + # xpack + 'License', 'Account', 'SyncInstanceTask', 'ChangeAuthPlan', 'GatherUserTask', +) diff --git a/apps/audits/migrations/0014_auto_20220505_1902.py b/apps/audits/migrations/0014_auto_20220505_1902.py new file mode 100644 index 000000000..8c483560c --- /dev/null +++ b/apps/audits/migrations/0014_auto_20220505_1902.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2022-05-05 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0013_auto_20211130_1037'), + ] + + operations = [ + migrations.AlterField( + model_name='operatelog', + name='action', + field=models.CharField(choices=[('create', 'Create'), ('view', 'View'), ('update', 'Update'), ('delete', 'Delete')], max_length=16, verbose_name='Action'), + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index 0cd17ede2..5dd8eb0a2 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -49,10 +49,12 @@ class FTPLog(OrgModelMixin): class OperateLog(OrgModelMixin): ACTION_CREATE = 'create' + ACTION_VIEW = 'view' ACTION_UPDATE = 'update' ACTION_DELETE = 'delete' ACTION_CHOICES = ( (ACTION_CREATE, _("Create")), + (ACTION_VIEW, _("View")), (ACTION_UPDATE, _("Update")), (ACTION_DELETE, _("Delete")) ) diff --git a/apps/audits/signal_handlers.py b/apps/audits/signal_handlers.py index daa3fa4bf..9ac565bc6 100644 --- a/apps/audits/signal_handlers.py +++ b/apps/audits/signal_handlers.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # +import time + from django.db.models.signals import ( post_save, m2m_changed, pre_delete ) @@ -21,7 +23,7 @@ from jumpserver.utils import current_request from users.models import User from users.signals import post_user_change_password from terminal.models import Session, Command -from .utils import write_login_log +from .utils import write_login_log, create_operate_log from . import models, serializers from .models import OperateLog from orgs.utils import current_org @@ -36,26 +38,6 @@ logger = get_logger(__name__) sys_logger = get_syslogger(__name__) json_render = JSONRenderer() -MODELS_NEED_RECORD = ( - # users - 'User', 'UserGroup', - # acls - 'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting', - # assets - 'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule', - 'CommandFilter', 'Platform', 'AuthBook', - # applications - 'Application', - # orgs - 'Organization', - # settings - 'Setting', - # perms - 'AssetPermission', 'ApplicationPermission', - # xpack - 'License', 'Account', 'SyncInstanceTask', 'ChangeAuthPlan', 'GatherUserTask', -) - class AuthBackendLabelMapping(LazyObject): @staticmethod @@ -69,7 +51,9 @@ class AuthBackendLabelMapping(LazyObject): backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO') backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _('Auth Token') backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _('WeCom') + backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _('FeiShu') backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _('DingTalk') + backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _('Temporary token') return backend_label_mapping def _setup(self): @@ -79,28 +63,6 @@ class AuthBackendLabelMapping(LazyObject): AUTH_BACKEND_LABEL_MAPPING = AuthBackendLabelMapping() -def create_operate_log(action, sender, resource): - user = current_request.user if current_request else None - if not user or not user.is_authenticated: - return - model_name = sender._meta.object_name - if model_name not in MODELS_NEED_RECORD: - return - with translation.override('en'): - resource_type = sender._meta.verbose_name - remote_addr = get_request_ip(current_request) - - data = { - "user": str(user), 'action': action, 'resource_type': resource_type, - 'resource': str(resource), 'remote_addr': remote_addr, - } - with transaction.atomic(): - try: - models.OperateLog.objects.create(**data) - except Exception as e: - logger.error("Create operate log error: {}".format(e)) - - M2M_NEED_RECORD = { User.groups.through._meta.object_name: ( _('User and Group'), @@ -315,6 +277,7 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs): logger.debug('User login success: {}'.format(user.username)) check_different_city_login_if_need(user, request) data = generate_data(user.username, request, login_type=login_type) + request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S") data.update({'mfa': int(user.mfa_enabled), 'status': True}) write_login_log(**data) diff --git a/apps/audits/tasks.py b/apps/audits/tasks.py index 171fbe633..10fb67f44 100644 --- a/apps/audits/tasks.py +++ b/apps/audits/tasks.py @@ -7,7 +7,7 @@ from celery import shared_task from ops.celery.decorator import ( register_as_period_task ) -from .models import UserLoginLog, OperateLog +from .models import UserLoginLog, OperateLog, FTPLog from common.utils import get_log_keep_day @@ -29,7 +29,7 @@ def clean_ftp_log_period(): now = timezone.now() days = get_log_keep_day('FTP_LOG_KEEP_DAYS') expired_day = now - datetime.timedelta(days=days) - OperateLog.objects.filter(datetime__lt=expired_day).delete() + FTPLog.objects.filter(datetime__lt=expired_day).delete() @register_as_period_task(interval=3600*24) diff --git a/apps/audits/utils.py b/apps/audits/utils.py index e569a7ebb..1fadbccee 100644 --- a/apps/audits/utils.py +++ b/apps/audits/utils.py @@ -1,9 +1,17 @@ import csv import codecs -from django.http import HttpResponse -from .const import DEFAULT_CITY -from common.utils import validate_ip, get_ip_city +from django.http import HttpResponse +from django.db import transaction +from django.utils import translation + +from audits.models import OperateLog +from common.utils import validate_ip, get_ip_city, get_request_ip, get_logger +from jumpserver.utils import current_request +from .const import DEFAULT_CITY, MODELS_NEED_RECORD + + +logger = get_logger(__name__) def get_excel_response(filename): @@ -36,3 +44,25 @@ def write_login_log(*args, **kwargs): city = get_ip_city(ip) or DEFAULT_CITY kwargs.update({'ip': ip, 'city': city}) UserLoginLog.objects.create(**kwargs) + + +def create_operate_log(action, sender, resource): + user = current_request.user if current_request else None + if not user or not user.is_authenticated: + return + model_name = sender._meta.object_name + if model_name not in MODELS_NEED_RECORD: + return + with translation.override('en'): + resource_type = sender._meta.verbose_name + remote_addr = get_request_ip(current_request) + + data = { + "user": str(user), 'action': action, 'resource_type': resource_type, + 'resource': str(resource), 'remote_addr': remote_addr, + } + with transaction.atomic(): + try: + OperateLog.objects.create(**data) + except Exception as e: + logger.error("Create operate log error: {}".format(e)) diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index c0064f9bd..85fda3c1e 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -5,9 +5,11 @@ from .connection_token import * from .token import * from .mfa import * from .access_key import * +from .confirm import * from .login_confirm import * from .sso import * from .wecom import * from .dingtalk import * from .feishu import * from .password import * +from .temp_token import * diff --git a/apps/authentication/api/access_key.py b/apps/authentication/api/access_key.py index 0762d0de9..bbda04c02 100644 --- a/apps/authentication/api/access_key.py +++ b/apps/authentication/api/access_key.py @@ -2,14 +2,14 @@ # from rest_framework.viewsets import ModelViewSet - -from common.permissions import IsValidUser from .. import serializers +from rbac.permissions import RBACPermission class AccessKeyViewSet(ModelViewSet): serializer_class = serializers.AccessKeySerializer search_fields = ['^id', '^secret'] + permission_classes = [RBACPermission] def get_queryset(self): return self.request.user.access_keys.all() diff --git a/apps/authentication/api/confirm.py b/apps/authentication/api/confirm.py new file mode 100644 index 000000000..d3a32a7c8 --- /dev/null +++ b/apps/authentication/api/confirm.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +import time + +from django.utils.translation import ugettext_lazy as _ +from rest_framework.generics import RetrieveAPIView, CreateAPIView +from rest_framework.response import Response +from rest_framework import status + +from common.permissions import IsValidUser, UserConfirmation +from ..const import ConfirmType +from ..serializers import ConfirmSerializer + + +class ConfirmBindORUNBindOAuth(RetrieveAPIView): + permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) + + def retrieve(self, request, *args, **kwargs): + return Response('ok') + + +class ConfirmApi(RetrieveAPIView, CreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = ConfirmSerializer + + def get_confirm_backend(self, confirm_type): + backend_classes = ConfirmType.get_can_confirm_backend_classes(confirm_type) + if not backend_classes: + return + for backend_cls in backend_classes: + backend = backend_cls(self.request.user, self.request) + if not backend.check(): + continue + return backend + + def retrieve(self, request, *args, **kwargs): + confirm_type = request.query_params.get('confirm_type') + backend = self.get_confirm_backend(confirm_type) + if backend is None: + msg = _('This action require verify your MFA') + return Response(data={'error': msg}, status=status.HTTP_404_NOT_FOUND) + + data = { + 'confirm_type': backend.name, + 'content': backend.content, + } + return Response(data=data) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + confirm_type = validated_data.get('confirm_type') + mfa_type = validated_data.get('mfa_type') + secret_key = validated_data.get('secret_key') + + backend = self.get_confirm_backend(confirm_type) + ok, msg = backend.authenticate(secret_key, mfa_type) + if ok: + request.session['CONFIRM_LEVEL'] = ConfirmType.values.index(confirm_type) + 1 + request.session['CONFIRM_TIME'] = int(time.time()) + return Response('ok') + return Response({'error': msg}, status=400) diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index f8c64e417..f0a7e0a63 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -1,73 +1,98 @@ -# -*- coding: utf-8 -*- -# -import urllib.parse -import json -from typing import Callable import os +import json import base64 -import ctypes - -from django.conf import settings -from django.core.cache import cache -from django.shortcuts import get_object_or_404 +import urllib.parse from django.http import HttpResponse -from django.utils import timezone -from django.utils.translation import ugettext as _ -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework.viewsets import GenericViewSet -from rest_framework.decorators import action +from django.shortcuts import get_object_or_404 from rest_framework.exceptions import PermissionDenied -from rest_framework import serializers +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import status +from rest_framework.request import Request -from applications.models import Application -from authentication.signals import post_auth_failed -from common.utils import get_logger, random_string -from common.mixins.api import SerializerMixin -from common.utils.common import get_file_by_arch -from orgs.mixins.api import RootOrgViewMixin +from common.drf.api import JMSModelViewSet from common.http import is_true +from orgs.mixins.api import RootOrgViewMixin from perms.models.base import Action -from perms.utils.application.permission import get_application_actions -from perms.utils.asset.permission import get_asset_actions -from common.const.http import PATCH +from terminal.models import EndpointRule from ..serializers import ( - ConnectionTokenSerializer, ConnectionTokenSecretSerializer, + ConnectionTokenSerializer, ConnectionTokenSecretSerializer, SuperConnectionTokenSerializer, + ConnectionTokenDisplaySerializer, ) - -logger = get_logger(__name__) -__all__ = ['UserConnectionTokenViewSet'] +from ..models import ConnectionToken -class ClientProtocolMixin: - """ - 下载客户端支持的连接文件,里面包含了 token,和 其他连接信息 +__all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet'] - - [x] RDP - - [ ] KoKo - 本质上,这里还是暴露出 token 来,进行使用 - """ +class ConnectionTokenMixin: request: Request - get_serializer: Callable - create_token: Callable - def get_request_resource(self, serializer): + @staticmethod + def check_token_valid(token: ConnectionToken): + is_valid, error = token.check_valid() + if not is_valid: + raise PermissionDenied(error) + + @staticmethod + def get_request_resources(serializer): + user = serializer.validated_data.get('user') asset = serializer.validated_data.get('asset') application = serializer.validated_data.get('application') - system_user = serializer.validated_data['system_user'] + system_user = serializer.validated_data.get('system_user') + return user, asset, application, system_user - user = serializer.validated_data.get('user') - if not user or not self.request.user.is_superuser: - user = self.request.user - return asset, application, system_user, user + @staticmethod + def check_user_has_resource_permission(user, asset, application, system_user): + from perms.utils.asset import has_asset_system_permission + from perms.utils.application import has_application_system_permission + + if asset and not has_asset_system_permission(user, asset, system_user): + error = f'User not has this asset and system user permission: ' \ + f'user={user.id} system_user={system_user.id} asset={asset.id}' + raise PermissionDenied(error) + + if application and not has_application_system_permission(user, application, system_user): + error = f'User not has this application and system user permission: ' \ + f'user={user.id} system_user={system_user.id} application={application.id}' + raise PermissionDenied(error) + + def get_smart_endpoint(self, protocol, asset=None, application=None): + if asset: + target_ip = asset.get_target_ip() + elif application: + target_ip = application.get_target_ip() + else: + target_ip = '' + endpoint = EndpointRule.match_endpoint(target_ip, protocol, self.request) + return endpoint @staticmethod def parse_env_bool(env_key, env_default, true_value, false_value): return true_value if is_true(os.getenv(env_key, env_default)) else false_value - def get_rdp_file_content(self, serializer): - options = { + def get_client_protocol_data(self, token: ConnectionToken): + from assets.models import SystemUser + protocol = token.system_user.protocol + username = token.user.username + rdp_config = ssh_token = '' + if protocol == SystemUser.Protocol.rdp: + filename, rdp_config = self.get_rdp_file_info(token) + elif protocol == SystemUser.Protocol.ssh: + filename, ssh_token = self.get_ssh_token(token) + else: + raise ValueError('Protocol not support: {}'.format(protocol)) + + return { + "filename": filename, + "protocol": protocol, + "username": username, + "token": ssh_token, + "config": rdp_config + } + + def get_rdp_file_info(self, token: ConnectionToken): + rdp_options = { 'full address:s': '', 'username:s': '', # 'screen mode id:i': '1', @@ -93,419 +118,192 @@ class ClientProtocolMixin: 'bookmarktype:i': '3', 'use redirection server name:i': '0', 'smart sizing:i': '1', - #'drivestoredirect:s': '*', + # 'drivestoredirect:s': '*', # 'domain:s': '' # 'alternate shell:s:': '||MySQLWorkbench', # 'remoteapplicationname:s': 'Firefox', # 'remoteapplicationcmdline:s': '', } - asset, application, system_user, user = self.get_request_resource(serializer) + # 设置磁盘挂载 + drives_redirect = is_true(self.request.query_params.get('drives_redirect')) + if drives_redirect: + actions = Action.choices_to_value(token.actions) + if actions & Action.UPDOWNLOAD == Action.UPDOWNLOAD: + rdp_options['drivestoredirect:s'] = '*' + + # 设置全屏 + full_screen = is_true(self.request.query_params.get('full_screen')) + rdp_options['screen mode id:i'] = '2' if full_screen else '1' + + # 设置 RDP Server 地址 + endpoint = self.get_smart_endpoint( + protocol='rdp', asset=token.asset, application=token.application + ) + rdp_options['full address:s'] = f'{endpoint.host}:{endpoint.rdp_port}' + + # 设置用户名 + rdp_options['username:s'] = '{}|{}'.format(token.user.username, str(token.id)) + if token.system_user.ad_domain: + rdp_options['domain:s'] = token.system_user.ad_domain + + # 设置宽高 height = self.request.query_params.get('height') width = self.request.query_params.get('width') - full_screen = is_true(self.request.query_params.get('full_screen')) - drives_redirect = is_true(self.request.query_params.get('drives_redirect')) - token, secret = self.create_token(user, asset, application, system_user) - - # 设置磁盘挂载 - if drives_redirect: - actions = 0 - if asset: - actions = get_asset_actions(user, asset, system_user) - elif application: - actions = get_application_actions(user, application, system_user) - - if actions & Action.UPDOWNLOAD == Action.UPDOWNLOAD: - options['drivestoredirect:s'] = '*' - - # 全屏 - options['screen mode id:i'] = '2' if full_screen else '1' - - # RDP Server 地址 - address = settings.TERMINAL_RDP_ADDR - if not address or address == 'localhost:3389': - address = self.request.get_host().split(':')[0] + ':3389' - options['full address:s'] = address - # 用户名 - options['username:s'] = '{}|{}'.format(user.username, token) - if system_user.ad_domain: - options['domain:s'] = system_user.ad_domain - # 宽高 if width and height: - options['desktopwidth:i'] = width - options['desktopheight:i'] = height - options['winposstr:s:'] = f'0,1,0,0,{width},{height}' + rdp_options['desktopwidth:i'] = width + rdp_options['desktopheight:i'] = height + rdp_options['winposstr:s:'] = f'0,1,0,0,{width},{height}' - options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32') - options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0') + # 设置其他选项 + rdp_options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32') + rdp_options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0') - if asset: - name = asset.hostname - elif application: - name = application.name - application.get_rdp_remote_app_setting() - - app = f'||jmservisor' - options['remoteapplicationmode:i'] = '1' - options['alternate shell:s'] = app - options['remoteapplicationprogram:s'] = app - options['remoteapplicationname:s'] = name - options['remoteapplicationcmdline:s'] = '- ' + self.get_encrypt_cmdline(application) + if token.asset: + name = token.asset.hostname + elif token.application and token.application.category_remote_app: + app = '||jmservisor' + name = token.application.name + rdp_options['remoteapplicationmode:i'] = '1' + rdp_options['alternate shell:s'] = app + rdp_options['remoteapplicationprogram:s'] = app + rdp_options['remoteapplicationname:s'] = name else: name = '*' + filename = "{}-{}-jumpserver".format(token.user.username, name) + filename = urllib.parse.quote(filename) + content = '' - for k, v in options.items(): + for k, v in rdp_options.items(): content += f'{k}:{v}\n' - return name, content - def get_ssh_token(self, serializer): - asset, application, system_user, user = self.get_request_resource(serializer) - token, secret = self.create_token(user, asset, application, system_user) - if asset: - name = asset.hostname - elif application: - name = application.name + return filename, content + + def get_ssh_token(self, token: ConnectionToken): + if token.asset: + name = token.asset.hostname + elif token.application: + name = token.application.name else: name = '*' + filename = f'{token.user.username}-{name}-jumpserver' - content = { - 'ip': settings.TERMINAL_KOKO_HOST, - 'port': str(settings.TERMINAL_KOKO_SSH_PORT), - 'username': f'JMS-{token}', - 'password': secret - } - token = json.dumps(content) - return name, token - - def get_encrypt_cmdline(self, app: Application): - parameters = app.get_rdp_remote_app_setting()['parameters'] - parameters = parameters.encode('ascii') - - lib_path = get_file_by_arch('xpack/libs', 'librailencrypt.so') - lib = ctypes.CDLL(lib_path) - lib.encrypt.argtypes = [ctypes.c_char_p, ctypes.c_int] - lib.encrypt.restype = ctypes.c_char_p - - rst = lib.encrypt(parameters, len(parameters)) - rst = rst.decode('ascii') - return rst - - @action(methods=['POST', 'GET'], detail=False, url_path='rdp/file') - def get_rdp_file(self, request, *args, **kwargs): - if self.request.method == 'GET': - data = self.request.query_params - else: - data = self.request.data - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - name, data = self.get_rdp_file_content(serializer) - response = HttpResponse(data, content_type='application/octet-stream') - filename = "{}-{}-jumpserver.rdp".format(self.request.user.username, name) - filename = urllib.parse.quote(filename) - response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename - return response - - def get_valid_serializer(self): - if self.request.method == 'GET': - data = self.request.query_params - else: - data = self.request.data - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - return serializer - - def get_client_protocol_data(self, serializer): - asset, application, system_user, user = self.get_request_resource(serializer) - protocol = system_user.protocol - username = user.username - config, token = '', '' - if protocol == 'rdp': - name, config = self.get_rdp_file_content(serializer) - elif protocol == 'ssh': - name, token = self.get_ssh_token(serializer) - else: - raise ValueError('Protocol not support: {}'.format(protocol)) - - filename = "{}-{}-jumpserver".format(username, name) - data = { - "filename": filename, - "protocol": system_user.protocol, - "username": username, - "token": token, - "config": config - } - return data - - @action(methods=['POST', 'GET'], detail=False, url_path='client-url') - def get_client_protocol_url(self, request, *args, **kwargs): - serializer = self.get_valid_serializer() - try: - protocol_data = self.get_client_protocol_data(serializer) - except ValueError as e: - return Response({'error': str(e)}, status=401) - - protocol_data = json.dumps(protocol_data).encode() - protocol_data = base64.b64encode(protocol_data).decode() - data = { - 'url': 'jms://{}'.format(protocol_data), - } - return Response(data=data) - - -class SecretDetailMixin: - valid_token: Callable - request: Request - get_serializer: Callable - - @staticmethod - def _get_application_secret_detail(application): - gateway = None - remote_app = None - asset = None - - if application.category_remote_app: - remote_app = application.get_rdp_remote_app_setting() - asset = application.get_remote_app_asset() - domain = asset.domain - else: - domain = application.domain - - if domain and domain.has_gateway(): - gateway = domain.random_gateway() - - return { - 'asset': asset, - 'application': application, - 'gateway': gateway, - 'domain': domain, - 'remote_app': remote_app, - } - - @staticmethod - def _get_asset_secret_detail(asset): - gateway = None - if asset and asset.domain and asset.domain.has_gateway(): - gateway = asset.domain.random_gateway() - - return { - 'asset': asset, - 'application': None, - 'domain': asset.domain, - 'gateway': gateway, - 'remote_app': None, - } - - @action(methods=['POST'], detail=False, url_path='secret-info/detail') - def get_secret_detail(self, request, *args, **kwargs): - perm_required = 'authentication.view_connectiontokensecret' - - # 非常重要的 api,再逻辑层再判断一下,双重保险 - if not request.user.has_perm(perm_required): - raise PermissionDenied('Not allow to view secret') - - token = request.data.get('token', '') - try: - value, user, system_user, asset, app, expired_at, actions = self.valid_token(token) - except serializers.ValidationError as e: - post_auth_failed.send( - sender=self.__class__, username='', request=self.request, - reason=_('Invalid token') - ) - raise e - - data = dict( - id=token, secret=value.get('secret', ''), - user=user, system_user=system_user, - expired_at=expired_at, actions=actions + endpoint = self.get_smart_endpoint( + protocol='ssh', asset=token.asset, application=token.application ) - cmd_filter_kwargs = { - 'system_user_id': system_user.id, - 'user_id': user.id, - } - if asset: - asset_detail = self._get_asset_secret_detail(asset) - system_user.load_asset_more_auth(asset.id, user.username, user.id) - data['type'] = 'asset' - data.update(asset_detail) - cmd_filter_kwargs['asset_id'] = asset.id - else: - app_detail = self._get_application_secret_detail(app) - system_user.load_app_more_auth(app.id, user.username, user.id) - data['type'] = 'application' - data.update(app_detail) - cmd_filter_kwargs['application_id'] = app.id - - from assets.models import CommandFilterRule - cmd_filter_rules = CommandFilterRule.get_queryset(**cmd_filter_kwargs) - data['cmd_filter_rules'] = cmd_filter_rules - - serializer = self.get_serializer(data) - return Response(data=serializer.data, status=200) - - -class TokenCacheMixin: - CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}' - - def get_token_cache_key(self, token): - return self.CACHE_KEY_PREFIX.format(token) - - def get_token_ttl(self, token): - key = self.get_token_cache_key(token) - return cache.ttl(key) - - def set_token_to_cache(self, token, value, ttl=5*60): - key = self.get_token_cache_key(token) - cache.set(key, value, timeout=ttl) - - def get_token_from_cache(self, token): - key = self.get_token_cache_key(token) - value = cache.get(key, None) - return value - - def renewal_token(self, token, ttl=5*60): - value = self.get_token_from_cache(token) - if value: - pre_ttl = self.get_token_ttl(token) - self.set_token_to_cache(token, value, ttl) - post_ttl = self.get_token_ttl(token) - ok = True - msg = f'{pre_ttl}s is renewed to {post_ttl}s.' - else: - ok = False - msg = 'Token is not found.' data = { - 'ok': ok, - 'msg': msg + 'ip': endpoint.host, + 'port': str(endpoint.ssh_port), + 'username': 'JMS-{}'.format(str(token.id)), + 'password': token.secret } - return data + token = json.dumps(data) + return filename, token -class UserConnectionTokenViewSet( - RootOrgViewMixin, SerializerMixin, ClientProtocolMixin, - SecretDetailMixin, TokenCacheMixin, GenericViewSet -): +class ConnectionTokenViewSet(ConnectionTokenMixin, RootOrgViewMixin, JMSModelViewSet): + filterset_fields = ( + 'type', + 'user_display', 'system_user_display', 'application_display', 'asset_display' + ) + search_fields = filterset_fields serializer_classes = { 'default': ConnectionTokenSerializer, + 'list': ConnectionTokenDisplaySerializer, + 'retrieve': ConnectionTokenDisplaySerializer, 'get_secret_detail': ConnectionTokenSecretSerializer, } rbac_perms = { - 'GET': 'authentication.view_connectiontoken', + 'retrieve': 'authentication.view_connectiontoken', 'create': 'authentication.add_connectiontoken', - 'renewal': 'authentication.add_superconnectiontoken', + 'expire': 'authentication.add_connectiontoken', 'get_secret_detail': 'authentication.view_connectiontokensecret', 'get_rdp_file': 'authentication.add_connectiontoken', 'get_client_protocol_url': 'authentication.add_connectiontoken', } + queryset = ConnectionToken.objects.all() - @staticmethod - def check_resource_permission(user, asset, application, system_user): - from perms.utils.asset import has_asset_system_permission - from perms.utils.application import has_application_system_permission - - if asset and not has_asset_system_permission(user, asset, system_user): - error = f'User not has this asset and system user permission: ' \ - f'user={user.id} system_user={system_user.id} asset={asset.id}' - raise PermissionDenied(error) - if application and not has_application_system_permission(user, application, system_user): - error = f'User not has this application and system user permission: ' \ - f'user={user.id} system_user={system_user.id} application={application.id}' - raise PermissionDenied(error) - return True - - @action(methods=[PATCH], detail=False) - def renewal(self, request, *args, **kwargs): - """ 续期 Token """ - perm_required = 'authentication.add_superconnectiontoken' - if not request.user.has_perm(perm_required): - raise PermissionDenied('No permissions for authentication.add_superconnectiontoken') - token = request.data.get('token', '') - data = self.renewal_token(token) - status_code = 200 if data.get('ok') else 404 - return Response(data=data, status=status_code) - - def create_token(self, user, asset, application, system_user, ttl=5*60): - # 再次强调一下权限 - perm_required = 'authentication.add_superconnectiontoken' - if user != self.request.user and not self.request.user.has_perm(perm_required): - raise PermissionDenied('Only can create user token') - self.check_resource_permission(user, asset, application, system_user) - token = random_string(36) - secret = random_string(16) - value = { - 'id': token, - 'secret': secret, - 'user': str(user.id), - 'username': user.username, - 'system_user': str(system_user.id), - 'system_user_name': system_user.name, - 'created_by': str(self.request.user), - 'date_created': str(timezone.now()) - } - - if asset: - value.update({ - 'type': 'asset', - 'asset': str(asset.id), - 'hostname': asset.hostname, - }) - elif application: - value.update({ - 'type': 'application', - 'application': application.id, - 'application_name': str(application) - }) - - self.set_token_to_cache(token, value, ttl) - return token, secret - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + def create_connection_token(self): + data = self.request.query_params if self.request.method == 'GET' else self.request.data + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + token: ConnectionToken = serializer.instance + return token - asset, application, system_user, user = self.get_request_resource(serializer) - token, secret = self.create_token(user, asset, application, system_user) - tp = 'app' if application else 'asset' + def perform_create(self, serializer): + user, asset, application, system_user = self.get_request_resources(serializer) + self.check_user_has_resource_permission(user, asset, application, system_user) + return super(ConnectionTokenViewSet, self).perform_create(serializer) + + @action(methods=['POST'], detail=False, url_path='secret-info/detail') + def get_secret_detail(self, request, *args, **kwargs): + # 非常重要的 api,在逻辑层再判断一下,双重保险 + perm_required = 'authentication.view_connectiontokensecret' + if not request.user.has_perm(perm_required): + raise PermissionDenied('Not allow to view secret') + token_id = request.data.get('token') or '' + token = get_object_or_404(ConnectionToken, pk=token_id) + self.check_token_valid(token) + token.load_system_user_auth() + serializer = self.get_serializer(instance=token) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(methods=['POST', 'GET'], detail=False, url_path='rdp/file') + def get_rdp_file(self, request, *args, **kwargs): + token = self.create_connection_token() + self.check_token_valid(token) + filename, content = self.get_rdp_file_info(token) + filename = '{}.rdp'.format(filename) + response = HttpResponse(content, content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename + return response + + @action(methods=['POST', 'GET'], detail=False, url_path='client-url') + def get_client_protocol_url(self, request, *args, **kwargs): + token = self.create_connection_token() + self.check_token_valid(token) + try: + protocol_data = self.get_client_protocol_data(token) + except ValueError as e: + return Response(data={'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + protocol_data = json.dumps(protocol_data).encode() + protocol_data = base64.b64encode(protocol_data).decode() data = { - "id": token, 'secret': secret, - 'type': tp, 'protocol': system_user.protocol + 'url': 'jms://{}'.format(protocol_data) } - return Response(data, status=201) + return Response(data=data) - def valid_token(self, token): - from users.models import User - from assets.models import SystemUser, Asset - from applications.models import Application - from perms.utils.asset.permission import validate_permission as asset_validate_permission - from perms.utils.application.permission import validate_permission as app_validate_permission + @action(methods=['PATCH'], detail=True) + def expire(self, request, *args, **kwargs): + instance = self.get_object() + instance.expire() + return Response(status=status.HTTP_204_NO_CONTENT) - value = self.get_token_from_cache(token) - if not value: - raise serializers.ValidationError('Token not found') - user = get_object_or_404(User, id=value.get('user')) - if not user.is_valid: - raise serializers.ValidationError("User not valid, disabled or expired") +class SuperConnectionTokenViewSet(ConnectionTokenViewSet): + serializer_classes = { + 'default': SuperConnectionTokenSerializer, + } + rbac_perms = { + 'create': 'authentication.add_superconnectiontoken', + 'renewal': 'authentication.add_superconnectiontoken' + } - system_user = get_object_or_404(SystemUser, id=value.get('system_user')) - asset = None - app = None - if value.get('type') == 'asset': - asset = get_object_or_404(Asset, id=value.get('asset')) - if not asset.is_active: - raise serializers.ValidationError("Asset disabled") - has_perm, actions, expired_at = asset_validate_permission(user, asset, system_user) - else: - app = get_object_or_404(Application, id=value.get('application')) - has_perm, actions, expired_at = app_validate_permission(user, app, system_user) + @action(methods=['PATCH'], detail=False) + def renewal(self, request, *args, **kwargs): + from common.utils.timezone import as_current_tz - if not has_perm: - raise serializers.ValidationError('Permission expired or invalid') - return value, user, system_user, asset, app, expired_at, actions + token_id = request.data.get('token') or '' + token = get_object_or_404(ConnectionToken, pk=token_id) + date_expired = as_current_tz(token.date_expired) + if token.is_expired: + raise PermissionDenied('Token is expired at: {}'.format(date_expired)) + token.renewal() + data = { + 'ok': True, + 'msg': f'Token is renewed, date expired: {date_expired}' + } + return Response(data=data, status=status.HTTP_200_OK) - def get(self, request): - token = request.query_params.get('token') - value = self.get_token_from_cache(token) - if not value: - return Response('', status=404) - return Response(value) diff --git a/apps/authentication/api/dingtalk.py b/apps/authentication/api/dingtalk.py index da03f015b..817b5276e 100644 --- a/apps/authentication/api/dingtalk.py +++ b/apps/authentication/api/dingtalk.py @@ -2,10 +2,11 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid from users.models import User from common.utils import get_logger +from common.permissions import UserConfirmation from common.mixins.api import RoleUserMixin, RoleAdminMixin +from authentication.const import ConfirmType from authentication import errors logger = get_logger(__file__) @@ -26,9 +27,8 @@ class DingTalkQRUnBindBase(APIView): class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): user_id_url_kwarg = 'user_id' - \ No newline at end of file diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py index aaed60db9..878ec6e2d 100644 --- a/apps/authentication/api/feishu.py +++ b/apps/authentication/api/feishu.py @@ -2,10 +2,11 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid from users.models import User from common.utils import get_logger +from common.permissions import UserConfirmation from common.mixins.api import RoleUserMixin, RoleAdminMixin +from authentication.const import ConfirmType from authentication import errors logger = get_logger(__file__) @@ -26,7 +27,7 @@ class FeiShuQRUnBindBase(APIView): class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 9adedb1e0..22594b88e 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -25,5 +25,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView): ticket = self.get_ticket() if ticket: request.session.pop('auth_ticket_id', '') - ticket.close(processor=self.get_user_from_session()) + ticket.close() return Response('', status=200) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 90aeb7fc4..fe81149e3 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -10,22 +10,17 @@ from rest_framework.generics import CreateAPIView from rest_framework.serializers import ValidationError from rest_framework.response import Response -from common.permissions import IsValidUser, NeedMFAVerify from common.utils import get_logger from common.exceptions import UnexpectError from users.models.user import User -from ..serializers import OtpVerifySerializer from .. import serializers from .. import errors -from ..mfa.otp import MFAOtp from ..mixins import AuthMixin - logger = get_logger(__name__) __all__ = [ - 'MFAChallengeVerifyApi', 'UserOtpVerifyApi', - 'MFASendCodeApi' + 'MFAChallengeVerifyApi', 'MFASendCodeApi' ] @@ -88,30 +83,3 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView): raise ValidationError(data) except errors.NeedMoreInfoError as e: return Response(e.as_data(), status=200) - - -class UserOtpVerifyApi(CreateAPIView): - permission_classes = (IsValidUser,) - serializer_class = OtpVerifySerializer - - def get(self, request, *args, **kwargs): - return Response({'code': 'valid', 'msg': 'verified'}) - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - code = serializer.validated_data["code"] - otp = MFAOtp(request.user) - - ok, error = otp.check_code(code) - if ok: - request.session["MFA_VERIFY_TIME"] = int(time.time()) - return Response({"ok": "1"}) - else: - return Response({"error": _("Code is invalid, {}").format(error)}, status=400) - - def get_permissions(self): - if self.request.method.lower() == 'get' \ - and settings.SECURITY_VIEW_AUTH_NEED_MFA: - self.permission_classes = [NeedMFAVerify] - return super().get_permissions() diff --git a/apps/authentication/api/temp_token.py b/apps/authentication/api/temp_token.py new file mode 100644 index 000000000..6e640edd6 --- /dev/null +++ b/apps/authentication/api/temp_token.py @@ -0,0 +1,29 @@ +from django.utils import timezone +from rest_framework.response import Response +from rest_framework.decorators import action + +from common.drf.api import JMSModelViewSet +from ..models import TempToken +from ..serializers import TempTokenSerializer +from rbac.permissions import RBACPermission + + +class TempTokenViewSet(JMSModelViewSet): + serializer_class = TempTokenSerializer + permission_classes = [RBACPermission] + http_method_names = ['post', 'get', 'options', 'patch'] + rbac_perms = { + 'expire': 'authentication.change_temptoken', + } + + def get_queryset(self): + username = self.request.user.username + return TempToken.objects.filter(username=username).order_by('-date_created') + + @action(methods=['PATCH'], detail=True, url_path='expire') + def expire(self, *args, **kwargs): + instance = self.get_object() + instance.date_expired = timezone.now() + instance.save() + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index df8c6eb3f..3bc8a33d1 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # -from django.shortcuts import redirect from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.generics import CreateAPIView @@ -28,8 +27,10 @@ class TokenCreateApi(AuthMixin, CreateAPIView): def create(self, request, *args, **kwargs): self.create_session_if_need() # 如果认证没有过,检查账号密码 + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) try: - user = self.check_user_auth_if_need() + user = self.get_user_or_auth(serializer.validated_data) self.check_user_mfa_if_need(user) self.check_user_login_confirm_if_need(user) self.send_auth_signal(success=True, user=user) diff --git a/apps/authentication/api/wecom.py b/apps/authentication/api/wecom.py index 486efde21..cdde00bc9 100644 --- a/apps/authentication/api/wecom.py +++ b/apps/authentication/api/wecom.py @@ -2,10 +2,11 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid from users.models import User from common.utils import get_logger +from common.permissions import UserConfirmation from common.mixins.api import RoleUserMixin, RoleAdminMixin +from authentication.const import ConfirmType from authentication import errors logger = get_logger(__file__) @@ -26,9 +27,8 @@ class WeComQRUnBindBase(APIView): class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): user_id_url_kwarg = 'user_id' - \ No newline at end of file diff --git a/apps/authentication/backends/base.py b/apps/authentication/backends/base.py index 64faf3334..84cdeab27 100644 --- a/apps/authentication/backends/base.py +++ b/apps/authentication/backends/base.py @@ -1,10 +1,11 @@ -from django.contrib.auth.backends import BaseBackend from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model from users.models import User from common.utils import get_logger +UserModel = get_user_model() logger = get_logger(__file__) diff --git a/apps/authentication/backends/drf.py b/apps/authentication/backends/drf.py index 595ae35b6..10c191b1b 100644 --- a/apps/authentication/backends/drf.py +++ b/apps/authentication/backends/drf.py @@ -198,6 +198,6 @@ class SignatureAuthentication(signature.SignatureAuthentication): return None, None user, secret = key.user, str(key.secret) return user, secret - except AccessKey.DoesNotExist: + except (AccessKey.DoesNotExist, exceptions.ValidationError): return None, None diff --git a/apps/authentication/backends/ldap.py b/apps/authentication/backends/ldap.py index 1c8a80cb1..354d6211e 100644 --- a/apps/authentication/backends/ldap.py +++ b/apps/authentication/backends/ldap.py @@ -53,7 +53,7 @@ class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): else: built = False - return (user, built) + return user, built def pre_check(self, username, password): if not settings.AUTH_LDAP: @@ -75,6 +75,9 @@ class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): def authenticate(self, request=None, username=None, password=None, **kwargs): logger.info('Authentication LDAP backend') + if username is None or password is None: + logger.info('No username or password') + return None match, msg = self.pre_check(username, password) if not match: logger.info('Authenticate failed: {}'.format(msg)) @@ -154,6 +157,8 @@ class LDAPUser(_LDAPUser): def _populate_user_from_attributes(self): for field, attr in self.settings.USER_ATTR_MAP.items(): + if field in ['groups']: + continue try: value = self.attrs[attr][0] value = value.strip() diff --git a/apps/authentication/backends/oidc/backends.py b/apps/authentication/backends/oidc/backends.py index d1d7bd48c..9866e84f3 100644 --- a/apps/authentication/backends/oidc/backends.py +++ b/apps/authentication/backends/oidc/backends.py @@ -18,6 +18,7 @@ from django.urls import reverse from django.conf import settings from common.utils import get_logger +from users.utils import construct_user_email from ..base import JMSBaseAuthBackend from .utils import validate_and_return_id_token, build_absolute_uri @@ -39,17 +40,22 @@ class UserMixin: logger.debug(log_prompt.format('start')) sub = claims['sub'] - name = claims.get('name', sub) - username = claims.get('preferred_username', sub) - email = claims.get('email', "{}@{}".format(username, 'jumpserver.openid')) - logger.debug( - log_prompt.format( - "sub: {}|name: {}|username: {}|email: {}".format(sub, name, username, email) - ) - ) + + # Construct user attrs value + user_attrs = {} + for field, attr in settings.AUTH_OPENID_USER_ATTR_MAP.items(): + user_attrs[field] = claims.get(attr, sub) + email = user_attrs.get('email', '') + email = construct_user_email(user_attrs.get('username'), email) + user_attrs.update({'email': email}) + + logger.debug(log_prompt.format(user_attrs)) + + username = user_attrs.get('username') + name = user_attrs.get('name') user, created = get_user_model().objects.get_or_create( - username=username, defaults={"name": name, "email": email} + username=username, defaults=user_attrs ) logger.debug(log_prompt.format("user: {}|created: {}".format(user, created))) logger.debug(log_prompt.format("Send signal => openid create or update user")) @@ -103,21 +109,44 @@ class OIDCAuthCodeBackend(OIDCBaseBackend): # Prepares the token payload that will be used to request an authentication token to the # token endpoint of the OIDC provider. logger.debug(log_prompt.format('Prepares token payload')) + """ + The reason for need not client_id and client_secret in token_payload. + OIDC protocol indicate client's token_endpoint_auth_method only accept one type in + - client_secret_basic + - client_secret_post + - client_secret_jwt + - private_key_jwt + - none + If the client offer more than one auth method type to OIDC, OIDC will auth client failed. + OIDC default use client_secret_basic, + this type only need in headers add Authorization=Basic xxx. + + More info see: https://github.com/jumpserver/jumpserver/issues/8165 + More info see: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication + """ token_payload = { - 'client_id': settings.AUTH_OPENID_CLIENT_ID, - 'client_secret': settings.AUTH_OPENID_CLIENT_SECRET, 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': build_absolute_uri( request, path=reverse(settings.AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME) ) } - - # Prepares the token headers that will be used to request an authentication token to the - # token endpoint of the OIDC provider. - logger.debug(log_prompt.format('Prepares token headers')) - basic_token = "{}:{}".format(settings.AUTH_OPENID_CLIENT_ID, settings.AUTH_OPENID_CLIENT_SECRET) - headers = {"Authorization": "Basic {}".format(base64.b64encode(basic_token.encode()).decode())} + if settings.AUTH_OPENID_CLIENT_AUTH_METHOD == 'client_secret_post': + token_payload.update({ + 'client_id': settings.AUTH_OPENID_CLIENT_ID, + 'client_secret': settings.AUTH_OPENID_CLIENT_SECRET, + }) + headers = None + else: + # Prepares the token headers that will be used to request an authentication token to the + # token endpoint of the OIDC provider. + logger.debug(log_prompt.format('Prepares token headers')) + basic_token = "{}:{}".format( + settings.AUTH_OPENID_CLIENT_ID, settings.AUTH_OPENID_CLIENT_SECRET + ) + headers = { + "Authorization": "Basic {}".format(base64.b64encode(basic_token.encode()).decode()) + } # Calls the token endpoint. logger.debug(log_prompt.format('Call the token endpoint')) @@ -258,6 +287,11 @@ class OIDCAuthPasswordBackend(OIDCBaseBackend): try: claims_response.raise_for_status() claims = claims_response.json() + preferred_username = claims.get('preferred_username') + if preferred_username and \ + preferred_username.lower() == username.lower() and \ + preferred_username != username: + return except Exception as e: error = "Json claims response error, claims response " \ "content is: {}, error is: {}".format(claims_response.content, str(e)) @@ -286,5 +320,3 @@ class OIDCAuthPasswordBackend(OIDCBaseBackend): openid_user_login_failed.send( sender=self.__class__, request=request, username=username, reason="User is invalid" ) - return None - diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 170534370..84f88165a 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -13,20 +13,23 @@ User = get_user_model() class CreateUserMixin: - def get_django_user(self, username, password=None, *args, **kwargs): + @staticmethod + def get_django_user(username, password=None, *args, **kwargs): if isinstance(username, bytes): username = username.decode() - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - if '@' in username: - email = username - else: - email_suffix = settings.EMAIL_SUFFIX - email = '{}@{}'.format(username, email_suffix) - user = User(username=username, name=username, email=email) - user.source = user.Source.radius.value - user.save() + user = User.objects.filter(username=username).first() + if user: + return user + + if '@' in username: + email = username + else: + email_suffix = settings.EMAIL_SUFFIX + email = '{}@{}'.format(username, email_suffix) + + user = User(username=username, name=username, email=email) + user.source = user.Source.radius.value + user.save() return user def _perform_radius_auth(self, client, packet): diff --git a/apps/authentication/backends/saml2/backends.py b/apps/authentication/backends/saml2/backends.py index e1b1fb1eb..0ac0efe1c 100644 --- a/apps/authentication/backends/saml2/backends.py +++ b/apps/authentication/backends/saml2/backends.py @@ -14,7 +14,7 @@ from ..base import JMSModelBackend __all__ = ['SAML2Backend'] -logger = get_logger(__file__) +logger = get_logger(__name__) class SAML2Backend(JMSModelBackend): diff --git a/apps/authentication/backends/saml2/views.py b/apps/authentication/backends/saml2/views.py index b0b8fef8d..e91fd0660 100644 --- a/apps/authentication/backends/saml2/views.py +++ b/apps/authentication/backends/saml2/views.py @@ -74,27 +74,37 @@ class PrepareRequestMixin: return idp_settings @staticmethod - def get_attribute_consuming_service(): - attr_mapping = settings.SAML2_RENAME_ATTRIBUTES - if attr_mapping and isinstance(attr_mapping, dict): - attr_list = [ - { - "name": sp_key, - "friendlyName": idp_key, "isRequired": True - } - for idp_key, sp_key in attr_mapping.items() - ] - request_attribute_template = { - "attributeConsumingService": { - "isDefault": False, - "serviceName": "JumpServer", - "serviceDescription": "JumpServer", - "requestedAttributes": attr_list - } + def get_request_attributes(): + attr_mapping = settings.SAML2_RENAME_ATTRIBUTES or {} + attr_map_reverse = {v: k for k, v in attr_mapping.items()} + need_attrs = ( + ('username', 'username', True), + ('email', 'email', True), + ('name', 'name', False), + ('phone', 'phone', False), + ('comment', 'comment', False), + ) + attr_list = [] + for name, friend_name, is_required in need_attrs: + rename_name = attr_map_reverse.get(friend_name) + name = rename_name if rename_name else name + attr_list.append({ + "name": name, "isRequired": is_required, + "friendlyName": friend_name, + }) + return attr_list + + def get_attribute_consuming_service(self): + attr_list = self.get_request_attributes() + request_attribute_template = { + "attributeConsumingService": { + "isDefault": False, + "serviceName": "JumpServer", + "serviceDescription": "JumpServer", + "requestedAttributes": attr_list } - return request_attribute_template - else: - return {} + } + return request_attribute_template @staticmethod def get_advanced_settings(): @@ -167,11 +177,14 @@ class PrepareRequestMixin: def get_attributes(self, saml_instance): user_attrs = {} + attr_mapping = settings.SAML2_RENAME_ATTRIBUTES attrs = saml_instance.get_attributes() valid_attrs = ['username', 'name', 'email', 'comment', 'phone'] for attr, value in attrs.items(): attr = attr.rsplit('/', 1)[-1] + if attr_mapping and attr_mapping.get(attr): + attr = attr_mapping.get(attr) if attr not in valid_attrs: continue user_attrs[attr] = self.value_to_str(value) diff --git a/apps/authentication/backends/token.py b/apps/authentication/backends/token.py new file mode 100644 index 000000000..be9cb9032 --- /dev/null +++ b/apps/authentication/backends/token.py @@ -0,0 +1,26 @@ +from django.utils import timezone +from django.conf import settings +from django.core.exceptions import PermissionDenied + +from authentication.models import TempToken +from .base import JMSModelBackend + + +class TempTokenAuthBackend(JMSModelBackend): + model = TempToken + + def authenticate(self, request, username='', password='', *args, **kwargs): + token = self.model.objects.filter(username=username, secret=password).first() + if not token: + return None + if not token.is_valid: + raise PermissionDenied('Token is invalid, expired at {}'.format(token.date_expired)) + + token.verified = True + token.date_verified = timezone.now() + token.save() + return token.user + + @staticmethod + def is_enabled(): + return settings.AUTH_TEMP_TOKEN diff --git a/apps/authentication/confirm/__init__.py b/apps/authentication/confirm/__init__.py new file mode 100644 index 000000000..1c3acd05b --- /dev/null +++ b/apps/authentication/confirm/__init__.py @@ -0,0 +1,5 @@ +from .mfa import ConfirmMFA +from .password import ConfirmPassword +from .relogin import ConfirmReLogin + +CONFIRM_BACKENDS = [ConfirmReLogin, ConfirmPassword, ConfirmMFA] diff --git a/apps/authentication/confirm/base.py b/apps/authentication/confirm/base.py new file mode 100644 index 000000000..63258abce --- /dev/null +++ b/apps/authentication/confirm/base.py @@ -0,0 +1,30 @@ +import abc + + +class BaseConfirm(abc.ABC): + + def __init__(self, user, request): + self.user = user + self.request = request + + @property + @abc.abstractmethod + def name(self) -> str: + return '' + + @property + @abc.abstractmethod + def display_name(self) -> str: + return '' + + @abc.abstractmethod + def check(self) -> bool: + return False + + @property + def content(self): + return '' + + @abc.abstractmethod + def authenticate(self, secret_key, mfa_type) -> tuple: + return False, 'Error msg' diff --git a/apps/authentication/confirm/mfa.py b/apps/authentication/confirm/mfa.py new file mode 100644 index 000000000..a9d3dd1ab --- /dev/null +++ b/apps/authentication/confirm/mfa.py @@ -0,0 +1,26 @@ +from users.models import User + +from .base import BaseConfirm + + +class ConfirmMFA(BaseConfirm): + name = 'mfa' + display_name = 'MFA' + + def check(self): + return self.user.active_mfa_backends and self.user.mfa_enabled + + @property + def content(self): + backends = User.get_user_mfa_backends(self.user) + return [{ + 'name': backend.name, + 'disabled': not bool(backend.is_active()), + 'display_name': backend.display_name, + 'placeholder': backend.placeholder, + } for backend in backends] + + def authenticate(self, secret_key, mfa_type): + mfa_backend = self.user.get_mfa_backend_by_type(mfa_type) + ok, msg = mfa_backend.check_code(secret_key) + return ok, msg diff --git a/apps/authentication/confirm/password.py b/apps/authentication/confirm/password.py new file mode 100644 index 000000000..944ab8f24 --- /dev/null +++ b/apps/authentication/confirm/password.py @@ -0,0 +1,17 @@ +from django.utils.translation import ugettext_lazy as _ + +from authentication.mixins import authenticate +from .base import BaseConfirm + + +class ConfirmPassword(BaseConfirm): + name = 'password' + display_name = _('Password') + + def check(self): + return self.user.is_password_authenticate() + + def authenticate(self, secret_key, mfa_type): + ok = authenticate(self.request, username=self.user.username, password=secret_key) + msg = '' if ok else _('Authentication failed password incorrect') + return ok, msg diff --git a/apps/authentication/confirm/relogin.py b/apps/authentication/confirm/relogin.py new file mode 100644 index 000000000..447a17ab0 --- /dev/null +++ b/apps/authentication/confirm/relogin.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from .base import BaseConfirm + +SPECIFIED_TIME = 5 + +RELOGIN_ERROR = _('Login time has exceeded {} minutes, please login again').format(SPECIFIED_TIME) + + +class ConfirmReLogin(BaseConfirm): + name = 'relogin' + display_name = 'Re-Login' + + def check(self): + return not self.user.is_password_authenticate() + + def authenticate(self, secret_key, mfa_type): + now = timezone.now().strftime("%Y-%m-%d %H:%M:%S") + now = datetime.strptime(now, '%Y-%m-%d %H:%M:%S') + login_time = self.request.session.get('login_time') + msg = RELOGIN_ERROR + if not login_time: + return False, msg + login_time = datetime.strptime(login_time, '%Y-%m-%d %H:%M:%S') + if (now - login_time).seconds >= SPECIFIED_TIME * 60: + return False, msg + return True, '' diff --git a/apps/authentication/const.py b/apps/authentication/const.py index f5cf56471..d85afed75 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -1,2 +1,37 @@ +from django.db.models import TextChoices + +from authentication.confirm import CONFIRM_BACKENDS +from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin +from .mfa import MFAOtp, MFASms, MFARadius + RSA_PRIVATE_KEY = 'rsa_private_key' RSA_PUBLIC_KEY = 'rsa_public_key' + +CONFIRM_BACKEND_MAP = {backend.name: backend for backend in CONFIRM_BACKENDS} + + +class ConfirmType(TextChoices): + ReLogin = ConfirmReLogin.name, ConfirmReLogin.display_name + PASSWORD = ConfirmPassword.name, ConfirmPassword.display_name + MFA = ConfirmMFA.name, ConfirmMFA.display_name + + @classmethod + def get_can_confirm_types(cls, confirm_type): + start = cls.values.index(confirm_type) + types = cls.values[start:] + types.reverse() + return types + + @classmethod + def get_can_confirm_backend_classes(cls, confirm_type): + types = cls.get_can_confirm_types(confirm_type) + backend_classes = [ + CONFIRM_BACKEND_MAP[tp] for tp in types if tp in CONFIRM_BACKEND_MAP + ] + return backend_classes + + +class MFAType(TextChoices): + OTP = MFAOtp.name, MFAOtp.display_name + SMS = MFASms.name, MFASms.display_name + Radius = MFARadius.name, MFARadius.display_name diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py deleted file mode 100644 index 9a75b4691..000000000 --- a/apps/authentication/errors.py +++ /dev/null @@ -1,367 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.utils.translation import ugettext_lazy as _ -from django.urls import reverse -from django.conf import settings -from rest_framework import status - -from common.exceptions import JMSException -from .signals import post_auth_failed -from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil - -reason_password_failed = 'password_failed' -reason_password_decrypt_failed = 'password_decrypt_failed' -reason_mfa_failed = 'mfa_failed' -reason_mfa_unset = 'mfa_unset' -reason_user_not_exist = 'user_not_exist' -reason_password_expired = 'password_expired' -reason_user_invalid = 'user_invalid' -reason_user_inactive = 'user_inactive' -reason_user_expired = 'user_expired' -reason_backend_not_match = 'backend_not_match' -reason_acl_not_allow = 'acl_not_allow' -only_local_users_are_allowed = 'only_local_users_are_allowed' - -reason_choices = { - reason_password_failed: _('Username/password check failed'), - reason_password_decrypt_failed: _('Password decrypt failed'), - reason_mfa_failed: _('MFA failed'), - reason_mfa_unset: _('MFA unset'), - 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."), - reason_user_expired: _("This account is expired"), - reason_backend_not_match: _("Auth backend not match"), - reason_acl_not_allow: _("ACL is not allowed"), - only_local_users_are_allowed: _("Only local users are allowed") -} -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_user_login_msg = _( - "The account has been locked " - "(please contact admin to unlock it or try again after {} minutes)" -) -block_ip_login_msg = _( - "The ip has been locked " - "(please contact admin to unlock it or try again after {} minutes)" -) -block_mfa_msg = _( - "The account has been locked " - "(please contact admin to unlock it or try again after {} minutes)" -) -mfa_error_msg = _( - "{error}, " - "You can also try {times_try} times " - "(The account will be temporarily locked for {block_time} minutes)" -) -mfa_required_msg = _("MFA required") -mfa_unset_msg = _("MFA not set, please set it first") -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) - LoginBlockUtil(self.username, self.ip).incr_failed_count() - - -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, - } - - def __str__(self): - return str(self.msg) - - -class BlockGlobalIpLoginError(AuthFailedError): - error = 'block_global_ip_login' - - def __init__(self, username, ip, **kwargs): - self.msg = block_ip_login_msg.format(settings.SECURITY_LOGIN_IP_LIMIT_TIME) - LoginIpBlockUtil(ip).set_block_if_need() - super().__init__(username=username, ip=ip, **kwargs) - - -class CredentialError( - AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, BlockGlobalIpLoginError, AuthFailedError -): - def __init__(self, error, username, ip, request): - super().__init__(error=error, username=username, ip=ip, request=request) - util = LoginBlockUtil(username, ip) - times_remainder = util.get_remainder_times() - block_time = settings.SECURITY_LOGIN_LIMIT_TIME - - if times_remainder < 1: - self.msg = block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) - return - - default_msg = invalid_login_msg.format( - times_try=times_remainder, 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: str - - def __init__(self, username, request, ip, mfa_type, error): - super().__init__(username=username, request=request) - - util = MFABlockUtils(username, ip) - times_remainder = util.incr_failed_count() - block_time = settings.SECURITY_LOGIN_LIMIT_TIME - - if times_remainder: - self.msg = mfa_error_msg.format( - error=error, times_try=times_remainder, block_time=block_time - ) - else: - self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) - - -class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError): - error = 'block_mfa' - - def __init__(self, username, request, ip): - self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) - super().__init__(username=username, request=request, ip=ip) - - -class MFAUnsetError(Exception): - error = reason_mfa_unset - msg = mfa_unset_msg - - def __init__(self, user, request, url): - self.url = url - - -class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError): - error = 'block_login' - - def __init__(self, username, ip): - self.msg = block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) - 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 __init__(self, error='', msg='', mfa_types=()): - super().__init__(error=error, msg=msg) - self.choices = mfa_types - - def as_data(self): - return { - 'error': self.error, - 'msg': self.msg, - 'data': { - 'choices': self.choices, - 'url': reverse('api-auth:mfa-challenge') - } - } - - -class ACLError(AuthFailedNeedLogMixin, AuthFailedError): - msg = reason_acl_not_allow - error = 'acl_error' - - def __init__(self, msg, **kwargs): - self.msg = msg - super().__init__(**kwargs) - - def as_data(self): - return { - "error": reason_acl_not_allow, - "msg": self.msg - } - - -class LoginIPNotAllowed(ACLError): - def __init__(self, username, request, **kwargs): - self.username = username - self.request = request - super().__init__(_("IP is not allowed"), **kwargs) - - -class TimePeriodNotAllowed(ACLError): - def __init__(self, username, request, **kwargs): - self.username = username - self.request = request - super().__init__(_("Time Period is not allowed"), **kwargs) - - -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) - - -class SSOAuthClosed(JMSException): - default_code = 'sso_auth_closed' - default_detail = _('SSO auth closed') - - -class PasswordTooSimple(JMSException): - default_code = 'passwd_too_simple' - default_detail = _('Your password is too simple, please change it for security') - - def __init__(self, url, *args, **kwargs): - super().__init__(*args, **kwargs) - self.url = url - - -class PasswordNeedUpdate(JMSException): - default_code = 'passwd_need_update' - default_detail = _('You should to change your password before login') - - def __init__(self, url, *args, **kwargs): - super().__init__(*args, **kwargs) - self.url = url - - -class PasswordRequireResetError(JMSException): - default_code = 'passwd_has_expired' - default_detail = _('Your password has expired, please reset before logging in') - - def __init__(self, url, *args, **kwargs): - super().__init__(*args, **kwargs) - self.url = url - - -class WeComCodeInvalid(JMSException): - default_code = 'wecom_code_invalid' - default_detail = 'Code invalid, can not get user info' - - -class WeComBindAlready(JMSException): - default_code = 'wecom_bind_already' - default_detail = 'WeCom already binded' - - -class WeComNotBound(JMSException): - default_code = 'wecom_not_bound' - default_detail = 'WeCom is not bound' - - -class DingTalkNotBound(JMSException): - default_code = 'dingtalk_not_bound' - default_detail = 'DingTalk is not bound' - - -class FeiShuNotBound(JMSException): - default_code = 'feishu_not_bound' - default_detail = 'FeiShu is not bound' - - -class PasswordInvalid(JMSException): - default_code = 'passwd_invalid' - default_detail = _('Your password is invalid') - - -class MFACodeRequiredError(AuthFailedError): - error = 'mfa_code_required' - msg = _("Please enter MFA code") - - -class SMSCodeRequiredError(AuthFailedError): - error = 'sms_code_required' - msg = _("Please enter SMS code") - - -class UserPhoneNotSet(AuthFailedError): - error = 'phone_not_set' - msg = _('Phone not set') diff --git a/apps/authentication/errors/__init__.py b/apps/authentication/errors/__init__.py new file mode 100644 index 000000000..1ab7bc8ae --- /dev/null +++ b/apps/authentication/errors/__init__.py @@ -0,0 +1,4 @@ +from .const import * +from .mfa import * +from .failed import * +from .redirect import * diff --git a/apps/authentication/errors/const.py b/apps/authentication/errors/const.py new file mode 100644 index 000000000..530bcf150 --- /dev/null +++ b/apps/authentication/errors/const.py @@ -0,0 +1,67 @@ +from django.utils.translation import gettext_lazy as _ + + +reason_password_failed = 'password_failed' +reason_password_decrypt_failed = 'password_decrypt_failed' +reason_mfa_failed = 'mfa_failed' +reason_mfa_unset = 'mfa_unset' +reason_user_not_exist = 'user_not_exist' +reason_password_expired = 'password_expired' +reason_user_invalid = 'user_invalid' +reason_user_inactive = 'user_inactive' +reason_user_expired = 'user_expired' +reason_backend_not_match = 'backend_not_match' +reason_acl_not_allow = 'acl_not_allow' +only_local_users_are_allowed = 'only_local_users_are_allowed' + +reason_choices = { + reason_password_failed: _('Username/password check failed'), + reason_password_decrypt_failed: _('Password decrypt failed'), + reason_mfa_failed: _('MFA failed'), + reason_mfa_unset: _('MFA unset'), + 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."), + reason_user_expired: _("This account is expired"), + reason_backend_not_match: _("Auth backend not match"), + reason_acl_not_allow: _("ACL is not allowed"), + only_local_users_are_allowed: _("Only local users are allowed") +} +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_user_login_msg = _( + "The account has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +block_ip_login_msg = _( + "The ip has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +block_mfa_msg = _( + "The account has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +mfa_error_msg = _( + "{error}, " + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +mfa_required_msg = _("MFA required") +mfa_unset_msg = _("MFA not set, please set it first") +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 {}") diff --git a/apps/authentication/errors/failed.py b/apps/authentication/errors/failed.py new file mode 100644 index 000000000..118fd6d6e --- /dev/null +++ b/apps/authentication/errors/failed.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil +from ..signals import post_auth_failed +from . import const + + +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) + LoginBlockUtil(self.username, self.ip).incr_failed_count() + + +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, + } + + def __str__(self): + return str(self.msg) + + +class BlockGlobalIpLoginError(AuthFailedError): + error = 'block_global_ip_login' + + def __init__(self, username, ip, **kwargs): + self.msg = const.block_ip_login_msg.format(settings.SECURITY_LOGIN_IP_LIMIT_TIME) + LoginIpBlockUtil(ip).set_block_if_need() + super().__init__(username=username, ip=ip, **kwargs) + + +class CredentialError( + AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, + BlockGlobalIpLoginError, AuthFailedError +): + def __init__(self, error, username, ip, request): + super().__init__(error=error, username=username, ip=ip, request=request) + util = LoginBlockUtil(username, ip) + times_remainder = util.get_remainder_times() + block_time = settings.SECURITY_LOGIN_LIMIT_TIME + + if times_remainder < 1: + self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + return + + default_msg = const.invalid_login_msg.format( + times_try=times_remainder, block_time=block_time + ) + if error == const.reason_password_failed: + self.msg = default_msg + else: + self.msg = const.reason_choices.get(error, default_msg) + + +class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): + error = const.reason_mfa_failed + msg: str + + def __init__(self, username, request, ip, mfa_type, error): + super().__init__(username=username, request=request) + + util = MFABlockUtils(username, ip) + times_remainder = util.incr_failed_count() + block_time = settings.SECURITY_LOGIN_LIMIT_TIME + + if times_remainder: + self.msg = const.mfa_error_msg.format( + error=error, times_try=times_remainder, block_time=block_time + ) + else: + self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + + +class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError): + error = 'block_mfa' + + def __init__(self, username, request, ip): + self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + super().__init__(username=username, request=request, ip=ip) + + +class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError): + error = 'block_login' + + def __init__(self, username, ip): + self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + super().__init__(username=username, ip=ip) + + +class SessionEmptyError(AuthFailedError): + msg = const.session_empty_msg + error = 'session_empty' + + +class ACLError(AuthFailedNeedLogMixin, AuthFailedError): + msg = const.reason_acl_not_allow + error = 'acl_error' + + def __init__(self, msg, **kwargs): + self.msg = msg + super().__init__(**kwargs) + + def as_data(self): + return { + "error": const.reason_acl_not_allow, + "msg": self.msg + } + + +class LoginIPNotAllowed(ACLError): + def __init__(self, username, request, **kwargs): + self.username = username + self.request = request + super().__init__(_("IP is not allowed"), **kwargs) + + +class TimePeriodNotAllowed(ACLError): + def __init__(self, username, request, **kwargs): + self.username = username + self.request = request + super().__init__(_("Time Period is not allowed"), **kwargs) + + +class MFACodeRequiredError(AuthFailedError): + error = 'mfa_code_required' + msg = _("Please enter MFA code") + + +class SMSCodeRequiredError(AuthFailedError): + error = 'sms_code_required' + msg = _("Please enter SMS code") + + +class UserPhoneNotSet(AuthFailedError): + error = 'phone_not_set' + msg = _('Phone not set') diff --git a/apps/authentication/errors/mfa.py b/apps/authentication/errors/mfa.py new file mode 100644 index 000000000..4b7f5a57e --- /dev/null +++ b/apps/authentication/errors/mfa.py @@ -0,0 +1,38 @@ +from django.utils.translation import ugettext_lazy as _ + +from common.exceptions import JMSException + + +class SSOAuthClosed(JMSException): + default_code = 'sso_auth_closed' + default_detail = _('SSO auth closed') + + +class WeComCodeInvalid(JMSException): + default_code = 'wecom_code_invalid' + default_detail = 'Code invalid, can not get user info' + + +class WeComBindAlready(JMSException): + default_code = 'wecom_bind_already' + default_detail = 'WeCom already binded' + + +class WeComNotBound(JMSException): + default_code = 'wecom_not_bound' + default_detail = 'WeCom is not bound' + + +class DingTalkNotBound(JMSException): + default_code = 'dingtalk_not_bound' + default_detail = 'DingTalk is not bound' + + +class FeiShuNotBound(JMSException): + default_code = 'feishu_not_bound' + default_detail = 'FeiShu is not bound' + + +class PasswordInvalid(JMSException): + default_code = 'passwd_invalid' + default_detail = _('Your password is invalid') diff --git a/apps/authentication/errors/redirect.py b/apps/authentication/errors/redirect.py new file mode 100644 index 000000000..bf334133d --- /dev/null +++ b/apps/authentication/errors/redirect.py @@ -0,0 +1,106 @@ +from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse + +from common.exceptions import JMSException +from . import const + + +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 NeedRedirectError(JMSException): + def __init__(self, url, *args, **kwargs): + self.url = url + + +class MFARequiredError(NeedMoreInfoError): + msg = const.mfa_required_msg + error = 'mfa_required' + + def __init__(self, error='', msg='', mfa_types=()): + super().__init__(error=error, msg=msg) + self.choices = mfa_types + + def as_data(self): + return { + 'error': self.error, + 'msg': self.msg, + 'data': { + 'choices': self.choices, + '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 = const.login_confirm_wait_msg + error = 'login_confirm_wait' + + +class LoginConfirmOtherError(LoginConfirmBaseError): + error = 'login_confirm_error' + + def __init__(self, ticket_id, status): + msg = const.login_confirm_error_msg.format(status) + super().__init__(ticket_id=ticket_id, msg=msg) + + +class PasswordTooSimple(NeedRedirectError): + default_code = 'passwd_too_simple' + default_detail = _('Your password is too simple, please change it for security') + + def __init__(self, url, *args, **kwargs): + super().__init__(url, *args, **kwargs) + + +class PasswordNeedUpdate(NeedRedirectError): + default_code = 'passwd_need_update' + default_detail = _('You should to change your password before login') + + def __init__(self, url, *args, **kwargs): + super().__init__(url, *args, **kwargs) + + +class PasswordRequireResetError(NeedRedirectError): + default_code = 'passwd_has_expired' + default_detail = _('Your password has expired, please reset before logging in') + + def __init__(self, url, *args, **kwargs): + super().__init__(url, *args, **kwargs) + + +class MFAUnsetError(NeedRedirectError): + error = const.reason_mfa_unset + msg = const.mfa_unset_msg + + def __init__(self, url, *args, **kwargs): + super().__init__(url, *args, **kwargs) diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index 16ca659c0..e80407d15 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -1,15 +1,25 @@ # -*- coding: utf-8 -*- # - from django import forms from django.conf import settings from django.utils.translation import ugettext_lazy as _ from captcha.fields import CaptchaField, CaptchaTextInput +from common.utils import get_logger, decrypt_password + +logger = get_logger(__name__) + + +class EncryptedField(forms.CharField): + def to_python(self, value): + value = super().to_python(value) + return decrypt_password(value) + class UserLoginForm(forms.Form): days_auto_login = int(settings.SESSION_COOKIE_AGE / 3600 / 24) - disable_days_auto_login = settings.SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE or days_auto_login < 1 + disable_days_auto_login = settings.SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE \ + or days_auto_login < 1 username = forms.CharField( label=_('Username'), max_length=100, @@ -18,7 +28,7 @@ class UserLoginForm(forms.Form): 'autofocus': 'autofocus' }) ) - password = forms.CharField( + password = EncryptedField( label=_('Password'), widget=forms.PasswordInput, max_length=1024, strip=False ) diff --git a/apps/authentication/middleware.py b/apps/authentication/middleware.py index 9481c3ff6..b4241f12d 100644 --- a/apps/authentication/middleware.py +++ b/apps/authentication/middleware.py @@ -1,5 +1,11 @@ +import base64 + from django.shortcuts import redirect, reverse +from django.utils.deprecation import MiddlewareMixin from django.http import HttpResponse +from django.conf import settings + +from common.utils import gen_key_pair class MFAMiddleware: @@ -34,3 +40,50 @@ class MFAMiddleware: url = reverse('authentication:login-mfa') + '?_=middleware' return redirect(url) + + +class SessionCookieMiddleware(MiddlewareMixin): + + @staticmethod + def set_cookie_public_key(request, response): + if request.path.startswith('/api'): + return + pub_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME + public_key = request.session.get(pub_key_name) + cookie_key = request.COOKIES.get(pub_key_name) + if public_key and public_key == cookie_key: + return + + pri_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME + private_key, public_key = gen_key_pair() + public_key_decode = base64.b64encode(public_key.encode()).decode() + request.session[pub_key_name] = public_key_decode + request.session[pri_key_name] = private_key + response.set_cookie(pub_key_name, public_key_decode) + + @staticmethod + def set_cookie_session_prefix(request, response): + key = settings.SESSION_COOKIE_NAME_PREFIX_KEY + value = settings.SESSION_COOKIE_NAME_PREFIX + if request.COOKIES.get(key) == value: + return response + response.set_cookie(key, value) + + @staticmethod + def set_cookie_session_expire(request, response): + if not request.session.get('auth_session_expiration_required'): + return + value = 'age' + if settings.SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE or \ + not request.session.get('auto_login', False): + value = 'close' + + age = request.session.get_expiry_age() + response.set_cookie('jms_session_expire', value, max_age=age) + request.session.pop('auth_session_expiration_required', None) + + def process_response(self, request, response: HttpResponse): + self.set_cookie_session_prefix(request, response) + self.set_cookie_public_key(request, response) + self.set_cookie_session_expire(request, response) + return response diff --git a/apps/authentication/migrations/0010_temptoken.py b/apps/authentication/migrations/0010_temptoken.py new file mode 100644 index 000000000..b76ae0f97 --- /dev/null +++ b/apps/authentication/migrations/0010_temptoken.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.14 on 2022-04-08 07:04 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0009_auto_20220310_0616'), + ] + + operations = [ + migrations.CreateModel( + name='TempToken', + 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')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated 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')), + ('username', models.CharField(max_length=128, verbose_name='Username')), + ('secret', models.CharField(max_length=64, verbose_name='Secret')), + ('verified', models.BooleanField(default=False, verbose_name='Verified')), + ('date_verified', models.DateTimeField(null=True, verbose_name='Date verified')), + ('date_expired', models.DateTimeField(verbose_name='Date expired')), + ], + options={ + 'verbose_name': 'Temporary token', + }, + ), + ] diff --git a/apps/authentication/migrations/0011_auto_20220705_1940.py b/apps/authentication/migrations/0011_auto_20220705_1940.py new file mode 100644 index 000000000..527003d9e --- /dev/null +++ b/apps/authentication/migrations/0011_auto_20220705_1940.py @@ -0,0 +1,89 @@ +# Generated by Django 3.2.12 on 2022-07-05 11:40 + +import authentication.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0021_auto_20220629_1826'), + ('assets', '0091_auto_20220629_1826'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0010_temptoken'), + ] + + operations = [ + migrations.AddField( + model_name='connectiontoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='applications.application', verbose_name='Application'), + ), + migrations.AddField( + model_name='connectiontoken', + name='application_display', + field=models.CharField(default='', max_length=128, verbose_name='Application display'), + ), + migrations.AddField( + model_name='connectiontoken', + name='asset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='assets.asset', verbose_name='Asset'), + ), + migrations.AddField( + model_name='connectiontoken', + name='asset_display', + field=models.CharField(default='', max_length=128, verbose_name='Asset display'), + ), + migrations.AddField( + model_name='connectiontoken', + name='date_expired', + field=models.DateTimeField(default=authentication.models.date_expired_default, verbose_name='Date expired'), + ), + migrations.AddField( + model_name='connectiontoken', + name='org_id', + field=models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization'), + ), + migrations.AddField( + model_name='connectiontoken', + name='secret', + field=models.CharField(default='', max_length=64, verbose_name='Secret'), + ), + migrations.AddField( + model_name='connectiontoken', + name='system_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='assets.systemuser', verbose_name='System user'), + ), + migrations.AddField( + model_name='connectiontoken', + name='system_user_display', + field=models.CharField(default='', max_length=128, verbose_name='System user display'), + ), + migrations.AddField( + model_name='connectiontoken', + name='type', + field=models.CharField(choices=[('asset', 'Asset'), ('application', 'Application')], default='asset', max_length=16, verbose_name='Type'), + ), + migrations.AddField( + model_name='connectiontoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AddField( + model_name='connectiontoken', + name='user_display', + field=models.CharField(default='', max_length=128, verbose_name='User display'), + ), + migrations.AlterField( + model_name='connectiontoken', + name='id', + field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), + ), + migrations.AlterModelOptions( + name='connectiontoken', + options={'ordering': ('-date_expired',), 'permissions': [('view_connectiontokensecret', 'Can view connection token secret')], 'verbose_name': 'Connection token'}, + ), + ] diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 56216e91f..403f7186d 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -23,9 +23,7 @@ from acls.models import LoginACL from users.models import User from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil from . import errors -from .utils import rsa_decrypt, gen_key_pair from .signals import post_auth_success, post_auth_failed -from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY logger = get_logger(__name__) @@ -58,6 +56,7 @@ def authenticate(request=None, **credentials): for backend, backend_path in _get_backends(return_tuples=True): # 检查用户名是否允许认证 (预先检查,不浪费认证时间) + logger.info('Try using auth backend: {}'.format(str(backend))) if not backend.username_allow_authenticate(username): continue @@ -91,46 +90,8 @@ def authenticate(request=None, **credentials): auth.authenticate = authenticate -class PasswordEncryptionViewMixin: - request = None - - def get_decrypted_password(self, password=None, username=None): - request = self.request - if hasattr(request, 'data'): - data = request.data - else: - data = request.POST - - username = username or data.get('username') - password = password or data.get('password') - - password = self.decrypt_passwd(password) - if not password: - self.raise_password_decrypt_failed(username=username) - return password - - def raise_password_decrypt_failed(self, username): - ip = self.get_request_ip() - raise errors.CredentialError( - error=errors.reason_password_decrypt_failed, - username=username, ip=ip, request=self.request - ) - - def decrypt_passwd(self, raw_passwd): - # 获取解密密钥,对密码进行解密 - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if rsa_private_key is None: - return raw_passwd - - try: - return rsa_decrypt(raw_passwd, rsa_private_key) - except Exception as e: - logger.error(e, exc_info=True) - logger.error( - f'Decrypt password failed: password[{raw_passwd}] ' - f'rsa_private_key[{rsa_private_key}]' - ) - return None +class CommonMixin: + request: Request def get_request_ip(self): ip = '' @@ -139,26 +100,6 @@ class PasswordEncryptionViewMixin: ip = ip or get_request_ip(self.request) return ip - def get_context_data(self, **kwargs): - # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 - rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if not all([rsa_private_key, rsa_public_key]): - rsa_private_key, rsa_public_key = gen_key_pair() - rsa_public_key = rsa_public_key.replace('\n', '\\n') - self.request.session[RSA_PRIVATE_KEY] = rsa_private_key - self.request.session[RSA_PUBLIC_KEY] = rsa_public_key - - kwargs.update({ - 'rsa_public_key': rsa_public_key, - }) - return super().get_context_data(**kwargs) - - -class CommonMixin(PasswordEncryptionViewMixin): - request: Request - get_request_ip: Callable - def raise_credential_error(self, error): raise self.partial_credential_error(error=error) @@ -193,20 +134,13 @@ class CommonMixin(PasswordEncryptionViewMixin): user.backend = self.request.session.get("auth_backend") return user - def get_auth_data(self, decrypt_passwd=False): + def get_auth_data(self, data): request = self.request - if hasattr(request, 'data'): - data = request.data - else: - data = request.POST items = ['username', 'password', 'challenge', 'public_key', 'auto_login'] username, password, challenge, public_key, auto_login = bulk_get(data, items, default='') ip = self.get_request_ip() self._set_partial_credential_error(username=username, ip=ip, request=request) - - if decrypt_passwd: - password = self.get_decrypted_password() password = password + challenge.strip() return username, password, public_key, ip, auto_login @@ -259,8 +193,8 @@ class MFAMixin: def _check_if_no_active_mfa(self, user): active_mfa_mapper = user.active_mfa_backends_mapper if not active_mfa_mapper: - url = reverse('authentication:user-otp-enable-start') - raise errors.MFAUnsetError(user, self.request, url) + set_url = reverse('authentication:user-otp-enable-start') + raise errors.MFAUnsetError(set_url, user, self.request) def _check_login_page_mfa_if_need(self, user): if not settings.SECURITY_MFA_IN_LOGIN_PAGE: @@ -403,18 +337,18 @@ class AuthACLMixin: raise errors.TimePeriodNotAllowed(username=user.username, request=self.request) def get_ticket(self): - from tickets.models import Ticket + from tickets.models import ApplyLoginTicket 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 = Ticket.all().filter(id=ticket_id).first() + ticket = ApplyLoginTicket.all().filter(id=ticket_id).first() return ticket def get_ticket_or_create(self, confirm_setting): ticket = self.get_ticket() - if not ticket or ticket.status_closed: + if not ticket or ticket.is_status(ticket.Status.closed): ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) return ticket @@ -423,16 +357,17 @@ class AuthACLMixin: ticket = self.get_ticket() if not ticket: raise errors.LoginConfirmOtherError('', "Not found") - if ticket.status_open: + + if ticket.is_status(ticket.Status.open): raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.state_approve: + elif ticket.is_state(ticket.State.approved): self.request.session["auth_confirm"] = "1" return - elif ticket.state_reject: + elif ticket.is_state(ticket.State.rejected): raise errors.LoginConfirmOtherError( ticket.id, ticket.get_state_display() ) - elif ticket.state_close: + elif ticket.is_state(ticket.State.closed): raise errors.LoginConfirmOtherError( ticket.id, ticket.get_state_display() ) @@ -482,10 +417,10 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost need = cache.get(self.key_prefix_captcha.format(ip)) return need - def check_user_auth(self, decrypt_passwd=False): + def check_user_auth(self, valid_data=None): # pre check self.check_is_block() - username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd) + username, password, public_key, ip, auto_login = self.get_auth_data(valid_data) self._check_only_allow_exists_user_auth(username) # check auth @@ -508,13 +443,15 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost LoginIpBlockUtil(ip).clean_block_if_need() return user - def mark_password_ok(self, user, auto_login=False): + def mark_password_ok(self, user, auto_login=False, auth_backend=None): request = self.request request.session['auth_password'] = 1 request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS request.session['user_id'] = str(user.id) request.session['auto_login'] = auto_login - request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) + if not auth_backend: + auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) + request.session['auth_backend'] = auth_backend def check_oauth2_auth(self, user: User, auth_backend): ip = self.get_request_ip() @@ -534,14 +471,15 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost LoginIpBlockUtil(ip).clean_block_if_need() MFABlockUtils(user.username, ip).clean_failed_count() - self.mark_password_ok(user, False) + self.mark_password_ok(user, False, auth_backend) return user - def check_user_auth_if_need(self, decrypt_passwd=False): + def get_user_or_auth(self, valid_data): request = self.request - if not request.session.get('auth_password'): - return self.check_user_auth(decrypt_passwd=decrypt_passwd) - return self.get_user_from_session() + if request.session.get('auth_password'): + return self.get_user_from_session() + else: + return self.check_user_auth(valid_data) def clear_auth_mark(self): keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id'] diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 3b469eb95..4c34f1d8e 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -1,11 +1,15 @@ import uuid - +from datetime import datetime, timedelta +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from rest_framework.authtoken.models import Token from django.conf import settings -from django.db import models +from rest_framework.authtoken.models import Token +from orgs.mixins.models import OrgModelMixin -from common.db.models import BaseCreateUpdateModel +from django.db import models +from common.utils import lazyproperty +from common.utils.timezone import as_current_tz +from common.db.models import BaseCreateUpdateModel, JMSBaseModel class AccessKey(models.Model): @@ -54,16 +58,218 @@ class SSOToken(BaseCreateUpdateModel): verbose_name = _('SSO token') -class ConnectionToken(BaseCreateUpdateModel): - # Todo: 未来可能放到这里,不记录到 redis 了,虽然方便,但是不易于审计 - # Todo: add connection token 可能要授权给 普通用户, 或者放开就行 +def date_expired_default(): + return timezone.now() + timedelta(seconds=settings.CONNECTION_TOKEN_EXPIRATION) + + +class ConnectionToken(OrgModelMixin, JMSBaseModel): + class Type(models.TextChoices): + asset = 'asset', _('Asset') + application = 'application', _('Application') + + type = models.CharField( + max_length=16, default=Type.asset, choices=Type.choices, verbose_name=_("Type") + ) + secret = models.CharField(max_length=64, default='', verbose_name=_("Secret")) + date_expired = models.DateTimeField( + default=date_expired_default, verbose_name=_("Date expired") + ) + + user = models.ForeignKey( + 'users.User', on_delete=models.SET_NULL, verbose_name=_('User'), + related_name='connection_tokens', null=True, blank=True + ) + user_display = models.CharField(max_length=128, default='', verbose_name=_("User display")) + system_user = models.ForeignKey( + 'assets.SystemUser', on_delete=models.SET_NULL, verbose_name=_('System user'), + related_name='connection_tokens', null=True, blank=True + ) + system_user_display = models.CharField( + max_length=128, default='', verbose_name=_("System user display") + ) + asset = models.ForeignKey( + 'assets.Asset', on_delete=models.SET_NULL, verbose_name=_('Asset'), + related_name='connection_tokens', null=True, blank=True + ) + asset_display = models.CharField(max_length=128, default='', verbose_name=_("Asset display")) + application = models.ForeignKey( + 'applications.Application', on_delete=models.SET_NULL, verbose_name=_('Application'), + related_name='connection_tokens', null=True, blank=True + ) + application_display = models.CharField( + max_length=128, default='', verbose_name=_("Application display") + ) class Meta: + ordering = ('-date_expired',) verbose_name = _('Connection token') permissions = [ ('view_connectiontokensecret', _('Can view connection token secret')) ] + @classmethod + def get_default_date_expired(cls): + return date_expired_default() + + @property + def is_expired(self): + return self.date_expired < timezone.now() + + @property + def expire_time(self): + interval = self.date_expired - timezone.now() + seconds = interval.total_seconds() + if seconds < 0: + seconds = 0 + return int(seconds) + + def expire(self): + self.date_expired = timezone.now() + self.save() + + @property + def is_valid(self): + return not self.is_expired + + def is_type(self, tp): + return self.type == tp + + def renewal(self): + """ 续期 Token,将来支持用户自定义创建 token 后,续期策略要修改 """ + self.date_expired = self.get_default_date_expired() + self.save() + + actions = expired_at = None # actions 和 expired_at 在 check_valid() 中赋值 + + def check_valid(self): + from perms.utils.asset.permission import validate_permission as asset_validate_permission + from perms.utils.application.permission import validate_permission as app_validate_permission + + if self.is_expired: + is_valid = False + error = _('Connection token expired at: {}').format(as_current_tz(self.date_expired)) + return is_valid, error + + if not self.user: + is_valid = False + error = _('User not exists') + return is_valid, error + if not self.user.is_valid: + is_valid = False + error = _('User invalid, disabled or expired') + return is_valid, error + + if not self.system_user: + is_valid = False + error = _('System user not exists') + return is_valid, error + + if self.is_type(self.Type.asset): + if not self.asset: + is_valid = False + error = _('Asset not exists') + return is_valid, error + if not self.asset.is_active: + is_valid = False + error = _('Asset inactive') + return is_valid, error + has_perm, actions, expired_at = asset_validate_permission( + self.user, self.asset, self.system_user + ) + if not has_perm: + is_valid = False + error = _('User has no permission to access asset or permission expired') + return is_valid, error + self.actions = actions + self.expired_at = expired_at + + elif self.is_type(self.Type.application): + if not self.application: + is_valid = False + error = _('Application not exists') + return is_valid, error + has_perm, actions, expired_at = app_validate_permission( + self.user, self.application, self.system_user + ) + if not has_perm: + is_valid = False + error = _('User has no permission to access application or permission expired') + return is_valid, error + self.actions = actions + self.expired_at = expired_at + + return True, '' + + @lazyproperty + def domain(self): + if self.asset: + return self.asset.domain + if not self.application: + return + if self.application.category_remote_app: + asset = self.application.get_remote_app_asset() + domain = asset.domain if asset else None + else: + domain = self.application.domain + return domain + + @lazyproperty + def gateway(self): + from assets.models import Domain + if not self.domain: + return + self.domain: Domain + return self.domain.random_gateway() + + @lazyproperty + def remote_app(self): + if not self.application: + return {} + if not self.application.category_remote_app: + return {} + return self.application.get_rdp_remote_app_setting() + + @lazyproperty + def cmd_filter_rules(self): + from assets.models import CommandFilterRule + kwargs = { + 'user_id': self.user.id, + 'system_user_id': self.system_user.id, + } + if self.asset: + kwargs['asset_id'] = self.asset.id + elif self.application: + kwargs['application_id'] = self.application_id + rules = CommandFilterRule.get_queryset(**kwargs) + return rules + + def load_system_user_auth(self): + if self.asset: + self.system_user.load_asset_more_auth(self.asset.id, self.user.username, self.user.id) + elif self.application: + self.system_user.load_app_more_auth(self.application.id, self.user.username, self.user.id) + + +class TempToken(JMSBaseModel): + username = models.CharField(max_length=128, verbose_name=_("Username")) + secret = models.CharField(max_length=64, verbose_name=_("Secret")) + verified = models.BooleanField(default=False, verbose_name=_("Verified")) + date_verified = models.DateTimeField(null=True, verbose_name=_("Date verified")) + date_expired = models.DateTimeField(verbose_name=_("Date expired")) + + class Meta: + verbose_name = _("Temporary token") + + @property + def user(self): + from users.models import User + return User.objects.filter(username=self.username).first() + + @property + def is_valid(self): + not_expired = self.date_expired and self.date_expired > timezone.now() + return not self.verified and not_expired + class SuperConnectionToken(ConnectionToken): class Meta: diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py deleted file mode 100644 index f6661ba38..000000000 --- a/apps/authentication/serializers.py +++ /dev/null @@ -1,227 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.utils import timezone -from rest_framework import serializers - -from common.utils import get_object_or_none -from users.models import User -from assets.models import Asset, SystemUser, Gateway, Domain, CommandFilterRule -from applications.models import Application -from users.serializers import UserProfileSerializer -from assets.serializers import ProtocolsField -from perms.serializers.base import ActionsField -from .models import AccessKey - -__all__ = [ - 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', - 'MFAChallengeSerializer', 'SSOTokenSerializer', - 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', - 'PasswordVerifySerializer', 'MFASelectTypeSerializer', -] - - -class AccessKeySerializer(serializers.ModelSerializer): - class Meta: - model = AccessKey - fields = ['id', 'secret', 'is_active', 'date_created'] - read_only_fields = ['id', 'secret', 'date_created'] - - -class OtpVerifySerializer(serializers.Serializer): - code = serializers.CharField(max_length=6, min_length=6) - - -class PasswordVerifySerializer(serializers.Serializer): - password = serializers.CharField() - - -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 update_last_login(self, user): - user.last_login = timezone.now() - user.save(update_fields=['last_login']) - - def get_request_user(self): - request = self.context.get('request') - if request.user and request.user.is_authenticated: - 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) - ) - return user - - def create(self, validated_data): - request = self.context.get('request') - user = self.get_request_user() - - token, date_expired = user.create_bearer_token(request) - self.update_last_login(user) - - instance = { - "token": token, - "date_expired": date_expired, - "user": user - } - return instance - - -class MFASelectTypeSerializer(serializers.Serializer): - type = serializers.CharField() - username = serializers.CharField(required=False, allow_blank=True, allow_null=True) - - -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 SSOTokenSerializer(serializers.Serializer): - username = serializers.CharField(write_only=True) - login_url = serializers.CharField(read_only=True) - next = serializers.CharField(write_only=True, allow_blank=True, required=False, allow_null=True) - - -class ConnectionTokenSerializer(serializers.Serializer): - user = serializers.CharField(max_length=128, required=False, allow_blank=True) - system_user = serializers.CharField(max_length=128, required=True) - asset = serializers.CharField(max_length=128, required=False) - application = serializers.CharField(max_length=128, required=False) - - @staticmethod - def validate_user(user_id): - from users.models import User - user = User.objects.filter(id=user_id).first() - if user is None: - raise serializers.ValidationError('user id not exist') - return user - - @staticmethod - def validate_system_user(system_user_id): - from assets.models import SystemUser - system_user = SystemUser.objects.filter(id=system_user_id).first() - if system_user is None: - raise serializers.ValidationError('system_user id not exist') - return system_user - - @staticmethod - def validate_asset(asset_id): - from assets.models import Asset - asset = Asset.objects.filter(id=asset_id).first() - if asset is None: - raise serializers.ValidationError('asset id not exist') - return asset - - @staticmethod - def validate_application(app_id): - from applications.models import Application - app = Application.objects.filter(id=app_id).first() - if app is None: - raise serializers.ValidationError('app id not exist') - return app - - def validate(self, attrs): - asset = attrs.get('asset') - application = attrs.get('application') - if not asset and not application: - raise serializers.ValidationError('asset or application required') - if asset and application: - raise serializers.ValidationError('asset and application should only one') - return super().validate(attrs) - - -class ConnectionTokenUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ['id', 'name', 'username', 'email'] - - -class ConnectionTokenAssetSerializer(serializers.ModelSerializer): - protocols = ProtocolsField(label='Protocols', read_only=True) - - class Meta: - model = Asset - fields = ['id', 'hostname', 'ip', 'protocols', 'org_id'] - - -class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer): - class Meta: - model = SystemUser - fields = ['id', 'name', 'username', 'password', 'private_key', 'protocol', 'ad_domain', 'org_id'] - - -class ConnectionTokenGatewaySerializer(serializers.ModelSerializer): - class Meta: - model = Gateway - fields = ['id', 'ip', 'port', 'username', 'password', 'private_key'] - - -class ConnectionTokenRemoteAppSerializer(serializers.Serializer): - program = serializers.CharField() - working_directory = serializers.CharField() - parameters = serializers.CharField() - - -class ConnectionTokenApplicationSerializer(serializers.ModelSerializer): - attrs = serializers.JSONField(read_only=True) - - class Meta: - model = Application - fields = ['id', 'name', 'category', 'type', 'attrs', 'org_id'] - - -class ConnectionTokenDomainSerializer(serializers.ModelSerializer): - gateways = ConnectionTokenGatewaySerializer(many=True, read_only=True) - - class Meta: - model = Domain - fields = ['id', 'name', 'gateways'] - - -class ConnectionTokenFilterRuleSerializer(serializers.ModelSerializer): - - class Meta: - model = CommandFilterRule - fields = [ - 'id', 'type', 'content', 'ignore_case', 'pattern', - 'priority', 'action', - 'date_created', - ] - - -class ConnectionTokenSecretSerializer(serializers.Serializer): - id = serializers.CharField(read_only=True) - secret = serializers.CharField(read_only=True) - type = serializers.ChoiceField(choices=[('application', 'Application'), ('asset', 'Asset')]) - user = ConnectionTokenUserSerializer(read_only=True) - asset = ConnectionTokenAssetSerializer(read_only=True) - remote_app = ConnectionTokenRemoteAppSerializer(read_only=True) - application = ConnectionTokenApplicationSerializer(read_only=True) - system_user = ConnectionTokenSystemUserSerializer(read_only=True) - cmd_filter_rules = ConnectionTokenFilterRuleSerializer(many=True) - domain = ConnectionTokenDomainSerializer(read_only=True) - gateway = ConnectionTokenGatewaySerializer(read_only=True) - actions = ActionsField() - expired_at = serializers.IntegerField() diff --git a/apps/authentication/serializers/__init__.py b/apps/authentication/serializers/__init__.py new file mode 100644 index 000000000..65994a58c --- /dev/null +++ b/apps/authentication/serializers/__init__.py @@ -0,0 +1,4 @@ +from .token import * +from .connection_token import * +from .password_mfa import * +from .confirm import * diff --git a/apps/authentication/serializers/confirm.py b/apps/authentication/serializers/confirm.py new file mode 100644 index 000000000..9c0da463c --- /dev/null +++ b/apps/authentication/serializers/confirm.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from common.drf.fields import EncryptedField +from ..const import ConfirmType, MFAType + + +class ConfirmSerializer(serializers.Serializer): + confirm_type = serializers.ChoiceField(required=True, allow_blank=True, choices=ConfirmType.choices) + mfa_type = serializers.ChoiceField(required=False, allow_blank=True, choices=MFAType.choices) + secret_key = EncryptedField(allow_blank=True) diff --git a/apps/authentication/serializers/connection_token.py b/apps/authentication/serializers/connection_token.py new file mode 100644 index 000000000..1b639bec6 --- /dev/null +++ b/apps/authentication/serializers/connection_token.py @@ -0,0 +1,195 @@ +from rest_framework import serializers + +from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from authentication.models import ConnectionToken +from common.utils import pretty_string +from common.utils.random import random_string +from assets.models import Asset, SystemUser, Gateway, Domain, CommandFilterRule +from users.models import User +from applications.models import Application +from assets.serializers import ProtocolsField +from perms.serializers.base import ActionsField + + +__all__ = [ + 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', + 'SuperConnectionTokenSerializer', 'ConnectionTokenDisplaySerializer' +] + + +class ConnectionTokenSerializer(OrgResourceModelSerializerMixin): + type_display = serializers.ReadOnlyField(source='get_type_display', label=_("Type display")) + is_valid = serializers.BooleanField(read_only=True, label=_('Validity')) + expire_time = serializers.IntegerField(read_only=True, label=_('Expired time')) + + class Meta: + model = ConnectionToken + fields_mini = ['id', 'type'] + fields_small = fields_mini + [ + 'secret', 'date_expired', + 'date_created', 'date_updated', 'created_by', 'updated_by', + 'org_id', 'org_name', + ] + fields_fk = [ + 'user', 'system_user', 'asset', 'application', + ] + read_only_fields = [ + # 普通 Token 不支持指定 user + 'user', 'is_valid', 'expire_time', + 'type_display', 'user_display', 'system_user_display', 'asset_display', + 'application_display', + ] + fields = fields_small + fields_fk + read_only_fields + + def validate(self, attrs): + fields_attrs = self.construct_internal_fields_attrs(attrs) + attrs.update(fields_attrs) + return attrs + + @property + def request_user(self): + request = self.context.get('request') + if request: + return request.user + + def get_user(self, attrs): + return self.request_user + + def construct_internal_fields_attrs(self, attrs): + user = self.get_user(attrs) + system_user = attrs.get('system_user') or '' + asset = attrs.get('asset') or '' + application = attrs.get('application') or '' + secret = attrs.get('secret') or random_string(64) + date_expired = attrs.get('date_expired') or ConnectionToken.get_default_date_expired() + + if isinstance(asset, Asset): + tp = ConnectionToken.Type.asset + org_id = asset.org_id + elif isinstance(application, Application): + tp = ConnectionToken.Type.application + org_id = application.org_id + else: + raise serializers.ValidationError(_('Asset or application required')) + + return { + 'type': tp, + 'user': user, + 'secret': secret, + 'date_expired': date_expired, + 'user_display': pretty_string(str(user), max_length=128), + 'system_user_display': pretty_string(str(system_user), max_length=128), + 'asset_display': pretty_string(str(asset), max_length=128), + 'application_display': pretty_string(str(application), max_length=128), + 'org_id': org_id, + } + + +class ConnectionTokenDisplaySerializer(ConnectionTokenSerializer): + class Meta(ConnectionTokenSerializer.Meta): + extra_kwargs = { + 'secret': {'write_only': True}, + } + + +# +# SuperConnectionTokenSerializer +# + + +class SuperConnectionTokenSerializer(ConnectionTokenSerializer): + + class Meta(ConnectionTokenSerializer.Meta): + read_only_fields = [ + 'validity', + 'user_display', 'system_user_display', 'asset_display', 'application_display', + ] + + def get_user(self, attrs): + return attrs.get('user') or self.request_user + + +# +# Connection Token Secret +# + + +class ConnectionTokenUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'name', 'username', 'email'] + + +class ConnectionTokenAssetSerializer(serializers.ModelSerializer): + protocols = ProtocolsField(label='Protocols', read_only=True) + + class Meta: + model = Asset + fields = ['id', 'hostname', 'ip', 'protocols', 'org_id'] + + +class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer): + class Meta: + model = SystemUser + fields = [ + 'id', 'name', 'username', 'password', 'private_key', + 'protocol', 'ad_domain', 'org_id' + ] + + +class ConnectionTokenGatewaySerializer(serializers.ModelSerializer): + class Meta: + model = Gateway + fields = ['id', 'ip', 'port', 'username', 'password', 'private_key'] + + +class ConnectionTokenRemoteAppSerializer(serializers.Serializer): + program = serializers.CharField(allow_null=True, allow_blank=True) + working_directory = serializers.CharField(allow_null=True, allow_blank=True) + parameters = serializers.CharField(allow_null=True, allow_blank=True) + + +class ConnectionTokenApplicationSerializer(serializers.ModelSerializer): + attrs = serializers.JSONField(read_only=True) + + class Meta: + model = Application + fields = ['id', 'name', 'category', 'type', 'attrs', 'org_id'] + + +class ConnectionTokenDomainSerializer(serializers.ModelSerializer): + gateways = ConnectionTokenGatewaySerializer(many=True, read_only=True) + + class Meta: + model = Domain + fields = ['id', 'name', 'gateways'] + + +class ConnectionTokenCmdFilterRuleSerializer(serializers.ModelSerializer): + class Meta: + model = CommandFilterRule + fields = [ + 'id', 'type', 'content', 'ignore_case', 'pattern', + 'priority', 'action', 'date_created', + ] + + +class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin): + user = ConnectionTokenUserSerializer(read_only=True) + asset = ConnectionTokenAssetSerializer(read_only=True) + application = ConnectionTokenApplicationSerializer(read_only=True) + remote_app = ConnectionTokenRemoteAppSerializer(read_only=True) + system_user = ConnectionTokenSystemUserSerializer(read_only=True) + gateway = ConnectionTokenGatewaySerializer(read_only=True) + domain = ConnectionTokenDomainSerializer(read_only=True) + cmd_filter_rules = ConnectionTokenCmdFilterRuleSerializer(many=True) + actions = ActionsField() + expired_at = serializers.IntegerField() + + class Meta: + model = ConnectionToken + fields = [ + 'id', 'secret', 'type', 'user', 'asset', 'application', 'system_user', + 'remote_app', 'cmd_filter_rules', 'domain', 'gateway', 'actions', 'expired_at', + ] diff --git a/apps/authentication/serializers/password_mfa.py b/apps/authentication/serializers/password_mfa.py new file mode 100644 index 000000000..f52274e49 --- /dev/null +++ b/apps/authentication/serializers/password_mfa.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import serializers + +from common.drf.fields import EncryptedField + +__all__ = [ + 'MFAChallengeSerializer', 'MFASelectTypeSerializer', + 'PasswordVerifySerializer', +] + + +class PasswordVerifySerializer(serializers.Serializer): + password = EncryptedField() + + +class MFASelectTypeSerializer(serializers.Serializer): + type = serializers.CharField() + username = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + +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 diff --git a/apps/authentication/serializers/token.py b/apps/authentication/serializers/token.py new file mode 100644 index 000000000..d1e87c0c0 --- /dev/null +++ b/apps/authentication/serializers/token.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from common.utils import get_object_or_none, random_string +from users.models import User +from users.serializers import UserProfileSerializer +from ..models import AccessKey, TempToken + +__all__ = [ + 'AccessKeySerializer', 'BearerTokenSerializer', + 'SSOTokenSerializer', 'TempTokenSerializer', +] + + +class AccessKeySerializer(serializers.ModelSerializer): + class Meta: + model = AccessKey + fields = ['id', 'secret', 'is_active', 'date_created'] + read_only_fields = ['id', 'secret', 'date_created'] + + +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 update_last_login(self, user): + user.last_login = timezone.now() + user.save(update_fields=['last_login']) + + def get_request_user(self): + request = self.context.get('request') + if request.user and request.user.is_authenticated: + 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) + ) + return user + + def create(self, validated_data): + request = self.context.get('request') + user = self.get_request_user() + + token, date_expired = user.create_bearer_token(request) + self.update_last_login(user) + + instance = { + "token": token, + "date_expired": date_expired, + "user": user + } + return instance + + +class SSOTokenSerializer(serializers.Serializer): + username = serializers.CharField(write_only=True) + login_url = serializers.CharField(read_only=True) + next = serializers.CharField(write_only=True, allow_blank=True, required=False, allow_null=True) + + +class TempTokenSerializer(serializers.ModelSerializer): + is_valid = serializers.BooleanField(label=_("Is valid"), read_only=True) + + class Meta: + model = TempToken + fields = [ + 'id', 'username', 'secret', 'verified', 'is_valid', + 'date_created', 'date_updated', 'date_verified', + 'date_expired', + ] + read_only_fields = fields + + def create(self, validated_data): + request = self.context.get('request') + if not request or not request.user: + raise PermissionError() + + secret = random_string(36) + username = request.user.username + kwargs = { + 'username': username, 'secret': secret, + 'date_expired': timezone.now() + timezone.timedelta(seconds=5*60), + } + token = TempToken(**kwargs) + token.save() + return token diff --git a/apps/authentication/signal_handlers.py b/apps/authentication/signal_handlers.py index 0d2a617f9..ac155dcf0 100644 --- a/apps/authentication/signal_handlers.py +++ b/apps/authentication/signal_handlers.py @@ -35,6 +35,9 @@ def on_user_auth_login_success(sender, user, request, **kwargs): session.delete() cache.set(lock_key, request.session.session_key, None) + # 标记登录,设置 cookie,前端可以控制刷新, Middleware 会拦截这个生成 cookie + request.session['auth_session_expiration_required'] = 1 + @receiver(openid_user_login_success) def on_oidc_user_login_success(sender, request, user, create=False, **kwargs): diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 677ee70d4..27ef9d61e 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -5,16 +5,16 @@ - + - {{ JMS_TITLE }} + {{ INTERFACE.login_title }} {% include '_head_css_js.html' %} - + - -