Repository: TencentBlueKing/bk-user

Issue:
fix: update reset password logic for better protect user info



Discussion:

Resolving patch:
diff --git a/src/bk-user/bkuser/apis/web/password/views.py b/src/bk-user/bkuser/apis/web/password/views.py
--- a/src/bk-user/bkuser/apis/web/password/views.py
+++ b/src/bk-user/bkuser/apis/web/password/views.py
@@ -36,6 +36,7 @@
 )
 from bkuser.apis.web.password.tokens import GenerateTokenTooFrequently, UserResetPasswordTokenManager
 from bkuser.apps.notification.helpers import gen_reset_password_url
+from bkuser.apps.tenant.constants import TenantUserStatus
 from bkuser.apps.tenant.models import TenantUser
 from bkuser.biz.organization import DataSourceUserHandler
 from bkuser.biz.validators import validate_user_new_password
@@ -59,25 +60,27 @@ class GetFirstTenantUserMixin:
     """
 
     def _get_first_tenant_user_by_phone(self, tenant_id: str, phone: str, phone_country_code: str) -> TenantUser:
-        # FIXME (su) 补充 status 过滤
         tenant_users = TenantUser.objects.filter_by_phone(tenant_id, phone, phone_country_code).filter(
-            data_source_user__data_source__plugin_id=DataSourcePluginEnum.LOCAL
+            data_source_user__data_source__plugin_id=DataSourcePluginEnum.LOCAL, status=TenantUserStatus.ENABLED
         )
 
         if not tenant_users.exists():
+            logger.warning(
+                "failed to get tenant user by phone +%s %s in tenant %s", phone_country_code, phone, tenant_id
+            )
             raise error_codes.TENANT_USER_NOT_EXIST.f(
-                _("手机号码 +{} {} 在租户 {} 中匹配到不到用户").format(phone_country_code, phone, tenant_id),
+                _("手机号码 +{} {} 在租户 {} 中匹配不到用户").format(phone_country_code, phone, tenant_id),
             )
 
         return tenant_users.first()
 
     def _get_first_tenant_user_by_email(self, tenant_id: str, email: str) -> TenantUser:
-        # FIXME (su) 补充 status 过滤
         tenant_users = TenantUser.objects.filter_by_email(tenant_id, email).filter(
-            data_source_user__data_source__plugin_id=DataSourcePluginEnum.LOCAL
+            data_source_user__data_source__plugin_id=DataSourcePluginEnum.LOCAL, status=TenantUserStatus.ENABLED
         )
 
         if not tenant_users.exists():
+            logger.warning("failed to get tenant user by email %s in tenant %s", email, tenant_id)
             raise error_codes.TENANT_USER_NOT_EXIST.f(_("邮箱 {} 在租户 {} 中匹配不到用户").format(email, tenant_id))
 
         return tenant_users.first()
@@ -102,16 +105,13 @@ def post(self, request, *args, **kwargs):
         tenant_id, phone, phone_country_code = params["tenant_id"], params["phone"], params["phone_country_code"]
         try:
             tenant_user = self._get_first_tenant_user_by_phone(tenant_id, phone, phone_country_code)
-        except Exception:
+            self._send_verification_code_to_user_phone(tenant_user)
+        except Exception as e:
+            logger.warning("failed to send validation code to phone +%s %s: %s", phone_country_code, phone, e)
+
             if settings.ALLOW_RAISE_ERROR_TO_USER_WHEN_RESET_PASSWORD:
                 raise
 
-            logger.warning(
-                "failed to get tenant user by phone +%s %s in tenant %s", phone_country_code, phone, tenant_id
-            )
-            return Response(status=status.HTTP_204_NO_CONTENT)
-
-        self._send_verification_code_to_user_phone(tenant_user)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
     def _send_verification_code_to_user_phone(self, tenant_user: TenantUser):
@@ -149,20 +149,15 @@ def post(self, request, *args, **kwargs):
 
         tenant_id = params["tenant_id"]
         phone, phone_country_code = params["phone"], params["phone_country_code"]
-        # 1. 找到匹配的租户用户
         try:
+            # 1. 找到匹配的租户用户
             tenant_user = self._get_first_tenant_user_by_phone(tenant_id, phone, phone_country_code)
+            # 2. 校验验证码是否正确
+            self._validate_verification_code(phone, phone_country_code, params["verification_code"])
         except Exception:
-            if settings.ALLOW_RAISE_ERROR_TO_USER_WHEN_RESET_PASSWORD:
-                raise
+            # 与用户名和密码校验相似，用户不存在或验证码错误，均返回相同的错误信息，避免遍历手机号问题
+            raise error_codes.INVALID_VERIFICATION_CODE.f(_("手机号码或验证码错误"))
 
-            logger.warning(
-                "failed to get tenant user by phone +%s %s in tenant %s", phone_country_code, phone, tenant_id
-            )
-            return Response(status=status.HTTP_204_NO_CONTENT)
-
-        # 2. 校验验证码是否正确
-        self._validate_verification_code(phone, phone_country_code, params["verification_code"])
         # 3. 获取重置密码链接
         url = self._gen_reset_password_url(tenant_user)
 
@@ -175,7 +170,7 @@ def _validate_verification_code(self, phone: str, phone_country_code: str, code:
             raise error_codes.INVALID_VERIFICATION_CODE.f(_("验证码错误"))
         except Exception:
             logger.exception("validate verification code for phone +%s %s failed", phone_country_code, phone)
-            raise error_codes.SEND_VERIFICATION_CODE_FAILED.f(_("验证码校验失败，请联系管理员处理"))
+            raise error_codes.INVALID_VERIFICATION_CODE.f(_("验证码校验失败，请联系管理员处理"))
 
     def _gen_reset_password_url(self, tenant_user: TenantUser) -> str:
         try:
@@ -204,14 +199,13 @@ def post(self, request, *args, **kwargs):
 
         try:
             tenant_user = self._get_first_tenant_user_by_email(params["tenant_id"], params["email"])
-        except Exception:
+            self._gen_and_send_reset_password_url(tenant_user)
+        except Exception as e:
+            logger.warning("failed to send reset password url to email %s: %s", params["email"], e)
+
             if settings.ALLOW_RAISE_ERROR_TO_USER_WHEN_RESET_PASSWORD:
                 raise
 
-            logger.warning("failed to get tenant user by email %s in tenant %s", params["email"], params["tenant_id"])
-            return Response(status=status.HTTP_204_NO_CONTENT)
-
-        self._gen_and_send_reset_password_url(tenant_user)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
     def _gen_and_send_reset_password_url(self, tenant_user: TenantUser) -> None:
diff --git a/src/bk-user/bkuser/monitoring/metrics/urls.py b/src/bk-user/bkuser/monitoring/metrics/urls.py
--- a/src/bk-user/bkuser/monitoring/metrics/urls.py
+++ b/src/bk-user/bkuser/monitoring/metrics/urls.py
@@ -8,8 +8,8 @@
 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.urls import url
+from django.conf.urls import re_path
 
 from . import views
 
-urlpatterns = [url(r"^metrics$", views.metric_view, name="prometheus-django-metrics")]
+urlpatterns = [re_path(r"^metrics$", views.metric_view, name="prometheus-django-metrics")]
diff --git a/src/bk-user/bkuser/urls.py b/src/bk-user/bkuser/urls.py
--- a/src/bk-user/bkuser/urls.py
+++ b/src/bk-user/bkuser/urls.py
@@ -9,7 +9,7 @@
 specific language governing permissions and limitations under the License.
 """
 from django.conf import settings
-from django.conf.urls import url
+from django.conf.urls import re_path
 from django.urls import include, path
 from django.views.decorators.cache import never_cache
 from drf_yasg import openapi
@@ -60,5 +60,5 @@
 
 # static file
 urlpatterns += [
-    url("", never_cache(VueTemplateView.as_view())),
+    re_path("", never_cache(VueTemplateView.as_view())),
 ]


Test patch:
diff --git a/src/bk-user/tests/apis/web/password/test_password.py b/src/bk-user/tests/apis/web/password/test_password.py
--- a/src/bk-user/tests/apis/web/password/test_password.py
+++ b/src/bk-user/tests/apis/web/password/test_password.py
@@ -31,121 +31,202 @@
 
 
 @pytest.fixture()
-def tenant_user(default_tenant, full_local_data_source) -> TenantUser:
+def tenant_user(random_tenant, full_local_data_source) -> TenantUser:
     init_local_data_source_identity_infos(full_local_data_source)
-    sync_users_depts_to_tenant(default_tenant, full_local_data_source)
-    return TenantUser.objects.filter(data_source_id=full_local_data_source.id).first()
+    sync_users_depts_to_tenant(random_tenant, full_local_data_source)
+    return TenantUser.objects.filter(tenant=random_tenant, data_source_id=full_local_data_source.id).first()
 
 
 class TestResetPasswordByPhoneAfterForget:
     """忘记密码后通过手机找回"""
 
-    @mock.patch("bkuser.component.cmsi.send_mail", return_value=None)
-    @mock.patch("bkuser.component.cmsi.send_sms", return_value=None)
-    def test_standard(self, mocked_send_sms, mocked_send_mail, api_client, tenant_user):
+    @pytest.fixture(autouse=True)
+    def _mock_cmsi_component(self):
+        with mock.patch(
+            "bkuser.component.cmsi.send_mail",
+            return_value=None,
+        ), mock.patch(
+            "bkuser.component.cmsi.send_sms",
+            return_value=None,
+        ):
+            yield
+
+    def test_standard(self, api_client, tenant_user):
+        # 1. 模拟发送验证码
+        phone, phone_country_code = tenant_user.phone_info
+        resp = api_client.post(
+            reverse("password.send_verification_code"),
+            data={"tenant_id": tenant_user.tenant_id, "phone": phone, "phone_country_code": phone_country_code},
+        )
+
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+        # 2. 从缓存中获取验证码用于认证，认证后获取到密码重置链接
+        mgr = VerificationCodeManager(phone, phone_country_code, VerificationCodeScene.RESET_PASSWORD)
+        code = mgr.cache.get(mgr.code_cache_key)
+        resp = api_client.post(
+            reverse("password.get_passwd_reset_url_by_verification_code"),
+            data={
+                "tenant_id": tenant_user.tenant_id,
+                "phone": phone,
+                "phone_country_code": phone_country_code,
+                "verification_code": code,
+            },
+        )
+
+        assert resp.status_code == status.HTTP_200_OK
+        reset_password_url = resp.data["reset_password_url"]
+        assert reset_password_url is not None
+
+        # 3. 解析密码重置链接获取 Token
+        reset_token = parse_qs(urlparse(reset_password_url).query)["token"][0]
+
+        # 4. 通过 Token 匹配租户用户
+        resp = api_client.get(
+            reverse("password.list_users_by_reset_passwd_token"),
+            data={"token": reset_token},
+        )
+        assert resp.status_code == status.HTTP_200_OK
+        assert tenant_user.id in [user["tenant_user_id"] for user in resp.data]
+
+        # 5. 通过 Token 重置密码，分批次生成再拼接，确保各种字符都有
+        new_password = (
+            generate_random_string(length=16, chars=string.ascii_letters)
+            + generate_random_string(length=8, chars=string.digits)
+            + generate_random_string(length=8, chars=string.punctuation)
+        )
+        resp = api_client.post(
+            reverse("password.reset_passwd_by_token"),
+            data={
+                "tenant_user_id": tenant_user.id,
+                "password": new_password,
+                "token": reset_token,
+            },
+        )
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+        identity_info = LocalDataSourceIdentityInfo.objects.get(user=tenant_user.data_source_user)
+        assert check_password(new_password, identity_info.password)
+
+    def test_send_verification_code_with_invalid_phone(self, api_client, tenant_user):
+        phone, phone_country_code = tenant_user.phone_info
+        # 修改手机号码导致无效
+        phone = phone[:-3] + "555"
+
+        url = reverse("password.send_verification_code")
+        data = {"tenant_id": tenant_user.tenant_id, "phone": phone, "phone_country_code": phone_country_code}
+        # 安全起见，默认情况下不会有异常
+        resp = api_client.post(url, data)
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+    def test_send_verification_code_with_invalid_phone_and_raise_exception(self, api_client, tenant_user):
+        phone, phone_country_code = tenant_user.phone_info
+        # 修改手机号码导致无效
+        phone = phone[:-3] + "333"
+
+        url = reverse("password.send_verification_code")
+        data = {"tenant_id": tenant_user.tenant_id, "phone": phone, "phone_country_code": phone_country_code}
         with override_settings(ALLOW_RAISE_ERROR_TO_USER_WHEN_RESET_PASSWORD=True):
-            # 1. 模拟发送验证码
-            phone, phone_country_code = tenant_user.phone_info
-            resp = api_client.post(
-                reverse("password.send_verification_code"),
-                data={"tenant_id": tenant_user.tenant_id, "phone": phone, "phone_country_code": phone_country_code},
-            )
-
-            assert resp.status_code == status.HTTP_204_NO_CONTENT
-
-            # 2. 从缓存中获取验证码用于认证，认证后获取到密码重置链接
-            mgr = VerificationCodeManager(phone, phone_country_code, VerificationCodeScene.RESET_PASSWORD)
-            code = mgr.cache.get(mgr.code_cache_key)
-            resp = api_client.post(
-                reverse("password.get_passwd_reset_url_by_verification_code"),
-                data={
-                    "tenant_id": tenant_user.tenant_id,
-                    "phone": phone,
-                    "phone_country_code": phone_country_code,
-                    "verification_code": code,
-                },
-            )
-
-            assert resp.status_code == status.HTTP_200_OK
-            reset_password_url = resp.data["reset_password_url"]
-            assert reset_password_url is not None
-
-            # 3. 解析密码重置链接获取 Token
-            reset_token = parse_qs(urlparse(reset_password_url).query)["token"][0]
-
-            # 4. 通过 Token 匹配租户用户
-            resp = api_client.get(
-                reverse("password.list_users_by_reset_passwd_token"),
-                data={"token": reset_token},
-            )
-            assert resp.status_code == status.HTTP_200_OK
-            assert tenant_user.id in [user["tenant_user_id"] for user in resp.data]
-
-            # 5. 通过 Token 重置密码，分批次生成再拼接，确保各种字符都有
-            new_password = (
-                generate_random_string(length=16, chars=string.ascii_letters)
-                + generate_random_string(length=8, chars=string.digits)
-                + generate_random_string(length=8, chars=string.punctuation)
-            )
-            resp = api_client.post(
-                reverse("password.reset_passwd_by_token"),
-                data={
-                    "tenant_user_id": tenant_user.id,
-                    "password": new_password,
-                    "token": reset_token,
-                },
-            )
-            assert resp.status_code == status.HTTP_204_NO_CONTENT
-
-            identity_info = LocalDataSourceIdentityInfo.objects.get(user=tenant_user.data_source_user)
-            assert check_password(new_password, identity_info.password)
+            resp = api_client.post(url, data)
+            assert resp.status_code == status.HTTP_400_BAD_REQUEST
+            assert "匹配不到用户" in resp.data["message"]
+
+    def test_get_reset_password_url_with_invalid_phone_or_token(self, api_client, tenant_user):
+        # 验证码错误
+        url = reverse("password.get_passwd_reset_url_by_verification_code")
+        data = {
+            "tenant_id": tenant_user.tenant_id,
+            "phone": tenant_user.phone_info[0],
+            "phone_country_code": tenant_user.phone_info[1],
+            "verification_code": "123456",
+        }
+        resp = api_client.post(url, data)
+        assert resp.status_code == status.HTTP_400_BAD_REQUEST
+        assert "手机号码或验证码错误" in resp.data["message"]
+
+        # 手机号错误，错误信息应该是一致的
+        data["phone"] = data["phone"][:-1] + "5"
+        resp = api_client.post(url, data)
+        assert resp.status_code == status.HTTP_400_BAD_REQUEST
+        assert "手机号码或验证码错误" in resp.data["message"]
 
 
 class TestResetPasswordByEmailAfterForget:
     """忘记密码后通过邮件找回"""
 
-    @mock.patch("bkuser.component.cmsi.send_mail", return_value=None)
-    @mock.patch("bkuser.component.cmsi.send_sms", return_value=None)
-    def test_standard(self, mocked_send_sms, mocked_send_mail, api_client, tenant_user):
+    @pytest.fixture(autouse=True)
+    def _mock_cmsi_component(self):
+        with mock.patch(
+            "bkuser.component.cmsi.send_mail",
+            return_value=None,
+        ), mock.patch(
+            "bkuser.component.cmsi.send_sms",
+            return_value=None,
+        ):
+            yield
+
+    def test_standard(self, api_client, tenant_user):
+        # 1. 发送重置密码链接邮件
+        resp = api_client.post(
+            reverse("password.send_passwd_reset_email"),
+            data={"tenant_id": tenant_user.tenant_id, "email": tenant_user.email},
+        )
+
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+        # 2. 搞一个能用的，关联 email 的 token，但是需要先干掉频控锁
+        mgr = UserResetPasswordTokenManager()
+        lock_key = f"email:{tenant_user.tenant_id}:{tenant_user.email}"
+        mgr.cache.delete(lock_key)
+
+        reset_token = mgr.gen_token(tenant_user, TokenRelatedObjType.EMAIL)
+
+        # 3. 通过 Token 匹配租户用户
+        resp = api_client.get(
+            reverse("password.list_users_by_reset_passwd_token"),
+            data={"token": reset_token},
+        )
+        assert resp.status_code == status.HTTP_200_OK
+        assert tenant_user.id in [user["tenant_user_id"] for user in resp.data]
+
+        # 4. 通过 Token 重置密码，分批次生成再拼接，确保各种字符都有
+        new_password = (
+            generate_random_string(length=16, chars=string.ascii_letters)
+            + generate_random_string(length=8, chars=string.digits)
+            + generate_random_string(length=8, chars=string.punctuation)
+        )
+        resp = api_client.post(
+            reverse("password.reset_passwd_by_token"),
+            data={
+                "tenant_user_id": tenant_user.id,
+                "password": new_password,
+                "token": reset_token,
+            },
+        )
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+        identity_info = LocalDataSourceIdentityInfo.objects.get(user=tenant_user.data_source_user)
+        assert check_password(new_password, identity_info.password)
+
+    def test_send_reset_pwd_url_with_invalid_email(self, api_client, tenant_user):
+        email = tenant_user.email
+        # 修改邮箱导致无效
+        email = "xxx" + email
+
+        url = reverse("password.send_passwd_reset_email")
+        data = {"tenant_id": tenant_user.tenant_id, "email": email}
+        # 安全起见，默认情况下不会有异常
+        resp = api_client.post(url, data)
+        assert resp.status_code == status.HTTP_204_NO_CONTENT
+
+    def test_send_reset_pwd_url_with_invalid_email_and_raise_exception(self, api_client, tenant_user):
+        email = tenant_user.email
+        # 修改邮箱导致无效
+        email = "yyy" + email
+
+        url = reverse("password.send_passwd_reset_email")
+        data = {"tenant_id": tenant_user.tenant_id, "email": email}
         with override_settings(ALLOW_RAISE_ERROR_TO_USER_WHEN_RESET_PASSWORD=True):
-            # 1. 发送重置密码链接邮件
-            resp = api_client.post(
-                reverse("password.send_passwd_reset_email"),
-                data={"tenant_id": tenant_user.tenant_id, "email": tenant_user.email},
-            )
-
-            assert resp.status_code == status.HTTP_204_NO_CONTENT
-
-            # 2. 搞一个能用的，关联 email 的 token，但是需要先干掉频控锁
-            mgr = UserResetPasswordTokenManager()
-            lock_key = f"email:{tenant_user.tenant_id}:{tenant_user.email}"
-            mgr.cache.delete(lock_key)
-
-            reset_token = mgr.gen_token(tenant_user, TokenRelatedObjType.EMAIL)
-
-            # 3. 通过 Token 匹配租户用户
-            resp = api_client.get(
-                reverse("password.list_users_by_reset_passwd_token"),
-                data={"token": reset_token},
-            )
-            assert resp.status_code == status.HTTP_200_OK
-            assert tenant_user.id in [user["tenant_user_id"] for user in resp.data]
-
-            # 4. 通过 Token 重置密码，分批次生成再拼接，确保各种字符都有
-            new_password = (
-                generate_random_string(length=16, chars=string.ascii_letters)
-                + generate_random_string(length=8, chars=string.digits)
-                + generate_random_string(length=8, chars=string.punctuation)
-            )
-            resp = api_client.post(
-                reverse("password.reset_passwd_by_token"),
-                data={
-                    "tenant_user_id": tenant_user.id,
-                    "password": new_password,
-                    "token": reset_token,
-                },
-            )
-            assert resp.status_code == status.HTTP_204_NO_CONTENT
-
-            identity_info = LocalDataSourceIdentityInfo.objects.get(user=tenant_user.data_source_user)
-            assert check_password(new_password, identity_info.password)
+            resp = api_client.post(url, data)
+            assert resp.status_code == status.HTTP_400_BAD_REQUEST
+            assert "匹配不到用户" in resp.data["message"]