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

feat: 支持密码过期返回token重置密码链接 & 支持初始密码强制修改 #146

Merged
merged 3 commits into from
Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from all 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/audit/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ class LogInFailReason(AutoLowerEnum):
TOO_MANY_FAILURE = auto()
LOCKED_USER = auto()
DISABLED_USER = auto()
SHOULD_CHANGE_INITIAL_PASSWORD = auto()

_choices_labels = (
(BAD_PASSWORD, "密码错误"),
(EXPIRED_PASSWORD, "密码过期"),
(TOO_MANY_FAILURE, "密码错误次数过多"),
(LOCKED_USER, "用户已锁定"),
(DISABLED_USER, "用户已删除"),
(SHOULD_CHANGE_INITIAL_PASSWORD, "需要修改初始密码"),
)


Expand Down
33 changes: 20 additions & 13 deletions src/api/bkuser_core/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
specific language governing permissions and limitations under the License.
"""
import copy
from typing import Optional

from django.conf import settings
from django.utils.translation import gettext_lazy as _
Expand All @@ -23,16 +24,18 @@ class CoreAPIError(APIException):

def __init__(self, code):
self.code = code
self.data = {}
super().__init__(str(self))

def __str__(self):
return f"CoreAPIError {self.code.status_code}-{self.code.code_name}"

def format(self, message=None, replace=False, **kwargs):
def format(self, message: Optional[str] = None, replace: bool = False, data: Optional[dict] = None, **kwargs):
"""Using a customized message for this ErrorCode

:param data: exception body
:param str message: if not given, default message will be used
:param bool replace: relace default message if true
:param bool replace: replace default message if true
"""
self.code = copy.copy(self.code)
if message:
Expand All @@ -45,6 +48,9 @@ def format(self, message=None, replace=False, **kwargs):
if kwargs:
self.code.message = self.code.message.format(**kwargs)

if data:
self.data = data

return self

def f(self, message=None, **kwargs):
Expand All @@ -59,7 +65,7 @@ def code_num(self):
return self.code.code_num


class ErrorCode(object):
class ErrorCode:
"""Error code"""

def __init__(self, code_name, message, code_num=-1, status_code=HTTP_400_BAD_REQUEST):
Expand All @@ -69,7 +75,7 @@ def __init__(self, code_name, message, code_num=-1, status_code=HTTP_400_BAD_REQ
self.status_code = status_code


class ErrorCodeCollection(object):
class ErrorCodeCollection:
"""A collection of ErrorCodes"""

def __init__(self):
Expand Down Expand Up @@ -98,24 +104,25 @@ def __getattr__(self, code_name):
ErrorCode("RESOURCE_RESTORATION_FAILED", _("资源恢复失败")),
# 登陆相关
ErrorCode("USER_DOES_NOT_EXIST", _("账号不存在"), 3210010),
ErrorCode("TOO_MANY_TRY", _("密码输入错误次数过多,已被锁定"), 3210011),
ErrorCode("USERNAME_FORMAT_ERROR", _("账户名格式错误"), 3210012),
ErrorCode("DOMAIN_UNKNOWN", _("未知登陆域"), 3210017),
ErrorCode("PASSWORD_ERROR", _("账户或者密码错误,请重新输入"), 3210013),
ErrorCode("USER_EXIST_MANY", _("存在多个同名账号,请联系管理员"), 3210014),
ErrorCode("USER_IS_DISABLED", _("账号已被管理员禁用,请联系管理员"), 3210016),
ErrorCode("USER_IS_LOCKED", _("账号长时间未登录,已被冻结,请联系管理员"), 3210015),
ErrorCode("PASSWORD_ERROR", _("账户名和密码不匹配"), 3210013),
ErrorCode(
"PASSWORD_DUPLICATED",
_("新密码不能与最近{}次密码相同").format(settings.MAX_PASSWORD_HISTORY),
),
ErrorCode("USER_IS_DISABLED", _("账号已被管理员禁用,请联系管理员"), 3210016),
ErrorCode("DOMAIN_UNKNOWN", _("未知登陆域"), 3210017),
ErrorCode("PASSWORD_EXPIRED", _("该账户密码已到期,请修改密码后登录"), 3210018),
ErrorCode("TOO_MANY_TRY", _("密码输入错误次数过多,已被锁定"), 3210011),
ErrorCode("CATEGORY_NOT_ENABLED", _("用户目录未启用"), 3210019),
ErrorCode("ERROR_FORMAT", _("传入参数错误"), 3210019),
ErrorCode("ERROR_FORMAT", _("传入参数错误"), 3210020),
ErrorCode("SHOULD_CHANGE_INITIAL_PASSWORD", _("请修改初始密码"), 3210021),
# 用户相关
ErrorCode("EMAIL_NOT_PROVIDED", _("该用户没有提供邮箱,发送邮件失败")),
ErrorCode("USER_ALREADY_EXISTED", _("该目录下此用户名已存在"), status_code=HTTP_409_CONFLICT),
ErrorCode("SAVE_USER_INFO_FAILED", _("保存用户信息失败")),
ErrorCode(
"PASSWORD_DUPLICATED",
_("新密码不能与最近{}次密码相同").format(settings.MAX_PASSWORD_HISTORY),
),
# 上传文件相关
ErrorCode("FILE_IMPORT_TOO_LARGE", _("上传文件过大")),
ErrorCode("FILE_IMPORT_FORMAT_ERROR", _("上传文件格式错误")),
Expand Down
1 change: 1 addition & 0 deletions src/api/bkuser_core/common/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def get_ee_exception_response(exc, context):
# 主动抛出的已知异常
data["code"] = exc.code_num
data["message"] = exc.message
data["data"] = exc.data or None
elif isinstance(exc, Http404):
data["message"] = "404, could not be found"
elif isinstance(exc, PermissionDenied):
Expand Down
1 change: 1 addition & 0 deletions src/api/bkuser_core/common/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import json

from django.utils.deprecation import MiddlewareMixin

from .http import force_response_ee_format, force_response_raw_format, should_use_raw_response
Expand Down
1 change: 1 addition & 0 deletions src/api/bkuser_core/config/common/.env-tmpl
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
BK_PAAS_URL="http://paas.example.com"
BK_USER_SAAS_URL="http://bkuser-saas.example.com"

BK_APP_CODE="bk-user"
BK_APP_SECRET="some-default-token"
Expand Down
4 changes: 3 additions & 1 deletion src/api/bkuser_core/config/common/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@
# 最大的自定义字段数量(暂未启用)
MAX_DYNAMIC_FIELDS = 20

# 默认用户 Token 的过期时间
# 默认用户 Token 的过期时间(用于发送邮件)
DEFAULT_TOKEN_EXPIRE_SECONDS = 12 * 60 * 60
# 页面临时生成用户 Token
PAGE_TOKEN_EXPIRE_SECONDS = 5 * 60

# 国际号码段默认值
DEFAULT_COUNTRY_CODE = "86"
Expand Down
5 changes: 0 additions & 5 deletions src/api/bkuser_core/config/overlays/dev.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,3 @@ if ENABLE_PROFILING:

# silk middleware should be placed before any middleware using process_request
MIDDLEWARE.insert(0, "silk.middleware.SilkyMiddleware")

# ==============================================================================
# SaaS
# ==============================================================================
SAAS_URL = urllib.parse.urljoin(BK_PAAS_URL, f"/o/{SAAS_CODE}/")
2 changes: 0 additions & 2 deletions src/api/bkuser_core/config/overlays/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,3 @@
file_name=APP_ID,
package_name="bkuser_core",
)

SAAS_URL = urllib.parse.urljoin(BK_PAAS_URL, f"/o/{SAAS_CODE}/")
2 changes: 0 additions & 2 deletions src/api/bkuser_core/config/overlays/stag.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,3 @@
file_name=APP_ID,
package_name="bkuser_core",
)

SAAS_URL = urllib.parse.urljoin(BK_PAAS_URL, f"/t/{SAAS_CODE}/")
5 changes: 0 additions & 5 deletions src/api/bkuser_core/config/overlays/unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,3 @@
# profiling
# ==============================================================================
ENABLE_PROFILING = False

# ==============================================================================
# SaaS
# ==============================================================================
SAAS_URL = urllib.parse.urljoin(BK_PAAS_URL, f"/o/{SAAS_CODE}/")
5 changes: 2 additions & 3 deletions src/api/bkuser_core/profiles/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,10 @@ def get_extras_default_values(self) -> dict:


class ProfileTokenManager(models.Manager):
def create(self, profile):
def create(self, profile, token_expire_seconds: int = settings.DEFAULT_TOKEN_EXPIRE_SECONDS):
token = secrets.token_urlsafe(32)

# TODO: use another way generate token, - is not safe for swagger sdk, still not know why
token = token.replace("-", "0")

expire_time = now() + datetime.timedelta(seconds=settings.DEFAULT_TOKEN_EXPIRE_SECONDS)
expire_time = now() + datetime.timedelta(seconds=token_expire_seconds)
return super().create(token=token, profile=profile, expire_time=expire_time)
13 changes: 8 additions & 5 deletions src/api/bkuser_core/profiles/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
specific language governing permissions and limitations under the License.
"""
import logging
import urllib.parse

from bkuser_core.celery import app
from bkuser_core.common.notifier import send_mail
from bkuser_core.profiles import exceptions
from bkuser_core.profiles.constants import PASSWD_RESET_VIA_SAAS_EMAIL_TMPL
from bkuser_core.profiles.models import Profile
from bkuser_core.profiles.utils import make_passwd_reset_url_by_token
from bkuser_core.user_settings.loader import ConfigProvider
from django.conf import settings

from . import exceptions
from .models import Profile

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -49,8 +50,10 @@ def send_password_by_email(profile_id: int, raw_password: str = None, init: bool
# 从平台重置密码
if token:
email_config = config_loader["reset_mail_config"]
url = settings.SAAS_URL + "set_password?token=%s " % token
message = email_config["content"].format(url=url, reset_url=settings.SAAS_URL + "reset_password ")
message = email_config["content"].format(
url=make_passwd_reset_url_by_token(token),
reset_url=urllib.parse.urljoin(settings.SAAS_URL, "reset_password"),
)
# 在用户管理里管理操作重置密码
else:
email_config = config_loader["reset_mail_config"]
Expand Down
6 changes: 6 additions & 0 deletions src/api/bkuser_core/profiles/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import random
import re
import string
import urllib.parse
from typing import TYPE_CHECKING, Tuple

from bkuser_core.categories.models import ProfileCategory
Expand Down Expand Up @@ -186,3 +187,8 @@ def check_former_passwords(
former_passwords = [x.password for x in reset_records]

return new_password in former_passwords


def make_passwd_reset_url_by_token(token: str):
"""make reset"""
return urllib.parse.urljoin(settings.SAAS_URL, f"set_password?token={token}")
51 changes: 39 additions & 12 deletions src/api/bkuser_core/profiles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
align_country_iso_code,
check_former_passwords,
force_use_raw_username,
make_passwd_reset_url_by_token,
make_password_by_config,
parse_username_domain,
)
Expand Down Expand Up @@ -542,6 +543,7 @@ def login(self, request):
raise error_codes.PASSWORD_ERROR

time_aware_now = now()
config_loader = ConfigProvider(category_id=category.id)
# Admin 用户只需直接判断 密码是否正确 (只有本地目录有密码配置)
if not profile.is_superuser and category.type in [CategoryType.LOCAL.value]:

Expand All @@ -556,18 +558,17 @@ def login(self, request):
request=request,
params={"is_success": False, "reason": LogInFailReason.DISABLED_USER.value},
)
raise error_codes.USER_IS_DISABLED
raise error_codes.PASSWORD_ERROR
elif profile.status == ProfileStatus.LOCKED.value:
create_profile_log(
profile=profile,
operation="LogIn",
request=request,
params={"is_success": False, "reason": LogInFailReason.LOCKED_USER.value},
)
raise error_codes.USER_IS_LOCKED
raise error_codes.PASSWORD_ERROR

# 获取密码配置
config_loader = ConfigProvider(category_id=category.id)
auto_unlock_seconds = int(config_loader["auto_unlock_seconds"])
max_trail_times = int(config_loader["max_trail_times"])

Expand All @@ -583,7 +584,10 @@ def login(self, request):
request=request,
params={"is_success": False, "reason": LogInFailReason.TOO_MANY_FAILURE.value},
)
raise error_codes.TOO_MANY_TRY.f(f"请 {retry_after_wait}s 后再试")

logger.info(f"用户<{profile}> 登录失败错误过多,已被锁定,请 {retry_after_wait}s 后再试")
# 当密码输入错误时,不暴露不同的信息,避免用户名爆破
raise error_codes.PASSWORD_ERROR

try:
login_class = get_plugin_by_category(category).login_handler_cls
Expand All @@ -594,16 +598,10 @@ def login(self, request):
category.display_name,
category.id,
)
raise error_codes.LOAD_LOGIN_HANDLER_FAILED
raise error_codes.PASSWORD_ERROR

try:
login_class().check(profile, password)
create_profile_log(
profile=profile,
operation="LogIn",
request=request,
params={"is_success": True},
)
except Exception:
create_profile_log(
profile=profile,
Expand All @@ -614,6 +612,19 @@ def login(self, request):
logger.exception("check profile<%s> failed", profile.username)
raise error_codes.PASSWORD_ERROR
else:
# 密码状态校验:初始密码未修改
if config_loader.get("force_reset_first_login") and profile.password_update_time is None:
create_profile_log(
profile=profile,
operation="LogIn",
request=request,
params={"is_success": False, "reason": LogInFailReason.SHOULD_CHANGE_INITIAL_PASSWORD.value},
)

raise error_codes.SHOULD_CHANGE_INITIAL_PASSWORD.format(
data=self._generate_reset_passwd_url_with_token(profile)
)

# 密码状态校验:密码过期
valid_period = datetime.timedelta(days=profile.password_valid_days)
if (
Expand All @@ -627,10 +638,26 @@ def login(self, request):
request=request,
params={"is_success": False, "reason": LogInFailReason.EXPIRED_PASSWORD.value},
)
raise error_codes.PASSWORD_EXPIRED

raise error_codes.PASSWORD_EXPIRED.format(data=self._generate_reset_passwd_url_with_token(profile))

create_profile_log(profile=profile, operation="LogIn", request=request, params={"is_success": True})
return Response(data=local_serializers.ProfileSerializer(profile, context={"request": request}).data)

@staticmethod
def _generate_reset_passwd_url_with_token(profile: Profile) -> dict:
data = {}
try:
token_holder = ProfileTokenHolder.objects.create(
profile=profile, token_expire_seconds=settings.PAGE_TOKEN_EXPIRE_SECONDS
)
except Exception: # pylint: disable=broad-except
logger.exception("failed to create token for password reset")
else:
data.update({"reset_password_url": make_passwd_reset_url_by_token(token_holder.token)})

return data

@method_decorator(clear_cache_if_succeed)
@swagger_auto_schema(request_body=local_serializers.LoginUpsertSerializer)
def upsert(self, request):
Expand Down
Loading