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:
+64
-30
@@ -1,12 +1,13 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
|
||||
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import user as user_crud
|
||||
@@ -16,8 +17,16 @@ from fastapi.responses import FileResponse
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1)
|
||||
key_id: str = Field(min_length=1)
|
||||
challenge: str = Field(min_length=16)
|
||||
ciphertext: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LoginKeyResponse(BaseModel):
|
||||
key_id: str
|
||||
public_key: str
|
||||
challenge: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ExtendResponse(BaseModel):
|
||||
@@ -25,9 +34,8 @@ class ExtendResponse(BaseModel):
|
||||
expiresAt: datetime
|
||||
|
||||
|
||||
class UnlockRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1)
|
||||
class UnlockRequest(LoginRequest):
|
||||
pass
|
||||
|
||||
|
||||
class UnlockResponse(BaseModel):
|
||||
@@ -40,6 +48,42 @@ AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avata
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def issue_user_token(db_user) -> Token:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSession):
|
||||
decrypted = decrypt_login_payload(
|
||||
key_id=payload.key_id,
|
||||
challenge=payload.challenge,
|
||||
ciphertext=payload.ciphertext,
|
||||
)
|
||||
if not decrypted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无法验证登录凭据",
|
||||
)
|
||||
db_user = await user_crud.get_by_email(db, decrypted.email)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号不存在",
|
||||
)
|
||||
if not verify_password(decrypted.password, db_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="密码错误",
|
||||
)
|
||||
return db_user
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
@@ -54,35 +98,29 @@ async def register(
|
||||
return {"message": "注册成功,等待管理员审核"}
|
||||
|
||||
|
||||
@router.get("/login-key", response_model=LoginKeyResponse)
|
||||
async def get_login_key() -> LoginKeyResponse:
|
||||
challenge = create_login_challenge()
|
||||
return LoginKeyResponse(
|
||||
key_id=settings.LOGIN_RSA_KEY_ID,
|
||||
public_key=get_public_key_pem(),
|
||||
challenge=challenge.value,
|
||||
expires_at=challenge.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
db_user = await user_crud.get_by_email(db, payload.email)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号不存在",
|
||||
)
|
||||
if not verify_password(payload.password, db_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="密码错误",
|
||||
)
|
||||
db_user = await authenticate_encrypted_password(payload, db)
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号未审核或不可用",
|
||||
)
|
||||
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
return issue_user_token(db_user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
@@ -133,11 +171,7 @@ async def unlock_session(
|
||||
payload: UnlockRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UnlockResponse:
|
||||
db_user = await user_crud.get_by_email(db, payload.email)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
|
||||
if not verify_password(payload.password, db_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
|
||||
db_user = await authenticate_encrypted_password(payload, db)
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||
session_start = datetime.now(timezone.utc)
|
||||
|
||||
@@ -283,13 +283,8 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
|
||||
planned_site_count=getattr(study, "planned_site_count", None),
|
||||
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
|
||||
summary_note=getattr(study, "summary_note", None) or "",
|
||||
objective_note=getattr(study, "objective_note", None) or "",
|
||||
status=getattr(study, "status", None) or "",
|
||||
visit_interval_days=getattr(study, "visit_interval_days", None),
|
||||
visit_total=getattr(study, "visit_total", None),
|
||||
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
|
||||
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
|
||||
visit_schedule=getattr(study, "visit_schedule", None) or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -360,17 +355,9 @@ def _ensure_study_timeline_valid(
|
||||
*,
|
||||
plan_start_date: date | None,
|
||||
plan_end_date: date | None,
|
||||
visit_window_start_offset: int | None,
|
||||
visit_window_end_offset: int | None,
|
||||
) -> None:
|
||||
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||||
if (
|
||||
visit_window_start_offset is not None
|
||||
and visit_window_end_offset is not None
|
||||
and visit_window_start_offset > visit_window_end_offset
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
|
||||
|
||||
|
||||
def _resolve_version_label(record: Any) -> str:
|
||||
@@ -649,8 +636,6 @@ async def create_study(
|
||||
_ensure_study_timeline_valid(
|
||||
plan_start_date=study_in.plan_start_date,
|
||||
plan_end_date=study_in.plan_end_date,
|
||||
visit_window_start_offset=study_in.visit_window_start_offset,
|
||||
visit_window_end_offset=study_in.visit_window_end_offset,
|
||||
)
|
||||
study_in.code = study_in.code.strip()
|
||||
if not study_in.code:
|
||||
@@ -746,21 +731,9 @@ async def update_study(
|
||||
|
||||
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
|
||||
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
|
||||
next_window_start = (
|
||||
study_in.visit_window_start_offset
|
||||
if "visit_window_start_offset" in study_in.model_fields_set
|
||||
else study.visit_window_start_offset
|
||||
)
|
||||
next_window_end = (
|
||||
study_in.visit_window_end_offset
|
||||
if "visit_window_end_offset" in study_in.model_fields_set
|
||||
else study.visit_window_end_offset
|
||||
)
|
||||
_ensure_study_timeline_valid(
|
||||
plan_start_date=next_plan_start,
|
||||
plan_end_date=next_plan_end,
|
||||
visit_window_start_offset=next_window_start,
|
||||
visit_window_end_offset=next_window_end,
|
||||
)
|
||||
|
||||
updated = await study_crud.update(db, study, study_in)
|
||||
|
||||
@@ -173,9 +173,9 @@ async def update_subject(
|
||||
await _ensure_subject_active(db, subject)
|
||||
old_status = subject.status
|
||||
updated = await subject_crud.update_subject(db, subject, subject_in)
|
||||
# auto-generate visits when enrolled
|
||||
if subject_in.status and subject_in.status == "ENROLLED" and (subject_in.enrollment_date or updated.enrollment_date):
|
||||
await subject_crud.generate_default_visits(db, updated)
|
||||
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
|
||||
if updated.baseline_date:
|
||||
await subject_crud.sync_visits_from_baseline(db, updated)
|
||||
detail = None
|
||||
if subject_in.status and subject_in.status != old_status:
|
||||
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}"
|
||||
|
||||
@@ -90,15 +90,12 @@ async def create_visit(
|
||||
if next_visit_code == "V1" and visit_in.planned_date:
|
||||
study = await study_crud.get(db, study_id)
|
||||
if study:
|
||||
await visit_crud.create_followup_visits(
|
||||
await visit_crud.create_scheduled_visits(
|
||||
db,
|
||||
study_id=study_id,
|
||||
subject=subject,
|
||||
base_date=visit_in.planned_date,
|
||||
visit_total=study.visit_total,
|
||||
visit_interval_days=study.visit_interval_days,
|
||||
window_start_offset=study.visit_window_start_offset,
|
||||
window_end_offset=study.visit_window_end_offset,
|
||||
visit_schedule=study.visit_schedule,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
|
||||
Reference in New Issue
Block a user