Add optimization branch updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user