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 @@
+