Skip to content

Commit

Permalink
feat: 密码过期邮件提醒功能 TencentBlueKing#117
Browse files Browse the repository at this point in the history
  • Loading branch information
Canway-shiisa committed Feb 25, 2022
1 parent 1def8a0 commit a1dde0a
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 7 deletions.
37 changes: 37 additions & 0 deletions src/api/bkuser_core/common/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
logger = logging.getLogger(__name__)

DEFAULT_EMAIL_SENDER = "bk-user-core-api"
DEFAULT_SMS_SENDER = "bk-user-core-api"


class ReceiversCouldNotBeEmpty(Exception):
Expand All @@ -30,6 +31,10 @@ class SendMailFailed(Exception):
"""发送邮件失败"""


class SendSmsFailed(Exception):
"""发送短信失败"""


def send_mail(receivers: List[str], message: str, sender: str = None, title: str = None):
"""发邮件"""
if not receivers:
Expand Down Expand Up @@ -70,3 +75,35 @@ def send_mail(receivers: List[str], message: str, sender: str = None, title: str
ret.get("message", "unknown error"),
)
raise SendMailFailed(ret.get("message", "unknown error"))


def send_sms(receivers: List[str], message: str, sender: str = None):
"""发短信"""
if not receivers:
raise ReceiversCouldNotBeEmpty(_("收件人不能为空"))

receivers_str = ",".join(receivers)

client = get_client_by_raw_username(user=sender or DEFAULT_SMS_SENDER)

message_encoded = force_text(base64.b64encode(message.encode("utf-8")))
logger.info(
"going to send sms to %s, via %s",
receivers_str,
DEFAULT_EMAIL_SENDER,
)

send_sms_params = {
"content": message_encoded,
"receiver": receivers_str,
"is_content_base64": True,
}
ret = client.cmsi.send_sms(**send_sms_params)

if not ret.get("result", False):
logger.error(
"Failed to send sms notification %s for %s",
receivers_str,
ret.get("message", "unknown error"),
)
raise SendSmsFailed(ret.get("message", "unknown error"))
4 changes: 4 additions & 0 deletions src/api/bkuser_core/profiles/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ class ProfileEmailEmpty(Exception):
"""用户邮箱为空"""


class ProfileTelephoneEmpty(Exception):
"""用户手机号码为空"""


class CountryISOCodeNotMatch(Exception):
"""Country Code 不匹配"""
102 changes: 101 additions & 1 deletion src/api/bkuser_core/profiles/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@
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 logging
import urllib.parse

from bkuser_core.categories.constants import CategoryType
from bkuser_core.categories.models import ProfileCategory
from bkuser_core.celery import app
from bkuser_core.common.notifier import send_mail
from bkuser_core.common.notifier import send_mail, send_sms
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 celery.task import periodic_task
from django.conf import settings
from django.utils.timezone import now

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -65,3 +70,98 @@ def send_password_by_email(profile_id: int, raw_password: str = None, init: bool
message=message,
title=email_config["title"],
)


class Notification:
"""
通过 邮件/短信 的方式发送密码过期通知
"""

def __init__(self, profile):
self.config_loader = ConfigProvider(profile.category_id)
self.profile = profile
self.url = settings.LOGIN_REDIRECT_TO

valid_period = datetime.timedelta(days=profile.password_valid_days)
self.expire_at = ((profile.password_update_time or profile.latest_password_update_time) + valid_period) - now()

def handler(self):

notice_method_map = {
"send_email": self._notice_by_email,
"send_sms": self._notice_by_sms,
}

for notice_method in self.config_loader["notice_method"]:
notice_method_map[notice_method]()

def _notice_by_email(self):

if not self.profile.email:
logger.exception("profiles<%s> has no valid email", self.profile.username)
raise exceptions.ProfileEmailEmpty

if (self.expire_at.days < 0) or (self.expire_at.days in self.config_loader["notice_time"]):
logger.info(
"--------- going to send notification of password expiration for Profile(%s) via email ----------",
self.profile.id,
)

email_config = (
self.config_loader["expired_email_config"]
if self.expire_at.days < 0
else self.config_loader["expiring_email_config"]
)

message = (
email_config["content"].format(username=self.profile.username, url=self.url)
if self.expire_at.days < 0
else email_config["content"].format(
username=self.profile.username, expire_at=self.expire_at.days, url=self.url
)
)

send_mail(
sender=email_config["sender"],
receivers=[self.profile.email],
message=message,
title=email_config["title"],
)

def _notice_by_sms(self):

if not self.profile.telephone:
logger.exception("profiles<%s> has no valid telephone", self.profile.telephone)
raise exceptions.ProfileTelephoneEmpty

if (self.expire_at.days < 0) or (self.expire_at.days in self.config_loader["notice_time"]):
logger.info(
"--------- going to send notification of password expiration for Profile(%s) via sms ----------",
self.profile.id,
)

sms_config = (
self.config_loader["expired_sms_config"]
if self.expire_at.days < 0
else self.config_loader["expiring_sms_config"]
)

message = (
sms_config["content"].format(username=self.profile.username, url=self.url)
if self.expire_at.days < 0
else sms_config["content"].format(
username=self.profile.username, expire_at=self.expire_at.days, url=self.url
)
)

send_sms(receivers=[self.profile.telephone], message=message)


@periodic_task(run_every=30)
def notice_for_password_expiration():
"""密码过期通知"""
category_ids = ProfileCategory.objects.filter(type=CategoryType.LOCAL.value).values_list("id")
local_profiles = Profile.objects.filter(category_id__in=category_ids, password_valid_days__gt=0)

for profile in local_profiles:
Notification(profile=profile).handler()
8 changes: 2 additions & 6 deletions src/api/bkuser_core/user_settings/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,9 @@ class SettingsEnableNamespaces(AutoLowerEnum):
PASSWORD = auto()
CONNECTION = auto()
FIELDS = auto()
NOTIFICATION = auto()

_choices_labels = (
(GENERAL, "通用"),
(PASSWORD, "密码"),
(CONNECTION, "连接"),
(FIELDS, "连接"),
)
_choices_labels = ((GENERAL, "通用"), (PASSWORD, "密码"), (CONNECTION, "连接"), (FIELDS, "连接"), (NOTIFICATION, "通知"))


class InitPasswordMethod(AutoLowerEnum):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 3.2.5 on 2022-02-23 03:08

from django.db import migrations, models
import jsonfield.fields


class Migration(migrations.Migration):

dependencies = [
('user_settings', '0011_alter_extend_fields_connection_settings'),
]

operations = [
migrations.AlterField(
model_name='settingmeta',
name='default',
field=jsonfield.fields.JSONField(default=None, verbose_name='默认值'),
),
migrations.AlterField(
model_name='settingmeta',
name='namespace',
field=models.CharField(
choices=[
('general', '通用'),
('password', '密码'),
('connection', '连接'),
('fields', '连接'),
('notification', '通知')],
db_index=True,
default='general',
max_length=32,
verbose_name='命名空间'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# -*- 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.
"""
from __future__ import unicode_literals

from bkuser_core.categories.constants import CategoryType
from bkuser_core.user_settings.constants import SettingsEnableNamespaces
from django.db import migrations


def forwards_func(apps, schema_editor):
"""添加默认用户目录通知设置"""

SettingMeta = apps.get_model("user_settings", "SettingMeta")
Setting = apps.get_model("user_settings", "Setting")
ProfileCategory = apps.get_model("categories", "ProfileCategory")

local_notification_settings = [
dict(
key="notice_method",
default=["send_email", "send_sms"]
),
dict(
key="notice_time",
default=[1, 7, 15]
),
dict(
key="expiring_email_config",
default={
"title": "【蓝鲸智云企业版】密码到期提醒",
"sender": "蓝鲸智云企业版",
"content": "{username},您好!您的蓝鲸智云企业版平台密码将于{expire_at}天后到期,"
"为避免影响使用,请尽快登陆平台({url})修改密码。蓝鲸智云平台用户管理处",
}
),
dict(
key="expired_email_config",
default={
"title": "【蓝鲸智云企业版】密码到期提醒",
"sender": "蓝鲸智云企业版",
"content": "{username},您好!您的蓝鲸智云企业版平台密码已过期,为避免影响使用,"
"请尽快登陆平台({url})修改密码。蓝鲸智云平台用户管理处",
},
),
dict(
key="expiring_sms_config",
default={
"sender": "蓝鲸智云企业版",
"content": "【蓝鲸智云企业版】密码到期提醒!{username},您好,您的蓝鲸平台密码将于{expire_at}天后到期,"
"为避免影响使用,请尽快登陆平台({url})修改密码。"
},
),
dict(
key="expired_sms_config",
default={
"sender": "蓝鲸智云企业版",
"content": "【蓝鲸智云企业版】密码到期提醒!{username}您好!您的蓝鲸智云企业版平台密码已过期,"
"为避免影响使用,请尽快登陆平台({url})修改密码。",
},
),

]

for x in local_notification_settings:
meta = SettingMeta.objects.create(
namespace=SettingsEnableNamespaces.NOTIFICATION.value,
category_type=CategoryType.LOCAL.value,
required=True,
**x
)
# 保证已存在的目录拥有默认配置
for c in ProfileCategory.objects.filter(type=CategoryType.LOCAL.value):
Setting.objects.get_or_create(meta=meta, category_id=c.id, value=meta.default)


def backwards_func(apps, schema_editor):
SettingMeta = apps.get_model("user_settings", "SettingMeta")
meta = SettingMeta.objects.get(
namespace=SettingsEnableNamespaces.NOTIFICATION.value,
category_type=CategoryType.LOCAL.value
)
Setting = apps.get_model("user_settings", "Setting")
Setting.objects.filter(category__type=CategoryType.LOCAL.value, meta=meta).delete()
meta.delete()


class Migration(migrations.Migration):

dependencies = [
("user_settings", "0012_auto_20220223_1108"),
]

operations = [migrations.RunPython(forwards_func, backwards_func)]

0 comments on commit a1dde0a

Please # to comment.