修改钉钉免登

This commit is contained in:
kx_guo 2026-09-05 17:33:21 +08:00
parent 970dc8bfee
commit 5b10096bbf
6 changed files with 122 additions and 20 deletions

View File

@ -15,7 +15,9 @@ LLM_VISION_MODEL=qwen3-vl-plus
LLM_TIMEOUT_SECONDS=90 LLM_TIMEOUT_SECONDS=90
# 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务 # 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务
SMS_DEV_MODE=true SMS_DEV_MODE=true
# 钉钉工作台登录(创建钉钉企业内部应用后填写) # 钉钉工作台免登(企业内部应用)
DINGTALK_CORP_ID=
DINGTALK_AGENT_ID=
DINGTALK_CLIENT_ID= DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET= DINGTALK_CLIENT_SECRET=
DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback

View File

@ -27,11 +27,13 @@ npm run dev
正式环境请通过环境变量覆盖账号、密码、`SECRET_KEY` 和 `DATABASE_URL`。 正式环境请通过环境变量覆盖账号、密码、`SECRET_KEY` 和 `DATABASE_URL`。
## 钉钉工作台登录 ## 钉钉工作台免登
运营后台支持从钉钉工作台发起 OAuth 登录。请在钉钉开放平台创建企业内部应用,配置 OAuth 回调地址(必须使用 HTTPS,开发环境可使用内网穿透地址),然后在项目根目录 `.env` 填写: 从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在项目根目录 `.env` 填写:
```env ```env
DINGTALK_CORP_ID=企业CorpId
DINGTALK_AGENT_ID=应用AgentId
DINGTALK_CLIENT_ID=钉钉应用ClientID DINGTALK_CLIENT_ID=钉钉应用ClientID
DINGTALK_CLIENT_SECRET=钉钉应用ClientSecret DINGTALK_CLIENT_SECRET=钉钉应用ClientSecret
DINGTALK_REDIRECT_URI=https://你的域名/api/admin/auth/dingtalk/callback DINGTALK_REDIRECT_URI=https://你的域名/api/admin/auth/dingtalk/callback
@ -42,7 +44,7 @@ DINGTALK_ALLOWED_UNION_IDS=
DINGTALK_SUPER_ADMIN_UNION_IDS= DINGTALK_SUPER_ADMIN_UNION_IDS=
``` ```
将钉钉应用的登录回调配置为与 `DINGTALK_REDIRECT_URI` 完全一致。未配置 ClientID/ClientSecret 时,登录页仍可使用原有账号密码登录,并会提示钉钉登录尚未配置。 `DINGTALK_CLIENT_ID/SECRET` 填应用的 AppKey/AppSecret;`DINGTALK_CORP_ID` 填企业 CorpId;`DINGTALK_AGENT_ID` 用于标识工作台应用。当前免登接口不使用浏览器 OAuth 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。
MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用项目 `data/app.db` 运行,便于本地快速验收。 MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用项目 `data/app.db` 运行,便于本地快速验收。

View File

@ -27,7 +27,9 @@ class Settings:
llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT_SECONDS", "90")) llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
# 开发环境返回验证码,接入短信服务后请关闭并在发送函数中对接供应商。 # 开发环境返回验证码,接入短信服务后请关闭并在发送函数中对接供应商。
sms_dev_mode = os.getenv("SMS_DEV_MODE", "true").lower() == "true" sms_dev_mode = os.getenv("SMS_DEV_MODE", "true").lower() == "true"
# DingTalk workbench OAuth settings. # DingTalk workbench SSO/OAuth settings.
dingtalk_corp_id = os.getenv("DINGTALK_CORP_ID", "")
dingtalk_agent_id = os.getenv("DINGTALK_AGENT_ID", "")
dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID", "") dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID", "")
dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "") dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "")
dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback") dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback")

View File

@ -285,6 +285,10 @@ def _dingtalk_configured() -> bool:
return bool(settings.dingtalk_client_id and settings.dingtalk_client_secret) return bool(settings.dingtalk_client_id and settings.dingtalk_client_secret)
def _dingtalk_workbench_configured() -> bool:
return bool(settings.dingtalk_corp_id and settings.dingtalk_agent_id and settings.dingtalk_client_id and settings.dingtalk_client_secret)
def _dingtalk_exchange_code(code: str) -> dict: def _dingtalk_exchange_code(code: str) -> dict:
request = urllib.request.Request( request = urllib.request.Request(
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
@ -317,18 +321,41 @@ def _dingtalk_user(access_token: str) -> dict:
raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc
def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser: def _dingtalk_workbench_user(code: str) -> dict:
if not _dingtalk_configured(): """Resolve a workbench JSAPI requestAuthCode code."""
raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员") token_request = urllib.request.Request(
code = code.strip() "https://oapi.dingtalk.com/gettoken?" + urllib.parse.urlencode({
if not code: "appkey": settings.dingtalk_client_id,
raise HTTPException(status_code=400, detail="钉钉授权码不能为空") "appsecret": settings.dingtalk_client_secret,
token_data = _dingtalk_exchange_code(code) }),
access_token = token_data.get("accessToken") or token_data.get("access_token") method="GET",
)
try:
with urllib.request.urlopen(token_request, timeout=10) as response:
token_data = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise HTTPException(status_code=502, detail="钉钉应用凭证校验失败,请稍后重试") from exc
access_token = token_data.get("access_token")
if not access_token: if not access_token:
raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌") raise HTTPException(status_code=502, detail="钉钉未返回应用访问令牌,请检查应用权限")
profile = _dingtalk_user(access_token) user_request = urllib.request.Request(
union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("openId") or "").strip() "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?" + urllib.parse.urlencode({"access_token": access_token}),
data=json.dumps({"code": code}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(user_request, timeout=10) as response:
user_data = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请检查应用免登权限") from exc
if user_data.get("errcode", 0) not in (0, None):
raise HTTPException(status_code=502, detail=f"钉钉用户信息读取失败:{user_data.get('errmsg', '请检查应用权限')}")
return user_data.get("result") or user_data
def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> AdminUser:
union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("userid") or profile.get("userId") or "").strip()
if not union_id: if not union_id:
raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识") raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识")
if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids: if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids:
@ -354,6 +381,20 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
return user return user
def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
if not _dingtalk_configured():
raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员")
code = code.strip()
if not code:
raise HTTPException(status_code=400, detail="钉钉授权码不能为空")
token_data = _dingtalk_exchange_code(code)
access_token = token_data.get("accessToken") or token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌")
profile = _dingtalk_user(access_token)
return _dingtalk_create_session(profile, db, response)
def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool:
return user.role_code == "super_admin" or task.owner_id == user.id return user.role_code == "super_admin" or task.owner_id == user.id
@ -1994,7 +2035,19 @@ async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depend
@app.get("/api/admin/auth/dingtalk/config") @app.get("/api/admin/auth/dingtalk/config")
def dingtalk_login_config(): def dingtalk_login_config():
return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}} return {"success": True, "data": {"enabled": _dingtalk_workbench_configured(), "corp_id": settings.dingtalk_corp_id, "agent_id": settings.dingtalk_agent_id, "redirect_uri": settings.dingtalk_redirect_uri}}
@app.post("/api/admin/auth/dingtalk/workbench")
def dingtalk_workbench_login(payload: dict, response: Response, db: Session = Depends(get_db)):
if not _dingtalk_workbench_configured():
raise HTTPException(status_code=503, detail="钉钉工作台免登尚未配置,请联系管理员")
code = str(payload.get("code") or "").strip()
if not code:
raise HTTPException(status_code=400, detail="钉钉免登授权码不能为空")
profile = _dingtalk_workbench_user(code)
user = _dingtalk_create_session(profile, db, response)
return {"success": True, "data": {"user": public_user(user)}}
@app.get("/api/admin/auth/dingtalk/start") @app.get("/api/admin/auth/dingtalk/start")

View File

@ -7,6 +7,7 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/2.15.15/dingtalk.open.js"></script>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

View File

@ -89,6 +89,33 @@ function LoginPage({ onLogin }) {
const autoLoginStarted = useRef(false); const autoLoginStarted = useRef(false);
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk"; const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk";
function requestDingTalkAuthCode(corpId) {
return new Promise((resolve, reject) => {
const ding = window.dd;
if (!ding) return reject(new Error("未检测到钉钉工作台环境,请从钉钉工作台打开"));
let settled = false;
const finish = (callback) => (value) => {
if (settled) return;
settled = true;
callback(value);
};
const success = finish((result) => {
const code = result?.code || result?.authCode;
code ? resolve(code) : reject(new Error("钉钉未返回免登授权码"));
});
const failure = finish((error) => reject(new Error(error?.errorMessage || error?.message || "钉钉免登授权失败")));
const invoke = () => {
try {
ding.runtime.permission.requestAuthCode({ corpId, onSuccess: success, onFail: failure });
} catch (error) {
failure(error);
}
};
if (typeof ding.ready === "function") ding.ready(invoke);
else invoke();
window.setTimeout(() => failure(new Error("钉钉免登请求超时,请确认应用已发布并开通免登权限")), 10000);
});
}
async function loginWithDingTalk() { async function loginWithDingTalk() {
setDingtalkLoading(true); setDingtalkLoading(true);
setError(""); setError("");
@ -101,6 +128,21 @@ function LoginPage({ onLogin }) {
setAutoDingtalkLogin(false); setAutoDingtalkLogin(false);
} }
} }
async function loginWithDingTalkWorkbench() {
setDingtalkLoading(true);
setError("");
try {
const config = await api("/api/admin/auth/dingtalk/config");
if (!config.data?.enabled || !config.data?.corp_id) throw new Error("钉钉工作台免登尚未配置,请联系管理员");
const code = await requestDingTalkAuthCode(config.data.corp_id);
const result = await api("/api/admin/auth/dingtalk/workbench", { method: "POST", body: JSON.stringify({ code }) });
onLogin(result.data.user);
} catch (err) {
setError(err.message);
setAutoDingtalkLogin(false);
setDingtalkLoading(false);
}
}
async function submit(event) { async function submit(event) {
event.preventDefault(); event.preventDefault();
setError(""); setError("");
@ -123,7 +165,7 @@ function LoginPage({ onLogin }) {
if (!inDingtalkWorkbench || query.get("dingtalk") === "success" || autoLoginStarted.current) return; if (!inDingtalkWorkbench || query.get("dingtalk") === "success" || autoLoginStarted.current) return;
autoLoginStarted.current = true; autoLoginStarted.current = true;
setAutoDingtalkLogin(true); setAutoDingtalkLogin(true);
loginWithDingTalk(); loginWithDingTalkWorkbench();
}, []); }, []);
if (autoDingtalkLogin) { if (autoDingtalkLogin) {
return <div className="loading-screen">正在验证钉钉身份…</div>; return <div className="loading-screen">正在验证钉钉身份…</div>;
@ -142,8 +184,8 @@ function LoginPage({ onLogin }) {
<div className="brand-title">{Z.brand}</div> <div className="brand-title">{Z.brand}</div>
<h1>{Z.backend}</h1> <h1>{Z.backend}</h1>
<p className="login-subtitle">从钉钉工作台进入,使用钉钉身份安全登录</p> <p className="login-subtitle">从钉钉工作台进入,使用钉钉身份安全登录</p>
<button className="dingtalk-login-button" type="button" onClick={loginWithDingTalk} disabled={dingtalkLoading}> <button className="dingtalk-login-button" type="button" onClick={inDingtalkWorkbench ? loginWithDingTalkWorkbench : loginWithDingTalk} disabled={dingtalkLoading}>
{dingtalkLoading ? "正在跳转钉钉…" : "使用钉钉登录"} {dingtalkLoading ? "正在验证钉钉身份…" : "使用钉钉登录"}
</button> </button>
<div className="login-divider"><span>或使用后台账号</span></div> <div className="login-divider"><span>或使用后台账号</span></div>
<form onSubmit={submit} className="login-form"> <form onSubmit={submit} className="login-form">