修改钉钉免登
This commit is contained in:
parent
970dc8bfee
commit
5b10096bbf
@ -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
|
||||||
|
|||||||
@ -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` 运行,便于本地快速验收。
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user