Skip to content

Commit

Permalink
feat(bklogin): support tenant api
Browse files Browse the repository at this point in the history
  • Loading branch information
nannan00 committed Dec 10, 2024
1 parent 9042122 commit 00c668f
Show file tree
Hide file tree
Showing 18 changed files with 528 additions and 346 deletions.
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

0 comments on commit 00c668f

Please sign in to comment.