143 lines
5.2 KiB
Python
143 lines
5.2 KiB
Python
|
|
"""飞书 OAuth 登录。"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import secrets
|
|||
|
|
import urllib.parse
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
from jose import JWTError, jwt
|
|||
|
|
|
|||
|
|
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_REDIRECT_URI, JWT_SECRET
|
|||
|
|
|
|||
|
|
FEISHU_AUTHORIZE_URL = "https://passport.feishu.cn/suite/passport/oauth/authorize"
|
|||
|
|
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
|
|||
|
|
FEISHU_USER_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
|
|||
|
|
FEISHU_TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
|||
|
|
FEISHU_CONTACT_USER_URL = "https://open.feishu.cn/open-apis/contact/v3/users/{user_id}"
|
|||
|
|
|
|||
|
|
STATE_ALG = "HS256"
|
|||
|
|
STATE_EXPIRE_MINUTES = 10
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_feishu_configured() -> bool:
|
|||
|
|
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_authorize_url() -> tuple[str, str]:
|
|||
|
|
# 使用签名 state,避免服务重启/多进程导致内存 state 丢失
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
state = jwt.encode(
|
|||
|
|
{
|
|||
|
|
"nonce": secrets.token_urlsafe(12),
|
|||
|
|
"iat": int(now.timestamp()),
|
|||
|
|
"exp": int((now + timedelta(minutes=STATE_EXPIRE_MINUTES)).timestamp()),
|
|||
|
|
"typ": "feishu_oauth_state",
|
|||
|
|
},
|
|||
|
|
JWT_SECRET,
|
|||
|
|
algorithm=STATE_ALG,
|
|||
|
|
)
|
|||
|
|
params = {
|
|||
|
|
"client_id": FEISHU_APP_ID,
|
|||
|
|
"redirect_uri": FEISHU_REDIRECT_URI,
|
|||
|
|
"response_type": "code",
|
|||
|
|
"state": state,
|
|||
|
|
}
|
|||
|
|
return f"{FEISHU_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}", state
|
|||
|
|
|
|||
|
|
|
|||
|
|
def verify_state(state: str) -> bool:
|
|||
|
|
try:
|
|||
|
|
payload = jwt.decode(state, JWT_SECRET, algorithms=[STATE_ALG])
|
|||
|
|
return payload.get("typ") == "feishu_oauth_state"
|
|||
|
|
except JWTError:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def exchange_code(code: str) -> dict[str, Any]:
|
|||
|
|
"""用授权码换 user_access_token 并拉取用户信息。"""
|
|||
|
|
with httpx.Client(timeout=30.0) as client:
|
|||
|
|
token_resp = client.post(
|
|||
|
|
FEISHU_TOKEN_URL,
|
|||
|
|
json={
|
|||
|
|
"grant_type": "authorization_code",
|
|||
|
|
"code": code,
|
|||
|
|
"app_id": FEISHU_APP_ID,
|
|||
|
|
"app_secret": FEISHU_APP_SECRET,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
token_resp.raise_for_status()
|
|||
|
|
token_data = token_resp.json()
|
|||
|
|
if token_data.get("code") != 0:
|
|||
|
|
raise RuntimeError(token_data.get("msg") or "飞书 token 交换失败")
|
|||
|
|
|
|||
|
|
access_token = token_data["data"]["access_token"]
|
|||
|
|
user_resp = client.get(
|
|||
|
|
FEISHU_USER_URL,
|
|||
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|||
|
|
)
|
|||
|
|
user_resp.raise_for_status()
|
|||
|
|
user_data = user_resp.json()
|
|||
|
|
if user_data.get("code") != 0:
|
|||
|
|
raise RuntimeError(user_data.get("msg") or "飞书用户信息获取失败")
|
|||
|
|
|
|||
|
|
info = user_data["data"]
|
|||
|
|
department_ids: list[str] = []
|
|||
|
|
user_id = info.get("user_id")
|
|||
|
|
tenant_key = info.get("tenant_key")
|
|||
|
|
open_id = info.get("open_id") or info.get("openId")
|
|||
|
|
if open_id:
|
|||
|
|
try:
|
|||
|
|
tenant_access_token = _get_tenant_access_token(client)
|
|||
|
|
contact_user = _get_contact_user_profile(client, tenant_access_token, open_id)
|
|||
|
|
user_id = contact_user.get("user_id") or user_id
|
|||
|
|
tenant_key = contact_user.get("tenant_key") or tenant_key
|
|||
|
|
raw_department_ids = contact_user.get("department_ids")
|
|||
|
|
if isinstance(raw_department_ids, list):
|
|||
|
|
department_ids = [str(x) for x in raw_department_ids if x]
|
|||
|
|
except Exception:
|
|||
|
|
# 联系人接口失败时不阻断登录
|
|||
|
|
department_ids = []
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"open_id": open_id,
|
|||
|
|
"union_id": info.get("union_id") or info.get("unionId"),
|
|||
|
|
"user_id": user_id,
|
|||
|
|
"tenant_key": tenant_key,
|
|||
|
|
"department_ids": department_ids,
|
|||
|
|
"name": info.get("name") or info.get("en_name") or "飞书用户",
|
|||
|
|
"email": info.get("email") or info.get("enterprise_email"),
|
|||
|
|
"avatar_url": info.get("avatar_url") or info.get("avatar_big"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_tenant_access_token(client: httpx.Client) -> str:
|
|||
|
|
resp = client.post(
|
|||
|
|
FEISHU_TENANT_TOKEN_URL,
|
|||
|
|
json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET},
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
if data.get("code") != 0:
|
|||
|
|
raise RuntimeError(data.get("msg") or "飞书 tenant token 获取失败")
|
|||
|
|
token = data.get("tenant_access_token")
|
|||
|
|
if not token:
|
|||
|
|
raise RuntimeError("飞书 tenant token 为空")
|
|||
|
|
return token
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_contact_user_profile(
|
|||
|
|
client: httpx.Client, tenant_access_token: str, open_id: str
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
resp = client.get(
|
|||
|
|
FEISHU_CONTACT_USER_URL.format(user_id=urllib.parse.quote(open_id, safe="")),
|
|||
|
|
params={"user_id_type": "open_id", "department_id_type": "open_department_id"},
|
|||
|
|
headers={"Authorization": f"Bearer {tenant_access_token}"},
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
if data.get("code") != 0:
|
|||
|
|
raise RuntimeError(data.get("msg") or "飞书联系人信息获取失败")
|
|||
|
|
return data.get("data", {}).get("user", {})
|