From 63457aab1170153c8ef8fd6159a93115654e6ef2 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 29 May 2026 09:36:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=B1=80=E5=9F=9F=E7=BD=91=E7=99=BB=E5=BD=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/auth.py | 45 ++++++++++++++-- backend/tests/test_registration.py | 53 +++++++++++++++++++ docker-compose.yaml | 3 ++ frontend/.env.example | 2 + frontend/src/api/auth.ts | 12 ++++- frontend/src/store/auth.test.ts | 83 +++++++++++++++++++++++++++++- frontend/src/store/auth.ts | 34 +++++++----- frontend/src/types/api.ts | 5 ++ nginx/Dockerfile | 5 ++ scripts/install-ctms.sh | 30 +++++++++++ 10 files changed, 253 insertions(+), 19 deletions(-) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index fd7c6e52..52ba8773 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -22,6 +22,11 @@ class LoginRequest(BaseModel): ciphertext: str = Field(min_length=1) +class DevLoginRequest(BaseModel): + email: str = Field(min_length=1) + password: str = Field(min_length=1) + + class LoginKeyResponse(BaseModel): key_id: str public_key: str @@ -84,6 +89,29 @@ async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSessio return db_user +async def authenticate_plain_password(payload: DevLoginRequest, db: AsyncSession): + 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="密码错误", + ) + return db_user + + +def ensure_user_active(db_user) -> None: + if db_user.status != UserStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号未审核或不可用", + ) + + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( payload: UserRegisterRequest, @@ -112,15 +140,22 @@ async def login_for_access_token( payload: LoginRequest, db: AsyncSession = Depends(get_db_session) ) -> Token: db_user = await authenticate_encrypted_password(payload, db) - if db_user.status != UserStatus.ACTIVE: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="账号未审核或不可用", - ) + ensure_user_active(db_user) return issue_user_token(db_user) +@router.post("/dev-login", response_model=Token) +async def dev_login_for_access_token( + payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session) +) -> Token: + if settings.ENV != "development": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + db_user = await authenticate_plain_password(payload, db) + ensure_user_active(db_user) + return issue_user_token(db_user) + + @router.get("/me", response_model=UserRead) async def read_me(current_user=Depends(get_current_user)) -> UserRead: return current_user diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index c30629aa..8e72bfc9 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -12,6 +12,7 @@ import json import os from app.main import create_app +from app.core.config import settings from app.core.deps import get_db_session from app.core.security import hash_password from app.crud import user as user_crud @@ -200,6 +201,58 @@ async def test_plaintext_login_is_rejected(client_and_db): assert resp.status_code == 422 +@pytest.mark.asyncio +async def test_dev_login_allows_plaintext_only_in_development(client_and_db): + client, _ = client_and_db + original_env = settings.ENV + settings.ENV = "development" + try: + resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) + finally: + settings.ENV = original_env + + assert resp.status_code == 200 + assert resp.json()["token_type"] == "bearer" + assert resp.json()["access_token"] + + +@pytest.mark.asyncio +async def test_dev_login_is_disabled_outside_development(client_and_db): + client, _ = client_and_db + original_env = settings.ENV + settings.ENV = "production" + try: + resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) + finally: + settings.ENV = original_env + + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_dev_login_rejects_inactive_users(client_and_db): + client, SessionLocal = client_and_db + original_env = settings.ENV + settings.ENV = "development" + payload = { + "email": "pending-dev-login@test.com", + "password": "Password123", + "full_name": "Pending Dev Login", + "clinical_department": "Clinical", + } + try: + await client.post("/api/v1/auth/register", json=payload) + resp = await client.post( + "/api/v1/auth/dev-login", + json={"email": payload["email"], "password": payload["password"]}, + ) + finally: + settings.ENV = original_env + + assert resp.status_code == 401 + assert "账号未审核" in resp.json().get("detail", "") + + @pytest.mark.asyncio async def test_login_challenge_cannot_be_reused(client_and_db): client, _ = client_and_db diff --git a/docker-compose.yaml b/docker-compose.yaml index 44fb55d6..3f54f7a9 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -62,6 +62,9 @@ services: build: context: . dockerfile: nginx/Dockerfile + args: + VITE_RUNTIME_ENV: ${ENV:-production} + VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-false} container_name: ctms_nginx restart: always ports: diff --git a/frontend/.env.example b/frontend/.env.example index b560999e..9150c955 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,6 @@ # Frontend feature flags and timeline overrides +VITE_RUNTIME_ENV=production +VITE_ALLOW_INSECURE_DEV_LOGIN=false VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index b82ee2f2..260aafb7 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,10 +1,20 @@ import type { AxiosResponse } from "axios"; import api, { apiGet, apiPatch, apiPost } from "./axios"; -import type { UserMeResponse, LoginRequest, LoginResponse, LoginKeyResponse, RegisterRequest } from "../types/api"; +import type { + UserMeResponse, + LoginRequest, + DevLoginRequest, + LoginResponse, + LoginKeyResponse, + RegisterRequest, +} from "../types/api"; export const login = (payload: LoginRequest): Promise> => apiPost("/api/v1/auth/login", payload); +export const devLogin = (payload: DevLoginRequest): Promise> => + apiPost("/api/v1/auth/dev-login", payload); + export const getLoginKey = (): Promise> => apiGet("/api/v1/auth/login-key"); diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index 60a4e677..8340b132 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -1,6 +1,31 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; +const getLoginKeyMock = vi.fn(); +const loginMock = vi.fn(); +const devLoginMock = vi.fn(); +const fetchMeMock = vi.fn(); +const encryptLoginPayloadMock = vi.fn(); + +vi.mock("../api/auth", () => ({ + getLoginKey: getLoginKeyMock, + login: loginMock, + devLogin: devLoginMock, + fetchMe: fetchMeMock, +})); + +vi.mock("../utils/loginCrypto", () => ({ + encryptLoginPayload: encryptLoginPayloadMock, +})); + +vi.mock("../api/studies", () => ({ + fetchStudies: vi.fn().mockResolvedValue({ data: { items: [] } }), +})); + +vi.mock("../api/projectPermissions", () => ({ + fetchMyApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }), +})); + type StorageLike = { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void; @@ -27,6 +52,30 @@ const createStorage = (): StorageLike => { describe("auth store logout", () => { beforeEach(() => { setActivePinia(createPinia()); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv("VITE_RUNTIME_ENV", "production"); + vi.stubGlobal("isSecureContext", true); + getLoginKeyMock.mockResolvedValue({ + data: { + key_id: "default", + public_key: "public-key", + challenge: "challenge-value-with-enough-length", + }, + }); + loginMock.mockResolvedValue({ data: { access_token: "encrypted-token", token_type: "bearer" } }); + devLoginMock.mockResolvedValue({ data: { access_token: "dev-token", token_type: "bearer" } }); + fetchMeMock.mockResolvedValue({ + data: { + id: "user-1", + email: "admin@test.com", + full_name: "Admin", + clinical_department: "Admin", + status: "ACTIVE", + is_admin: true, + }, + }); + encryptLoginPayloadMock.mockResolvedValue("ciphertext"); Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true, @@ -46,4 +95,36 @@ describe("auth store logout", () => { expect(session.lockReason).toBeNull(); expect(session.lockEmail).toBe(""); }); + + it("uses encrypted login by default outside a secure browser context", async () => { + vi.stubGlobal("isSecureContext", false); + const { useAuthStore } = await import("./auth"); + const auth = useAuthStore(); + + await auth.login("admin@test.com", "admin123"); + + expect(getLoginKeyMock).toHaveBeenCalledOnce(); + expect(encryptLoginPayloadMock).toHaveBeenCalledOnce(); + expect(loginMock).toHaveBeenCalledWith({ + key_id: "default", + challenge: "challenge-value-with-enough-length", + ciphertext: "ciphertext", + }); + expect(devLoginMock).not.toHaveBeenCalled(); + }); + + it("uses dev login only when enabled in development outside a secure browser context", async () => { + vi.stubGlobal("isSecureContext", false); + vi.stubEnv("VITE_RUNTIME_ENV", "development"); + vi.stubEnv("VITE_ALLOW_INSECURE_DEV_LOGIN", "true"); + const { useAuthStore } = await import("./auth"); + const auth = useAuthStore(); + + await auth.login("admin@test.com", "admin123"); + + expect(devLoginMock).toHaveBeenCalledWith({ email: "admin@test.com", password: "admin123" }); + expect(getLoginKeyMock).not.toHaveBeenCalled(); + expect(encryptLoginPayloadMock).not.toHaveBeenCalled(); + expect(loginMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 0b245797..5b0af475 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -import { getLoginKey, login as apiLogin, fetchMe } from "../api/auth"; +import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth"; import { setToken, clearToken, getToken } from "../utils/auth"; import { encryptLoginPayload } from "../utils/loginCrypto"; import type { UserInfo } from "../types/api"; @@ -9,6 +9,9 @@ import { useSessionStore } from "./session"; export const useAuthStore = defineStore("auth", () => { const LAST_LOGIN_EMAIL_KEY = "ctms_last_login_email"; + const allowInsecureDevLogin = + import.meta.env.VITE_RUNTIME_ENV === "development" && + import.meta.env.VITE_ALLOW_INSECURE_DEV_LOGIN === "true"; const token = ref(getToken()); const user = ref(null); const loading = ref(false); @@ -17,17 +20,10 @@ export const useAuthStore = defineStore("auth", () => { const login = async (email: string, password: string) => { loading.value = true; try { - const { data: loginKey } = await getLoginKey(); - const ciphertext = await encryptLoginPayload(loginKey.public_key, { - email, - password, - challenge: loginKey.challenge, - }); - const { data } = await apiLogin({ - key_id: loginKey.key_id, - challenge: loginKey.challenge, - ciphertext, - }); + const { data } = + allowInsecureDevLogin && !window.isSecureContext + ? await devLogin({ email, password }) + : await encryptedLogin(email, password); token.value = data.access_token; setToken(data.access_token); // 避免锁屏态下 fetchMe 被请求拦截 @@ -44,6 +40,20 @@ export const useAuthStore = defineStore("auth", () => { } }; + const encryptedLogin = async (email: string, password: string) => { + const { data: loginKey } = await getLoginKey(); + const ciphertext = await encryptLoginPayload(loginKey.public_key, { + email, + password, + challenge: loginKey.challenge, + }); + return apiLogin({ + key_id: loginKey.key_id, + challenge: loginKey.challenge, + ciphertext, + }); + }; + const fetchMeAction = async () => { const { data } = await fetchMe(); user.value = data; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 339a7086..506b9800 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -20,6 +20,11 @@ export interface LoginRequest { ciphertext: string; } +export interface DevLoginRequest { + email: string; + password: string; +} + export interface LoginResponse { access_token: string; token_type: string; diff --git a/nginx/Dockerfile b/nginx/Dockerfile index d394f9fc..5dec2076 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -2,6 +2,11 @@ FROM node:20-alpine AS frontend-build WORKDIR /app +ARG VITE_RUNTIME_ENV=production +ARG VITE_ALLOW_INSECURE_DEV_LOGIN=false +ENV VITE_RUNTIME_ENV=$VITE_RUNTIME_ENV +ENV VITE_ALLOW_INSECURE_DEV_LOGIN=$VITE_ALLOW_INSECURE_DEV_LOGIN + COPY frontend/package*.json ./ RUN npm ci diff --git a/scripts/install-ctms.sh b/scripts/install-ctms.sh index 3e672b83..42a614fe 100755 --- a/scripts/install-ctms.sh +++ b/scripts/install-ctms.sh @@ -384,6 +384,8 @@ write_env_file() { { printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name" printf 'ENV=%s\n' "$runtime_env" + printf 'VITE_RUNTIME_ENV=%s\n' "$runtime_env" + printf 'VITE_ALLOW_INSECURE_DEV_LOGIN=%s\n' "$([[ "$runtime_env" == "development" ]] && printf 'true' || printf 'false')" printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret" printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id" printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key" @@ -393,6 +395,33 @@ write_env_file() { ok ".env 写入完成" } +upsert_env_value() { + local key="$1" value="$2" + local temp_file + temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")" + if [[ -f "$ENV_FILE" && -n "$(read_env_value "$key" || true)" ]]; then + awk -v key="$key" -v value="$value" ' + BEGIN { prefix = key "=" } + index($0, prefix) == 1 { print key "=" value; next } + { print } + ' "$ENV_FILE" > "$temp_file" + else + [[ -f "$ENV_FILE" ]] && cat "$ENV_FILE" > "$temp_file" + printf '%s=%s\n' "$key" "$value" >> "$temp_file" + fi + mv "$temp_file" "$ENV_FILE" +} + +sync_frontend_build_env() { + local runtime_env="$1" + local allow_insecure_dev_login="false" + [[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true" + + upsert_env_value VITE_RUNTIME_ENV "$runtime_env" + upsert_env_value VITE_ALLOW_INSECURE_DEV_LOGIN "$allow_insecure_dev_login" + ok "前端构建环境变量已同步" +} + prepare_env_file() { step "准备环境配置文件" local project_name="$1" runtime_env="$2" login_key_id="$3" @@ -568,6 +597,7 @@ main() { check_dependencies confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV" prepare_env_file "$project_name" "$runtime_env" "$login_key_id" + sync_frontend_build_env "$runtime_env" run_compose_config run_backend_init run_build_and_start