diff --git a/.env.example b/.env.example index 57c4524..b595856 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,9 @@ LLM_VISION_MODEL=qwen3-vl-plus LLM_TIMEOUT_SECONDS=90 # 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务 SMS_DEV_MODE=true -# 钉钉工作台登录(创建钉钉企业内部应用后填写) +# 钉钉工作台免登(企业内部应用) +DINGTALK_CORP_ID= +DINGTALK_AGENT_ID= DINGTALK_CLIENT_ID= DINGTALK_CLIENT_SECRET= DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback diff --git a/README.md b/README.md index 3dfcde9..0854552 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ npm run dev 正式环境请通过环境变量覆盖账号、密码、`SECRET_KEY` 和 `DATABASE_URL`。 -## 钉钉工作台登录 +## 钉钉工作台免登 -运营后台支持从钉钉工作台发起 OAuth 登录。请在钉钉开放平台创建企业内部应用,配置 OAuth 回调地址(必须使用 HTTPS,开发环境可使用内网穿透地址),然后在项目根目录 `.env` 填写: +从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在项目根目录 `.env` 填写: ```env +DINGTALK_CORP_ID=企业CorpId +DINGTALK_AGENT_ID=应用AgentId DINGTALK_CLIENT_ID=钉钉应用ClientID DINGTALK_CLIENT_SECRET=钉钉应用ClientSecret DINGTALK_REDIRECT_URI=https://你的域名/api/admin/auth/dingtalk/callback @@ -42,7 +44,7 @@ DINGTALK_ALLOWED_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` 运行,便于本地快速验收。 diff --git a/backend/app/config.py b/backend/app/config.py index 7f98292..8e695c9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -27,7 +27,9 @@ class Settings: llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT_SECONDS", "90")) # 开发环境返回验证码,接入短信服务后请关闭并在发送函数中对接供应商。 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_secret = os.getenv("DINGTALK_CLIENT_SECRET", "") dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback") diff --git a/backend/app/main.py b/backend/app/main.py index 3dfed97..5a415a5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -285,6 +285,10 @@ def _dingtalk_configured() -> bool: 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: request = urllib.request.Request( "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 -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") +def _dingtalk_workbench_user(code: str) -> dict: + """Resolve a workbench JSAPI requestAuthCode code.""" + token_request = urllib.request.Request( + "https://oapi.dingtalk.com/gettoken?" + urllib.parse.urlencode({ + "appkey": settings.dingtalk_client_id, + "appsecret": settings.dingtalk_client_secret, + }), + 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: - raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌") - profile = _dingtalk_user(access_token) - union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("openId") or "").strip() + raise HTTPException(status_code=502, detail="钉钉未返回应用访问令牌,请检查应用权限") + user_request = urllib.request.Request( + "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: raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识") 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 +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: 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") 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") diff --git a/frontend/index.html b/frontend/index.html index 8ec41b6..a40b320 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,7 @@
+ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 79db806..3bddd7e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -89,6 +89,33 @@ function LoginPage({ onLogin }) { const autoLoginStarted = useRef(false); const query = new URLSearchParams(window.location.search); 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() { setDingtalkLoading(true); setError(""); @@ -101,6 +128,21 @@ function LoginPage({ onLogin }) { 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) { event.preventDefault(); setError(""); @@ -123,7 +165,7 @@ function LoginPage({ onLogin }) { if (!inDingtalkWorkbench || query.get("dingtalk") === "success" || autoLoginStarted.current) return; autoLoginStarted.current = true; setAutoDingtalkLogin(true); - loginWithDingTalk(); + loginWithDingTalkWorkbench(); }, []); if (autoDingtalkLogin) { return
正在验证钉钉身份…
; @@ -142,8 +184,8 @@ function LoginPage({ onLogin }) {
{Z.brand}

{Z.backend}

从钉钉工作台进入,使用钉钉身份安全登录

-
或使用后台账号