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:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
+43 -20
View File
@@ -2,12 +2,36 @@ import uuid
from datetime import date, datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
class StudyCreate(BaseModel):
class VisitScheduleItem(BaseModel):
visit_code: str = Field(min_length=1, max_length=50)
baseline_offset_days: int = Field(ge=0, le=3650)
window_before_days: int = Field(ge=0, le=365)
window_after_days: int = Field(ge=0, le=365)
class VisitScheduleMixin(BaseModel):
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def validate_visit_schedule(self):
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudyCreate(VisitScheduleMixin):
name: str = Field(min_length=1)
code: str = Field(min_length=1)
project_full_name: Optional[str] = None
@@ -28,14 +52,8 @@ class StudyCreate(BaseModel):
planned_enrollment_count: Optional[int] = None
enrollment_monthly_goal_note: Optional[str] = None
enrollment_stage_breakdown: Optional[str] = None
summary_note: Optional[str] = None
objective_note: Optional[str] = None
phase: Optional[str] = None
status: StudyStatus = "DRAFT"
visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None
visit_window_end_offset: Optional[int] = None
class StudyUpdate(BaseModel):
@@ -59,15 +77,25 @@ class StudyUpdate(BaseModel):
planned_enrollment_count: Optional[int] = None
enrollment_monthly_goal_note: Optional[str] = None
enrollment_stage_breakdown: Optional[str] = None
summary_note: Optional[str] = None
objective_note: Optional[str] = None
phase: Optional[str] = None
status: Optional[StudyStatus] = None
is_locked: Optional[bool] = None
visit_interval_days: Optional[int] = None
visit_total: Optional[int] = None
visit_window_start_offset: Optional[int] = None
visit_window_end_offset: Optional[int] = None
visit_schedule: Optional[list[VisitScheduleItem]] = None
@model_validator(mode="after")
def validate_visit_schedule(self):
if self.visit_schedule is None:
return self
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudyRead(BaseModel):
@@ -92,15 +120,10 @@ class StudyRead(BaseModel):
planned_enrollment_count: Optional[int]
enrollment_monthly_goal_note: Optional[str]
enrollment_stage_breakdown: Optional[str]
summary_note: Optional[str]
objective_note: Optional[str]
phase: Optional[str]
status: StudyStatus
is_locked: bool
visit_interval_days: Optional[int]
visit_total: Optional[int]
visit_window_start_offset: Optional[int]
visit_window_end_offset: Optional[int]
visit_schedule: list[VisitScheduleItem]
created_by: Optional[uuid.UUID]
created_at: datetime