doclib_sso.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. """门户用户与 Cloudreve 账号一一对应,并签发一次性进入票据。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import hmac
  5. import json
  6. import secrets
  7. import threading
  8. import time
  9. import urllib.error
  10. import urllib.request
  11. from typing import Optional
  12. from app.core.config import settings
  13. _lock = threading.Lock()
  14. _tickets: dict[str, tuple[float, dict]] = {}
  15. _cached_admin_token: str = ""
  16. _cached_admin_token_until: float = 0
  17. def cloudreve_email(user_name: str) -> str:
  18. """文档库邮箱:p.{门户用户名}@sso.shanxia.local。用户名需唯一。"""
  19. raw = (user_name or "").strip()
  20. safe = "".join(ch for ch in raw if ch.isalnum() or ch in ".-_")
  21. if not safe:
  22. safe = hashlib.sha256(raw.encode()).hexdigest()[:16]
  23. return f"p.{safe}@sso.shanxia.local"
  24. def _legacy_id_email(user_id: str) -> str:
  25. """改用用户名之前的邮箱,只用来把已有账号改过去,避免另建一个空账号。"""
  26. raw = (user_id or "").strip().lower()
  27. safe = "".join(ch for ch in raw if ch.isalnum() or ch == "-")
  28. if not safe:
  29. safe = hashlib.sha256(raw.encode()).hexdigest()[:32]
  30. return f"p.{safe}@sso.shanxia.local"
  31. def _password(user_id: str) -> str:
  32. digest = hmac.new(
  33. settings.SECRET_KEY.encode(),
  34. f"doclib:{user_id}".encode(),
  35. hashlib.sha256,
  36. ).hexdigest()
  37. return digest[:24]
  38. def issue_ticket(login: dict) -> str:
  39. code = secrets.token_urlsafe(24)
  40. with _lock:
  41. now = time.time()
  42. expired = [key for key, (exp, _) in _tickets.items() if exp < now]
  43. for key in expired:
  44. _tickets.pop(key, None)
  45. _tickets[code] = (now + 60, login)
  46. return code
  47. def consume_ticket(code: str) -> Optional[dict]:
  48. with _lock:
  49. item = _tickets.pop((code or "").strip(), None)
  50. if not item:
  51. return None
  52. exp, login = item
  53. if exp < time.time():
  54. return None
  55. return login
  56. def _request(method: str, path: str, payload: Optional[dict] = None, token: str = "") -> dict:
  57. url = settings.DOCLIB_BASE_URL.rstrip("/") + "/api/v4" + path
  58. body = None if payload is None else json.dumps(payload).encode()
  59. req = urllib.request.Request(url, data=body, method=method)
  60. req.add_header("Content-Type", "application/json")
  61. if token:
  62. req.add_header("Authorization", "Bearer " + token)
  63. try:
  64. with urllib.request.urlopen(req, timeout=30) as resp:
  65. data = json.load(resp)
  66. except urllib.error.HTTPError as exc:
  67. exc.read()
  68. raise RuntimeError(f"文档库接口失败({exc.code})") from exc
  69. if data.get("code") not in (0, None) and "data" not in data:
  70. raise RuntimeError(data.get("msg") or "文档库接口失败")
  71. if isinstance(data, dict) and data.get("code") not in (0, None):
  72. raise RuntimeError(data.get("msg") or "文档库接口失败")
  73. return data
  74. def _admin_access_token() -> str:
  75. global _cached_admin_token, _cached_admin_token_until
  76. now = time.time()
  77. if _cached_admin_token and now < _cached_admin_token_until:
  78. return _cached_admin_token
  79. data = _request(
  80. "POST",
  81. "/session/token",
  82. {"email": settings.DOCLIB_ADMIN_EMAIL, "password": settings.DOCLIB_ADMIN_PASSWORD},
  83. )
  84. token = data["data"]["token"]["access_token"]
  85. _cached_admin_token = token
  86. _cached_admin_token_until = now + 20 * 60
  87. return token
  88. def _find_user(email: str, token: str) -> Optional[dict]:
  89. data = _request(
  90. "POST",
  91. "/admin/user",
  92. {
  93. "page": 1,
  94. "page_size": 20,
  95. "order_by": "id",
  96. "order_direction": "asc",
  97. "conditions": {"user_email": email},
  98. },
  99. token,
  100. )
  101. users = (data.get("data") or {}).get("users") or []
  102. for user in users:
  103. if str(user.get("email") or "").lower() == email.lower():
  104. return user
  105. return None
  106. def _ensure_user(email: str, nick: str, token: str) -> dict:
  107. existing = _find_user(email, token)
  108. display = (nick or email.split("@", 1)[0])[:50]
  109. if existing is None:
  110. created = _request(
  111. "PUT",
  112. "/admin/user",
  113. {
  114. "password": _password(email),
  115. "user": {
  116. "id": 0,
  117. "email": email,
  118. "nick": display,
  119. "status": "active",
  120. "group_users": 2,
  121. },
  122. },
  123. token,
  124. )
  125. return created.get("data") or {}
  126. if (existing.get("nick") or "") != display:
  127. _request(
  128. "PUT",
  129. f"/admin/user/{existing['id']}",
  130. {
  131. "user": {
  132. "id": existing["id"],
  133. "email": email,
  134. "nick": display,
  135. "status": existing.get("status") or "active",
  136. "group_users": existing.get("group_users") or 2,
  137. }
  138. },
  139. token,
  140. )
  141. return existing
  142. def _move_user(user: dict, email: str, nick: str, token: str) -> dict:
  143. display = (nick or email.split("@", 1)[0])[:50]
  144. _request(
  145. "PUT",
  146. f"/admin/user/{user['id']}",
  147. {
  148. "password": _password(email),
  149. "user": {
  150. "id": user["id"],
  151. "email": email,
  152. "nick": display,
  153. "status": user.get("status") or "active",
  154. "group_users": user.get("group_users") or 2,
  155. },
  156. },
  157. token,
  158. )
  159. moved = dict(user)
  160. moved["email"] = email
  161. moved["nick"] = display
  162. return moved
  163. def ensure_portal_user(user_id: str, nick: str, token: str) -> dict:
  164. """按门户用户名找到或改名已有文档库账号,没有则新建。"""
  165. name = (nick or "").strip() or (user_id or "").strip()
  166. email = cloudreve_email(name)
  167. current = _find_user(email, token)
  168. if current is not None:
  169. return _move_user(current, email, name, token)
  170. legacy = _find_user(_legacy_id_email(user_id), token) if user_id else None
  171. if legacy is not None:
  172. return _move_user(legacy, email, name, token)
  173. return _ensure_user(email, name, token)
  174. def _login(email: str) -> dict:
  175. # 密码按邮箱派生,和门户用户名一一对应。
  176. data = _request(
  177. "POST",
  178. "/session/token",
  179. {"email": email, "password": _password(email)},
  180. )
  181. return data["data"]
  182. def _reset_password(user: dict, email: str, token: str) -> None:
  183. _request(
  184. "PUT",
  185. f"/admin/user/{user['id']}",
  186. {
  187. "password": _password(email),
  188. "user": {
  189. "id": user["id"],
  190. "email": email,
  191. "nick": user.get("nick") or email.split("@", 1)[0],
  192. "status": user.get("status") or "active",
  193. "group_users": user.get("group_users") or 2,
  194. },
  195. },
  196. token,
  197. )
  198. def open_session(user_id: str, nick: str) -> dict:
  199. """确保 Cloudreve 用户存在并登录,返回一次性进入地址。"""
  200. name = (nick or "").strip() or (user_id or "").strip()
  201. email = cloudreve_email(name)
  202. token = _admin_access_token()
  203. user = ensure_portal_user(user_id, name, token)
  204. try:
  205. login = _login(email)
  206. except RuntimeError:
  207. _admin_token_clear()
  208. token = _admin_access_token()
  209. user = _find_user(email, token) or user
  210. _reset_password(user, email, token)
  211. login = _login(email)
  212. code = issue_ticket(login)
  213. portal = settings.DOCLIB_PORTAL_ORIGIN.rstrip("/")
  214. doclib = settings.DOCLIB_PUBLIC_URL.rstrip("/")
  215. open_url = f"{doclib}/sso?code={code}&portal={portal}"
  216. return {
  217. "openUrl": open_url,
  218. "cloudreveEmail": email,
  219. "cloudreveUserId": (login.get("user") or {}).get("id"),
  220. }
  221. def _admin_token_clear() -> None:
  222. global _cached_admin_token, _cached_admin_token_until
  223. _cached_admin_token = ""
  224. _cached_admin_token_until = 0