Skip to content

Commit

Permalink
Merge pull request #146 from IMBlues/login-merge
Browse files Browse the repository at this point in the history
feat: 支持密码过期返回token重置密码链接 & 支持初始密码强制修改
  • Loading branch information
IMBlues authored Nov 23, 2021
2 parents b314108 + 35df78a commit 1ccaac3
Show file tree
Hide file tree
Showing 25 changed files with 191 additions and 88 deletions.
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

0 comments on commit 1ccaac3

Please sign in to comment.