Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bklogin): support tenant api #2011

Merged
merged 4 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions src/bk-login/bklogin/authentication/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,22 @@

class BkTokenProcessor:
"""
BKToken处理
生成并加密Token & 解密Token
BKToken 处理
生成并加密 Token & 解密 Token
"""

def __init__(self):
# 加密器,默认读取django settings里配置的加密密钥和加密类
# 加密器,默认读取 django settings 里配置的加密密钥和加密类
self.crypter = EncryptHandler()

@staticmethod
def _salt(length: int = 8) -> str:
"""生成长度为length 的随机字符串"""
"""生成长度为 length 的随机字符串"""
allow_chars = string.ascii_letters + string.digits
return "".join([random.choice(allow_chars) for __ in range(length)])

def generate(self, username: str, expires_at: int) -> str:
"""token生成"""
"""token 生成"""
# 加盐
plain_token = "%s|%s|%s" % (expires_at, username, self._salt())

Expand All @@ -58,7 +58,7 @@ def generate(self, username: str, expires_at: int) -> str:

def parse(self, bk_token: str) -> Tuple[str, int]:
"""
token解析
token 解析
:return: username, expires_at
"""
try:
Expand Down Expand Up @@ -88,7 +88,7 @@ def parse(self, bk_token: str) -> Tuple[str, int]:

class BkTokenManager:
def __init__(self):
# Token加密密钥
# Token 加密密钥
self.bk_token_processor = BkTokenProcessor()
# Token 过期间隔
self.cookie_age = settings.BK_TOKEN_COOKIE_AGE
Expand All @@ -97,7 +97,7 @@ def __init__(self):
# Token 校验时间允许误差
self.offset_error_age = settings.BK_TOKEN_OFFSET_ERROR_AGE

# Token生成失败的重试次数
# Token 生成失败的重试次数
self.allowed_retry_count = 5

def generate(self, username: str) -> Tuple[str, datetime.datetime]:
Expand All @@ -107,22 +107,22 @@ def generate(self, username: str) -> Tuple[str, datetime.datetime]:
"""
bk_token = ""
expires_at = int(time.time())
# 重试5次
# 重试 5 次
retry_count = 0
while not bk_token and retry_count < self.allowed_retry_count:
now_time = int(time.time())
# Token过期时间
# Token 过期时间
expires_at = now_time + self.cookie_age
# Token 无操作失效时间
inactive_expires_at = now_time + self.inactive_age
# 生成bk_token
# 生成 bk_token
bk_token = self.bk_token_processor.generate(username, expires_at)
# DB记录
# DB 记录
try:
BkToken.objects.create(token=bk_token, inactive_expires_at=inactive_expires_at)
except Exception: # noqa: PERF203
logger.exception("Login ticket failed to be saved during ticket generation")
# 循环结束前将bk_token置空后重新生成
# 循环结束前将 bk_token 置空后重新生成
bk_token = "" if retry_count + 1 < self.allowed_retry_count else bk_token
retry_count += 1
# Note: quote_plus 是为了兼容 2.x 版本,保持一致,避免用于 Cookie 时调用方未进行 url encode
Expand All @@ -136,32 +136,32 @@ def is_valid(self, bk_token: str) -> Tuple[bool, str, str]:
if not bk_token:
return False, "", _("参数 bk_token 缺失")

# Note: unquote_plus 是为了兼容 2.x 版本, 因为旧版本在设置 bk_token Cookie 时做了 quote_plus 转换编码
# Note: unquote_plus 是为了兼容 2.x 版本,因为旧版本在设置 bk_token Cookie 时做了 quote_plus 转换编码
bk_token = unquote_plus(bk_token)
# 解析bk_token获取username和过期时间
# 解析 bk_token 获取 username 和过期时间
try:
username, expires_at = self.bk_token_processor.parse(bk_token)
except ValueError as error:
return False, "", str(error)

# 检查DB是存在
# 检查 DB 是存在
try:
bk_token_obj = BkToken.objects.get(token=bk_token)
is_logout = bk_token_obj.is_logout
inactive_expires_at = bk_token_obj.inactive_expires_at
except Exception:
return False, "", _("不存在 bk_token[%s] 的记录").format(bk_token)

# token已注销
# token 已注销
if is_logout:
return False, "", _("登录态已注销")

now = int(time.time())
# token有效期已过
# token 有效期已过
if now > expires_at + self.offset_error_age:
return False, "", _("登录态已过期")

# token有效期大于当前时间的有效期
# token 有效期大于当前时间的有效期
if expires_at - now > self.cookie_age + self.offset_error_age:
return False, "", _("登录态有效期不合法")

Expand All @@ -182,6 +182,6 @@ def set_invalid(bk_token: str):
"""
设置登录态失效
"""
# Note: unquote_plus 是为了兼容 2.x 版本, 因为旧版本在设置 bk_token Cookie 时做了 quote_plus 转换编码
# Note: unquote_plus 是为了兼容 2.x 版本,因为旧版本在设置 bk_token Cookie 时做了 quote_plus 转换编码
bk_token = unquote_plus(bk_token)
BkToken.objects.filter(token=bk_token).update(is_logout=True)
56 changes: 28 additions & 28 deletions src/bk-login/bklogin/authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
logger = logging.getLogger(__name__)


# 确保无论何时,响应必然有CSRFToken Cookie
# 确保无论何时,响应必然有 CSRFToken Cookie
@method_decorator(ensure_csrf_cookie, name="dispatch")
class LoginView(View):
"""
Expand All @@ -61,11 +61,11 @@ class LoginView(View):
template_name = "index.html"

def _get_redirect_url(self, request):
"""如果安全的话,返回用户发起的重定向URL"""
# 重定向URL
"""如果安全的话,返回用户发起的重定向 URL"""
# 重定向 URL
redirect_to = request.GET.get(REDIRECT_FIELD_NAME) or self.default_redirect_to

# 检查回调URL是否安全,防钓鱼
# 检查回调 URL 是否安全,防钓鱼
url_is_safe = url_has_allowed_host_and_scheme(
url=redirect_to,
allowed_hosts={*settings.ALLOWED_REDIRECT_HOSTS},
Expand All @@ -77,7 +77,7 @@ def get(self, request, *args, **kwargs):
"""登录页面"""
# 回调到业务系统的地址
redirect_url = self._get_redirect_url(request)
# 存储到当前session里,待认证成功后取出后重定向
# 存储到当前 session 里,待认证成功后取出后重定向
request.session["redirect_uri"] = redirect_url

# 返回登录页面
Expand Down Expand Up @@ -159,19 +159,19 @@ class PluginErrorContext(pydantic.BaseModel):
http_method: str


# 先对所有请求豁免CSRF校验,由dispatch里根据需要手动执行CSRF校验
# 先对所有请求豁免 CSRF 校验,由 dispatch 里根据需要手动执行 CSRF 校验
@method_decorator(csrf_exempt, name="dispatch")
class IdpPluginDispatchView(View):
def dispatch(self, request, *args, **kwargs):
"""
根据路径参数 idp_id 和 action 将请求路由调度到各个插件
"""
# Session里获取当前登录的租户
# Session 里获取当前登录的租户
sign_in_tenant_id = request.session.get(SIGN_IN_TENANT_ID_SESSION_KEY)
# 路径优先
if kwargs.get("tenant_id"):
sign_in_tenant_id = kwargs.get("tenant_id")
# session记录登录的租户
# session 记录登录的租户
request.session[SIGN_IN_TENANT_ID_SESSION_KEY] = sign_in_tenant_id
if not sign_in_tenant_id:
raise error_codes.NO_PERMISSION.f(_("未选择需要登录的租户"))
Expand All @@ -191,7 +191,7 @@ def dispatch(self, request, *args, **kwargs):
plugin_cls = get_plugin_cls(idp.plugin.id)
except NotImplementedError as error:
raise error_codes.PLUGIN_SYSTEM_ERROR.f(
_("认证源[{}]获取插件[{}]失败, {}").format(idp.name, idp.plugin.name, error),
_("认证源 [{}] 获取插件 [{}] 失败,{}").format(idp.name, idp.plugin.name, error),
)

# (2)初始化插件
Expand All @@ -200,19 +200,19 @@ def dispatch(self, request, *args, **kwargs):
plugin = plugin_cls(cfg=plugin_cfg)
except pydantic.ValidationError:
logger.exception("idp(%s) init plugin(%s) config failed", idp.id, idp.plugin.id)
# Note: 不可将error对外,因为配置信息比较敏感
# Note: 不可将 error 对外,因为配置信息比较敏感
raise error_codes.PLUGIN_SYSTEM_ERROR.f(
_("认证源[{}]初始化插件配置[{}]失败").format(idp.name, idp.plugin.name),
_("认证源 [{}] 初始化插件配置 [{}] 失败").format(idp.name, idp.plugin.name),
)
except Exception as error:
logger.exception("idp(%s) load plugin(%s) failed", idp.id, idp.plugin.id)
raise error_codes.PLUGIN_SYSTEM_ERROR.f(
_("认证源[{}]加载插件[{}]失败, {}").format(idp.name, idp.plugin.name, error),
_("认证源 [{}] 加载插件 [{}] 失败,{}").format(idp.name, idp.plugin.name, error),
)

idp_info = IdpBasicInfo(id=idp.id, name=idp.name, plugin_id=idp.plugin.id, plugin_name=idp.plugin.name)
# (3)dispatch
# FIXME: 如何对身份凭证类的认证进行手动csrf校验,或者如何添加csrf_protect装饰器
# FIXME: 如何对身份凭证类的认证进行手动 csrf 校验,或者如何添加 csrf_protect 装饰器
# 身份凭证类型
if isinstance(plugin, BaseCredentialIdpPlugin):
return self._dispatch_credential_idp_plugin(
Expand Down Expand Up @@ -246,7 +246,7 @@ def wrap_plugin_error(self, context: PluginErrorContext, func: Callable, *func_a
context.idp.plugin_id,
)
raise error_codes.PLUGIN_SYSTEM_ERROR.f(
_("认证源[{}]执行插件[{}]失败").format(context.idp.name, context.idp.plugin_name),
_("认证源 [{}] 执行插件 [{}] 失败").format(context.idp.name, context.idp.plugin_name),
)

def _dispatch_credential_idp_plugin(
Expand Down Expand Up @@ -303,8 +303,8 @@ def _dispatch_federation_idp_plugin(
return HttpResponseRedirect(redirect_uri)

# 第三方登录成功后回调回蓝鲸
# Note: 大部分都是GET重定向,对于某些第三方登录,可能存在POST请求
# 比如SAML的传输绑定有3种: HTTP Artifact、HTTP POST、和 HTTP Redirect
# Note: 大部分都是 GET 重定向,对于某些第三方登录,可能存在 POST 请求
# 比如 SAML 的传输绑定有 3 种:HTTP Artifact、HTTP POST、和 HTTP Redirect
if dispatch_cfs in [
(BuiltinActionEnum.CALLBACK, AllowedHttpMethodEnum.GET),
(BuiltinActionEnum.CALLBACK, AllowedHttpMethodEnum.POST),
Expand Down Expand Up @@ -337,7 +337,7 @@ def _auth_backend(
tenant_users = bk_user_api.list_matched_tencent_user(sign_in_tenant_id, idp_id, user_infos)
if not tenant_users:
raise error_codes.OBJECT_NOT_FOUND.f(
_("认证成功,但用户在租户({})下未有对应账号").format(sign_in_tenant_id),
_("认证成功,但用户在租户 ({}) 下未有对应账号").format(sign_in_tenant_id),
)

return [i.model_dump(include={"id", "username", "full_name"}) for i in tenant_users]
Expand All @@ -361,9 +361,9 @@ def get(self, request, *args, **kwargs):
user_id = tenant_users[0]["id"]

response = HttpResponseRedirect(redirect_to=request.session.get("redirect_uri"))
# 生成Cookie
# 生成 Cookie
bk_token, expired_at = BkTokenManager().generate(user_id)
# 设置Cookie
# 设置 Cookie
response.set_cookie(
settings.BK_TOKEN_COOKIE_NAME,
bk_token,
Expand All @@ -373,7 +373,7 @@ def get(self, request, *args, **kwargs):
secure=False,
)

# 删除Session
# 删除 Session
request.session.clear()

return response
Expand All @@ -384,12 +384,12 @@ def get(self, request, *args, **kwargs):
"""
用户认证后,获取认证成功后的租户用户列表
"""
# Session里获取当前登录的租户
# Session 里获取当前登录的租户
sign_in_tenant_id = request.session.get(SIGN_IN_TENANT_ID_SESSION_KEY)
if not sign_in_tenant_id:
raise error_codes.NO_PERMISSION.f(_("未选择需要登录的租户"))

# Session里获取已认证过的租户用户
# Session 里获取已认证过的租户用户
tenant_users = request.session.get(ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY)
if not tenant_users:
raise error_codes.NO_PERMISSION.f(_("未经过用户认证步骤"))
Expand All @@ -402,7 +402,7 @@ def get(self, request, *args, **kwargs):
class SignInTenantUserCreateApi(View):
def post(self, request, *args, **kwargs):
"""
确认登录的用户,生成bk_token Cookie, 返回重定向业务系统的地址
确认登录的用户,生成 bk_token Cookie, 返回重定向业务系统的地址
"""
request_body = parse_request_body_json(request.body)
user_id = request_body.get("user_id")
Expand All @@ -416,13 +416,13 @@ def post(self, request, *args, **kwargs):
if user_id not in tenant_user_ids:
raise error_codes.NO_PERMISSION.f(_("该用户不可登录"))

# TODO:支持MFA、首次登录强制修改密码登录操作
# TODO: 首次登录强制修改密码登录 => 设置临时场景票据,类似登录态,比如bk_token_for_force_change_password
# TODO:支持 MFA、首次登录强制修改密码登录操作
# TODO: 首次登录强制修改密码登录 => 设置临时场景票据,类似登录态,比如 bk_token_for_force_change_password

response = APISuccessResponse({"redirect_uri": request.session.get("redirect_uri")})
# 生成Cookie
# 生成 Cookie
bk_token, expired_at = BkTokenManager().generate(user_id)
# 设置Cookie
# 设置 Cookie
response.set_cookie(
settings.BK_TOKEN_COOKIE_NAME,
bk_token,
Expand All @@ -432,7 +432,7 @@ def post(self, request, *args, **kwargs):
secure=False,
)

# 删除Session
# 删除 Session
request.session.clear()

return response
4 changes: 2 additions & 2 deletions src/bk-login/bklogin/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ class ErrorCodes:
)
NOT_SUPPORTED = ErrorCode(_("不支持"))

# 调用系统API异常
REMOTE_REQUEST_ERROR = ErrorCode(_("调用系统API异常"))
# 调用系统 API 异常
REMOTE_REQUEST_ERROR = ErrorCode(_("调用系统 API 异常"))


# 实例化一个全局对象
Expand Down
Loading
Loading