支持开发环境局域网登录测试

This commit is contained in:
Cheng Zhou
2026-05-29 09:36:07 +08:00
parent c9b677e96a
commit 63457aab11
10 changed files with 253 additions and 19 deletions
+40 -5
View File
@@ -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
+53
View File
@@ -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
+3
View File
@@ -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:
+2
View File
@@ -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
+11 -1
View File
@@ -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<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/dev-login", payload);
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
+82 -1
View File
@@ -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();
});
});
+22 -12
View File
@@ -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<string | null>(getToken());
const user = ref<UserInfo | null>(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;
+5
View File
@@ -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;
+5
View File
@@ -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
+30
View File
@@ -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