feat(authentication): 类似腾讯企业邮单点登录功能

pull/4438/head
xinwen 2020-07-31 18:18:52 +08:00 committed by 老广
parent 4e7a5d8d4f
commit 90f03dda62
17 changed files with 329 additions and 57 deletions

View File

@ -6,3 +6,4 @@ from .token import *
from .mfa import *
from .access_key import *
from .login_confirm import *
from .sso import *

View File

@ -0,0 +1,77 @@
from uuid import UUID
from urllib.parse import urlencode
from django.contrib.auth import login
from django.conf import settings
from django.http.response import HttpResponseRedirect
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.request import Request
from common.utils.timezone import utcnow
from common.const.http import POST, GET
from common.drf.api import JmsGenericViewSet
from common.drf.serializers import EmptySerializer
from common.permissions import IsSuperUser
from common.utils import reverse
from users.models import User
from ..serializers import SSOTokenSerializer
from ..models import SSOToken
from ..filters import AuthKeyQueryDeclaration
from ..mixins import AuthMixin
from ..errors import SSOAuthClosed
class SSOViewSet(AuthMixin, JmsGenericViewSet):
queryset = SSOToken.objects.all()
serializer_classes = {
'get_login_url': SSOTokenSerializer,
'login': EmptySerializer
}
@action(methods=[POST], detail=False, permission_classes=[IsSuperUser])
def get_login_url(self, request, *args, **kwargs):
if not settings.AUTH_SSO:
raise SSOAuthClosed()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
username = serializer.validated_data['username']
user = User.objects.get(username=username)
operator = request.user.username
# TODO `created_by` 和 `created_by` 可以通过 `ThreadLocal` 统一处理
token = SSOToken.objects.create(user=user, created_by=operator, updated_by=operator)
query = {
'authkey': token.authkey
}
login_url = '%s?%s' % (reverse('api-auth:sso-login', external=True), urlencode(query))
return Response(data={'login_url': login_url})
@action(methods=[GET], detail=False, filter_backends=[AuthKeyQueryDeclaration], permission_classes=[])
def login(self, request: Request, *args, **kwargs):
"""
此接口违反了 `Restful` 的规范
`GET` 应该是安全的方法但此接口是不安全的
"""
authkey = request.query_params.get('authkey')
try:
authkey = UUID(authkey)
token = SSOToken.objects.get(authkey=authkey, expired=False)
# 先过期,只能访问这一次
token.expired = True
token.save()
except (ValueError, SSOToken.DoesNotExist):
self.send_auth_signal(success=False, reason=f'authkey invalid: {authkey}')
return HttpResponseRedirect(reverse('authentication:login'))
# 判断是否过期
if (utcnow().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL:
self.send_auth_signal(success=False, reason=f'authkey timeout: {authkey}')
return HttpResponseRedirect(reverse('authentication:login'))
user = token.user
login(self.request, user, 'authentication.backends.api.SSOAuthentication')
self.send_auth_signal(success=True, user=user)
return HttpResponseRedirect(reverse('index'))

View File

@ -5,14 +5,13 @@ import uuid
import time
from django.core.cache import cache
from django.conf import settings
from django.utils.translation import ugettext as _
from django.utils.six import text_type
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from rest_framework import HTTP_HEADER_ENCODING
from rest_framework import authentication, exceptions
from common.auth import signature
from rest_framework.authentication import CSRFCheck
from common.utils import get_object_or_none, make_signature, http_to_unixtime
from ..models import AccessKey, PrivateToken
@ -197,3 +196,10 @@ class SignatureAuthentication(signature.SignatureAuthentication):
return user, secret
except AccessKey.DoesNotExist:
return None, None
class SSOAuthentication(ModelBackend):
"""
什么也不做呀😺
"""
pass

View File

@ -4,6 +4,7 @@ from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import (
increase_login_failed_count, get_login_failed_count
@ -205,3 +206,8 @@ class LoginConfirmOtherError(LoginConfirmBaseError):
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')

View File

@ -0,0 +1,15 @@
from rest_framework import filters
from rest_framework.compat import coreapi, coreschema
class AuthKeyQueryDeclaration(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
name='authkey', location='query', required=True, type='string',
schema=coreschema.String(
title='authkey',
description='authkey'
)
)
]

View File

@ -0,0 +1,32 @@
# Generated by Django 2.2.10 on 2020-07-31 08:36
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0003_loginconfirmsetting'),
]
operations = [
migrations.CreateModel(
name='SSOToken',
fields=[
('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')),
('authkey', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='Token')),
('expired', models.BooleanField(default=False, verbose_name='Expired')),
('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,10 +1,13 @@
import uuid
from django.db import models
from functools import partial
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __
from rest_framework.authtoken.models import Token
from django.conf import settings
from django.utils.crypto import get_random_string
from common.db import models
from common.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city
@ -76,3 +79,12 @@ class LoginConfirmSetting(CommonModelMixin):
def __str__(self):
return '{} confirm'.format(self.user.username)
class SSOToken(models.JMSBaseModel):
"""
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)
出于安全考虑这里的 `token` 使用一次随即过期但我们保留每一个生成过的 `token`
"""
authkey = models.UUIDField(primary_key=True, default=uuid.uuid4, verbose_name=_('Token'))
expired = models.BooleanField(default=False, verbose_name=_('Expired'))
user = models.ForeignKey('users.User', on_delete=models.PROTECT, verbose_name=_('User'), db_constraint=False)

View File

@ -5,12 +5,12 @@ from rest_framework import serializers
from common.utils import get_object_or_none
from users.models import User
from users.serializers import UserProfileSerializer
from .models import AccessKey, LoginConfirmSetting
from .models import AccessKey, LoginConfirmSetting, SSOToken
__all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
]
@ -76,3 +76,8 @@ class LoginConfirmSettingSerializer(serializers.ModelSerializer):
model = LoginConfirmSetting
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
read_only_fields = ['date_created', 'date_updated']
class SSOTokenSerializer(serializers.Serializer):
username = serializers.CharField(write_only=True)
login_url = serializers.CharField(read_only=True)

View File

@ -8,6 +8,7 @@ from .. import api
app_name = 'authentication'
router = DefaultRouter()
router.register('access-keys', api.AccessKeyViewSet, 'access-key')
router.register('sso', api.SSOViewSet, 'sso')
urlpatterns = [

View File

@ -1,4 +1,18 @@
from functools import partial
"""
此文件作为 `django.db.models` shortcut
这样做的优点与缺点为
优点
- 包命名都统一为 `models`
- 用户在使用的时候只导入本文件即可
缺点
- 此文件中添加代码的时候注意不要跟 `django.db.models` 中的命名冲突
"""
import uuid
from django.db.models import *
from django.utils.translation import ugettext_lazy as _
class Choice(str):
@ -46,3 +60,20 @@ class ChoiceSetType(type):
class ChoiceSet(metaclass=ChoiceSetType):
choices = None # 用于 Django Model 中的 choices 配置, 为了代码提示在此声明
class JMSBaseModel(Model):
created_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
updated_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Updated by'))
date_created = DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
date_updated = DateTimeField(auto_now=True, verbose_name=_('Date updated'))
class Meta:
abstract = True
class JMSModel(JMSBaseModel):
id = UUIDField(default=uuid.uuid4, primary_key=True)
class Meta:
abstract = True

View File

@ -0,0 +1,45 @@
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist as DJObjectDoesNotExist
from django.http import Http404
from django.utils.translation import gettext
from rest_framework import exceptions
from rest_framework.views import set_rollback
from rest_framework.response import Response
from common.exceptions import JMSObjectDoesNotExist
def extract_object_name(exc, index=0):
"""
`index` 是从 0 开始数的 比如
`No User matches the given query.`
提取 `User``index=1`
"""
(msg, *_) = exc.args
return gettext(msg.split(sep=' ', maxsplit=index + 1)[index])
def common_exception_handler(exc, context):
if isinstance(exc, Http404):
exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 1))
elif isinstance(exc, PermissionDenied):
exc = exceptions.PermissionDenied()
elif isinstance(exc, DJObjectDoesNotExist):
exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 0))
if isinstance(exc, exceptions.APIException):
headers = {}
if getattr(exc, 'auth_header', None):
headers['WWW-Authenticate'] = exc.auth_header
if getattr(exc, 'wait', None):
headers['Retry-After'] = '%d' % exc.wait
if isinstance(exc.detail, (list, dict)):
data = exc.detail
else:
data = {'detail': exc.detail}
set_rollback()
return Response(data, status=exc.status_code, headers=headers)
return None

View File

@ -1,8 +1,20 @@
# -*- coding: utf-8 -*-
#
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException
from rest_framework import status
class JMSException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
class JMSObjectDoesNotExist(APIException):
status_code = status.HTTP_404_NOT_FOUND
default_code = 'object_does_not_exist'
default_detail = _('%s object does not exist.')
def __init__(self, detail=None, code=None, object_name=None):
if detail is None and object_name:
detail = self.default_detail % object_name
super(JMSObjectDoesNotExist, self).__init__(detail=detail, code=code)

View File

@ -211,6 +211,9 @@ class Config(dict):
'CAS_LOGOUT_COMPLETELY': True,
'CAS_VERSION': 3,
'AUTH_SSO': False,
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'jumpserver.org',
@ -440,6 +443,8 @@ class DynamicConfig:
backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend')
if self.static_config.get('AUTH_RADIUS'):
backends.insert(0, 'authentication.backends.radius.RadiusBackend')
if self.static_config.get('AUTH_SSO'):
backends.insert(0, 'authentication.backends.api.SSOAuthentication')
return backends
def XPACK_LICENSE_IS_VALID(self):

View File

@ -94,6 +94,9 @@ CAS_VERSION = CONFIG.CAS_VERSION
CAS_ROOT_PROXIED_AS = CONFIG.CAS_ROOT_PROXIED_AS
CAS_CHECK_NEXT = lambda: lambda _next_page: True
# SSO Auth
AUTH_SSO = CONFIG.AUTH_SSO
AUTH_SSO_AUTHKEY_TTL = CONFIG.AUTH_SSO_AUTHKEY_TTL
# Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION

View File

@ -40,6 +40,7 @@ REST_FRAMEWORK = {
'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z',
'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S %z'],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler',
# 'PAGE_SIZE': 100,
# 'MAX_PAGE_SIZE': 5000

Binary file not shown.

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: JumpServer 0.3.3\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-07-29 15:03+0800\n"
"POT-Creation-Date: 2020-07-31 19:20+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: ibuler <ibuler@qq.com>\n"
"Language-Team: JumpServer team<ibuler@qq.com>\n"
@ -131,9 +131,10 @@ msgstr "参数"
#: applications/models/remote_app.py:39 assets/models/asset.py:224
#: assets/models/base.py:240 assets/models/cluster.py:28
#: assets/models/cmd_filter.py:26 assets/models/cmd_filter.py:60
#: assets/models/group.py:21 common/mixins/models.py:49 orgs/models.py:23
#: orgs/models.py:316 perms/models/base.py:54 users/models/user.py:530
#: users/serializers/group.py:35 users/templates/users/user_detail.html:97
#: assets/models/group.py:21 common/db/models.py:66 common/mixins/models.py:49
#: orgs/models.py:23 orgs/models.py:316 perms/models/base.py:54
#: users/models/user.py:530 users/serializers/group.py:35
#: users/templates/users/user_detail.html:97
#: xpack/plugins/change_auth_plan/models.py:81 xpack/plugins/cloud/models.py:56
#: xpack/plugins/cloud/models.py:146 xpack/plugins/gathered_user/models.py:30
msgid "Created by"
@ -144,7 +145,7 @@ msgstr "创建者"
#: applications/models/remote_app.py:42 assets/models/asset.py:225
#: assets/models/base.py:238 assets/models/cluster.py:26
#: assets/models/domain.py:23 assets/models/gathered_user.py:19
#: assets/models/group.py:22 assets/models/label.py:25
#: assets/models/group.py:22 assets/models/label.py:25 common/db/models.py:68
#: common/mixins/models.py:50 ops/models/adhoc.py:38 ops/models/command.py:27
#: orgs/models.py:24 orgs/models.py:314 perms/models/base.py:55
#: users/models/group.py:18 users/templates/users/user_group_detail.html:58
@ -241,7 +242,7 @@ msgstr "节点"
#: assets/models/asset.py:196 assets/models/cmd_filter.py:22
#: assets/models/domain.py:55 assets/models/label.py:22
#: authentication/models.py:45
#: authentication/models.py:48
msgid "Is active"
msgstr "激活"
@ -379,7 +380,8 @@ msgid "SSH public key"
msgstr "SSH公钥"
#: assets/models/base.py:239 assets/models/gathered_user.py:20
#: common/mixins/models.py:51 ops/models/adhoc.py:39 orgs/models.py:315
#: common/db/models.py:69 common/mixins/models.py:51 ops/models/adhoc.py:39
#: orgs/models.py:315
msgid "Date updated"
msgstr "更新日期"
@ -534,9 +536,9 @@ msgid "Default asset group"
msgstr "默认资产组"
#: assets/models/label.py:15 audits/models.py:36 audits/models.py:56
#: audits/models.py:69 audits/serializers.py:77 authentication/models.py:43
#: orgs/models.py:16 orgs/models.py:312 perms/forms/asset_permission.py:83
#: perms/forms/database_app_permission.py:38
#: audits/models.py:69 audits/serializers.py:77 authentication/models.py:46
#: authentication/models.py:90 orgs/models.py:16 orgs/models.py:312
#: perms/forms/asset_permission.py:83 perms/forms/database_app_permission.py:38
#: perms/forms/remote_app_permission.py:40 perms/models/base.py:49
#: templates/index.html:78 terminal/backends/command/models.py:18
#: terminal/backends/command/serializers.py:12 terminal/models.py:185
@ -1042,94 +1044,94 @@ msgstr "运行用户"
msgid "Code is invalid"
msgstr "Code无效"
#: authentication/backends/api.py:53
#: authentication/backends/api.py:52
msgid "Invalid signature header. No credentials provided."
msgstr ""
#: authentication/backends/api.py:56
#: authentication/backends/api.py:55
msgid "Invalid signature header. Signature string should not contain spaces."
msgstr ""
#: authentication/backends/api.py:63
#: authentication/backends/api.py:62
msgid "Invalid signature header. Format like AccessKeyId:Signature"
msgstr ""
#: authentication/backends/api.py:67
#: authentication/backends/api.py:66
msgid ""
"Invalid signature header. Signature string should not contain invalid "
"characters."
msgstr ""
#: authentication/backends/api.py:87 authentication/backends/api.py:103
#: authentication/backends/api.py:86 authentication/backends/api.py:102
msgid "Invalid signature."
msgstr ""
#: authentication/backends/api.py:94
#: authentication/backends/api.py:93
msgid "HTTP header: Date not provide or not %a, %d %b %Y %H:%M:%S GMT"
msgstr ""
#: authentication/backends/api.py:99
#: authentication/backends/api.py:98
msgid "Expired, more than 15 minutes"
msgstr ""
#: authentication/backends/api.py:106
#: authentication/backends/api.py:105
msgid "User disabled."
msgstr "用户已禁用"
#: authentication/backends/api.py:124
#: authentication/backends/api.py:123
msgid "Invalid token header. No credentials provided."
msgstr ""
#: authentication/backends/api.py:127
#: authentication/backends/api.py:126
msgid "Invalid token header. Sign string should not contain spaces."
msgstr ""
#: authentication/backends/api.py:134
#: authentication/backends/api.py:133
msgid ""
"Invalid token header. Sign string should not contain invalid characters."
msgstr ""
#: authentication/backends/api.py:145
#: authentication/backends/api.py:144
msgid "Invalid token or cache refreshed."
msgstr ""
#: authentication/errors.py:22
#: authentication/errors.py:23
msgid "Username/password check failed"
msgstr "用户名/密码 校验失败"
#: authentication/errors.py:23
#: authentication/errors.py:24
msgid "Password decrypt failed"
msgstr "密码解密失败"
#: authentication/errors.py:24
#: authentication/errors.py:25
msgid "MFA failed"
msgstr "多因子认证失败"
#: authentication/errors.py:25
#: authentication/errors.py:26
msgid "MFA unset"
msgstr "多因子认证没有设定"
#: authentication/errors.py:26
#: authentication/errors.py:27
msgid "Username does not exist"
msgstr "用户名不存在"
#: authentication/errors.py:27
#: authentication/errors.py:28
msgid "Password expired"
msgstr "密码已过期"
#: authentication/errors.py:28
#: authentication/errors.py:29
msgid "Disabled or expired"
msgstr "禁用或失效"
#: authentication/errors.py:29
#: authentication/errors.py:30
msgid "This account is inactive."
msgstr "此账户已禁用"
#: authentication/errors.py:39
#: authentication/errors.py:40
msgid "No session found, check your cookie"
msgstr "会话已变更,刷新页面"
#: authentication/errors.py:41
#: authentication/errors.py:42
#, python-brace-format
msgid ""
"The username or password you entered is incorrect, please enter it again. "
@ -1139,37 +1141,41 @@ msgstr ""
"您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将"
"被临时 锁定 {block_time} 分钟)"
#: authentication/errors.py:47
#: authentication/errors.py:48
msgid ""
"The account has been locked (please contact admin to unlock it or try again "
"after {} minutes)"
msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)"
#: authentication/errors.py:50 users/views/profile/otp.py:107
#: authentication/errors.py:51 users/views/profile/otp.py:107
#: users/views/profile/otp.py:146 users/views/profile/otp.py:166
msgid "MFA code invalid, or ntp sync server time"
msgstr "MFA验证码不正确或者服务器端时间不对"
#: authentication/errors.py:52
#: authentication/errors.py:53
msgid "MFA required"
msgstr "需要多因子认证"
#: authentication/errors.py:53
#: authentication/errors.py:54
msgid "MFA not set, please set it first"
msgstr "多因子认证没有设置,请先完成设置"
#: authentication/errors.py:54
#: authentication/errors.py:55
msgid "Login confirm required"
msgstr "需要登录复核"
#: authentication/errors.py:55
#: authentication/errors.py:56
msgid "Wait login confirm ticket for accept"
msgstr "等待登录复核处理"
#: authentication/errors.py:56
#: authentication/errors.py:57
msgid "Login confirm ticket was {}"
msgstr "登录复核 {}"
#: authentication/errors.py:213
msgid "SSO auth closed"
msgstr "SSO 认证关闭了"
#: authentication/forms.py:26 authentication/forms.py:34
#: authentication/templates/authentication/login.html:38
#: authentication/templates/authentication/xpack_login.html:118
@ -1177,7 +1183,7 @@ msgstr "登录复核 {}"
msgid "MFA code"
msgstr "多因子认证验证码"
#: authentication/models.py:19
#: authentication/models.py:22
#: authentication/templates/authentication/_access_key_modal.html:32
#: perms/models/base.py:51 users/templates/users/_select_user_modal.html:18
#: users/templates/users/user_detail.html:132
@ -1185,23 +1191,31 @@ msgstr "多因子认证验证码"
msgid "Active"
msgstr "激活中"
#: authentication/models.py:39
#: authentication/models.py:42
msgid "Private Token"
msgstr "SSH密钥"
#: authentication/models.py:44 users/templates/users/user_detail.html:258
#: authentication/models.py:47 users/templates/users/user_detail.html:258
msgid "Reviewers"
msgstr "审批人"
#: authentication/models.py:53 tickets/models/ticket.py:27
#: authentication/models.py:56 tickets/models/ticket.py:27
#: users/templates/users/user_detail.html:250
msgid "Login confirm"
msgstr "登录复核"
#: authentication/models.py:63
#: authentication/models.py:66
msgid "City"
msgstr "城市"
#: authentication/models.py:88
msgid "Token"
msgstr ""
#: authentication/models.py:89
msgid "Expired"
msgstr "过期时间"
#: authentication/templates/authentication/_access_key_modal.html:6
msgid "API key list"
msgstr "API Key列表"
@ -1349,7 +1363,7 @@ msgstr "欢迎回来,请输入用户名和密码登录"
msgid "Please enable cookies and try again."
msgstr "设置你的浏览器支持cookie"
#: authentication/views/login.py:172
#: authentication/views/login.py:178
msgid ""
"Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>\n"
" Don't close this page"
@ -1357,15 +1371,15 @@ msgstr ""
"等待 <b>{}</b> 确认, 你也可以复制链接发给他/她 <br/>\n"
" 不要关闭本页面"
#: authentication/views/login.py:177
#: authentication/views/login.py:183
msgid "No ticket found"
msgstr "没有发现工单"
#: authentication/views/login.py:209
#: authentication/views/login.py:215
msgid "Logout success"
msgstr "退出登录成功"
#: authentication/views/login.py:210
#: authentication/views/login.py:216
msgid "Logout success, return login page"
msgstr "退出登录成功,返回到登录页面"
@ -1379,11 +1393,20 @@ msgstr "%(name)s 创建成功"
msgid "%(name)s was updated successfully"
msgstr "%(name)s 更新成功"
#: common/db/models.py:67
msgid "Updated by"
msgstr "更新人"
#: common/drf/parsers/csv.py:22
#, python-format
msgid "The max size of CSV is %d bytes"
msgstr "CSV 文件最大为 %d 字节"
#: common/exceptions.py:15
#, python-format
msgid "%s object does not exist."
msgstr "%s对象不存在"
#: common/fields/form.py:33
msgid "Not a valid json"
msgstr "不是合法json"
@ -5541,9 +5564,6 @@ msgstr "旗舰版"
#~ msgid "Corporation"
#~ msgstr "公司"
#~ msgid "Expired"
#~ msgstr "过期时间"
#~ msgid "Edition"
#~ msgstr "版本"