login_crypto.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。"""
  2. from __future__ import annotations
  3. import base64
  4. import json
  5. import logging
  6. import time
  7. from pathlib import Path
  8. from typing import Any, Dict, Optional, Tuple
  9. from cryptography.hazmat.backends import default_backend
  10. from cryptography.hazmat.primitives import serialization
  11. from cryptography.hazmat.primitives.asymmetric import padding
  12. from app.core.config import settings
  13. logger = logging.getLogger(__name__)
  14. _private_key = None # type: ignore
  15. _public_pem: Optional[str] = None
  16. # 未配置 LOGIN_RSA_PRIVATE_KEY_PEM 时,各 Worker/实例共用此内置密钥(勿按进程随机生成)
  17. _DEFAULT_PEM_PATH = Path(__file__).resolve().parent / "login_rsa_default.pem"
  18. def _normalize_pem_text(pem: str) -> str:
  19. text = (pem or "").strip()
  20. if "\\n" in text and "-----BEGIN" in text:
  21. text = text.replace("\\n", "\n")
  22. return text
  23. def _load_private_key_from_pem(pem: str):
  24. return serialization.load_pem_private_key(
  25. _normalize_pem_text(pem).encode("utf-8"),
  26. password=None,
  27. backend=default_backend(),
  28. )
  29. def ensure_initialized() -> None:
  30. """加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。"""
  31. global _private_key, _public_pem
  32. if _private_key is not None:
  33. return
  34. pem = _normalize_pem_text(settings.LOGIN_RSA_PRIVATE_KEY_PEM or "")
  35. if pem:
  36. _private_key = _load_private_key_from_pem(pem)
  37. logger.info("登录 RSA:已自配置加载私钥")
  38. elif _DEFAULT_PEM_PATH.is_file():
  39. _private_key = _load_private_key_from_pem(_DEFAULT_PEM_PATH.read_text(encoding="utf-8"))
  40. logger.warning(
  41. "登录 RSA:未配置 LOGIN_RSA_PRIVATE_KEY_PEM,已使用内置默认密钥;"
  42. "生产环境建议配置独立 PEM。"
  43. )
  44. else:
  45. raise RuntimeError(
  46. f"登录 RSA 私钥不可用:未配置 LOGIN_RSA_PRIVATE_KEY_PEM,"
  47. f"且缺少内置文件 {_DEFAULT_PEM_PATH}"
  48. )
  49. _public_pem = (
  50. _private_key.public_key()
  51. .public_bytes(
  52. encoding=serialization.Encoding.PEM,
  53. format=serialization.PublicFormat.SubjectPublicKeyInfo,
  54. )
  55. .decode("utf-8")
  56. )
  57. def get_public_pem() -> str:
  58. ensure_initialized()
  59. assert _public_pem is not None
  60. return _public_pem
  61. def decrypt_login_payload(b64_cipher: str) -> Dict[str, Any]:
  62. """Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。"""
  63. ensure_initialized()
  64. raw = base64.b64decode(b64_cipher)
  65. decrypted = _private_key.decrypt(raw, padding.PKCS1v15())
  66. text = decrypted.decode("utf-8")
  67. return json.loads(text)
  68. def _validate_ts(ts_val: Any) -> bool:
  69. if ts_val is None:
  70. return False
  71. try:
  72. client_ms = int(ts_val)
  73. except (TypeError, ValueError):
  74. return False
  75. now_ms = int(time.time() * 1000)
  76. skew_ms = max(30, int(settings.LOGIN_ENCRYPT_TS_SKEW_SEC)) * 1000
  77. return abs(now_ms - client_ms) <= skew_ms
  78. def extract_login_credentials(body: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]:
  79. """
  80. 从请求体解析用户名、密码。
  81. 返回 (username, password, error_message);error_message 非空时表示失败。
  82. """
  83. enc = body.get("encryptedLogin")
  84. if isinstance(enc, str) and enc.strip():
  85. try:
  86. plain = decrypt_login_payload(enc.strip())
  87. except Exception:
  88. return None, None, "登录凭证无效或已过期"
  89. if not _validate_ts(plain.get("ts")):
  90. return None, None, "登录凭证已过期,请重新登录"
  91. username = (plain.get("username") or "").strip()
  92. password = plain.get("password")
  93. if password is None:
  94. password = ""
  95. elif not isinstance(password, str):
  96. password = str(password)
  97. if not username or not password:
  98. return None, None, "用户名和密码不能为空"
  99. return username, password, None
  100. if settings.LOGIN_ALLOW_PLAINTEXT_LOGIN:
  101. username = (body.get("username") or "").strip()
  102. password = body.get("password") or ""
  103. if not username or not password:
  104. return None, None, "用户名和密码不能为空"
  105. return username, password, None
  106. return None, None, "请使用加密方式登录"