Skip to content

Commit

Permalink
feat: support reset password after forget (#1559)
Browse files Browse the repository at this point in the history
  • Loading branch information
narasux authored Mar 6, 2024
1 parent bdef798 commit 6aa0be8
Show file tree
Hide file tree
Showing 29 changed files with 1,609 additions and 448 deletions.
10 changes: 10 additions & 0 deletions src/bk-user/bkuser/apis/web/password/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""
20 changes: 20 additions & 0 deletions src/bk-user/bkuser/apis/web/password/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""
from blue_krill.data_types.enum import EnumField, StructuredEnum
from django.utils.translation import gettext_lazy as _


class TokenRelatedObjType(str, StructuredEnum):
"""令牌关联对象类型"""

TENANT_USER = EnumField("tenant_user", label=_("租户用户"))
PHONE = EnumField("phone", label=_("手机号"))
EMAIL = EnumField("email", label=_("邮箱"))
79 changes: 79 additions & 0 deletions src/bk-user/bkuser/apis/web/password/senders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from bkuser.apps.notification.constants import NotificationMethod, NotificationScene
from bkuser.apps.notification.notifier import TenantUserNotifier
from bkuser.apps.tenant.models import TenantUser
from bkuser.common.cache import Cache, CacheEnum, CacheKeyPrefixEnum
from bkuser.common.verification_code import VerificationCodeScene
from bkuser.utils.time import calc_remaining_seconds_today


class ExceedSendRateLimit(Exception):
"""超过发送次数限制"""


class PhoneVerificationCodeSender:
"""发送用户手机验证码(含次数检查)"""

def __init__(self, scene: VerificationCodeScene):
self.cache = Cache(CacheEnum.REDIS, CacheKeyPrefixEnum.VERIFICATION_CODE)
self.scene = scene

def send(self, tenant_user: TenantUser, code: str):
"""发送验证码到用户手机"""
if not self._can_send(tenant_user):
raise ExceedSendRateLimit(_("今日发送验证码次数超过上限"))

TenantUserNotifier(
NotificationScene.SEND_VERIFICATION_CODE,
method=NotificationMethod.SMS,
).send(tenant_user, verification_code=code)

def _can_send(self, tenant_user: TenantUser) -> bool:
phone, phone_country_code = tenant_user.phone_info
send_cnt_cache_key = f"{self.scene.value}:{phone_country_code}:{phone}:send_cnt"

send_cnt = self.cache.get(send_cnt_cache_key, 0)
if send_cnt >= settings.VERIFICATION_CODE_MAX_SEND_PER_DAY:
return False

self.cache.set(send_cnt_cache_key, send_cnt + 1, timeout=calc_remaining_seconds_today())
return True


class EmailResetPasswdTokenSender:
"""发送用户邮箱重置密码链接"""

def __init__(self):
self.cache = Cache(CacheEnum.REDIS, CacheKeyPrefixEnum.RESET_PASSWORD_TOKEN)

def send(self, tenant_user: TenantUser, token: str):
"""发送重置密码链接到用户邮箱"""
if not self._can_send(tenant_user):
raise ExceedSendRateLimit(_("超过发送次数限制"))

TenantUserNotifier(
NotificationScene.RESET_PASSWORD,
data_source_id=tenant_user.data_source_user.data_source_id,
).send(tenant_user, token=token)

def _can_send(self, tenant_user: TenantUser) -> bool:
send_cnt_cache_key = f"{tenant_user.email}:send_cnt"

send_cnt = self.cache.get(send_cnt_cache_key, 0)
if send_cnt >= settings.RESET_PASSWORD_TOKEN_MAX_SEND_PER_DAY:
return False

self.cache.set(send_cnt_cache_key, send_cnt + 1, timeout=calc_remaining_seconds_today())
return True
79 changes: 79 additions & 0 deletions src/bk-user/bkuser/apis/web/password/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""

from django.conf import settings
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from bkuser.apps.tenant.models import TenantUser
from bkuser.biz.tenant import TenantUserHandler
from bkuser.common.validators import validate_phone_with_country_code


class SendVerificationCodeInputSLZ(serializers.Serializer):
tenant_id = serializers.CharField(help_text="租户 ID")
phone = serializers.CharField(help_text="手机号码")
phone_country_code = serializers.CharField(
help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE
)

def validate(self, attrs):
try:
validate_phone_with_country_code(phone=attrs["phone"], country_code=attrs["phone_country_code"])
except ValueError as e:
raise ValidationError(str(e))

return attrs


class GenResetPasswordUrlByVerificationCodeInputSLZ(serializers.Serializer):
tenant_id = serializers.CharField(help_text="租户 ID")
phone = serializers.CharField(help_text="手机号码")
phone_country_code = serializers.CharField(
help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE
)
verification_code = serializers.CharField(help_text="验证码", max_length=32)

def validate(self, attrs):
try:
validate_phone_with_country_code(phone=attrs["phone"], country_code=attrs["phone_country_code"])
except ValueError as e:
raise ValidationError(str(e))

return attrs


class GenResetPasswordUrlByVerificationCodeOutputSLZ(serializers.Serializer):
reset_password_url = serializers.CharField(help_text="密码重置链接")


class SendResetPasswordEmailInputSLZ(serializers.Serializer):
tenant_id = serializers.CharField(help_text="租户 ID")
email = serializers.EmailField(help_text="邮箱")


class ListUserByResetPasswordTokenInputSLZ(serializers.Serializer):
token = serializers.CharField(help_text="密码重置 Token", max_length=255)


class TenantUserMatchedByTokenOutputSLZ(serializers.Serializer):
tenant_user_id = serializers.CharField(help_text="租户用户 ID", source="id")
username = serializers.CharField(help_text="用户名", source="data_source_user.username")
display_name = serializers.SerializerMethodField(help_text="展示用名称")

def get_display_name(self, obj: TenantUser) -> str:
return TenantUserHandler.generate_tenant_user_display_name(obj)


class ResetPasswordByTokenInputSLZ(serializers.Serializer):
tenant_user_id = serializers.CharField(help_text="租户用户 ID")
password = serializers.CharField(help_text="新密码")
token = serializers.CharField(help_text="密码重置 Token", max_length=255)
101 changes: 101 additions & 0 deletions src/bk-user/bkuser/apis/web/password/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""
import secrets
import string
from hashlib import md5
from typing import Dict

from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _

from bkuser import settings
from bkuser.apis.web.password.constants import TokenRelatedObjType
from bkuser.apps.tenant.models import TenantUser
from bkuser.common.cache import Cache, CacheEnum, CacheKeyPrefixEnum
from bkuser.plugins.constants import DataSourcePluginEnum


class GenerateTokenTooFrequently(Exception):
"""生成令牌过于频繁"""


class UserResetPasswordTokenManager:
"""用户重置密码 Token 管理器"""

lock_timeout = 60

def __init__(self):
self.cache = Cache(CacheEnum.REDIS, CacheKeyPrefixEnum.RESET_PASSWORD_TOKEN)

def gen_token(self, tenant_user: TenantUser, related_obj_type: TokenRelatedObjType) -> str:
"""生成 token"""
info = {"type": related_obj_type.value, "tenant_id": tenant_user.tenant_id}
if related_obj_type == TokenRelatedObjType.TENANT_USER:
info["tenant_user_id"] = tenant_user.id
elif related_obj_type == TokenRelatedObjType.EMAIL:
info["email"] = tenant_user.email
elif related_obj_type == TokenRelatedObjType.PHONE:
info["phone"], info["phone_country_code"] = tenant_user.phone_info

# 生成 token 有频率限制,不能短时间内频繁生成
lock_key = self._gen_lock_key_by_info(info)
if self.cache.get(lock_key):
raise GenerateTokenTooFrequently(_("生成令牌过于频繁"))

self.cache.set(lock_key, True, timeout=self.lock_timeout)

token = self._gen_token()
self.cache.set(self._gen_cache_key_by_token(token), info, timeout=settings.RESET_PASSWORD_TOKEN_VALID_TIME)
return token

def disable_token(self, token: str) -> None:
"""禁用 token"""
cache_key = self._gen_cache_key_by_token(token)
self.cache.delete(cache_key)

def list_users_by_token(self, token: str) -> QuerySet[TenantUser]:
"""根据 token 获取用户信息,返回可修改密码的用户列表"""
cache_key = self._gen_cache_key_by_token(token)
info = self.cache.get(cache_key, None)
if not info:
return TenantUser.objects.none()

if info["type"] == TokenRelatedObjType.EMAIL:
tenant_users = TenantUser.objects.filter_by_email(info["tenant_id"], info["email"])
elif info["type"] == TokenRelatedObjType.PHONE:
tenant_users = TenantUser.objects.filter_by_phone(
info["tenant_id"], info["phone"], info["phone_country_code"]
)
elif info["type"] == TokenRelatedObjType.TENANT_USER:
tenant_users = TenantUser.objects.filter(tenant_id=info["tenant_id"], id=info["tenant_user_id"])
else:
return TenantUser.objects.none()

# FIXME (su) 补充 status 过滤
# 只有本地数据源用户关联的租户用户才可以修改密码
return tenant_users.filter(data_source_user__data_source__plugin_id=DataSourcePluginEnum.LOCAL)

def _gen_token(self) -> str:
"""生成重置密码用 Token,字符集:数字,大小写字母"""
charset = string.ascii_letters + string.digits
return "".join(secrets.choice(charset) for _ in range(settings.RESET_PASSWORD_TOKEN_LENGTH))

def _gen_cache_key_by_token(self, token: str) -> str:
"""
压缩 token,避免 cache_key 过长
md5 -> 32, sha1 -> 40, sha256 -> 64
"""
return md5(token.encode("utf-8")).hexdigest()

def _gen_lock_key_by_info(self, info: Dict[str, str]) -> str:
"""根据 token 对应信息提供锁,避免短时间重复分配 token"""
return ":".join(str(val) for val in info.values())
58 changes: 58 additions & 0 deletions src/bk-user/bkuser/apis/web/password/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
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.
"""
from django.urls import path

from bkuser.apis.web.password import views

# 忘记密码交互设计:
# - 通过邮箱重置:
# - 发送重置密码链接到邮箱
# - 用户访问密码重置链接
# - 选择要重置的租户用户
# - 输入新密码(含确认)并提交
# - 通过手机号码重置:
# - 发送验证码到手机
# - 用户输入验证码,验证正确后跳转到密码重置链接
# - 选择要重置的租户用户
# - 输入新密码(含确认)并提交

urlpatterns = [
# 发送重置密码验证码到手机
path(
"operations/reset/methods/phone/verification-codes/",
views.SendVerificationCodeApi.as_view(),
name="password.send_verification_code",
),
# 通过验证码获取密码重置链接
path(
"operations/reset/methods/verification-code/token-urls/",
views.GenResetPasswordUrlByVerificationCodeApi.as_view(),
name="password.get_passwd_reset_url_by_verification_code",
),
# 发送密码重置邮件
path(
"operations/reset/methods/email/token-urls/",
views.SendResetPasswordEmailApi.as_view(),
name="password.send_passwd_reset_email",
),
# 通过密码重置 Token 获取可选租户用户
path(
"operations/reset/methods/token/users/",
views.ListUsersByResetPasswordTokenApi.as_view(),
name="password.list_users_by_reset_passwd_token",
),
# 通过密码重置 Token 重置密码
path(
"operations/reset/methods/token/passwords/",
views.ResetPasswordByTokenApi.as_view(),
name="password.reset_passwd_by_token",
),
]
Loading

0 comments on commit 6aa0be8

Please sign in to comment.