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 .mfa import *
from .access_key import * from .access_key import *
from .login_confirm 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 import time
from django.core.cache import cache from django.core.cache import cache
from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.six import text_type from django.utils.six import text_type
from django.contrib.auth import get_user_model 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 HTTP_HEADER_ENCODING
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from common.auth import signature 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 common.utils import get_object_or_none, make_signature, http_to_unixtime
from ..models import AccessKey, PrivateToken from ..models import AccessKey, PrivateToken
@ -197,3 +196,10 @@ class SignatureAuthentication(signature.SignatureAuthentication):
return user, secret return user, secret
except AccessKey.DoesNotExist: except AccessKey.DoesNotExist:
return None, None 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.urls import reverse
from django.conf import settings from django.conf import settings
from common.exceptions import JMSException
from .signals import post_auth_failed from .signals import post_auth_failed
from users.utils import ( from users.utils import (
increase_login_failed_count, get_login_failed_count increase_login_failed_count, get_login_failed_count
@ -205,3 +206,8 @@ class LoginConfirmOtherError(LoginConfirmBaseError):
def __init__(self, ticket_id, status): def __init__(self, ticket_id, status):
msg = login_confirm_error_msg.format(status) msg = login_confirm_error_msg.format(status)
super().__init__(ticket_id=ticket_id, msg=msg) 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 import uuid
from django.db import models from functools import partial
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __ from django.utils.translation import ugettext_lazy as _, ugettext as __
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.conf import settings 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.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city from common.utils import get_object_or_none, get_request_ip, get_ip_city
@ -76,3 +79,12 @@ class LoginConfirmSetting(CommonModelMixin):
def __str__(self): def __str__(self):
return '{} confirm'.format(self.user.username) 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 common.utils import get_object_or_none
from users.models import User from users.models import User
from users.serializers import UserProfileSerializer from users.serializers import UserProfileSerializer
from .models import AccessKey, LoginConfirmSetting from .models import AccessKey, LoginConfirmSetting, SSOToken
__all__ = [ __all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
] ]
@ -76,3 +76,8 @@ class LoginConfirmSettingSerializer(serializers.ModelSerializer):
model = LoginConfirmSetting model = LoginConfirmSetting
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated'] fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
read_only_fields = ['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' app_name = 'authentication'
router = DefaultRouter() router = DefaultRouter()
router.register('access-keys', api.AccessKeyViewSet, 'access-key') router.register('access-keys', api.AccessKeyViewSet, 'access-key')
router.register('sso', api.SSOViewSet, 'sso')
urlpatterns = [ 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): class Choice(str):
@ -46,3 +60,20 @@ class ChoiceSetType(type):
class ChoiceSet(metaclass=ChoiceSetType): class ChoiceSet(metaclass=ChoiceSetType):
choices = None # 用于 Django Model 中的 choices 配置, 为了代码提示在此声明 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 -*- # -*- coding: utf-8 -*-
# #
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework import status from rest_framework import status
class JMSException(APIException): class JMSException(APIException):
status_code = status.HTTP_400_BAD_REQUEST 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_LOGOUT_COMPLETELY': True,
'CAS_VERSION': 3, 'CAS_VERSION': 3,
'AUTH_SSO': False,
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
'OTP_VALID_WINDOW': 2, 'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer', 'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'jumpserver.org', 'EMAIL_SUFFIX': 'jumpserver.org',
@ -440,6 +443,8 @@ class DynamicConfig:
backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend') backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend')
if self.static_config.get('AUTH_RADIUS'): if self.static_config.get('AUTH_RADIUS'):
backends.insert(0, 'authentication.backends.radius.RadiusBackend') backends.insert(0, 'authentication.backends.radius.RadiusBackend')
if self.static_config.get('AUTH_SSO'):
backends.insert(0, 'authentication.backends.api.SSOAuthentication')
return backends return backends
def XPACK_LICENSE_IS_VALID(self): 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_ROOT_PROXIED_AS = CONFIG.CAS_ROOT_PROXIED_AS
CAS_CHECK_NEXT = lambda: lambda _next_page: True 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 # Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION

View File

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

Binary file not shown.

View File

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