Files
2026-10-23 02:46:55 +08:00

258 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""门户用户与 Cloudreve 账号一一对应,并签发一次性进入票据。"""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import threading
import time
import urllib.error
import urllib.request
from typing import Optional
from app.core.config import settings
_lock = threading.Lock()
_tickets: dict[str, tuple[float, dict]] = {}
_cached_admin_token: str = ""
_cached_admin_token_until: float = 0
def cloudreve_email(user_name: str) -> str:
"""文档库邮箱:p.{门户用户名}@sso.shanxia.local。用户名需唯一。"""
raw = (user_name or "").strip()
safe = "".join(ch for ch in raw if ch.isalnum() or ch in ".-_")
if not safe:
safe = hashlib.sha256(raw.encode()).hexdigest()[:16]
return f"p.{safe}@sso.shanxia.local"
def _legacy_id_email(user_id: str) -> str:
"""改用用户名之前的邮箱,只用来把已有账号改过去,避免另建一个空账号。"""
raw = (user_id or "").strip().lower()
safe = "".join(ch for ch in raw if ch.isalnum() or ch == "-")
if not safe:
safe = hashlib.sha256(raw.encode()).hexdigest()[:32]
return f"p.{safe}@sso.shanxia.local"
def _password(user_id: str) -> str:
digest = hmac.new(
settings.SECRET_KEY.encode(),
f"doclib:{user_id}".encode(),
hashlib.sha256,
).hexdigest()
return digest[:24]
def issue_ticket(login: dict) -> str:
code = secrets.token_urlsafe(24)
with _lock:
now = time.time()
expired = [key for key, (exp, _) in _tickets.items() if exp < now]
for key in expired:
_tickets.pop(key, None)
_tickets[code] = (now + 60, login)
return code
def consume_ticket(code: str) -> Optional[dict]:
with _lock:
item = _tickets.pop((code or "").strip(), None)
if not item:
return None
exp, login = item
if exp < time.time():
return None
return login
def _request(method: str, path: str, payload: Optional[dict] = None, token: str = "") -> dict:
url = settings.DOCLIB_BASE_URL.rstrip("/") + "/api/v4" + path
body = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
except urllib.error.HTTPError as exc:
exc.read()
raise RuntimeError(f"文档库接口失败({exc.code})") from exc
if data.get("code") not in (0, None) and "data" not in data:
raise RuntimeError(data.get("msg") or "文档库接口失败")
if isinstance(data, dict) and data.get("code") not in (0, None):
raise RuntimeError(data.get("msg") or "文档库接口失败")
return data
def _admin_access_token() -> str:
global _cached_admin_token, _cached_admin_token_until
now = time.time()
if _cached_admin_token and now < _cached_admin_token_until:
return _cached_admin_token
data = _request(
"POST",
"/session/token",
{"email": settings.DOCLIB_ADMIN_EMAIL, "password": settings.DOCLIB_ADMIN_PASSWORD},
)
token = data["data"]["token"]["access_token"]
_cached_admin_token = token
_cached_admin_token_until = now + 20 * 60
return token
def _find_user(email: str, token: str) -> Optional[dict]:
data = _request(
"POST",
"/admin/user",
{
"page": 1,
"page_size": 20,
"order_by": "id",
"order_direction": "asc",
"conditions": {"user_email": email},
},
token,
)
users = (data.get("data") or {}).get("users") or []
for user in users:
if str(user.get("email") or "").lower() == email.lower():
return user
return None
def _ensure_user(email: str, nick: str, token: str) -> dict:
existing = _find_user(email, token)
display = (nick or email.split("@", 1)[0])[:50]
if existing is None:
created = _request(
"PUT",
"/admin/user",
{
"password": _password(email),
"user": {
"id": 0,
"email": email,
"nick": display,
"status": "active",
"group_users": 2,
},
},
token,
)
return created.get("data") or {}
if (existing.get("nick") or "") != display:
_request(
"PUT",
f"/admin/user/{existing['id']}",
{
"user": {
"id": existing["id"],
"email": email,
"nick": display,
"status": existing.get("status") or "active",
"group_users": existing.get("group_users") or 2,
}
},
token,
)
return existing
def _move_user(user: dict, email: str, nick: str, token: str) -> dict:
display = (nick or email.split("@", 1)[0])[:50]
_request(
"PUT",
f"/admin/user/{user['id']}",
{
"password": _password(email),
"user": {
"id": user["id"],
"email": email,
"nick": display,
"status": user.get("status") or "active",
"group_users": user.get("group_users") or 2,
},
},
token,
)
moved = dict(user)
moved["email"] = email
moved["nick"] = display
return moved
def ensure_portal_user(user_id: str, nick: str, token: str) -> dict:
"""按门户用户名找到或改名已有文档库账号,没有则新建。"""
name = (nick or "").strip() or (user_id or "").strip()
email = cloudreve_email(name)
current = _find_user(email, token)
if current is not None:
return _move_user(current, email, name, token)
legacy = _find_user(_legacy_id_email(user_id), token) if user_id else None
if legacy is not None:
return _move_user(legacy, email, name, token)
return _ensure_user(email, name, token)
def _login(email: str) -> dict:
# 密码按邮箱派生,和门户用户名一一对应。
data = _request(
"POST",
"/session/token",
{"email": email, "password": _password(email)},
)
return data["data"]
def _reset_password(user: dict, email: str, token: str) -> None:
_request(
"PUT",
f"/admin/user/{user['id']}",
{
"password": _password(email),
"user": {
"id": user["id"],
"email": email,
"nick": user.get("nick") or email.split("@", 1)[0],
"status": user.get("status") or "active",
"group_users": user.get("group_users") or 2,
},
},
token,
)
def open_session(user_id: str, nick: str) -> dict:
"""确保 Cloudreve 用户存在并登录,返回一次性进入地址。"""
name = (nick or "").strip() or (user_id or "").strip()
email = cloudreve_email(name)
token = _admin_access_token()
user = ensure_portal_user(user_id, name, token)
try:
login = _login(email)
except RuntimeError:
_admin_token_clear()
token = _admin_access_token()
user = _find_user(email, token) or user
_reset_password(user, email, token)
login = _login(email)
code = issue_ticket(login)
portal = settings.DOCLIB_PORTAL_ORIGIN.rstrip("/")
doclib = settings.DOCLIB_PUBLIC_URL.rstrip("/")
open_url = f"{doclib}/sso?code={code}&portal={portal}"
return {
"openUrl": open_url,
"cloudreveEmail": email,
"cloudreveUserId": (login.get("user") or {}).get("id"),
}
def _admin_token_clear() -> None:
global _cached_admin_token, _cached_admin_token_until
_cached_admin_token = ""
_cached_admin_token_until = 0