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:
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
ConfirmStatus = Literal["待确认", "已确认", "退回"]
|
||||
@@ -143,6 +143,13 @@ class SetupProjectionSummary(BaseModel):
|
||||
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
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 ProjectPublishSnapshot(BaseModel):
|
||||
code: str = ""
|
||||
name: str = ""
|
||||
@@ -162,13 +169,21 @@ class ProjectPublishSnapshot(BaseModel):
|
||||
plan_end_date: str = ""
|
||||
planned_site_count: int | None = None
|
||||
planned_enrollment_count: int | None = None
|
||||
summary_note: str = ""
|
||||
objective_note: str = ""
|
||||
status: str = ""
|
||||
visit_interval_days: int | None = None
|
||||
visit_total: int | None = None
|
||||
visit_window_start_offset: int | None = None
|
||||
visit_window_end_offset: int | None = None
|
||||
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 StudySetupConfigRead(BaseModel):
|
||||
|
||||
@@ -10,12 +10,15 @@ class SubjectCreate(BaseModel):
|
||||
subject_no: str
|
||||
screening_date: Optional[date] = None
|
||||
consent_date: Optional[date] = None
|
||||
enrollment_date: Optional[date] = None
|
||||
baseline_date: Optional[date] = None
|
||||
|
||||
|
||||
class SubjectUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
consent_date: Optional[date] = None
|
||||
enrollment_date: Optional[date] = None
|
||||
baseline_date: Optional[date] = None
|
||||
completion_date: Optional[date] = None
|
||||
drop_reason: Optional[str] = None
|
||||
|
||||
@@ -29,6 +32,7 @@ class SubjectRead(BaseModel):
|
||||
screening_date: Optional[date]
|
||||
consent_date: Optional[date]
|
||||
enrollment_date: Optional[date]
|
||||
baseline_date: Optional[date]
|
||||
completion_date: Optional[date]
|
||||
drop_reason: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
@@ -23,7 +23,7 @@ class UserDisplay(BaseModel):
|
||||
|
||||
|
||||
class _PasswordValidator(BaseModel):
|
||||
password: Optional[str] = Field(default=None, min_length=8)
|
||||
password: Optional[str] = Field(default=None, min_length=8, max_length=72)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
@@ -36,7 +36,7 @@ class _PasswordValidator(BaseModel):
|
||||
|
||||
|
||||
class UserRegisterRequest(_PasswordValidator):
|
||||
password: str = Field(min_length=8)
|
||||
password: str = Field(min_length=8, max_length=72)
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1)
|
||||
role: RegisterRole
|
||||
@@ -44,7 +44,7 @@ class UserRegisterRequest(_PasswordValidator):
|
||||
|
||||
|
||||
class UserCreate(_PasswordValidator):
|
||||
password: str = Field(min_length=8)
|
||||
password: str = Field(min_length=8, max_length=72)
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1)
|
||||
role: UserRole
|
||||
|
||||
Reference in New Issue
Block a user