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 2 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
2 changes: 2 additions & 0 deletions src/api/bkuser_core/api/web/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
"""

EXCLUDE_SETTINGS_META_KEYS = ["password_rsa_private_key"]

ONE_MINUTE_OF_SECONDS = 60
neronkl marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 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,16 @@ class PasswordResetSendEmailInputSLZ(serializers.Serializer):
email = serializers.EmailField(required=True, max_length=254)


class PasswordResetSendSMSInputSLZ(serializers.Serializer):
username = serializers.CharField(required=False)
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 captcha 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 captcha 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/sent_verification_code_sms/",
neronkl marked this conversation as resolved.
Show resolved Hide resolved
views.PasswordResetSendVerificationCodeApi.as_view(),
name="password.reset.sent_verification_code_sms",
),
path(
"reset/verify_verification_code/",
views.PasswordVerifyVerificationCodeApi.as_view(),
name="password.reset.verify_verification_code",
),
path(
"reset/by_token/",
views.PasswordResetByTokenApi.as_view(),
Expand Down
205 changes: 205 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,205 @@
# -*- 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.constants import ONE_MINUTE_OF_SECONDS
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 VerificationCodeBaseHandler:
def __init__(self, profile):

self.profile = profile
if profile:
self.config_loader = ConfigProvider(category_id=profile.category_id)
else:
self.profile = Profile()
self.cache = caches["verification_code"]

def _generate_token(self) -> str:
hashed_value = f"{self.profile.username}|{self.profile.telephone}"
md = hashlib.md5()
md.update(hashed_value.encode("utf-8"))
return md.hexdigest()

neronkl marked this conversation as resolved.
Show resolved Hide resolved
def _set_data(self, key: str, data: Any, timeout: int = None, prefix: str = None):
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_data(self, key: str, prefix: str = None):
if prefix:
key = f"{prefix}_{key}"
data = self.cache.get(key=key)

return data

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

def _generate_ramdom_verification_code(self, code_length: int) -> str:
return "".join(random.sample(string.digits, code_length))


class ResetPasswordVerificationCodeHandler(VerificationCodeBaseHandler):
def get_reset_password_data(self, token: str) -> dict:
neronkl marked this conversation as resolved.
Show resolved Hide resolved
data = self._get_data(token, prefix="reset_password")
return json.loads(data) if data else {}

def set_reset_password_data(self, token, data, expire_seconds):
return self._set_data(token, data, expire_seconds, prefix="reset_password")

def delete_reset_password_data(self, token):
return self._delete(token, prefix="reset_password")

def is_profile_had_generated_verification_code(self, token: str) -> bool:
# 校验是否重复发送
if self.get_reset_password_data(token):
return True
return False

def get_reset_password_send_count(self, telephone: str) -> int:
data = self._get_data(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_data(telephone, count, timeout=expired_second, prefix="reset_password_send_count_")

def validate_before_generate(self, token: str):

# 校验是否重复发送
if self.is_profile_had_generated_verification_code(token):
neronkl marked this conversation as resolved.
Show resolved Hide resolved
effective_minutes = self.config_loader.get("verification_code_expire_seconds")
raise error_codes.VERIFICATION_CODE_HAD_SEND.f(
effective_minutes=effective_minutes // ONE_MINUTE_OF_SECONDS
)

# 是否在发送的当日次数中
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 生成
token = self._generate_token()
# 是否已经发送,是否超过当日发送次数
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 = self._generate_ramdom_verification_code(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 captcha_data in redis. token: {}".format(token))
# 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: int):
verification_code_data = self.get_reset_password_data(token=input_token)

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

logger.info(
"verify captcha, verification_code_data is %s. Posted token is %s, captcha=%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(
input_token, 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
50 changes: 50 additions & 0 deletions src/api/bkuser_core/api/web/password/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
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,
Expand Down Expand Up @@ -171,3 +174,50 @@ def get(self, request, *args, **kwargs):
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

username = data.get("username")
telephone = data["telephone"]
neronkl marked this conversation as resolved.
Show resolved Hide resolved

try:
if username:
username, domain = parse_username_domain(username)
if not domain:
domain = ProfileCategory.objects.get(default=True).domain

profile = Profile.objects.get(username=username, domain=domain, telephone=telephone)
else:
profile = Profile.objects.get(telephone=telephone)
neronkl marked this conversation as resolved.
Show resolved Hide resolved

except Profile.DoesNotExist:
logger.exception("failed to get profile by telephone<%s>", telephone)
raise error_codes.TELEPHONE_NOT_PROVIDED
neronkl marked this conversation as resolved.
Show resolved Hide resolved

except Profile.MultipleObjectsReturned:
logger.exception("failed to get profile by telephone<%s>", telephone)
raise error_codes.TELEPHONE_MULTI_BOUND

# 生成verification_code_token
verification_code_token = ResetPasswordVerificationCodeHandler(profile).generate_reset_password_token()
return Response(verification_code_token)


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
return Response(profile_token.token)
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_MULTI_BOUND", _("该手机号被多个用户绑定,请输入具体的用户名或联系管理员处理")),
neronkl marked this conversation as resolved.
Show resolved Hide resolved
ErrorCode("VERIFICATION_CODE_HAD_SEND", _("验证码已发送,有效时间为{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("FILE_IMPORT_TOO_LARGE", _("上传文件过大")),
ErrorCode("FILE_IMPORT_FORMAT_ERROR", _("上传文件格式错误")),
Expand Down
8 changes: 8 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,14 @@
"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
},
}
# 全局缓存过期时间,默认为一小时
GLOBAL_CACHES_TIMEOUT = env.int("GLOBAL_CACHES_TIMEOUT", default=60 * 60)
Expand Down
Loading