perf: Links in WeCom messages can be opened without re-logging in.

pull/14357/head
jiangweidong 2024-10-18 16:51:12 +08:00 committed by Bryan
parent cc1fcd2b98
commit 7c55c42582
6 changed files with 178 additions and 140 deletions

View File

@ -13,20 +13,17 @@ from authentication.const import ConfirmType
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from authentication.permissions import UserConfirmation from authentication.permissions import UserConfirmation
from common.sdk.im.wecom import URL from common.sdk.im.wecom import URL
from common.sdk.im.wecom import WeCom from common.sdk.im.wecom import WeCom, wecom_tool
from common.utils import get_logger from common.utils import get_logger
from common.utils.common import get_request_ip
from common.utils.django import reverse, get_object_or_none, safe_next_url from common.utils.django import reverse, get_object_or_none, safe_next_url
from common.utils.random import random_string
from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMixin from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMixin
from users.models import User from users.models import User
from users.views import UserVerifyPasswordView from users.views import UserVerifyPasswordView
from .base import BaseLoginCallbackView, BaseBindCallbackView from .base import BaseLoginCallbackView, BaseBindCallbackView
from .mixins import METAMixin, FlashMessageMixin from .mixins import METAMixin, FlashMessageMixin
logger = get_logger(__file__)
WECOM_STATE_SESSION_KEY = '_wecom_state' logger = get_logger(__file__)
class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, View): class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, View):
@ -45,7 +42,7 @@ class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashM
) )
def verify_state(self): def verify_state(self):
return self.verify_state_with_session_key(WECOM_STATE_SESSION_KEY) return wecom_tool.check_state(self.request.GET.get('state'), self.request)
def get_already_bound_response(self, redirect_url): def get_already_bound_response(self, redirect_url):
msg = _('WeCom is already bound') msg = _('WeCom is already bound')
@ -56,13 +53,10 @@ class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashM
class WeComQRMixin(WeComBaseMixin, View): class WeComQRMixin(WeComBaseMixin, View):
def get_qr_url(self, redirect_uri): def get_qr_url(self, redirect_uri):
state = random_string(16)
self.request.session[WECOM_STATE_SESSION_KEY] = state
params = { params = {
'appid': settings.WECOM_CORPID, 'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID, 'agentid': settings.WECOM_AGENTID,
'state': state, 'state': wecom_tool.gen_state(request=self.request),
'redirect_uri': redirect_uri, 'redirect_uri': redirect_uri,
} }
url = URL.QR_CONNECT + '?' + urlencode(params) url = URL.QR_CONNECT + '?' + urlencode(params)
@ -74,13 +68,11 @@ class WeComOAuthMixin(WeComBaseMixin, View):
def get_oauth_url(self, redirect_uri): def get_oauth_url(self, redirect_uri):
if not settings.AUTH_WECOM: if not settings.AUTH_WECOM:
return reverse('authentication:login') return reverse('authentication:login')
state = random_string(16)
self.request.session[WECOM_STATE_SESSION_KEY] = state
params = { params = {
'appid': settings.WECOM_CORPID, 'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID, 'agentid': settings.WECOM_AGENTID,
'state': state, 'state': wecom_tool.gen_state(request=self.request),
'redirect_uri': redirect_uri, 'redirect_uri': redirect_uri,
'response_type': 'code', 'response_type': 'code',
'scope': 'snsapi_base', 'scope': 'snsapi_base',

View File

@ -16,12 +16,6 @@ def digest(corp_id, corp_secret):
return dist return dist
def update_values(default: dict, others: dict):
for key in default.keys():
if key in others:
default[key] = others[key]
def set_default(data: dict, default: dict): def set_default(data: dict, default: dict):
for key in default.keys(): for key in default.keys():
if key not in data: if key not in data:

View File

@ -1,12 +1,14 @@
from typing import Iterable, AnyStr from typing import Iterable, AnyStr
from urllib.parse import urlencode
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from common.sdk.im.mixin import RequestMixin, BaseRequest from common.sdk.im.mixin import RequestMixin, BaseRequest
from common.sdk.im.utils import digest, update_values from common.sdk.im.utils import digest
from common.utils.common import get_logger from common.utils import reverse, random_string, get_logger, lazyproperty
from users.utils import construct_user_email, flatten_dict, map_attributes from users.utils import construct_user_email, flatten_dict, map_attributes
logger = get_logger(__name__) logger = get_logger(__name__)
@ -107,15 +109,6 @@ class WeCom(RequestMixin):
对于业务代码只需要关心由 用户id 消息不对 导致的错误其他错误不予理会 对于业务代码只需要关心由 用户id 消息不对 导致的错误其他错误不予理会
""" """
users = tuple(users) users = tuple(users)
extra_params = {
"safe": 0,
"enable_id_trans": 0,
"enable_duplicate_check": 0,
"duplicate_check_interval": 1800
}
update_values(extra_params, kwargs)
body = { body = {
"touser": '|'.join(users), "touser": '|'.join(users),
"msgtype": "text", "msgtype": "text",
@ -123,7 +116,7 @@ class WeCom(RequestMixin):
"text": { "text": {
"content": msg "content": msg
}, },
**extra_params **kwargs
} }
if markdown: if markdown:
body['msgtype'] = 'markdown' body['msgtype'] = 'markdown'
@ -144,15 +137,15 @@ class WeCom(RequestMixin):
if 'invaliduser' not in data: if 'invaliduser' not in data:
return () return ()
invaliduser = data['invaliduser'] invalid_user = data['invaliduser']
if not invaliduser: if not invalid_user:
return () return ()
if isinstance(invaliduser, str): if isinstance(invalid_user, str):
logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invaliduser}') logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invalid_user}')
raise WeComError raise WeComError
invalid_users = invaliduser.split('|') invalid_users = invalid_user.split('|')
return invalid_users return invalid_users
def get_user_id_by_code(self, code): def get_user_id_by_code(self, code):
@ -167,13 +160,12 @@ class WeCom(RequestMixin):
self._requests.check_errcode_is_0(data) self._requests.check_errcode_is_0(data)
USER_ID = 'UserId' user_id = 'UserId'
OPEN_ID = 'OpenId' open_id = 'OpenId'
if user_id in data:
if USER_ID in data: return data[user_id], user_id
return data[USER_ID], USER_ID elif open_id in data:
elif OPEN_ID in data: return data[open_id], open_id
return data[OPEN_ID], OPEN_ID
else: else:
logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId') logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId')
raise WeComError raise WeComError
@ -195,3 +187,37 @@ class WeCom(RequestMixin):
default_detail = self.default_user_detail(data, user_id) default_detail = self.default_user_detail(data, user_id)
detail = map_attributes(default_detail, info, self.attributes) detail = map_attributes(default_detail, info, self.attributes)
return detail return detail
class WeComTool(object):
WECOM_STATE_SESSION_KEY = '_wecom_state'
WECOM_STATE_VALUE = 'wecom'
@lazyproperty
def qr_cb_url(self):
return reverse('authentication:wecom-qr-login-callback', external=True)
def gen_state(self, request=None):
state = random_string(16)
if not request:
cache.set(state, self.WECOM_STATE_VALUE, timeout=60 * 60 * 24)
else:
request.session[self.WECOM_STATE_SESSION_KEY] = state
return state
def check_state(self, state, request=None):
return cache.get(state) == self.WECOM_STATE_VALUE or \
request.session[self.WECOM_STATE_SESSION_KEY] == state
def wrap_redirect_url(self, next_url):
params = {
'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID,
'state': self.gen_state(),
'redirect_uri': f'{self.qr_cb_url}?next={next_url}',
'response_type': 'code', 'scope': 'snsapi_base',
}
return URL.OAUTH_CONNECT + '?' + urlencode(params) + '#wechat_redirect'
wecom_tool = WeComTool()

View File

@ -127,13 +127,16 @@ class Message(metaclass=MessageType):
def get_html_msg(self) -> dict: def get_html_msg(self) -> dict:
return self.get_common_msg() return self.get_common_msg()
def get_markdown_msg(self) -> dict: @staticmethod
def html_to_markdown(html_msg):
h = HTML2Text() h = HTML2Text()
h.body_width = 300 h.body_width = 0
msg = self.get_html_msg() content = html_msg['message']
content = msg['message'] html_msg['message'] = h.handle(content)
msg['message'] = h.handle(content) return html_msg
return msg
def get_markdown_msg(self) -> dict:
return self.html_to_markdown(self.get_html_msg())
def get_text_msg(self) -> dict: def get_text_msg(self) -> dict:
h = HTML2Text() h = HTML2Text()

View File

@ -4,6 +4,7 @@ from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.sdk.im.wecom import wecom_tool
from common.utils import get_logger, reverse from common.utils import get_logger, reverse
from common.utils import lazyproperty from common.utils import lazyproperty
from common.utils.timezone import local_now_display from common.utils.timezone import local_now_display
@ -75,53 +76,50 @@ class CommandWarningMessage(CommandAlertMixin, UserMessage):
super().__init__(user) super().__init__(user)
self.command = command self.command = command
def get_html_msg(self) -> dict: def get_session_url(self, external=True):
command = self.command session_id = self.command.get('session', '')
org_id = self.command['org_id']
command_input = command['input'] session_url = ''
user = command['user']
asset = command['asset']
account = command.get('_account', '')
cmd_acl = command.get('_cmd_filter_acl')
cmd_group = command.get('_cmd_group')
session_id = command.get('session', '')
risk_level = command['risk_level']
org_id = command['org_id']
org_name = command.get('_org_name') or org_id
if session_id: if session_id:
session_url = reverse( session_url = reverse(
'api-terminal:session-detail', kwargs={'pk': session_id}, 'api-terminal:session-detail', kwargs={'pk': session_id},
external=True, api_to_ui=True external=external, api_to_ui=True
) + '?oid={}'.format(org_id) ) + '?oid={}'.format(org_id)
session_url = session_url.replace('/terminal/sessions/', '/audit/sessions/sessions/') session_url = session_url.replace('/terminal/sessions/', '/audit/sessions/sessions/')
else: return session_url
session_url = ''
# Command ACL def gen_html_string(self, **other_context):
cmd_acl_name = cmd_group_name = '' command = self.command
if cmd_acl: cmd_acl = command.get('_cmd_filter_acl')
cmd_acl_name = cmd_acl.name cmd_group = command.get('_cmd_group')
if cmd_group: org_id = command['org_id']
cmd_group_name = cmd_group.name org_name = command.get('_org_name') or org_id
cmd_acl_name = cmd_acl.name if cmd_acl else ''
cmd_group_name = cmd_group.name if cmd_group else ''
context = { context = {
'command': command_input, 'command': command['input'],
'user': user, 'user': command['user'],
'asset': asset, 'asset': command['asset'],
'account': account, 'account': command.get('_account', ''),
'cmd_filter_acl': cmd_acl_name, 'cmd_filter_acl': cmd_acl_name,
'cmd_group': cmd_group_name, 'cmd_group': cmd_group_name,
'session_url': session_url, 'risk_level': RiskLevelChoices.get_label(command['risk_level']),
'risk_level': RiskLevelChoices.get_label(risk_level),
'org': org_name, 'org': org_name,
} }
context.update(other_context)
message = render_to_string('terminal/_msg_command_warning.html', context) message = render_to_string('terminal/_msg_command_warning.html', context)
return { return {'subject': self.subject, 'message': message}
'subject': self.subject,
'message': message def get_wecom_msg(self):
} session_url = wecom_tool.wrap_redirect_url(
self.get_session_url(external=False)
)
message = self.gen_html_string(session_url=session_url)
return self.html_to_markdown(message)
def get_html_msg(self) -> dict:
return self.gen_html_string(session_url=self.get_session_url())
class CommandAlertMessage(CommandAlertMixin, SystemMessage): class CommandAlertMessage(CommandAlertMixin, SystemMessage):
@ -141,15 +139,18 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage):
command['session'] = Session.objects.first().id command['session'] = Session.objects.first().id
return cls(command) return cls(command)
def get_html_msg(self) -> dict: def get_session_url(self, external=True):
command = self.command
session_detail_url = reverse( session_detail_url = reverse(
'api-terminal:session-detail', kwargs={'pk': command['session']}, 'api-terminal:session-detail', api_to_ui=True,
external=True, api_to_ui=True kwargs={'pk': self.command['session']}, external=external,
) + '?oid={}'.format(self.command['org_id']) ) + '?oid={}'.format(self.command['org_id'])
session_detail_url = session_detail_url.replace( session_detail_url = session_detail_url.replace(
'/terminal/sessions/', '/audit/sessions/sessions/' '/terminal/sessions/', '/audit/sessions/sessions/'
) )
return session_detail_url
def gen_html_string(self, **other_context) -> dict:
command = self.command
level = RiskLevelChoices.get_label(command['risk_level']) level = RiskLevelChoices.get_label(command['risk_level'])
items = { items = {
_("Asset"): command['asset'], _("Asset"): command['asset'],
@ -159,14 +160,21 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage):
} }
context = { context = {
'items': items, 'items': items,
'session_url': session_detail_url,
"command": command['input'], "command": command['input'],
} }
context.update(other_context)
message = render_to_string('terminal/_msg_command_alert.html', context) message = render_to_string('terminal/_msg_command_alert.html', context)
return { return {'subject': self.subject, 'message': message}
'subject': self.subject,
'message': message def get_wecom_msg(self):
} session_url = wecom_tool.wrap_redirect_url(
self.get_session_url(external=False)
)
message = self.gen_html_string(session_url=session_url)
return self.html_to_markdown(message)
def get_html_msg(self) -> dict:
return self.gen_html_string(session_url=self.get_session_url())
class CommandExecutionAlert(CommandAlertMixin, SystemMessage): class CommandExecutionAlert(CommandAlertMixin, SystemMessage):
@ -189,16 +197,20 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage):
} }
return cls(cmd) return cls(cmd)
def get_html_msg(self) -> dict: def get_asset_urls(self, external=True, tran_func=None):
command = self.command
assets_with_url = [] assets_with_url = []
for asset in command['assets']: for asset in self.command['assets']:
url = reverse( url = reverse(
'assets:asset-detail', kwargs={'pk': asset.id}, 'assets:asset-detail', kwargs={'pk': asset.id},
api_to_ui=True, external=True, is_console=True api_to_ui=True, external=external, is_console=True
) + '?oid={}'.format(asset.org_id) ) + '?oid={}'.format(asset.org_id)
if tran_func:
url = tran_func(url)
assets_with_url.append([asset, url]) assets_with_url.append([asset, url])
return assets_with_url
def gen_html_string(self, **other_context):
command = self.command
level = RiskLevelChoices.get_label(command['risk_level']) level = RiskLevelChoices.get_label(command['risk_level'])
items = { items = {
@ -206,17 +218,23 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage):
_("Level"): level, _("Level"): level,
_("Date"): local_now_display(), _("Date"): local_now_display(),
} }
context = { context = {
'items': items, 'items': items,
'assets_with_url': assets_with_url,
'command': command['input'], 'command': command['input'],
} }
context.update(other_context)
message = render_to_string('terminal/_msg_command_execute_alert.html', context) message = render_to_string('terminal/_msg_command_execute_alert.html', context)
return { return {'subject': self.subject, 'message': message}
'subject': self.subject,
'message': message def get_wecom_msg(self):
} assets_with_url = self.get_asset_urls(
external=False, tran_func=wecom_tool.wrap_redirect_url
)
message = self.gen_html_string(assets_with_url=assets_with_url)
return self.html_to_markdown(message)
def get_html_msg(self) -> dict:
return self.gen_html_string(assets_with_url=self.get_asset_urls())
class StorageConnectivityMessage(SystemMessage): class StorageConnectivityMessage(SystemMessage):

View File

@ -4,12 +4,12 @@ from urllib.parse import urljoin
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.forms import model_to_dict from django.forms import model_to_dict
from django.shortcuts import reverse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.db.encoder import ModelJSONFieldEncoder from common.db.encoder import ModelJSONFieldEncoder
from common.utils import get_logger, random_string from common.sdk.im.wecom import wecom_tool
from common.utils import get_logger, random_string, reverse
from notifications.notifications import UserMessage from notifications.notifications import UserMessage
from . import const from . import const
from .models import Ticket from .models import Ticket
@ -22,16 +22,13 @@ class BaseTicketMessage(UserMessage):
ticket: Ticket ticket: Ticket
content_title: str content_title: str
@property def get_ticket_detail_url(self, external=True):
def ticket_detail_url(self): detail_url = const.TICKET_DETAIL_URL.format(
tp = self.ticket.type id=str(self.ticket.id), type=self.ticket.type
return urljoin(
settings.SITE_URL,
const.TICKET_DETAIL_URL.format(
id=str(self.ticket.id),
type=tp
)
) )
if not external:
return detail_url
return urljoin(settings.SITE_URL, detail_url)
@property @property
def content_title(self): def content_title(self):
@ -41,17 +38,31 @@ class BaseTicketMessage(UserMessage):
def subject(self): def subject(self):
raise NotImplementedError raise NotImplementedError
def get_html_msg(self) -> dict: def get_html_context(self):
context = dict( return {'ticket_detail_url': self.get_ticket_detail_url()}
title=self.content_title,
content=self.content, def get_wecom_context(self):
ticket_detail_url=self.ticket_detail_url ticket_detail_url = wecom_tool.wrap_redirect_url(
) [self.get_ticket_detail_url(external=False)]
message = render_to_string('tickets/_msg_ticket.html', context) )[0]
return { return {'ticket_detail_url': ticket_detail_url}
'subject': self.subject,
'message': message def gen_html_string(self, **other_context):
context = {
'title': self.content_title, 'content': self.content,
} }
context.update(other_context)
message = render_to_string(
'tickets/_msg_ticket.html', context
)
return {'subject': self.subject, 'message': message}
def get_html_msg(self) -> dict:
return self.gen_html_string(**self.get_html_context())
def get_wecom_msg(self):
message = self.gen_html_string(**self.get_wecom_context())
return self.html_to_markdown(message)
@classmethod @classmethod
def gen_test_msg(cls): def gen_test_msg(cls):
@ -113,27 +124,21 @@ class TicketAppliedToAssigneeMessage(BaseTicketMessage):
) )
return title return title
def get_ticket_approval_url(self): def get_ticket_approval_url(self, external=True):
url = reverse('tickets:direct-approve', kwargs={'token': self.token}) url = reverse('tickets:direct-approve', kwargs={'token': self.token})
if not external:
return url
return urljoin(settings.SITE_URL, url) return urljoin(settings.SITE_URL, url)
def get_html_msg(self) -> dict: def get_html_context(self):
context = dict( context = super().get_html_context()
title=self.content_title, context['ticket_approval_url'] = self.get_ticket_approval_url()
content=self.content, data = {
ticket_detail_url=self.ticket_detail_url 'ticket_id': self.ticket.id,
) 'approver_id': self.user.id, 'content': self.content,
ticket_approval_url = self.get_ticket_approval_url()
context.update({'ticket_approval_url': ticket_approval_url})
message = render_to_string('tickets/_msg_ticket.html', context)
cache.set(self.token, {
'ticket_id': self.ticket.id, 'approver_id': self.user.id,
'content': self.content,
}, 3600)
return {
'subject': self.subject, 'message': message
} }
cache.set(self.token, data, 3600)
return context
@classmethod @classmethod
def gen_test_msg(cls): def gen_test_msg(cls):