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

feature: 忘记密码支持手机号验证,进行密码重置 # issue631 #837

Merged
merged 18 commits into from
Dec 19, 2022
Merged
Show file tree
Hide file tree
Changes from 9 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
9 changes: 9 additions & 0 deletions src/api/bkuser_core/api/web/password/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ class PasswordResetSendEmailInputSLZ(serializers.Serializer):
email = serializers.EmailField(required=True, max_length=254)


class PasswordResetSendSMSInputSLZ(serializers.Serializer):
telephone = serializers.CharField(required=True, max_length=64)
neronkl marked this conversation as resolved.
Show resolved Hide resolved


class PasswordVerifyVerificationCodeInputSLZ(serializers.Serializer):
verification_code_token = serializers.CharField(required=True, max_length=254)
verification_code = serializers.CharField(required=True)


class PasswordResetByTokenInputSLZ(serializers.Serializer):
token = serializers.CharField(required=True, max_length=254)
password = Base64OrPlainField(required=True, max_length=254)
Expand Down
25 changes: 25 additions & 0 deletions src/api/bkuser_core/api/web/password/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
neronkl marked this conversation as resolved.
Show resolved Hide resolved
import logging

from bkuser_core.celery import app
from bkuser_core.common.notifier import send_sms

logger = logging.getLogger(__name__)


@app.task
def send_reset_password_verification_code_sms(profile_id: str, send_config: dict):
try:
logger.info(
"going to send verification_code of Profile(%s) via telephone(%s)",
profile_id,
send_config["receivers"],
)
send_sms(**send_config)
except Exception as e:
logger.info(
Canway-shiisa marked this conversation as resolved.
Show resolved Hide resolved
"Failed to send verification_code of Profile(%s) via telephone(%s): %s",
profile_id,
send_config["receivers"],
e,
)
neronkl marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 additions & 0 deletions src/api/bkuser_core/api/web/password/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
views.PasswordResetSendEmailApi.as_view(),
name="password.reset.sent_email",
),
path(
"reset/verification_code/send_sms/",
views.PasswordResetSendVerificationCodeApi.as_view(),
name="password.reset.sent_verification_code_sms",
),
path(
"reset/verification_code/verify/",
views.PasswordVerifyVerificationCodeApi.as_view(),
name="password.reset.verify_verification_code",
),
path(
"reset/by_token/",
views.PasswordResetByTokenApi.as_view(),
Expand Down
191 changes: 191 additions & 0 deletions src/api/bkuser_core/api/web/password/verification_code_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# -*- 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 datetime
import hashlib
import json
import logging
import random
import string
from typing import Any

from django.core.cache import caches
from django.utils.timezone import now

from bkuser_core.api.web.password.tasks import send_reset_password_verification_code_sms
from bkuser_core.common.error_codes import error_codes
from bkuser_core.profiles.models import Profile, ProfileTokenHolder
from bkuser_core.user_settings.loader import ConfigProvider

logger = logging.getLogger(__name__)


class ResetPasswordVerificationCodeHandler:
def __init__(self, profile_id: int = None):
if profile_id:
self.profile = Profile.objects.get(profile_id)
self.config_loader = ConfigProvider(category_id=self.profile.category_id)
neronkl marked this conversation as resolved.
Show resolved Hide resolved

self.cache = caches["verification_code"]

def _set_into_cache(self, key: str, data: Any, timeout: int = None, prefix: str = None):
neronkl marked this conversation as resolved.
Show resolved Hide resolved
if prefix:
key = f"{prefix}_{key}"
if timeout:
self.cache.set(key=key, timeout=timeout, value=data)
else:
self.cache.set(key=key, value=data)
neronkl marked this conversation as resolved.
Show resolved Hide resolved

def _get_from_cache(self, key: str, prefix: str = None):
if prefix:
key = f"{prefix}_{key}"
data = self.cache.get(key=key)
return data

def _delete_from_cache(self, key: str, prefix: str = None):
if prefix:
key = f"{prefix}_{key}"
self.cache.delete(key=key)

def _get_reset_password_data(self, token: str) -> dict:
data = self._get_from_cache(token, prefix="reset_password")
return json.loads(data) if data else {}

def _set_reset_password_data(self, token, verification_code_data=None, expire_seconds=None):
return self._set_into_cache(token, verification_code_data, expire_seconds, prefix="reset_password")

def _delete_reset_password_data(self, token):
return self._delete_from_cache(token, prefix="reset_password")

def _get_reset_password_send_count(self, telephone: str) -> int:
data = self._get_from_cache(key=telephone, prefix="reset_password_send_count_")
return int(data) if data else 0

def _set_reset_password_send_count(self, telephone: str, count: int):
# 当天23:59:59失效
current_datetime = now()
today_last_time = datetime.datetime(
year=current_datetime.year,
month=current_datetime.month,
day=current_datetime.day,
hour=23,
minute=59,
second=59,
)
# 再次发送的情况下 ++ 1 会重置时间; 计算当前时间距离凌晨时间
expired_second = today_last_time.timestamp() - current_datetime.timestamp()
return self._set_into_cache(telephone, count, timeout=expired_second, prefix="reset_password_send_count_")

def _validate_before_generate(self, token: str):
neronkl marked this conversation as resolved.
Show resolved Hide resolved

# 校验是否重复发送
if self._get_reset_password_data(token):
effective_minutes = self.config_loader.get("verification_code_expire_seconds")
raise error_codes.VERIFICATION_CODE_REPEATABLE_SENDING.f(effective_minutes=effective_minutes // 60)

# 是否在发送的当日次数中
limit_count = self.config_loader.get("reset_sms_send_max_limit")
reset_password_send_count = self._get_reset_password_send_count(self.profile.telephone)
if reset_password_send_count > limit_count:
raise error_codes.VERIFICATION_CODE_SEND_LIMIT

def generate_reset_password_token(self) -> str:
# token 生成
hashed_value = f"{self.profile.username}@{self.profile.domain}|{self.profile.telephone}"
md = hashlib.md5()
md.update(hashed_value.encode("utf-8"))
token = md.hexdigest()

# 是否已经发送,是否超过当日发送次数
self._validate_before_generate(token)
expire_seconds = self.config_loader.get("verification_code_expire_seconds")
verification_code_length = self.config_loader.get("verification_code_length")

# 生成验证码
verification_code = "".join(random.sample(string.digits, verification_code_length))
expired_at = now() + datetime.timedelta(seconds=expire_seconds)
verification_code_data = {
"profile_id": self.profile.id,
"verification_code": verification_code,
"error_count": 0,
# 设置过期点的时间戳,正常情况下会自动过期,但是输入错误的情况下 error_count ++ 1 会重置时间
"expired_at_timestamp": expired_at.timestamp(),
}

logger.info("Set the verification_code_data in redis. token: {}".format(token))
neronkl marked this conversation as resolved.
Show resolved Hide resolved
# redis 缓冲验证码
neronkl marked this conversation as resolved.
Show resolved Hide resolved
self._set_reset_password_data(token, json.dumps(verification_code_data), expire_seconds)

# 增加当日发送次数
send_count = self._get_reset_password_send_count(self.profile.telephone) + 1
self._set_reset_password_send_count(self.profile.telephone, send_count)

reset_password_sms_config = self.config_loader.get("reset_password_sms_config")

sms_message_send_config = {
"sender": reset_password_sms_config["sender"],
"message": reset_password_sms_config["content"].format(verification_code=verification_code),
"receivers": [self.profile.telephone],
}

send_reset_password_verification_code_sms.delay(
profile_id=self.profile.id, send_config=sms_message_send_config
)

return token

def verify_verification_code(self, input_token: str, input_verification_code: str):
neronkl marked this conversation as resolved.
Show resolved Hide resolved
verification_code_data = self._get_reset_password_data(token=input_token)

# token 校验
if not verification_code_data:
logger.info(
"verify verification_code, verification_code_data is invalid. "
"Posted token is %s, verification_code=%s",
input_token,
input_verification_code,
)
raise error_codes.VERIFICATION_CODE_INVALID

logger.info(
"verify verification_code, verification_code_data is %s. Posted token is %s, verification_code=%s",
verification_code_data,
input_token,
input_verification_code,
)

self.profile = Profile.objects.get(id=verification_code_data["profile_id"])
self.config_loader = ConfigProvider(category_id=self.profile.category_id)

# 验证码校验
if verification_code_data["verification_code"] != input_verification_code:
neronkl marked this conversation as resolved.
Show resolved Hide resolved
verification_code_data["error_count"] += 1
expired_second = verification_code_data["expired_at_timestamp"] - now().timestamp()
# 输入错误,刚好临近过期
if expired_second < 0:
raise error_codes.VERIFICATION_CODE_INVALID
self._set_reset_password_data(
token=input_token,
verification_code_data=json.dumps(verification_code_data),
expire_seconds=expired_second,
)
error_count_limit = self.config_loader.get("failed_verification_max_limit")
# 验证码试错次数
if verification_code_data["error_count"] > error_count_limit:
raise error_codes.VERIFICATION_CODE_FAILED_MAX_COUNT
# 验证码错误
raise error_codes.VERIFICATION_CODE_FAILED

# 验证通过删除缓存
self._delete_reset_password_data(input_token)

def generate_profile_token(self) -> ProfileTokenHolder:
token_holder = ProfileTokenHolder.objects.create(profile=self.profile)
return token_holder
65 changes: 63 additions & 2 deletions src/api/bkuser_core/api/web/password/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@
PasswordModifyInputSLZ,
PasswordResetByTokenInputSLZ,
PasswordResetSendEmailInputSLZ,
PasswordResetSendSMSInputSLZ,
PasswordVerifyVerificationCodeInputSLZ,
)
from bkuser_core.api.web.password.verification_code_handler import ResetPasswordVerificationCodeHandler
from bkuser_core.api.web.utils import (
get_category,
get_operator,
get_profile_by_telephone,
get_profile_by_username,
get_token_handler,
list_setting_metas,
validate_password,
Expand All @@ -34,7 +39,7 @@
from bkuser_core.audit.utils import create_general_log
from bkuser_core.categories.models import ProfileCategory
from bkuser_core.common.error_codes import error_codes
from bkuser_core.profiles.exceptions import ProfileEmailEmpty
from bkuser_core.profiles.exceptions import ProfileEmailEmpty, UsernameWithDomainFormatError
from bkuser_core.profiles.models import Profile, ProfileTokenHolder
from bkuser_core.profiles.signals import post_profile_update
from bkuser_core.profiles.tasks import send_password_by_email
Expand Down Expand Up @@ -169,5 +174,61 @@ def get(self, request, *args, **kwargs):
namespace = SettingsEnableNamespaces.PASSWORD.value
metas = list_setting_metas(category.type, None, namespace)
settings = Setting.objects.filter(meta__in=metas, category_id=profile.category_id)

return Response(self.serializer_class(settings, many=True).data)


class PasswordResetSendVerificationCodeApi(generics.CreateAPIView):
def post(self, request, *args, **kwargs):
slz = PasswordResetSendSMSInputSLZ(data=request.data)
slz.is_valid(raise_exception=True)

data = slz.validated_data

telephone_or_username = data["telephone"]
neronkl marked this conversation as resolved.
Show resolved Hide resolved

# 根据交互设计,和登录一样:只能猜测这里传输的username,还是telephone
# 存在着username=telephone的情况
try:
profile = get_profile_by_username(telephone_or_username)
neronkl marked this conversation as resolved.
Show resolved Hide resolved

except UsernameWithDomainFormatError:
# 无法解析说明可能是telephone
profile = get_profile_by_telephone(telephone_or_username)

except Profile.DoesNotExist:
logger.exception("failed to get profile by telephone<%s> or username<%s>", telephone_or_username)
raise error_codes.USER_DOES_NOT_EXIST

except Profile.MultipleObjectsReturned:
logger.exception("failed to find profile by telephone<%s> or username<%s>", telephone_or_username)
neronkl marked this conversation as resolved.
Show resolved Hide resolved
raise error_codes.TELEPHONE_BINDED_TO_MULTI_PROFILE

# 生成verification_code_token
verification_code_token = ResetPasswordVerificationCodeHandler(profile).generate_reset_password_token()
origin_telephone = profile.telephone
neronkl marked this conversation as resolved.
Show resolved Hide resolved

# 用户未绑定手机号,即使用户名就是手机号码
if not origin_telephone:
raise error_codes.TELEPHONE_NOT_PROVIDED

response_data = {
"verification_code_token": verification_code_token,
# 加匿返回手机号
neronkl marked this conversation as resolved.
Show resolved Hide resolved
"telephone": origin_telephone.replace(origin_telephone[3:7], '****'),
}
Canway-shiisa marked this conversation as resolved.
Show resolved Hide resolved
return Response(response_data)
neronkl marked this conversation as resolved.
Show resolved Hide resolved


class PasswordVerifyVerificationCodeApi(generics.CreateAPIView):
def post(self, request, *args, **kwargs):
slz = PasswordVerifyVerificationCodeInputSLZ(data=request.data)
slz.is_valid(raise_exception=True)

data = slz.validated_data

verification_code_handler = ResetPasswordVerificationCodeHandler()

verification_code_handler.verify_verification_code(data["verification_code_token"], data["verification_code"])
neronkl marked this conversation as resolved.
Show resolved Hide resolved
profile_token = verification_code_handler.generate_profile_token()
neronkl marked this conversation as resolved.
Show resolved Hide resolved
# 前端拿到token,作为query_params,拼接重置页面路由
return Response({"token": profile_token.token})
neronkl marked this conversation as resolved.
Show resolved Hide resolved
neronkl marked this conversation as resolved.
Show resolved Hide resolved
14 changes: 13 additions & 1 deletion src/api/bkuser_core/api/web/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from bkuser_core.profiles.cache import get_extras_default_from_local_cache
from bkuser_core.profiles.models import DynamicFieldInfo, Profile, ProfileTokenHolder
from bkuser_core.profiles.password import PasswordValidator
from bkuser_core.profiles.utils import check_former_passwords
from bkuser_core.profiles.utils import check_former_passwords, parse_username_domain
from bkuser_core.user_settings.exceptions import SettingHasBeenDisabledError
from bkuser_core.user_settings.loader import ConfigProvider
from bkuser_core.user_settings.models import SettingMeta
Expand Down Expand Up @@ -180,6 +180,18 @@ def get_token_handler(token: str) -> ProfileTokenHolder:
return token_holder


def get_profile_by_username(username):
username, domain = parse_username_domain(username)
if not domain:
domain = ProfileCategory.objects.get_default().domain
profile = Profile.objects.get(username=username, domain=domain)
return profile
neronkl marked this conversation as resolved.
Show resolved Hide resolved


def get_profile_by_telephone(telephone):
return Profile.objects.get(telephone=telephone)


def escape_value(input_value: str) -> str:
"""Replace special characters "&", "<" and ">" to HTML-safe sequences.
If the optional flag quote is true, the quotation mark character (")
Expand Down
7 changes: 7 additions & 0 deletions src/api/bkuser_core/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ def __getattr__(self, code_name):
ErrorCode("USER_ALREADY_EXISTED", _("该目录下此用户名已存在"), status_code=HTTP_409_CONFLICT),
ErrorCode("SAVE_USER_INFO_FAILED", _("保存用户信息失败")),
ErrorCode("PASSWORD_DUPLICATED", _("新密码不能与最近{max_password_history}次密码相同")),
ErrorCode("TELEPHONE_NOT_PROVIDED", _("该用户没有绑定手机号,发送短信失败")),
ErrorCode("TELEPHONE_BINDED_TO_MULTI_PROFILE", _("该手机号被多个用户绑定,请输入具体的用户名或联系管理员处理")),
ErrorCode("VERIFICATION_CODE_REPEATABLE_SENDING", _("验证码已发送,有效时间为{effective_minutes}分钟,请勿重复发送")),
neronkl marked this conversation as resolved.
Show resolved Hide resolved
ErrorCode("VERIFICATION_CODE_SEND_LIMIT", _("该手机号已超过当日重置密码短信发送限制次数")),
neronkl marked this conversation as resolved.
Show resolved Hide resolved
ErrorCode("VERIFICATION_CODE_INVALID", _("验证码失效,请重新发送")),
ErrorCode("VERIFICATION_CODE_FAILED", _("你所输入验证码错误,请重新输入")),
ErrorCode("VERIFICATION_CODE_FAILED_MAX_COUNT", _("你所输入验证码错误,验证次数已达上限,请验证码过期后重试")),
neronkl marked this conversation as resolved.
Show resolved Hide resolved
ErrorCode("OLD_PASSWORD_ERROR", _("原密码校验失败")),
# 上传文件相关
ErrorCode("FILE_IMPORT_TOO_LARGE", _("上传文件过大")),
Expand Down
11 changes: 11 additions & 0 deletions src/api/bkuser_core/config/common/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@
"LOCATION": "memory_cache_0",
"KEY_PREFIX": "bk_user",
},
"verification_code": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"TIMEOUT": 30 * 60,
"KEY_PREFIX": f"{REDIS_KEY_PREFIX}verification_code",
"VERSION": 1,
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient", "PASSWORD": REDIS_PASSWORD},
neronkl marked this conversation as resolved.
Show resolved Hide resolved
"SOCKET_CONNECT_TIMEOUT": 5, # socket 建立连接超时设置,单位秒
"SOCKET_TIMEOUT": 5, # 连接建立后的读写操作超时设置,单位秒
"IGNORE_EXCEPTIONS": True, # redis 只作为缓存使用, 触发异常不能影响正常逻辑,可能只是稍微慢点而已
},
}
# 全局缓存过期时间,默认为一小时
GLOBAL_CACHES_TIMEOUT = env.int("GLOBAL_CACHES_TIMEOUT", default=60 * 60)
Expand Down
Loading