feat: harden auth and study workflows
- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads - add login challenge replay protection, production RSA key validation, and auth tests - wire compose to environment-driven dev/prod settings without committing local secrets - update setup-config smoke scripts and Postman docs for encrypted login - add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
from app.core import login_crypto
|
||||
from app.core.config import settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def reset_login_crypto_state():
|
||||
login_crypto._private_key = None
|
||||
login_crypto._public_key_pem = None
|
||||
login_crypto._challenges.clear()
|
||||
|
||||
|
||||
def test_production_requires_configured_login_private_key(monkeypatch):
|
||||
reset_login_crypto_state()
|
||||
monkeypatch.setattr(settings, "ENV", "production")
|
||||
monkeypatch.setattr(settings, "LOGIN_RSA_PRIVATE_KEY", None)
|
||||
|
||||
with pytest.raises(ValueError, match="生产环境必须配置 LOGIN_RSA_PRIVATE_KEY"):
|
||||
create_app()
|
||||
|
||||
reset_login_crypto_state()
|
||||
|
||||
|
||||
def test_login_challenge_cache_has_max_active_limit(monkeypatch):
|
||||
reset_login_crypto_state()
|
||||
monkeypatch.setattr(settings, "ENV", "test")
|
||||
monkeypatch.setattr(settings, "LOGIN_CHALLENGE_MAX_ACTIVE", 2)
|
||||
|
||||
first = login_crypto.create_login_challenge()
|
||||
second = login_crypto.create_login_challenge()
|
||||
third = login_crypto.create_login_challenge()
|
||||
|
||||
assert len(login_crypto._challenges) == 2
|
||||
assert not login_crypto.consume_login_challenge(first.value)
|
||||
assert login_crypto.consume_login_challenge(second.value)
|
||||
assert login_crypto.consume_login_challenge(third.value)
|
||||
|
||||
reset_login_crypto_state()
|
||||
@@ -1,7 +1,15 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
from app.main import create_app
|
||||
from app.core.deps import get_db_session
|
||||
@@ -12,6 +20,55 @@ from app.models.user import User, UserRole, UserStatus
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@compiles(UUID, "sqlite")
|
||||
def compile_uuid_for_sqlite(_type, _compiler, **_kw):
|
||||
return "CHAR(32)"
|
||||
|
||||
|
||||
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
|
||||
key_resp = await client.get("/api/v1/auth/login-key")
|
||||
assert key_resp.status_code == 200
|
||||
login_key = key_resp.json()
|
||||
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
|
||||
plaintext = json.dumps(
|
||||
{
|
||||
"email": email,
|
||||
"password": password,
|
||||
"challenge": login_key["challenge"],
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
aes_key = AESGCM.generate_key(bit_length=256)
|
||||
iv = os.urandom(12)
|
||||
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
|
||||
encrypted_key = public_key.encrypt(
|
||||
aes_key,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"key_id": login_key["key_id"],
|
||||
"challenge": login_key["challenge"],
|
||||
"ciphertext": base64.b64encode(
|
||||
json.dumps(
|
||||
{
|
||||
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
|
||||
"iv": base64.b64encode(iv).decode("ascii"),
|
||||
"data": base64.b64encode(encrypted_data).decode("ascii"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
async def encrypted_login(client: AsyncClient, email: str, password: str):
|
||||
return await client.post("/api/v1/auth/login", json=await encrypted_auth_payload(client, email, password))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client_and_db():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, future=True)
|
||||
@@ -87,7 +144,7 @@ async def test_login_blocked_before_approval(client_and_db):
|
||||
"department": "Safety",
|
||||
}
|
||||
await client.post("/api/v1/auth/register", json=payload)
|
||||
resp = await client.post("/api/v1/auth/login", json={"email": payload["email"], "password": payload["password"]})
|
||||
resp = await encrypted_login(client, payload["email"], payload["password"])
|
||||
assert resp.status_code == 401
|
||||
assert "账号未审核" in resp.json().get("detail", "")
|
||||
|
||||
@@ -107,7 +164,7 @@ async def test_admin_can_approve_user(client_and_db):
|
||||
user = await user_crud.get_by_email(session, payload["email"])
|
||||
user_id = user.id
|
||||
|
||||
admin_login = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"})
|
||||
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
token = admin_login.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@@ -131,3 +188,71 @@ async def test_admin_role_cannot_register(client_and_db):
|
||||
}
|
||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||
assert resp.status_code in (400, 422)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plaintext_login_is_rejected(client_and_db):
|
||||
client, _ = client_and_db
|
||||
resp = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_challenge_cannot_be_reused(client_and_db):
|
||||
client, _ = client_and_db
|
||||
key_resp = await client.get("/api/v1/auth/login-key")
|
||||
login_key = key_resp.json()
|
||||
public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8"))
|
||||
plaintext = json.dumps(
|
||||
{
|
||||
"email": "admin@test.com",
|
||||
"password": "admin123",
|
||||
"challenge": login_key["challenge"],
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
aes_key = AESGCM.generate_key(bit_length=256)
|
||||
iv = os.urandom(12)
|
||||
encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None)
|
||||
encrypted_key = public_key.encrypt(
|
||||
aes_key,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
)
|
||||
)
|
||||
ciphertext = base64.b64encode(
|
||||
json.dumps(
|
||||
{
|
||||
"encrypted_key": base64.b64encode(encrypted_key).decode("ascii"),
|
||||
"iv": base64.b64encode(iv).decode("ascii"),
|
||||
"data": base64.b64encode(encrypted_data).decode("ascii"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).decode("ascii")
|
||||
payload = {
|
||||
"key_id": login_key["key_id"],
|
||||
"challenge": login_key["challenge"],
|
||||
"ciphertext": ciphertext,
|
||||
}
|
||||
|
||||
first = await client.post("/api/v1/auth/login", json=payload)
|
||||
second = await client.post("/api/v1/auth/login", json=payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlock_requires_encrypted_password(client_and_db):
|
||||
client, _ = client_and_db
|
||||
plaintext = await client.post("/api/v1/auth/unlock", json={"email": "admin@test.com", "password": "admin123"})
|
||||
encrypted = await client.post(
|
||||
"/api/v1/auth/unlock",
|
||||
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
|
||||
)
|
||||
|
||||
assert plaintext.status_code == 422
|
||||
assert encrypted.status_code == 200
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.crud.subject import should_generate_visits_after_subject_update
|
||||
from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display
|
||||
from app.schemas.study import StudyUpdate
|
||||
|
||||
|
||||
def test_build_visit_schedule_dates_uses_per_visit_windows():
|
||||
baseline_date = date(2026, 5, 8)
|
||||
|
||||
visits = build_visit_schedule_dates(
|
||||
[
|
||||
{
|
||||
"visit_code": "基线访视",
|
||||
"baseline_offset_days": 0,
|
||||
"window_before_days": 0,
|
||||
"window_after_days": 0,
|
||||
},
|
||||
{
|
||||
"visit_code": "V1",
|
||||
"baseline_offset_days": 7,
|
||||
"window_before_days": 2,
|
||||
"window_after_days": 2,
|
||||
},
|
||||
{
|
||||
"visit_code": "V2",
|
||||
"baseline_offset_days": 15,
|
||||
"window_before_days": 2,
|
||||
"window_after_days": 2,
|
||||
},
|
||||
{
|
||||
"visit_code": "V3",
|
||||
"baseline_offset_days": 23,
|
||||
"window_before_days": 3,
|
||||
"window_after_days": 3,
|
||||
},
|
||||
],
|
||||
baseline_date,
|
||||
)
|
||||
|
||||
assert [
|
||||
(visit["visit_code"], visit["baseline_offset_days"], visit["planned_date"], visit["window_start"], visit["window_end"])
|
||||
for visit in visits
|
||||
] == [
|
||||
("基线访视", 0, date(2026, 5, 8), date(2026, 5, 8), date(2026, 5, 8)),
|
||||
("V1", 7, date(2026, 5, 15), date(2026, 5, 13), date(2026, 5, 17)),
|
||||
("V2", 15, date(2026, 5, 23), date(2026, 5, 21), date(2026, 5, 25)),
|
||||
("V3", 23, date(2026, 5, 31), date(2026, 5, 28), date(2026, 6, 3)),
|
||||
]
|
||||
|
||||
|
||||
def test_build_visit_schedule_dates_keeps_visit_structure_without_baseline_date():
|
||||
visits = build_visit_schedule_dates(
|
||||
[
|
||||
{
|
||||
"visit_code": "基线访视",
|
||||
"baseline_offset_days": 0,
|
||||
"window_before_days": 0,
|
||||
"window_after_days": 0,
|
||||
},
|
||||
{
|
||||
"visit_code": "V1",
|
||||
"baseline_offset_days": 7,
|
||||
"window_before_days": 2,
|
||||
"window_after_days": 2,
|
||||
},
|
||||
],
|
||||
None,
|
||||
)
|
||||
|
||||
assert [
|
||||
(visit["visit_code"], visit["planned_date"], visit["window_start"], visit["window_end"])
|
||||
for visit in visits
|
||||
] == [
|
||||
("基线访视", None, None, None),
|
||||
("V1", None, None, None),
|
||||
]
|
||||
|
||||
|
||||
def test_study_update_allows_multiple_visits_with_same_baseline_offset():
|
||||
payload = StudyUpdate(
|
||||
visit_schedule=[
|
||||
{
|
||||
"visit_code": "筛选访视",
|
||||
"baseline_offset_days": 0,
|
||||
"window_before_days": 0,
|
||||
"window_after_days": 0,
|
||||
},
|
||||
{
|
||||
"visit_code": "基线访视",
|
||||
"baseline_offset_days": 0,
|
||||
"window_before_days": 0,
|
||||
"window_after_days": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"]
|
||||
|
||||
|
||||
def test_sort_visits_for_display_follows_configured_visit_order_without_dates():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="V1", planned_date=None),
|
||||
SimpleNamespace(visit_code="V2", planned_date=None),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=None),
|
||||
]
|
||||
visit_schedule = [
|
||||
{"visit_code": "基线访视"},
|
||||
{"visit_code": "V1"},
|
||||
{"visit_code": "V2"},
|
||||
]
|
||||
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["基线访视", "V1", "V2"]
|
||||
|
||||
|
||||
def test_sort_visits_for_display_does_not_infer_business_order():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=None),
|
||||
SimpleNamespace(visit_code="V1", planned_date=None),
|
||||
SimpleNamespace(visit_code="V2", planned_date=None),
|
||||
]
|
||||
visit_schedule = [
|
||||
{"visit_code": "V1"},
|
||||
{"visit_code": "V2"},
|
||||
{"visit_code": "基线访视"},
|
||||
]
|
||||
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"]
|
||||
|
||||
|
||||
def test_should_generate_visits_when_baseline_date_is_set_or_changed():
|
||||
assert should_generate_visits_after_subject_update(
|
||||
previous_baseline_date=None,
|
||||
next_baseline_date=date(2026, 5, 8),
|
||||
)
|
||||
assert should_generate_visits_after_subject_update(
|
||||
previous_baseline_date=date(2026, 5, 8),
|
||||
next_baseline_date=date(2026, 5, 9),
|
||||
)
|
||||
assert not should_generate_visits_after_subject_update(
|
||||
previous_baseline_date=None,
|
||||
next_baseline_date=None,
|
||||
)
|
||||
Reference in New Issue
Block a user