From f1a4e6c386646199dc920cf6c777b0dc02c8eb04 Mon Sep 17 00:00:00 2001 From: wuwg Date: Mon, 17 Nov 2025 15:31:00 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat(wework):=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增企业微信用户ID字段到sys_user表 - 添加企业微信OAuth客户端工具类及相关API调用 - 实现企业微信登录相关API接口,包括授权URL获取、回调处理及配置查询 - 配置企业微信登录相关参数,支持启用开关及回调地址设置 - 将企业微信登录接口添加至主API路由,支持无认证访问 - 更新用户模型支持企业微信用户ID字段存储 - 添加数据库迁移脚本,支持企业微信用户ID字段的新增与删除 - 配置白名单规则,允许企业微信登录相关接口免认证访问 - 集成企业微信消息加解密示例库及工具,支持回调数据安全处理 - 补充企业微信登录使用说明文档及示例代码,便于开发者参考和调试 --- .../alembic/versions/048_add_wework_userid.py | 29 ++ backend/apps/api.py | 3 +- backend/apps/system/api/wework.py | 134 +++++++++ backend/apps/system/models/user.py | 1 + backend/common/core/config.py | 7 + backend/common/utils/wework_utils.py | 137 +++++++++ backend/common/utils/weworkapi/README.md | 53 ++++ backend/common/utils/weworkapi/Readme.txt | 5 + backend/common/utils/weworkapi/Sample.py | 123 ++++++++ .../utils/weworkapi/WXBizJsonMsgCrypt.py | 282 ++++++++++++++++++ backend/common/utils/weworkapi/conf.py | 16 + backend/common/utils/weworkapi/ierror.py | 20 ++ backend/common/utils/whitelist.py | 4 + backend/keys/private_key.pem | 28 ++ backend/keys/public_key.pem | 9 + frontend/.env.development | 2 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 8 + frontend/src/api/login.ts | 12 + frontend/src/views/login/index.vue | 93 +++++- frontend/vite.config.ts | 3 + 21 files changed, 966 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/048_add_wework_userid.py create mode 100644 backend/apps/system/api/wework.py create mode 100644 backend/common/utils/wework_utils.py create mode 100644 backend/common/utils/weworkapi/README.md create mode 100644 backend/common/utils/weworkapi/Readme.txt create mode 100644 backend/common/utils/weworkapi/Sample.py create mode 100644 backend/common/utils/weworkapi/WXBizJsonMsgCrypt.py create mode 100644 backend/common/utils/weworkapi/conf.py create mode 100644 backend/common/utils/weworkapi/ierror.py create mode 100644 backend/keys/private_key.pem create mode 100644 backend/keys/public_key.pem diff --git a/backend/alembic/versions/048_add_wework_userid.py b/backend/alembic/versions/048_add_wework_userid.py new file mode 100644 index 00000000..d5914a63 --- /dev/null +++ b/backend/alembic/versions/048_add_wework_userid.py @@ -0,0 +1,29 @@ +"""wework + +Revision ID: 2809fbbf08bc +Revises: c1b794a961ce +Create Date: 2025-11-17 14:48:10.965213 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2809fbbf08bc' +down_revision = 'c1b794a961ce' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('sys_user', sa.Column('wework_userid', sa.VARCHAR(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('sys_user', 'wework_userid') + # ### end Alembic commands ### diff --git a/backend/apps/api.py b/backend/apps/api.py index 8b836c0d..6dd272ed 100644 --- a/backend/apps/api.py +++ b/backend/apps/api.py @@ -5,11 +5,12 @@ from apps.data_training.api import data_training from apps.datasource.api import datasource, table_relation from apps.mcp import mcp -from apps.system.api import login, user, aimodel, workspace, assistant +from apps.system.api import login, user, aimodel, workspace, assistant, wework from apps.terminology.api import terminology api_router = APIRouter() api_router.include_router(login.router) +api_router.include_router(wework.router) api_router.include_router(user.router) api_router.include_router(workspace.router) api_router.include_router(assistant.router) diff --git a/backend/apps/system/api/wework.py b/backend/apps/system/api/wework.py new file mode 100644 index 00000000..b24940d8 --- /dev/null +++ b/backend/apps/system/api/wework.py @@ -0,0 +1,134 @@ +""" +企业微信登录API +""" +from typing import Optional +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import select +from pydantic import BaseModel + +from common.core.deps import SessionDep, Trans +from common.core.config import settings +from common.core.security import create_access_token +from common.core.schemas import Token +from common.utils.wework_utils import WeWorkOAuthClient +from apps.system.models.user import UserModel +from apps.system.schemas.system_schema import BaseUserDTO +from apps.system.crud.user import get_user_by_account +from datetime import timedelta + +router = APIRouter(tags=["wework"], prefix="/wework") + + +class WeWorkAuthUrlResponse(BaseModel): + """企业微信授权URL响应""" + auth_url: str + enabled: bool + + +@router.get("/auth-url", response_model=WeWorkAuthUrlResponse) +async def get_wework_auth_url() -> WeWorkAuthUrlResponse: + """ + 获取企业微信授权链接 + + Returns: + 包含授权URL和是否启用的响应 + """ + if not settings.WEWORK_ENABLED: + return WeWorkAuthUrlResponse( + auth_url="", + enabled=False + ) + + redirect_uri = settings.WEWORK_REDIRECT_URI + auth_url = WeWorkOAuthClient.get_authorize_url(redirect_uri) + + return WeWorkAuthUrlResponse( + auth_url=auth_url, + enabled=True + ) + + +@router.get("/callback") +async def wework_callback( + session: SessionDep, + trans: Trans, + code: str = Query(..., description="企业微信授权码"), + state: Optional[str] = Query(None, description="状态参数") +) -> Token: + """ + 企业微信OAuth回调处理 + + Args: + code: 企业微信授权码 + state: 状态参数 + + Returns: + 访问令牌 + """ + if not settings.WEWORK_ENABLED: + raise HTTPException(status_code=400, detail="企业微信登录未启用") + + # 通过code获取用户信息 + user_info = await WeWorkOAuthClient.get_user_info(code) + if not user_info: + raise HTTPException(status_code=400, detail="获取企业微信用户信息失败") + + userid = user_info.get("userid") + if not userid: + raise HTTPException(status_code=400, detail="未获取到企业微信用户ID") + + # 查询数据库中是否存在该企业微信用户 + statement = select(UserModel).where(UserModel.wework_userid == userid) + db_user = session.exec(statement).first() + + if not db_user: + # 获取用户详细信息 + user_detail = await WeWorkOAuthClient.get_user_detail(userid) + if not user_detail: + raise HTTPException(status_code=400, detail="获取企业微信用户详细信息失败") + + # 创建新用户 + new_user = UserModel( + account=userid, # 使用企业微信userid作为账号 + name=user_detail.get("name", userid), + email=user_detail.get("email", f"{userid}@wework.local"), + wework_userid=userid, + status=1, # 默认启用 + oid=0 # 需要后续分配工作空间 + ) + session.add(new_user) + session.commit() + session.refresh(new_user) + db_user = new_user + + # 验证用户状态 + user = BaseUserDTO.model_validate(db_user.model_dump()) + + if user.status != 1: + raise HTTPException(status_code=400, detail=trans('i18n_login.user_disable', msg=trans('i18n_concat_admin'))) + + if not user.oid or user.oid == 0: + raise HTTPException(status_code=400, detail=trans('i18n_login.no_associated_ws', msg=trans('i18n_concat_admin'))) + + # 生成访问令牌 + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + user_dict = user.to_dict() + + return Token( + access_token=create_access_token(user_dict, expires_delta=access_token_expires) + ) + + +@router.get("/config") +async def get_wework_config() -> dict: + """ + 获取企业微信登录配置(公开接口,无需认证) + + Returns: + 包含启用状态的字典 + """ + return { + "enabled": settings.WEWORK_ENABLED, + "corpId": settings.WEWORK_CORP_ID if settings.WEWORK_ENABLED else "", + "agentId": settings.WEWORK_AGENT_ID if settings.WEWORK_ENABLED else "" + } diff --git a/backend/apps/system/models/user.py b/backend/apps/system/models/user.py index 078f02d2..ba80acb7 100644 --- a/backend/apps/system/models/user.py +++ b/backend/apps/system/models/user.py @@ -17,6 +17,7 @@ class BaseUserPO(SQLModel): status: int = Field(default=0, nullable=False) create_time: int = Field(default_factory=get_timestamp, sa_type=BigInteger(), nullable=False) language: str = Field(max_length=255, default="zh-CN") + wework_userid: Optional[str] = Field(default=None, max_length=255, nullable=True) # 企业微信用户ID class UserModel(SnowflakeBase, BaseUserPO, table=True): __tablename__ = "sys_user" diff --git a/backend/common/core/config.py b/backend/common/core/config.py index 67c50565..3fcd271b 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -116,5 +116,12 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str: ORACLE_CLIENT_PATH: str = '/opt/sqlbot/db_client/oracle_instant_client' + # 企业微信登录配置 + WEWORK_CORP_ID: str = 'ww0654948da71aab2e' # 企业ID + WEWORK_SECRET: str = 'nu1AQ4nAyt7snpJAmOlovBAN4-mtH0w-6qo4B45I-W0' # 应用Secret + WEWORK_AGENT_ID: str = '' # 应用AgentId + WEWORK_REDIRECT_URI: str = '' # OAuth回调地址 + WEWORK_ENABLED: bool = True # 是否启用企业微信登录 + settings = Settings() # type: ignore diff --git a/backend/common/utils/wework_utils.py b/backend/common/utils/wework_utils.py new file mode 100644 index 00000000..db414ce0 --- /dev/null +++ b/backend/common/utils/wework_utils.py @@ -0,0 +1,137 @@ +""" +企业微信OAuth工具类 +""" +import httpx +from typing import Optional, Dict, Any +from common.core.config import settings +from common.utils.utils import SQLBotLogUtil + + +class WeWorkOAuthClient: + """企业微信OAuth客户端""" + + BASE_URL = "https://qyapi.weixin.qq.com" + + @classmethod + async def get_access_token(cls) -> Optional[str]: + """ + 获取企业微信access_token + + Returns: + access_token字符串,失败返回None + """ + url = f"{cls.BASE_URL}/cgi-bin/gettoken" + params = { + "corpid": settings.WEWORK_CORP_ID, + "corpsecret": settings.WEWORK_SECRET + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params) + data = response.json() + + if data.get("errcode") == 0: + return data.get("access_token") + else: + SQLBotLogUtil.error(f"获取企业微信access_token失败: {data}") + return None + except Exception as e: + SQLBotLogUtil.error(f"获取企业微信access_token异常: {str(e)}") + return None + + @classmethod + async def get_user_info(cls, code: str) -> Optional[Dict[str, Any]]: + """ + 通过code获取企业微信用户信息 + + Args: + code: OAuth授权码 + + Returns: + 用户信息字典,失败返回None + """ + access_token = await cls.get_access_token() + if not access_token: + return None + + url = f"{cls.BASE_URL}/cgi-bin/auth/getuserinfo" + params = { + "access_token": access_token, + "code": code + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params) + data = response.json() + + if data.get("errcode") == 0: + return data + else: + SQLBotLogUtil.error(f"获取企业微信用户信息失败: {data}") + return None + except Exception as e: + SQLBotLogUtil.error(f"获取企业微信用户信息异常: {str(e)}") + return None + + @classmethod + async def get_user_detail(cls, userid: str) -> Optional[Dict[str, Any]]: + """ + 获取企业微信用户详细信息 + + Args: + userid: 企业微信用户ID + + Returns: + 用户详细信息字典,失败返回None + """ + access_token = await cls.get_access_token() + if not access_token: + return None + + url = f"{cls.BASE_URL}/cgi-bin/user/get" + params = { + "access_token": access_token, + "userid": userid + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params) + data = response.json() + + if data.get("errcode") == 0: + return data + else: + SQLBotLogUtil.error(f"获取企业微信用户详细信息失败: {data}") + return None + except Exception as e: + SQLBotLogUtil.error(f"获取企业微信用户详细信息异常: {str(e)}") + return None + + @classmethod + def get_authorize_url(cls, redirect_uri: str, state: str = "STATE") -> str: + """ + 获取企业微信OAuth授权链接 + + Args: + redirect_uri: 回调地址 + state: 状态参数 + + Returns: + 授权链接 + """ + import urllib.parse + + params = { + "appid": settings.WEWORK_CORP_ID, + "redirect_uri": urllib.parse.quote(redirect_uri), + "response_type": "code", + "scope": "snsapi_base", + "state": state, + "agentid": settings.WEWORK_AGENT_ID + } + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query_string}#wechat_redirect" diff --git a/backend/common/utils/weworkapi/README.md b/backend/common/utils/weworkapi/README.md new file mode 100644 index 00000000..6d3cca3a --- /dev/null +++ b/backend/common/utils/weworkapi/README.md @@ -0,0 +1,53 @@ + +# About +weworkapi_python 是为了简化开发者对企业微信API接口的使用而设计的,API调用库系列之python版本     +本库仅做示范用,并不保证完全无bug; +作者会不定期更新本库,但不保证与官方API接口文档同步,因此一切以[官方文档](https://work.weixin.qq.com/api/doc)为准。 + +更多来自个人开发者的其它语言的库推荐: +python : https://github.com/sbzhu/weworkapi_python abelzhu@tencent.com(企业微信团队) +ruby : https://github.com/mycolorway/wework MyColorway(个人开发者) +php : https://github.com/sbzhu/weworkapi_php abelzhu@tencent.com(企业微信团队) +golang : https://github.com/sbzhu/weworkapi_golang ryanjelin@tencent.com(企业微信团队) +golang : https://github.com/doubliekill/EnterpriseWechatSDK 1006401052yh@gmail.com(个人开发者) + +# Director +├── api // API 接口 +│   ├── examples // API接口的测试用例 +│   ├── README.md +│   └── src // API接口的关键逻辑 +├── callback // 加解密库,python2, xml格式) + +├── callback_json // 加解密库,Python2, json格式, 仅适用于企业机器人/智能机器人 + +├── callback_json_python3 // 加解密库,Python3, json格式, 仅适用于企业机器人/智能机器人 + +├── callback_python3 // 加解密库,python2, xml格式 + +├── conf.py +└── README.md + +# Usage +将本项目下载到你的目录,既可直接引用相关文件   +详细使用方法参考examples路径下的测试用例 + +# 关于token的缓存 +token是需要缓存的,不能每次调用都去获取token,[否则会中频率限制](https://work.weixin.qq.com/api/doc#10013/%E7%AC%AC%E5%9B%9B%E6%AD%A5%EF%BC%9A%E7%BC%93%E5%AD%98%E5%92%8C%E5%88%B7%E6%96%B0access_token) +在本库的设计里,token是以类里的一个变量缓存的 +比如api/src/CorpApi.py 里的access_token变量 +在类的生命周期里,这个accessToken都是存在的, 当且仅当发现token过期,CorpAPI类会自动刷新token +刷新机制在 api/src/AbstractApi.py +所以,使用时,只需要全局实例化一个CorpAPI类,不要析构它,就可一直用它调函数,不用关心 token +``` +api = CorpAPI(corpid, corpsecret); +api.dosomething() +api.dosomething() +api.dosomething() +.... +``` +当然,如果要更严格的做的话,建议自行修改,全局缓存token,比如存redis、存文件等,失效周期设置为2小时。 + +# Contact us +abelzhu@tencent.com + +# diff --git a/backend/common/utils/weworkapi/Readme.txt b/backend/common/utils/weworkapi/Readme.txt new file mode 100644 index 00000000..eea51572 --- /dev/null +++ b/backend/common/utils/weworkapi/Readme.txt @@ -0,0 +1,5 @@ +ע +1.WXBizMsgCrypt.pyļװWXBizMsgCryptӿ࣬ṩûҵ΢ŵӿڣSample.pyļṩʹӿڵʾierror.pyṩ˴롣 +2.WXBizMsgCryptװVerifyURL, DecryptMsg, EncryptMsgӿڣֱڿ֤صurlյûظϢĽԼ߻ظϢļ̡ܹʹ÷ԲοSample.pyļ +3.ӽЭοҵ΢Źٷĵ +4.õpycrypto⣬뿪аװ˿ʹá \ No newline at end of file diff --git a/backend/common/utils/weworkapi/Sample.py b/backend/common/utils/weworkapi/Sample.py new file mode 100644 index 00000000..47263521 --- /dev/null +++ b/backend/common/utils/weworkapi/Sample.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +######################################################################### +# Author: jonyqin +# Created Time: Thu 11 Sep 2014 03:55:41 PM CST +# File Name: Sample.py +# Description: WXBizJsonMsgCrypt 使用demo文件 +######################################################################### +from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt +import sys +import json + +if __name__ == "__main__": + # 企业在企业微信后台上设置的密钥相关配置在这里 TODO + sToken = "xxxxxxx" + sEncodingAESKey = "xxxxxxx" + sCorpID = "ww1436e0e65a779aee" + ''' + ------------使用示例一:验证回调URL--------------- + *企业开启回调模式时,企业号会向验证url发送一个get请求 + 假设点击验证时,企业收到类似请求: + * GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D + * HTTP/1.1 Host: qy.weixin.qq.com + + 接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr), + 这一步注意作URL解码。 + 2.验证消息体签名的正确性 + 3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信 + 第2,3步可以用企业微信提供的库函数VerifyURL来实现。 + ''' + wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID) + sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb" + sVerifyTimeStamp="1476416373" + sVerifyNonce="47744683" + sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA==" + ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr) + if(ret!=0): + print("ERR: VerifyURL ret: " + str(ret)) + sys.exit(1) + else: + print("done VerifyURL") + #验证URL成功,将sEchoStr返回给企业号 + + print("==============================") + ''' + ------------使用示例二:对用户回复的消息解密--------------- + 用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档 + 假设企业收到企业微信的回调消息如下: + POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1 + Host: qy.weixin.qq.com + + { + "tousername": "wx5823bf96d3bd56c7", + "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", + "agentid": 218 + } + + 企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce) + 2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档 + 第2,3步可以用企业微信提供的库函数DecryptMsg来实现。 + ''' + + sReqNonce = "1372623149" + sReqTimeStamp = "1409659813" + + sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e" + sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }'; + ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce) + if( ret!=0 ): + print("ERR: DecryptMsg ret: " + str(ret)) + sys.exit(1) + else: + print(sMsg) + # 解密成功,sMsg即为json格式的明文 + # TODO: 对明文的处理 + # ... + # ... + + print("==============================") + + ''' + ------------使用示例三:企业回复用户消息的加密--------------- + 企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。 + 假设企业需要回复用户的明文如下: + + { + "ToUserName": "mycreate", + "FromUserName":"wx5823bf96d3bd56c7", + "CreateTime": 1348831860, + "MsgType": "text", + "Content": "this is a test", + "MsgId": 1234567890123456, + "AgentID": 128 + } + + 为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。 + 2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。 + 以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。 + ''' + #sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }'; + sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}' + ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp) + if( ret!=0 ): + print("ERR: EncryptMsg ret: " + str(ret)) + sys.exit(1) + else: + print(sEncryptMsg) + #ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号 + print("==============================") + + ''' + 对上面加密的包进行解密 + ''' + sReqMsgSig = json.loads(sEncryptMsg)['msgsignature'] + sReqTimeStamp = json.loads(sEncryptMsg)['timestamp'] + sReqNonce = json.loads(sEncryptMsg)['nonce'] + + ret,sMsg=wxcpt.DecryptMsg( sEncryptMsg, sReqMsgSig, sReqTimeStamp, sReqNonce) + if( ret!=0 ): + print("ERR: DecryptMsg ret: " + str(ret)) + sys.exit(1) + else: + print(sMsg) diff --git a/backend/common/utils/weworkapi/WXBizJsonMsgCrypt.py b/backend/common/utils/weworkapi/WXBizJsonMsgCrypt.py new file mode 100644 index 00000000..c55056e3 --- /dev/null +++ b/backend/common/utils/weworkapi/WXBizJsonMsgCrypt.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python +#-*- encoding:utf-8 -*- + +""" 对企业微信发送给企业后台的消息加解密示例代码. +@copyright: Copyright (c) 1998-2020 Tencent Inc. + +""" +# ------------------------------------------------------------------------ + +import base64 +import string +import random +import hashlib +import time +import struct +from Crypto.Cipher import AES +import sys +import socket +import json + +import ierror + +""" +关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案 +请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。 +下载后,按照README中的“Installation”小节的提示进行pycrypto安装。 +""" +class FormatException(Exception): + pass + +def throw_exception(message, exception_class=FormatException): + """my define raise exception function""" + raise exception_class(message) + +class SHA1: + """计算企业微信的消息签名接口""" + + def getSHA1(self, token, timestamp, nonce, encrypt): + """用SHA1算法生成安全签名 + @param token: 票据 + @param timestamp: 时间戳 + @param encrypt: 密文 + @param nonce: 随机字符串 + @return: 安全签名 + """ + try: + # 确保所有输入都是字符串类型 + if isinstance(encrypt, bytes): + encrypt = encrypt.decode('utf-8') + + sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)] + sortlist.sort() + sha = hashlib.sha1() + sha.update("".join(sortlist).encode('utf-8')) + return ierror.WXBizMsgCrypt_OK, sha.hexdigest() + + except Exception as e: + print(e) + return ierror.WXBizMsgCrypt_ComputeSignature_Error, None + + +class JsonParse: + """提供提取消息格式中的密文及生成回复消息格式的接口""" + + # json消息模板 + AES_TEXT_RESPONSE_TEMPLATE = '''{ + "encrypt": "%(msg_encrypt)s", + "msgsignature": "%(msg_signaturet)s", + "timestamp": "%(timestamp)s", + "nonce": "%(nonce)s" + }''' + + def extract(self, jsontext): + """提取出json数据包中的加密消息 + @param jsontext: 待提取的json字符串 + @return: 提取出的加密消息字符串 + """ + try: + json_dict = json.loads(jsontext) + return ierror.WXBizMsgCrypt_OK, json_dict['encrypt'] + except Exception as e: + print(e) + return ierror.WXBizMsgCrypt_ParseJson_Error, None + def generate(self, encrypt, signature, timestamp, nonce): + """生成json消息 + @param encrypt: 加密后的消息密文 + @param signature: 安全签名 + @param timestamp: 时间戳 + @param nonce: 随机字符串 + @return: 生成的json字符串 + """ + resp_dict = { + 'msg_encrypt' : encrypt, + 'msg_signaturet': signature, + 'timestamp' : timestamp, + 'nonce' : nonce, + } + resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict + return resp_json + + +class PKCS7Encoder(): + """提供基于PKCS7算法的加解密接口""" + + block_size = 32 + def encode(self, text): + """ 对需要加密的明文进行填充补位 + @param text: 需要进行填充补位操作的明文(bytes类型) + @return: 补齐明文字符串(bytes类型) + """ + text_length = len(text) + # 计算需要填充的位数 + amount_to_pad = self.block_size - (text_length % self.block_size) + if amount_to_pad == 0: + amount_to_pad = self.block_size + # 获得补位所用的字符 + pad = bytes([amount_to_pad]) + # 确保text是bytes类型 + if isinstance(text, str): + text = text.encode('utf-8') + return text + pad * amount_to_pad + + def decode(self, decrypted): + """删除解密后明文的补位字符 + @param decrypted: 解密后的明文 + @return: 删除补位字符后的明文 + """ + pad = ord(decrypted[-1]) + if pad<1 or pad >32: + pad = 0 + return decrypted[:-pad] + + +class Prpcrypt(object): + """提供接收和推送给企业微信消息的加解密接口""" + + def __init__(self,key): + + #self.key = base64.b64decode(key+"=") + self.key = key + # 设置加解密模式为AES的CBC模式 + self.mode = AES.MODE_CBC + + + def encrypt(self, text, receiveid): + """对明文进行加密 + @param text: 需要加密的明文 + @return: 加密得到的字符串 + """ + # 16位随机字符串添加到明文开头 + text = text.encode() + text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode() + + # 使用自定义的填充方式对明文进行补位填充 + pkcs7 = PKCS7Encoder() + text = pkcs7.encode(text) + # 加密 + cryptor = AES.new(self.key, self.mode, self.key[:16]) + try: + ciphertext = cryptor.encrypt(text) + # 使用BASE64对加密后的字符串进行编码 + return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self,text,receiveid): + """对解密后的明文进行补位删除 + @param text: 密文 + @return: 删除填充补位后的明文 + """ + try: + cryptor = AES.new(self.key,self.mode,self.key[:16]) + # 使用BASE64对密文进行解码,然后AES-CBC解密 + plain_text = cryptor.decrypt(base64.b64decode(text)) + except Exception as e: + print(e) + return ierror.WXBizMsgCrypt_DecryptAES_Error,None + try: + pad = plain_text[-1] + # 去掉补位字符串 + #pkcs7 = PKCS7Encoder() + #plain_text = pkcs7.encode(plain_text) + # 去除16位随机字符串 + content = plain_text[16:-pad] + json_len = socket.ntohl(struct.unpack("I",content[ : 4])[0]) + json_content = content[4 : json_len+4].decode('utf-8') + from_receiveid = content[json_len+4:].decode('utf-8') + except Exception as e: + print(e) + return ierror.WXBizMsgCrypt_IllegalBuffer,None + if from_receiveid != receiveid: + print("receiveid not match", receiveid, from_receiveid) + return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None + return 0,json_content + + def get_random_str(self): + """ 随机生成16位字符串 + @return: 16位字符串 + """ + return str(random.randint(1000000000000000, 9999999999999999)).encode() + +class WXBizJsonMsgCrypt(object): + #构造函数 + def __init__(self,sToken,sEncodingAESKey,sReceiveId): + try: + self.key = base64.b64decode(sEncodingAESKey+"=") + assert len(self.key) == 32 + except: + throw_exception("[error]: EncodingAESKey unvalid !", FormatException) + # return ierror.WXBizMsgCrypt_IllegalAesKey,None + self.m_sToken = sToken + self.m_sReceiveId = sReceiveId + + #验证URL + #@param sMsgSignature: 签名串,对应URL参数的msg_signature + #@param sTimeStamp: 时间戳,对应URL参数的timestamp + #@param sNonce: 随机串,对应URL参数的nonce + #@param sEchoStr: 随机串,对应URL参数的echostr + #@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 + #@return:成功0,失败返回对应的错误码 + + def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): + sha1 = SHA1() + ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId) + return ret,sReplyEchoStr + + def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None): + #将企业回复用户的消息加密打包 + #@param sReplyMsg: 企业号待回复用户的消息,json格式的字符串 + #@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 + #@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce + #sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串, + #return:成功0,sEncryptMsg,失败返回对应的错误码None + pc = Prpcrypt(self.key) + ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) + encrypt = encrypt.decode('utf-8') + if ret != 0: + return ret,None + if timestamp is None: + timestamp = str(int(time.time())) + # 生成安全签名 + sha1 = SHA1() + ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) + if ret != 0: + return ret,None + jsonParse = JsonParse() + return ret,jsonParse.generate(encrypt, signature, timestamp, sNonce) + + def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): + # 检验消息的真实性,并且获取解密后的明文 + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sPostData: 密文,对应POST请求的数据 + # json_content: 解密后的原文,当return返回0时有效 + # @return: 成功0,失败返回对应的错误码 + # 验证安全签名 + jsonParse = JsonParse() + ret,encrypt = jsonParse.extract(sPostData) + if ret != 0: + return ret, None + sha1 = SHA1() + ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + print("signature not match") + print(signature) + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret,json_content = pc.decrypt(encrypt,self.m_sReceiveId) + return ret,json_content + + diff --git a/backend/common/utils/weworkapi/conf.py b/backend/common/utils/weworkapi/conf.py new file mode 100644 index 00000000..c37571de --- /dev/null +++ b/backend/common/utils/weworkapi/conf.py @@ -0,0 +1,16 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +## + # Copyright (C) 2018 All rights reserved. + # + # @File conf.py + # @Brief + # @Author abelzhu, abelzhu@tencent.com + # @Version 1.0 + # @Date 2018-02-23 + # + # + +## 设置为true会打印一些调试信息 +DEBUG = True + diff --git a/backend/common/utils/weworkapi/ierror.py b/backend/common/utils/weworkapi/ierror.py new file mode 100644 index 00000000..e1f16f49 --- /dev/null +++ b/backend/common/utils/weworkapi/ierror.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +######################################################################### +# Author: jonyqin +# Created Time: Thu 11 Sep 2014 01:53:58 PM CST +# File Name: ierror.py +# Description:定义错误码含义 +######################################################################### +WXBizMsgCrypt_OK = 0 +WXBizMsgCrypt_ValidateSignature_Error = -40001 +WXBizMsgCrypt_ParseJson_Error = -40002 +WXBizMsgCrypt_ComputeSignature_Error = -40003 +WXBizMsgCrypt_IllegalAesKey = -40004 +WXBizMsgCrypt_ValidateCorpid_Error = -40005 +WXBizMsgCrypt_EncryptAES_Error = -40006 +WXBizMsgCrypt_DecryptAES_Error = -40007 +WXBizMsgCrypt_IllegalBuffer = -40008 +WXBizMsgCrypt_EncodeBase64_Error = -40009 +WXBizMsgCrypt_DecodeBase64_Error = -40010 +WXBizMsgCrypt_GenReturnJson_Error = -40011 diff --git a/backend/common/utils/whitelist.py b/backend/common/utils/whitelist.py index ec2ea93b..da640874 100644 --- a/backend/common/utils/whitelist.py +++ b/backend/common/utils/whitelist.py @@ -35,6 +35,10 @@ "/system/assistant/app/*", "/system/assistant/picture/*", "/datasource/uploadExcel", + # 企业微信登录相关接口(无需认证) + "/wework/config", + "/wework/auth-url", + "/wework/callback", ] class WhitelistChecker: diff --git a/backend/keys/private_key.pem b/backend/keys/private_key.pem new file mode 100644 index 00000000..b7d8a3f0 --- /dev/null +++ b/backend/keys/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCs3LvIEMxr5bfK +9CqVTz1omZ+vp5Rudc1mTobSsizPYMI2ZtdR3UV47QO2nYqrZrrzv+hlTZ1MzLpi +qcd+xwa681pv7ivCTFprBjCiLtNdVlVAcP0mL/yNPCam99bvjaCkJRoIDZOFdQEC ++E8LGp+0UoxrbOrKThCbl92h6r6PGsE/NMGocxMyRZqx5cSu7MMwetW3SOQ4PgwE +H4GF2/KTIfc7RnfCiwr19t6D9/c7M7SxS6YBA0QAb566XF9A1TtUAPx+PYT1BQom +5bwV0G+A/C7iO6tYB7+5CMUSmLUBexbHjJLsdFJHblN+OgRBzdCDCIz0GoId1bcr +ykrYVvz/AgMBAAECggEAAjlyQ9LOLp1CzjPHeysNM+lg1VZ8tycrliNwjLQ062jo +AYyKkh0c0Go3Cq81okWs0NzUABRi781lS0ZlW3ZILzkDyD5HsIphp3rpk/dc9O3a +zvzP5SE882b7awP0GhpidM+daXkd6tu56vCIY9uF2UDZWbs0Z5dgPgBS88sc2fk4 +BqhFkuNWexJe9Gk+zCbrEsL5HngJvW9jNpBVnc1voz+RtyfZFSlaacp7Zx6ioEED +6AC8EH6dInpKR6qQP9tuGiKByg34RGkTjOIzzAES42Av+Gw2l2rq4goh/IgDpG/w +1GvWMS7+Rk3mZblNQuoQi7SzdmSOVSHWyRAoMry6IQKBgQDzXdY3Yf7xeViv8Hz9 +S0tS8Ct3N5hXVUER36FMhCXI2MI519y9UFMnTSLJfMvSvSRIkhDIYeVWuYQN5/D3 +j3GSU38XO2tA6T9FPCZx2J+LpyeerTruwc7mL7NH2oN8e/qqIRx4/hc2xsjXRY19 +mXGNLeH58oFbaRy2/BMQKV3mCQKBgQC11fIzgq+eQkeSSLwxnWSOtEE9RuBy8M/a +llz2onrV8Nmt/YzeXT3jHBxfA7h2lnQnnRzk8EOuNeSoT253WahGyBAvkDNOdDFx +WYUAtyi6BqH3bwcbaKKZS+jOxo1gdDlpgTObvw29aR5KWYyoL4v79xL+icnnqSKp +55Z3hzrMxwKBgQDpez5YdfjcfHncmdGgRMtoL6jTwI+0QcOivTUHrsR9U4/LZNCP +A/AzBWr5eXQmo3Wa9h+Jz3nyYRtTKBHjwEfKhbapeEUmyl/8MYAEuzoeBmkZb8LZ +azDvYBYVOovpMxzCee/SGJv23nGzLjRXqo3pT3q8jK8UHt0ApQg4+ibu2QKBgAgE +A2ZD54v5bgBD1o/1WT51Mb8KxdArT25aXIS+yt1tfZsO50vSHm1JivKhTltj7IDm +iySIduNSGt9UiREX0bQ1E4mVOL3Hn3pYsOVELbZtnmwy58RMi4Zocz3SQN80oGdH +O8gs1uU3og8y/WFYnzofPSYdWtoseYu12sswGeZ3AoGAU54t5bDNlN72KkZPpZ/n +D7/Nm3RE1Q4nZx1WvdjOxW3tdR4hL42dpSjrgThFUu5rVigdhbWQHzWfyyVLwWQf +SskDMlXut26dY7ZFpjge29miJl876qVN+n3ES9gu40AO2zishG5QTu2lq89qFYy/ +QT3r7jxlWjJWnY0mqIPQAas= +-----END PRIVATE KEY----- diff --git a/backend/keys/public_key.pem b/backend/keys/public_key.pem new file mode 100644 index 00000000..7dc95685 --- /dev/null +++ b/backend/keys/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArNy7yBDMa+W3yvQqlU89 +aJmfr6eUbnXNZk6G0rIsz2DCNmbXUd1FeO0Dtp2Kq2a687/oZU2dTMy6YqnHfscG +uvNab+4rwkxaawYwoi7TXVZVQHD9Ji/8jTwmpvfW742gpCUaCA2ThXUBAvhPCxqf +tFKMa2zqyk4Qm5fdoeq+jxrBPzTBqHMTMkWaseXEruzDMHrVt0jkOD4MBB+Bhdvy +kyH3O0Z3wosK9fbeg/f3OzO0sUumAQNEAG+eulxfQNU7VAD8fj2E9QUKJuW8FdBv +gPwu4jurWAe/uQjFEpi1AXsWx4yS7HRSR25TfjoEQc3QgwiM9BqCHdW3K8pK2Fb8 +/wIDAQAB +-----END PUBLIC KEY----- diff --git a/frontend/.env.development b/frontend/.env.development index f41acbae..fc618975 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1,2 @@ -VITE_API_BASE_URL=http://localhost:8000/api/v1 +VITE_API_BASE_URL=https://sqlbot.sabertrain.com/api/v1 VITE_APP_TITLE=SQLBot (Development) \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index dbd008a2..db2556b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@npkg/tinymce-plugins": "^0.0.7", "@tinymce/tinymce-vue": "^5.1.0", "@vueuse/core": "^14.0.0", + "@wecom/jssdk": "^2.3.2", "dayjs": "^1.11.13", "element-plus": "^2.10.1", "element-plus-secondary": "^1.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 88e649fa..4733ed7e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@vueuse/core': specifier: ^14.0.0 version: 14.0.0(vue@3.5.22(typescript@5.7.3)) + '@wecom/jssdk': + specifier: ^2.3.2 + version: 2.3.2 dayjs: specifier: ^1.11.13 version: 1.11.18 @@ -995,6 +998,9 @@ packages: '@vueuse/shared@9.13.0': resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} + '@wecom/jssdk@2.3.2': + resolution: {integrity: sha512-YNF8QAta2nAUbJNdCU00FfAiW3op8T4W6hH8NlmLX6fEjxb3JRcI6sdSUwDkwQOkCZsnK7Qjz9B+NGO6xsfBrw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4021,6 +4027,8 @@ snapshots: - '@vue/composition-api' - vue + '@wecom/jssdk@2.3.2': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 diff --git a/frontend/src/api/login.ts b/frontend/src/api/login.ts index f3e22954..309160dd 100644 --- a/frontend/src/api/login.ts +++ b/frontend/src/api/login.ts @@ -18,4 +18,16 @@ export const AuthApi = { }, logout: () => request.post('/auth/logout'), info: () => request.get('/user/info'), + + // 企业微信登录相关接口 + getWeWorkAuthUrl: () => request.get<{ auth_url: string; enabled: boolean }>('/wework/auth-url'), + weWorkCallback: (code: string, state?: string) => { + const params: any = { code } + if (state) params.state = state + return request.get<{ + access_token: string + token_type: string + }>('/wework/callback', { params }) + }, + getWeWorkConfig: () => request.get<{ enabled: boolean; corpId: string; agentId: string }>('/wework/config'), } diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index ce005ef4..20aeb29e 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -51,6 +51,14 @@ $t('common.login_') }} + + + + + @@ -59,10 +67,12 @@