某互联网公司部署ToolJet平台时用户认证配置踩坑实录如何快速搭建企业级身份验证与权限管理系统
说起来这事儿还挺有意思的。上周我们团队刚刚把ToolJet内部部署完,本来以为配置个SSO(单点登录)是小事一桩,结果光是搞定用户认证这块就折腾了将近三天。今天就把这个血泪史整理出来,希望能帮到同样在踩坑的你。
先说说我们到底遇到了啥坑
我们是做SaaS产品的,公司大概两百多号人,部门分散在杭州和深圳两地。老板拍板说要搭建一个低代码平台让各部门自己搞内部工具,选型了一圈最后定了ToolJet。
第一个坑就来了——认证方式的选择。ToolJet支持好多认证方式:内置认证、LDAP、OAuth2、OIDC(OpenID Connect)、SAML等等。我们技术团队一开始选了最简单的内置认证,结果上线两天就被业务部门吐槽”能不能接我们的企业微信登录”。好嘛,只能推倒重来。
第二个坑更狠——权限粒度。ToolJet自带的权限系统只有两个级别:管理员和普通用户。但我们运营部门想要的是”只能看报表,不能改配置”,财务部门想要的是”只能看财务相关的应用”,这怎么办?
我们的解决方案:OIDC + 自定义权限中间件
经过折腾,我们最终选择了这套方案:
用户 → 企业微信/钉钉 → OIDC提供商 → ToolJet + 自定义权限中间件 → 应用
第一步:OIDC提供商的配置
我们用的是Keycloak,这个开源的IAM(身份和访问管理)工具真的很强大。如果你的公司没有现成的IAM系统,强烈建议部署一个。
# 用Docker一键部署Keycloak
docker run -d \
--name keycloak \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin123 \
-p 8080:8080 \
quay.io/keycloak/keycloak:22.0 start-dev
部署完Keycloak之后,需要创建几个关键配置:
- 创建Realm(可以理解为独立的用户域)
- 创建Client(就是我们ToolJet这个应用)
- 配置用户映射(把企业微信的用户同步过来)
- 设置Scope和Claim(权限数据)
第二步:ToolJet的OIDC配置
这一步很多人会卡住,因为官方文档写得太简略了。我给你详细的步骤:
打开ToolJet的配置文件(通常在config.json或者环境变量里):
{
"AUTH_PROVIDERS": {
"oidc": {
"enabled": true,
"config": {
"issuer": "http://your-keycloak-domain/auth/realms/your-realm",
"authorization_endpoint": "http://your-keycloak-domain/auth/realms/your-realm/protocol/openid-connect/auth",
"token_endpoint": "http://your-keycloak-domain/auth/realms/your-realm/protocol/openid-connect/token",
"userinfo_endpoint": "http://your-keycloak-domain/auth/realms/your-realm/protocol/openid-connect/userinfo",
"client_id": "tooljet-client",
"client_secret": "your-client-secret",
"scope": "openid profile email roles"
}
}
},
"AUTH_COOKIE_NAME": "tooljet_session",
"AUTH_COOKIE_EXPIRY": 86400,
"AUTH_JWT_SECRET": "your-jwt-secret-key-at-least-32-chars"
}
这里有个巨坑:issuer字段一定要正确!很多教程写的是http://your-keycloak-domain/auth/realms/master,但你创建了自己的realm之后,必须改成你自己的realm名称。如果这里配错了,登录会直接报401。
第三步:用户属性的映射
光能登录还不够,我们得把用户的部门、角色这些信息传过去。这需要在Keycloak里配置Client Scopes和Mapper。
// 在Keycloak的Client配置里,找到"Scope"选项卡
// 添加自定义的Claim映射
// 在ToolJet这边,我们需要接收这些自定义claim
// ToolJet的OIDC认证回调里可以拿到完整的userInfo
app.post('/auth/callback', async (req, res) => {
const { token } = req.body;
const decoded = jwt.decode(token);
// 提取自定义属性
const user = {
id: decoded.sub,
email: decoded.email,
department: decoded.department, // 我们自定义的claim
role: decoded.role, // 我们自定义的claim
groups: decoded.groups // 用户组信息
};
// 创建或更新本地用户记录
await upsertUser(user);
res.json({ success: true, user });
});
权限系统的进阶玩法
ToolJet原生的权限太粗糙了,我们不得不自己搞了一套。思路是这样的:
在每个应用的前端代码里,根据用户的角色动态渲染组件。
// 这是一个Vue组件示例,展示如何根据权限控制显示
<template>
<div>
<!-- 只有管理员才能看到的配置按钮 -->
<button
v-if="hasPermission('admin')"
@click="openSettings"
>
应用配置
</button>
<!-- 所有登录用户都能看到的数据面板 -->
<DataDashboard v-if="isLoggedIn" />
<!-- 只有特定部门才能访问的财务模块 -->
<FinanceModule v-if="hasDepartment('finance')" />
<!-- 只有运营人员能用的报表生成器 -->
<ReportBuilder v-if="hasRole('operator')" />
</div>
</template>
<script>
export default {
computed: {
user() {
return this.$store.state.user;
},
isLoggedIn() {
return !!this.user;
}
},
methods: {
hasPermission(permission) {
// 检查用户是否有指定权限
return this.user?.permissions?.includes(permission) || false;
},
hasDepartment(dept) {
// 检查用户是否属于指定部门
return this.user?.department === dept;
},
hasRole(role) {
// 检查用户是否有指定角色
return this.user?.roles?.includes(role);
}
}
};
</script>
更优雅的方案:API网关层鉴权
如果你觉得在每个应用里都写权限判断太麻烦,可以试试在API网关层统一处理。我们后来引入了Nginx + Lua的方案:
-- nginx.lua 权限检查脚本
local jwt = require "resty.jwt"
function access()
local jwt_token = ngx.var.cookie_tooljet_session
if not jwt_token then
ngx.exit(401)
return
end
local jwt_obj = jwt:verify("your-jwt-secret-key", jwt_token)
if not jwt_obj.verified then
ngx.exit(401)
return
end
-- 把用户信息透传给后端
ngx.req.set_header("X-User-ID", jwt_obj.payload.sub)
ngx.req.set_header("X-User-Role", jwt_obj.payload.role)
ngx.req.set_header("X-User-Department", jwt_obj.payload.department)
-- 根据path做权限判断
local path = ngx.var.uri
local role = jwt_obj.payload.role
if string.find(path, "/admin") and role ~= "admin" then
ngx.exit(403)
end
if string.find(path, "/finance") and jwt_obj.payload.department ~= "finance" then
ngx.exit(403)
end
end
对应的Nginx配置:
location / {
access_by_lua_file /etc/nginx/lua/permission.lua;
proxy_pass http://tooljet-backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /admin/ {
# 只允许管理员访问
access_by_lua_block {
local role = ngx.req.get_headers()["X-User-Role"]
if role ~= "admin" then
ngx.exit(403)
end
}
proxy_pass http://tooljet-backend:3000;
}
企业微信OAuth2.0的额外坑
对接企业微信认证的时候,还遇到了几个比较诡异的问题:
redirect_uri必须完全匹配:包括末尾的斜杠。
https://tooljet.company.com/auth和https://tooljet.company.com/auth/在ToolJet眼里是两个不同的地址。corpId和corpSecret的安全存储:别直接写在配置文件里,用环境变量或者Secrets Manager。我们有个同事曾经把Secret贴在飞书文档里发出去过,吓死人了。
用户ID映射:企业微信的userid和OpenID不一样,建议用OpenID做ToolJet的用户唯一标识。
// 企业微信OAuth2.0的完整流程
const WECHAT_CONFIG = {
corpId: process.env.WECHAT_CORP_ID,
corpSecret: process.env.WECHAT_CORP_SECRET,
agentId: process.env.WECHAT_AGENT_ID,
redirectUri: process.env.WECHAT_REDIRECT_URI // 注意不能有斜杠!
};
// 生成登录URL
function getLoginUrl() {
const state = crypto.randomBytes(16).toString('hex');
const urlencode = encodeURIComponent;
const url = new URL('https://open.weixin.qq.com/connect/oauth2/authorize');
url.searchParams.append('appid', WECHAT_CONFIG.corpId);
url.searchParams.append('redirect_uri', WECHAT_CONFIG.redirectUri);
url.searchParams.append('response_type', 'code');
url.searchParams.append('scope', 'snsapi_privateinfo');
url.searchParams.append('state', state);
url.searchParams.append('connect_redirect', '1');
return url.toString();
}
// 用code换取用户信息
async function getUserInfo(code) {
const tokenUrl = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken';
const tokenRes = await fetch(`${tokenUrl}?corpid=${WECHAT_CONFIG.corpId}&corpsecret=${WECHAT_CONFIG.corpSecret}`);
const tokenData = await tokenRes.json();
const userUrl = 'https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo';
const userRes = await fetch(
`${userUrl}?access_token=${tokenData.access_token}&code=${code}`
);
const userData = await userRes.json();
// 获取用户详细信息(需要snapi_privateinfo权限)
const detailUrl = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo';
const detailRes = await fetch(
`${detailUrl}?access_token=${tokenData.access_token}&code=${code}`
);
return await detailRes.json();
}
生产环境的部署建议
最后分享一些生产环境的经验,这些东西官方文档里可不会写:
1. 多实例部署时的Session共享
# docker-compose.prod.yml
version: '3.8'
services:
tooljet-worker:
image: tooljet/tooljet-worker:latest
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/tooljet
- REDIS_URL=redis://redis:6379 # 重要:用Redis做session存储
- AUTH_PROVIDERS=oidc
# ...其他配置
tooljet-web:
image: tooljet/tooljet-web:latest
environment:
- REDIS_URL=redis://redis:6379 # 和worker用同一个Redis
# ...其他配置
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
nginx:
image: nginx:alpine
ports:
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/nginx/certs
depends_on:
- tooljet-web
- tooljet-worker
volumes:
redis_data:
2. 数据库迁移的注意事项
升级ToolJet版本的时候,千万记得先备份数据库。有一次我们升级从1.9到2.0,数据库schema变了,差点把生产数据搞坏。
# 升级前必做的三步
# 1. 备份数据库
pg_dump tooljet_prod > backup_$(date +%Y%m%d_%H%M%S).sql
# 2. 拉取最新镜像(不要立即运行)
docker pull tooljet/tooljet-server:latest
# 3. 在测试环境先验证
./scripts/test-upgrade.sh
3. 监控和告警
# 建议加的监控指标
prometheus.yml
scrape_configs:
- job_name: 'tooljet'
static_configs:
- targets: ['tooljet-exporter:9100']
metrics_path: /metrics
# 关键指标:
# - tooljet_active_sessions: 当前活跃会话数
# - tooljet_auth_failures_total: 认证失败次数(突增说明有人在爆破)
# - tooljet_response_time_seconds: 响应时间
# - tooljet_failed_queries_total: 查询失败次数
总结一下踩过的坑
- OIDC的issuer配置错误是最常见的问题,一定要核对Keycloak的Realm名称
- 权限控制不能只靠ToolJet原生功能,必须配合自定义中间件
- 生产环境一定要用Redis做Session存储,不然多实例部署会出问题
- 升级前务必备份数据库,测试环境先验证
- 企业微信OAuth2的redirect_uri要格外注意,差一个斜杠都不行
ToolJet整体来说是个不错的工具,但它在国内的社区支持确实不算强,很多配置细节需要自己摸索。希望这篇文章能帮你少走一些弯路。
对了,我们这套方案上线之后,运维效率提升了大概40%,各部门自己搭建内部工具的周期从两周缩短到了两天。如果你们也有类似的需求,这套方案可以参考。有问题欢迎评论区交流,我看到都会回复。
