login_crypto.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  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 typing import Any, Dict, Optional, Tuple
  8. from cryptography.hazmat.backends import default_backend
  9. from cryptography.hazmat.primitives import serialization
  10. from cryptography.hazmat.primitives.asymmetric import padding, rsa
  11. from app.core.config import settings
  12. logger = logging.getLogger(__name__)
  13. _private_key = None # type: ignore
  14. _public_pem: Optional[str] = None
  15. def ensure_initialized() -> None:
  16. """加载或生成 RSA 密钥对。"""
  17. global _private_key, _public_pem
  18. if _private_key is not None:
  19. return
  20. pem = (settings.LOGIN_RSA_PRIVATE_KEY_PEM or "").strip()
  21. # 兼容 .env 中单行保存 PEM(以 "\n" 形式转义换行)的场景
  22. if "\\n" in pem and "-----BEGIN" in pem:
  23. pem = pem.replace("\\n", "\n")
  24. if pem:
  25. _private_key = serialization.load_pem_private_key(
  26. pem.encode("utf-8"), password=None, backend=default_backend()
  27. )
  28. logger.info("登录 RSA:已自配置加载私钥")
  29. else:
  30. _private_key = rsa.generate_private_key(
  31. public_exponent=65537, key_size=2048, backend=default_backend()
  32. )
  33. logger.warning(
  34. "登录 RSA:未配置 LOGIN_RSA_PRIVATE_KEY_PEM,已生成进程内临时密钥;"
  35. "多进程/负载均衡下请配置固定 PEM。"
  36. )
  37. _public_pem = (
  38. _private_key.public_key()
  39. .public_bytes(
  40. encoding=serialization.Encoding.PEM,
  41. format=serialization.PublicFormat.SubjectPublicKeyInfo,
  42. )
  43. .decode("utf-8")
  44. )
  45. def get_public_pem() -> str:
  46. ensure_initialized()
  47. assert _public_pem is not None
  48. return _public_pem
  49. def decrypt_login_payload(b64_cipher: str) -> Dict[str, Any]:
  50. """Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。"""
  51. ensure_initialized()
  52. raw = base64.b64decode(b64_cipher)
  53. decrypted = _private_key.decrypt(raw, padding.PKCS1v15())
  54. text = decrypted.decode("utf-8")
  55. return json.loads(text)
  56. def _validate_ts(ts_val: Any) -> bool:
  57. if ts_val is None:
  58. return False
  59. try:
  60. client_ms = int(ts_val)
  61. except (TypeError, ValueError):
  62. return False
  63. now_ms = int(time.time() * 1000)
  64. skew_ms = max(30, int(settings.LOGIN_ENCRYPT_TS_SKEW_SEC)) * 1000
  65. return abs(now_ms - client_ms) <= skew_ms
  66. def extract_login_credentials(body: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]:
  67. """
  68. 从请求体解析用户名、密码。
  69. 返回 (username, password, error_message);error_message 非空时表示失败。
  70. """
  71. enc = body.get("encryptedLogin")
  72. if isinstance(enc, str) and enc.strip():
  73. try:
  74. plain = decrypt_login_payload(enc.strip())
  75. except Exception:
  76. return None, None, "登录凭证无效或已过期"
  77. if not _validate_ts(plain.get("ts")):
  78. return None, None, "登录凭证已过期,请重新登录"
  79. username = (plain.get("username") or "").strip()
  80. password = plain.get("password")
  81. if password is None:
  82. password = ""
  83. elif not isinstance(password, str):
  84. password = str(password)
  85. if not username or not password:
  86. return None, None, "用户名和密码不能为空"
  87. return username, password, None
  88. if settings.LOGIN_ALLOW_PLAINTEXT_LOGIN:
  89. username = (body.get("username") or "").strip()
  90. password = body.get("password") or ""
  91. if not username or not password:
  92. return None, None, "用户名和密码不能为空"
  93. return username, password, None
  94. return None, None, "请使用加密方式登录"