集成企业微信实现禅道扫码登录
本指南旨在说明如何将企业微信扫码登录功能集成到禅道项目管理系统。请注意,此修改涉及修改PHP源代码,请务必在操作前备份相关文件。本方案基于禅道版本 21.3,并要求用户与企业微信账户进行绑定。
所有代码修改均位于 ZenTao/app/zentao 目录下。
核心实现步骤
- 在
user模块的控制器中新增一个用于处理企业微信回调的接口,并实现登录逻辑。 - 在登录页面嵌入企业微信的扫码登录二维码组件。
详细操作指南
1. 新增 wxlogin 接口
a. 修改 lib/wechatapi/wechatapi.class.php
添加一个新方法,用于根据企业微信返回的 code 获取用户的 userid(相当于 OpenID)。
/**
* 根据扫码回调获取用户ID。
*
* @param string $code 企业微信返回的授权码。
* @return string 用户ID,如果失败则返回空字符串。
*/
public function getUserByScanCode($code)
{
$response = $this->queryAPI("{$this->apiUrl}auth/getuserinfo?access_token={$this->token}&code={$code}");
if ($this->isError()) {
// 记录错误日志,便于调试
// file_put_contents('/tmp/my_debug.log', "扫码回调错误: code : {$code} msg: " . json_encode($this->errors) . "\n", FILE_APPEND);
return '';
}
return $response->userid;
}
b. 修改 config/filter.php
配置允许通过的请求参数,防止被自动过滤。
$filter->user->wxlogin = new stdclass();
$filter->user->wxlogin->get['code'] = 'string'; // 回调 code
$filter->user->wxlogin->get['state'] = 'string'; // 自定义状态参数
$filter->user->wxlogin->get['providerID'] = 'string'; // 用于识别企业微信配置的 ID
c. 修改 config/zentaopms.php
将 user.wxlogin 方法加入到公共可访问列表,允许未登录用户访问。
$config->openMethods[] = 'user.wxlogin';
d. 修改 module/user/control.php
这是控制器层,新增 wxlogin 方法。其逻辑类似于原有的 login 方法,主要改动在于调用 userZen->wxCodeLogin() 来处理企业微信登录,并获取和传递相关参数。
/**
* 处理企业微信扫码登录。
*
* @param string $referer 来源页面。
* @access public
* @return void
*/
public function wxlogin($referer = '')
{
$code = $this->get->code ?? '';
$providerID = $this->get->providerID ?? '';
$state = $this->get->state ?? '';
// 可以在此处添加日志记录,便于调试
// file_put_contents('/tmp/my_debug.log', "wxlogin: code = {$code}, providerID = {$providerID} , state = {$state}\n", FILE_APPEND);
$viewType = $this->app->getViewType();
// 设置禅道序列号
if (empty($this->config->global->sn)) {
$this->loadModel('setting')->setSN();
}
// 重新加载语言项
if ($viewType == 'json' && $this->get->lang && $this->get->lang != $this->app->getClientLang()) {
$this->userZen->reloadLang($this->get->lang);
}
// 检查目录权限
$this->userZen->checkDirPermission();
// 设置来源网址
$this->referer = $this->userZen->setReferer($referer);
$loginLink = inlink('login');
$denyLink = inlink('deny');
$locateWebRoot = $this->config->webRoot . (helper::isWithTID() ? "?tid={$this->get->tid}" : '');
$locateReferer = $this->referer;
if (helper::isWithTID() && strpos($locateReferer, 'tid=') === false) {
$locateReferer .= (strpos($locateReferer, '?') === false ? '?' : '&') . "tid={$this->get->tid}";
}
// 如果用户已登录,则直接返回
if ($this->user->isLogon()) {
return $this->send($this->userZen->responseForLogon($this->referer, $viewType, $loginLink, $denyLink, $locateReferer, $locateWebRoot));
}
// 处理企业微信登录逻辑
$result = $this->userZen->wxCodeLogin($this->referer, $viewType, $loginLink, $denyLink, $locateReferer, $locateWebRoot, $code, $providerID, $state);
if ($result) {
// 清理输出缓冲区
while (ob_get_level()) {
ob_end_clean();
}
return $this->send($result);
}
// 登录失败,显示登录页面
helper::setcookie('tab', '', time());
$loginExpired = !(preg_match("/(m=|\/)(index)(&f=|-)(index)(&|-|\.)?/", strtolower($this->referer), $output) || $this->referer == $this->config->webRoot || empty($this->referer) || preg_match("/\/www\/$/", strtolower($this->referer), $output));
if ($this->cookie->logout) {
$loginExpired = false;
}
$this->view->title = $this->lang->user->login;
$this->view->plugins = $this->loadModel('extension')->getExpiringPlugins(true);
$this->view->unsafeSites = $this->loadModel('misc')->checkOneClickPackage();
$this->view->rand = updateSessionRandom();
$this->view->keepLogin = $this->cookie->keepLogin ? $this->cookie->keepLogin : 'off';
$this->view->sn = zget($this->config->global, 'sn', '');
$this->view->referer = $this->referer;
$this->view->loginExpired = $loginExpired;
$this->display();
}
/**
* 自定义登录失败响应。
* @param string $messageExt 附加错误信息。
* @return array 响应数组。
*/
public function responseForLoginFailExt($messageExt)
{
return array('result' => 'fail', 'message' => $messageExt);
}
e. 修改 module/user/zen.php
此文件类似于服务层,负责处理业务逻辑。
/**
* 处理企业微信扫码登录逻辑。
*
* @param string $referer 来源页面。
* @param string $viewType 视图类型。
* @param string $loginLink 登录链接。
* @param string $denyLink 禁止访问链接。
* @param string $locateReferer 跳转来源。
* @param string $locateWebRoot Web根目录。
* @param string $code 企业微信回调code。
* @param string $providerID 企业微信配置ID。
* @param string $state 自定义状态参数。
* @return array 登录结果。
*/
public function wxCodeLogin($referer = '', $viewType = '', $loginLink = '', $denyLink = '', $locateReferer = '', $locateWebRoot = '', $code = '', $providerID = '', $state = '')
{
$code = trim($code ?? '');
$providerID = trim($providerID ?? '');
$state = trim($state ?? '');
// 可选:验证 state 参数
// if (!$state || $state != 'your_expected_state') {
// return $this->responseForLoginFailExt('State参数无效');
// }
if (!$code) return $this->responseForLoginFailExt('Code参数缺失');
if (!$providerID) return $this->responseForLoginFailExt('ProviderID参数缺失');
// 加载企业微信 API 客户端
$webhookConfig = $this->loadModel('webhook')->getByID($providerID);
if (!$webhookConfig) {
return $this->responseForLoginFailExt('Webhook配置未找到');
}
$wxConfig = json_decode($webhookConfig->secret);
// file_put_contents('/tmp/my_debug.log', "Webhook Config: " . json_encode($webhookConfig) . " AppKey: {$wxConfig->appKey}\n", FILE_APPEND);
$this->app->loadClass('wechatapi', true);
$wechatApi = new wechatapi($wxConfig->appKey, $wxConfig->appSecret, $wxConfig->agentId);
// 通过 code 获取用户 ID
$userId = $wechatApi->getUserByScanCode($code);
// file_put_contents('/tmp/my_debug.log', "获取到的 UserID: {$userId}\n", FILE_APPEND);
if (!$userId) {
return $this->responseForLoginFailExt('未能从企业微信获取用户ID');
}
// 在 OAUTH 表中查找关联信息
$authInfo = $this->dao->select('*')
->from(TABLE_OAUTH)
->where('providerType')->eq('webhook')
->andWhere('providerID')->eq($providerID)
->andWhere('openID')->eq($userId) // 企业微信此处应为 userid
->fetch();
if (!$authInfo) {
return $this->responseForLoginFailExt('未找到用户与企业微信的绑定关系');
}
// 根据关联信息查找禅道用户
$user = $this->dao->select('*')
->from(TABLE_USER)
->where('deleted')->eq('0')
->andWhere('account')->eq($authInfo->account)
->fetch();
if (!$user) {
return $this->responseForLoginFailExt('未找到关联的禅道用户');
}
// 执行禅道原生登录流程
helper::setcookie('logout', false, 0);
$user = $this->user->login($user, true, false); // 这里的 true 和 false 可能需要根据实际情况调整
// 根据视图类型返回结果
if ($viewType == 'json') {
return array('status' => 'success', 'user' => $this->getUserForJSON($user));
}
// 判断是否需要跳转到来源页面
if (!$referer || strpos($referer, $loginLink) !== false || strpos($referer, $denyLink) !== false || strpos($referer, 'ajax') !== false || strpos($referer, 'block') !== false) {
return array('result' => 'success', 'locate' => $locateWebRoot);
}
// 解析来源页面的模块和方法
list($module, $method) = $this->parseLoginModuleAndMethod($referer);
// 校验模块和方法是否合法
if (empty($module) || empty($method) || !$this->app->checkModuleName($module, false) || !$this->app->checkMethodName($module, false)) {
return array('result' => 'success', 'locate' => $locateWebRoot);
}
// 检查权限并跳转
if (common::hasPriv($module, $method)) {
return array('result' => 'success', 'locate' => $locateReferer);
}
return array('result' => 'success', 'locate' => $locateWebRoot);
}
/**
* 解析登录时模块和方法。
* @param string $referer 来源URL。
* @return array 模块和方法数组。
*/
private function parseLoginModuleAndMethod($referer)
{
$pathInfo = parse_url($referer);
if (empty($pathInfo['path'])) return array('', '');
$path = trim($pathInfo['path'], '/');
$segments = explode('/', $path);
if (count($segments) >= 2) {
// 移除可能的前缀,例如 'index.php?m='
if ($segments[0] == 'index.php' && count($segments) >= 3) {
if ($segments[1] == 'm') return array($segments[2], isset($segments[4]) ? $segments[4] : ''); // 假设格式为 index.php?m=module&f=method
}
// 简单的 m-f 结构
if ($segments[0] == 'm' && $segments[2] == 'f') return array($segments[1], $segments[3]);
// 另一种可能的结构
if (isset($segments[1]) && isset($segments[3]) && $segments[2] == 'f') return array($segments[0], $segments[3]);
// 尝试标准 URL 结构 /module-method.html
if (strpos($segments[0], '-') !== false) {
list($module, $methodPart) = explode('-', $segments[0], 2);
$method = str_replace('.html', '', $methodPart);
return array($module, $method);
}
}
return array('', '');
}
/**
* 获取用于JSON响应的用户信息。
* @param object $user 用户对象。
* @return array 格式化后的用户信息。
*/
public function getUserForJSON($user)
{
// 此处可以根据需要自定义返回的用户信息字段
return array(
'id' => $user->id,
'account' => $user->account,
'realname' => $user->realname,
'avatar' => $user->avatar,
'role' => $user->role,
);
}
f. 修改 module/user/ui/login.html.php
复制 login.html.php 并重命名为 wxlogin.html.php。
g. 修改 module/user/view/m.login.html.php
复制 m.login.html.php 并重命名为 m.wxlogin.html.php。
2. 嵌入企业微信二维码
a. 修改 module/user/ui/login.html.php
在 <head> 部分引入企业微信的 JavaScript SDK。可以选择在线引入或本地引入。
- 在线引入:
<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.0.2.js"></script> - 本地引入:将 SDK 文件(如
wecom-jssdk-2.0.2.js)放置在www/js/目录下,然后使用<script src="<?php echo $config->webRoot;?>js/wecom-jssdk-2.0.2.js"></script>
将原有的登录 logo 部分替换为企业微信登录组件的容器,ID 设置为 wxCode。
修改前:
cell
(
set::width('5/12'),
setID('logo-box'),
set::style(array('background-image' => 'url(' . $imgBasePath . $config->user->loginImg['bg'] . ')')),
h::img(setID('login-logo'), set::style(array('top' => $logoVerticalMargin)), set::src($imgBasePath . $config->user->loginImg['logo'])),
h::img(setID('login-ai'), set::style(array('bottom' => $aiVerticalMargin)), set::src($imgBasePath . $config->user->loginImg['ai']))
),
修改后:
cell
(
set::width('5/12'),
setID('wxCode')
),
b. 修改 module/user/js/login.ui.js
在文件末尾添加 JavaScript 代码来初始化企业微信登录组件。
// 初始化企业微信登录组件
const wwLoginPanel = ww.createWWLoginPanel({
el: '#wxCode', // 挂载点
params: {
login_type: 'CorpApp', // 登录类型,根据企业微信应用类型选择
appid: 'YOUR_CORP_APP_ID', // 替换为您的企业微信应用 ID
agentid: 'YOUR_AGENT_ID', // 替换为您的企业微信 Agent ID
redirect_uri: 'YOUR_ZENTAO_DOMAIN/zentao/user-wxlogin.html?providerID=ID_FROM_WEBHOOK_TABLE', // 回调地址,需包含 webhook 表中的 providerID
state: 'YOUR_CUSTOM_STATE', // 自定义状态参数,用于安全校验
panel_size: 'small', // 组件尺寸
redirect_type: 'callback', // 跳转类型
},
// 登录状态检查回调
onCheckWeComLogin({ isWeComLogin }) {
console.log('Is WeCom Login:', isWeComLogin);
},
// 扫码成功后,获取 code 并自动跳转登录
onLoginSuccess({ code }) {
// 构建跳转链接,动态获取接口地址
let wxloginUrl = $.createLink('user', 'wxlogin');
// 拼接参数, providerID=X 是指 webhook 表中对应企业微信配置的 ID
location.href = `${wxloginUrl}?code=${code}&state=YOUR_CUSTOM_STATE&providerID=ID_FROM_WEBHOOK_TABLE`;
},
// 登录失败回调
onLoginFail(err) {
console.error('Login Failed:', err);
// 可以在此处显示错误提示
// ui.Modal.alert('登录失败,请重试。');
},
});
请务必将 appid, agentid, redirect_uri 中的域名和 providerID 替换为您实际的配置值。
c. 修改 module/user/css/login.ui.css
在此文件中可以调整登录页面的样式。
重要提示:
- 在企业微信管理后台的应用设置中,确保
redirect_uri已被正确配置并允许回调。 - 数据库的
zt_oauth表需要预先配置好企业微信的providerType('webhook')、providerID(与JS中的对应)、openID(企业微信用户ID)以及对应的account(禅道用户名)。