login_crypto.py 3.6 KB

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