Files
holy-python/auth_service.py
2026-08-04 14:02:45 +08:00

172 lines
6.6 KiB
Python

import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Any
from urllib.parse import urlencode
import requests
from config import Config
from database import connection, json_dumps, json_loads, now_iso
from task_service import TaskError
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _normalize_user(payload: dict[str, Any]) -> dict[str, str]:
user_id = (
payload.get("unionId")
or payload.get("unionid")
or payload.get("userid")
or payload.get("userId")
or payload.get("openId")
or payload.get("openid")
)
name = (
payload.get("nick")
or payload.get("nickName")
or payload.get("name")
or payload.get("mobile")
or user_id
)
if not user_id:
raise TaskError("DINGTALK_USER_ID_MISSING", "钉钉用户信息中缺少用户标识", 502, {"payload": payload})
return {
"ding_user_id": str(user_id),
"union_id": str(payload.get("unionId") or payload.get("unionid") or ""),
"open_id": str(payload.get("openId") or payload.get("openid") or ""),
"name": str(name or ""),
"avatar": str(payload.get("avatarUrl") or payload.get("avatar") or ""),
"mobile": str(payload.get("mobile") or ""),
"email": str(payload.get("email") or ""),
}
def build_dingtalk_login_url(frontend_redirect: str = "/tasks") -> dict[str, str]:
if not Config.DINGTALK_CLIENT_ID:
raise TaskError("DINGTALK_NOT_CONFIGURED", "未配置钉钉 DINGTALK_CLIENT_ID/DINGTALK_APP_KEY", 500)
state = secrets.token_urlsafe(24)
params = {
"redirect_uri": Config.DINGTALK_REDIRECT_URI,
"response_type": "code",
"client_id": Config.DINGTALK_CLIENT_ID,
"scope": "openid",
"state": f"{state}:{frontend_redirect or '/tasks'}",
"prompt": "consent",
}
return {"url": f"{Config.DINGTALK_AUTH_URL}?{urlencode(params)}", "state": params["state"]}
def exchange_dingtalk_user(code: str) -> dict[str, Any]:
if not Config.DINGTALK_CLIENT_ID or not Config.DINGTALK_CLIENT_SECRET:
raise TaskError("DINGTALK_NOT_CONFIGURED", "未配置钉钉应用 client_id/client_secret", 500)
token_resp = requests.post(
Config.DINGTALK_TOKEN_URL,
json={
"clientId": Config.DINGTALK_CLIENT_ID,
"clientSecret": Config.DINGTALK_CLIENT_SECRET,
"code": code,
"grantType": "authorization_code",
},
timeout=20,
)
if token_resp.status_code >= 400:
raise TaskError("DINGTALK_TOKEN_FAILED", "钉钉 access_token 获取失败", 502, {"body": token_resp.text})
token_body = token_resp.json()
access_token = token_body.get("accessToken") or token_body.get("access_token")
if not access_token:
raise TaskError("DINGTALK_TOKEN_MISSING", "钉钉响应中缺少 accessToken", 502, {"body": token_body})
user_resp = requests.get(
Config.DINGTALK_USER_URL,
headers={"x-acs-dingtalk-access-token": access_token},
timeout=20,
)
if user_resp.status_code >= 400:
raise TaskError("DINGTALK_USER_FAILED", "钉钉用户信息获取失败", 502, {"body": user_resp.text})
return user_resp.json()
def upsert_user_and_session(dingtalk_user: dict[str, Any]) -> tuple[dict[str, Any], str, datetime]:
normalized = _normalize_user(dingtalk_user)
token = secrets.token_urlsafe(36)
session_hash = _sha256(token)
now = now_iso()
expires_at_dt = datetime.now().astimezone() + timedelta(days=Config.SESSION_EXPIRE_DAYS)
expires_at = expires_at_dt.isoformat(timespec="seconds")
with connection() as conn:
existing = conn.execute(
"SELECT id FROM auth_users WHERE ding_user_id=?",
(normalized["ding_user_id"],),
).fetchone()
if existing:
user_id = existing["id"]
conn.execute(
"""UPDATE auth_users SET union_id=?,open_id=?,name=?,avatar=?,mobile=?,email=?,
raw_json=?,last_login_at=?,updated_at=? WHERE id=?""",
(
normalized["union_id"], normalized["open_id"], normalized["name"],
normalized["avatar"], normalized["mobile"], normalized["email"],
json_dumps(dingtalk_user), now, now, user_id,
),
)
else:
cursor = conn.execute(
"""INSERT INTO auth_users
(ding_user_id,union_id,open_id,name,avatar,mobile,email,raw_json,last_login_at,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
normalized["ding_user_id"], normalized["union_id"], normalized["open_id"],
normalized["name"], normalized["avatar"], normalized["mobile"],
normalized["email"], json_dumps(dingtalk_user), now, now, now,
),
)
user_id = cursor.lastrowid
conn.execute(
"""INSERT INTO auth_sessions (session_hash,user_id,expires_at,created_at,last_seen_at)
VALUES (?,?,?,?,?)""",
(session_hash, user_id, expires_at, now, now),
)
return get_user_by_session_token(token), token, expires_at_dt
def get_user_by_session_token(token: str | None) -> dict[str, Any] | None:
if not token:
return None
session_hash = _sha256(token)
current = now_iso()
with connection() as conn:
row = conn.execute(
"""SELECT s.id AS session_id,s.expires_at,u.* FROM auth_sessions s
JOIN auth_users u ON u.id=s.user_id
WHERE s.session_hash=? AND s.revoked_at IS NULL AND s.expires_at>?""",
(session_hash, current),
).fetchone()
if not row:
return None
conn.execute(
"UPDATE auth_sessions SET last_seen_at=? WHERE id=?",
(current, row["session_id"]),
)
raw = json_loads(row["raw_json"]) if row["raw_json"] else {}
return {
"id": str(row["ding_user_id"]),
"name": row["name"],
"account": row["mobile"] or row["email"] or row["ding_user_id"],
"avatar": row["avatar"],
"union_id": row["union_id"],
"open_id": row["open_id"],
"raw": raw,
}
def revoke_session(token: str | None) -> None:
if not token:
return
with connection() as conn:
conn.execute(
"UPDATE auth_sessions SET revoked_at=? WHERE session_hash=? AND revoked_at IS NULL",
(now_iso(), _sha256(token)),
)