-
Notifications
You must be signed in to change notification settings - Fork 66
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support reset password after forget (#1559)
- Loading branch information
Showing
29 changed files
with
1,609 additions
and
448 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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=_("邮箱")) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
), | ||
] |
Oops, something went wrong.