Add optimization branch updates

This commit is contained in:
汤学会
2026-06-12 16:29:05 +08:00
parent 64e7ac346d
commit 2a00248ac3
31 changed files with 838 additions and 76 deletions
+2
View File
@@ -10,3 +10,5 @@ MYSQL_PASSWORD=replace_me
ALLOWED_ORIGINS=http://localhost:5173
AUTH_SECRET_KEY=please-change-this-in-production
AUTH_TOKEN_EXPIRE_MINUTES=480
# 可选:多进程/多实例部署时配置固定登录 RSA 私钥,避免不同进程登录密钥不一致。
# AUTH_LOGIN_PRIVATE_KEY_PATH=/opt/ForgeFlow-ERP/backend/storage/auth_login_private_key.pem
+9 -1
View File
@@ -6,6 +6,7 @@ from app.db.session import get_db
from app.models.org import User
from app.schemas.database import (
AuthChangePasswordRequest,
AuthLoginKeyResponse,
AuthLoginRequest,
AuthResetPasswordRequest,
AuthSession,
@@ -19,13 +20,20 @@ from app.services.auth import (
require_system_admin,
verify_password,
)
from app.services.login_crypto import decrypt_login_password, get_login_public_key
router = APIRouter()
@router.get("/login-key", response_model=AuthLoginKeyResponse)
def login_key() -> AuthLoginKeyResponse:
return AuthLoginKeyResponse(**get_login_public_key().__dict__)
@router.post("/login", response_model=AuthSession)
def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession:
return login_with_password(db, payload.username, payload.password)
password = decrypt_login_password(payload.password_ciphertext, payload.login_key_id)
return login_with_password(db, payload.username, password)
@router.get("/me", response_model=LoginUser)
+8 -1
View File
@@ -90,7 +90,14 @@ class LoginUser(BaseModel):
class AuthLoginRequest(BaseModel):
username: str
password: str
password_ciphertext: str = Field(min_length=1)
login_key_id: str = Field(min_length=1)
class AuthLoginKeyResponse(BaseModel):
key_id: str
public_key_pem: str
algorithm: str
class AuthChangePasswordRequest(BaseModel):
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import base64
import hashlib
import os
from dataclasses import dataclass
from functools import lru_cache
from fastapi import HTTPException
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
@dataclass(frozen=True)
class LoginPublicKey:
key_id: str
public_key_pem: str
algorithm: str = "RSA-OAEP-SHA256"
def _normalize_env_pem(value: str) -> bytes:
return value.replace("\\n", "\n").encode("utf-8")
@lru_cache(maxsize=1)
def _load_private_key():
private_key_path = os.getenv("AUTH_LOGIN_PRIVATE_KEY_PATH")
private_key_pem = os.getenv("AUTH_LOGIN_PRIVATE_KEY")
if private_key_path:
with open(private_key_path, "rb") as key_file:
key_data = key_file.read()
return serialization.load_pem_private_key(key_data, password=None)
if private_key_pem:
return serialization.load_pem_private_key(_normalize_env_pem(private_key_pem), password=None)
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@lru_cache(maxsize=1)
def get_login_public_key() -> LoginPublicKey:
private_key = _load_private_key()
public_key = private_key.public_key()
public_der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
key_id = hashlib.sha256(public_der).hexdigest()[:16]
return LoginPublicKey(key_id=key_id, public_key_pem=public_pem)
def decrypt_login_password(password_ciphertext: str, login_key_id: str) -> str:
key_info = get_login_public_key()
if login_key_id != key_info.key_id:
raise HTTPException(status_code=400, detail="登录密钥已过期,请刷新后重试")
try:
ciphertext = base64.b64decode(password_ciphertext.encode("utf-8"), validate=True)
plaintext = _load_private_key().decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return plaintext.decode("utf-8")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail="登录密码密文无效,请刷新后重试") from exc
@@ -0,0 +1,41 @@
from __future__ import annotations
import base64
import unittest
from fastapi import HTTPException
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from app.services.login_crypto import decrypt_login_password, get_login_public_key
class AuthLoginEncryptionTest(unittest.TestCase):
def test_decrypts_password_encrypted_with_public_key(self) -> None:
key_info = get_login_public_key()
public_key = serialization.load_pem_public_key(key_info.public_key_pem.encode("utf-8"))
ciphertext = public_key.encrypt(
"safe-password-123".encode("utf-8"),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
decrypted = decrypt_login_password(base64.b64encode(ciphertext).decode("utf-8"), key_info.key_id)
self.assertEqual(decrypted, "safe-password-123")
def test_rejects_unknown_login_key_id(self) -> None:
key_info = get_login_public_key()
with self.assertRaises(HTTPException) as raised:
decrypt_login_password("not-a-real-ciphertext", f"{key_info.key_id}-old")
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "登录密钥已过期,请刷新后重试")
if __name__ == "__main__":
unittest.main()