完善邮件验证与密码重置安全流程

This commit is contained in:
Cheng Zhou
2026-06-30 09:44:24 +08:00
parent 6c2bcc59b2
commit b25055775e
27 changed files with 4437 additions and 526 deletions
+312 -33
View File
@@ -3,10 +3,12 @@ import pytest_asyncio
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import UUID as SA_UUID, select
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from datetime import datetime, timedelta, timezone
import base64
import json
import os
@@ -14,9 +16,17 @@ 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.core.security import hash_password, verify_password
from app.crud import user as user_crud
from app.db.base_class import Base
from app.models.audit_log import AuditLog
from app.models.email_settings import (
EmailVerificationCode,
EmailVerificationPurpose,
SmtpSecurity,
SystemEmailSettings,
)
from app.services import email_service
from tests.conftest import GUID
from app.models.study_member import StudyMember
from app.models.user import User, UserStatus
@@ -25,6 +35,41 @@ from app.schemas.user import UserRegisterRequest
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
async def mark_register_email_verified(SessionLocal, email: str) -> None:
now = datetime.now(timezone.utc)
async with SessionLocal() as session:
session.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.REGISTER,
code_hash=hash_password("123456"),
expires_at=now + timedelta(minutes=10),
verified_at=now,
)
)
await session.commit()
async def add_email_settings(SessionLocal, domain: str) -> None:
async with SessionLocal() as session:
session.add(
SystemEmailSettings(
register_domain=domain,
smtp_host="smtp.test",
smtp_port=465,
smtp_security=SmtpSecurity.SSL,
smtp_username="mailer",
smtp_password_encrypted="encrypted",
sender_email=f"mailer@{domain}",
allowed_register_domain=domain,
verification_code_ttl_minutes=10,
send_cooldown_seconds=60,
max_verify_attempts=5,
)
)
await session.commit()
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
key_resp = await client.get("/api/v1/auth/login-key")
assert key_resp.status_code == 200
@@ -114,7 +159,7 @@ async def client_and_db():
@pytest.mark.asyncio
async def test_register_creates_pending_user(client_and_db):
async def test_register_creates_active_user(client_and_db):
client, SessionLocal = client_and_db
payload = {
"email": "newuser@test.com",
@@ -122,12 +167,13 @@ async def test_register_creates_pending_user(client_and_db):
"full_name": "New User",
"clinical_department": "Clinical",
}
await mark_register_email_verified(SessionLocal, payload["email"])
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 201
async with SessionLocal() as session:
user = await user_crud.get_by_email(session, payload["email"])
assert user is not None
assert user.status == UserStatus.PENDING
assert user.status == UserStatus.ACTIVE
assert user.is_admin is False
member_rows = (
await session.execute(select(StudyMember).where(StudyMember.user_id == user.id))
@@ -135,6 +181,201 @@ async def test_register_creates_pending_user(client_and_db):
assert member_rows == []
@pytest.mark.asyncio
async def test_register_rejects_duplicate_email(client_and_db):
client, SessionLocal = client_and_db
payload = {
"email": "duplicate@test.com",
"password": "Password123",
"full_name": "Duplicate User",
"clinical_department": "Clinical",
}
await mark_register_email_verified(SessionLocal, payload["email"])
first_resp = await client.post("/api/v1/auth/register", json=payload)
assert first_resp.status_code == 201
second_resp = await client.post("/api/v1/auth/register", json=payload)
assert second_resp.status_code == 409
assert second_resp.json()["detail"] == "邮箱已注册"
@pytest.mark.asyncio
async def test_register_requires_verified_email_code(client_and_db):
client, _ = client_and_db
payload = {
"email": "unverified@test.com",
"password": "Password123",
"full_name": "Unverified User",
"clinical_department": "Clinical",
}
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 400
assert resp.json()["detail"] == "请先完成邮箱验证码校验"
@pytest.mark.asyncio
async def test_register_email_availability(client_and_db):
client, SessionLocal = client_and_db
available_resp = await client.get(
"/api/v1/auth/register/email-availability",
params={"email": "available@test.com"},
)
assert available_resp.status_code == 200
assert available_resp.json() == {"available": True}
payload = {
"email": "used@test.com",
"password": "Password123",
"full_name": "Used User",
"clinical_department": "Clinical",
}
await mark_register_email_verified(SessionLocal, payload["email"])
await client.post("/api/v1/auth/register", json=payload)
used_resp = await client.get(
"/api/v1/auth/register/email-availability",
params={"email": payload["email"]},
)
assert used_resp.status_code == 200
assert used_resp.json() == {"available": False}
@pytest.mark.asyncio
async def test_password_reset_code_is_one_time_and_ignores_link_records(client_and_db):
_, SessionLocal = client_and_db
email = "reset-once@test.com"
now = datetime.now(timezone.utc)
await add_email_settings(SessionLocal, "test.com")
async with SessionLocal() as session:
user = User(
email=email,
password_hash=hash_password("OldPassword123"),
full_name="Reset Once",
clinical_department="Clinical",
status=UserStatus.ACTIVE,
)
session.add(user)
session.add_all(
[
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
code_hash=hash_password("123456"),
expires_at=now + timedelta(minutes=10),
created_at=now,
),
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash="a" * 64,
expires_at=now + timedelta(minutes=10),
created_at=now + timedelta(seconds=1),
),
]
)
await session.commit()
await email_service.reset_password_with_code(session, email, "123456", "NewPassword123")
await session.refresh(user)
assert verify_password("NewPassword123", user.password_hash)
with pytest.raises(HTTPException) as exc_info:
await email_service.reset_password_with_code(session, email, "123456", "OtherPassword123")
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_password_reset_code_verification_issues_one_time_reset_token(client_and_db):
client, SessionLocal = client_and_db
email = "verified-reset@test.com"
now = datetime.now(timezone.utc)
await add_email_settings(SessionLocal, "test.com")
async with SessionLocal() as session:
session.add(
User(
email=email,
password_hash=hash_password("OldPassword123"),
full_name="Verified Reset",
clinical_department="Clinical",
status=UserStatus.ACTIVE,
)
)
session.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
code_hash=hash_password("654321"),
expires_at=now + timedelta(minutes=10),
)
)
await session.commit()
verify_response = await client.post(
"/api/v1/auth/password-reset/email-code/verify",
json={"email": email, "code": "654321"},
)
assert verify_response.status_code == 200
assert verify_response.json()["verified"] is True
reset_token = verify_response.json()["reset_token"]
reused_code_response = await client.post(
"/api/v1/auth/password-reset/email-code/verify",
json={"email": email, "code": "654321"},
)
assert reused_code_response.status_code == 400
reset_response = await client.post(
"/api/v1/auth/password-reset-link",
json={"token": reset_token, "password": "NewPassword123"},
)
assert reset_response.status_code == 200
reused_token_response = await client.post(
"/api/v1/auth/password-reset-link",
json={"token": reset_token, "password": "OtherPassword123"},
)
assert reused_token_response.status_code == 400
async with SessionLocal() as session:
user = await user_crud.get_by_email(session, email)
assert user is not None
assert verify_password("NewPassword123", user.password_hash)
@pytest.mark.asyncio
async def test_password_reset_code_send_reports_missing_account(client_and_db):
client, _ = client_and_db
response = await client.post(
"/api/v1/auth/password-reset/email-code/send",
json={"email": "missing@test.com"},
)
assert response.status_code == 404
assert response.json()["detail"] == "该邮箱未注册"
@pytest.mark.asyncio
async def test_password_reset_link_uses_configured_frontend_url(client_and_db, monkeypatch):
client, _ = client_and_db
captured: dict[str, str] = {}
async def fake_send_password_reset_link(db, email: str, *, frontend_origin: str) -> None:
captured["email"] = email
captured["frontend_origin"] = frontend_origin
monkeypatch.setattr(email_service, "send_password_reset_link", fake_send_password_reset_link)
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
response = await client.post(
"/api/v1/auth/password-reset-link/send",
json={"email": "victim@test.com"},
headers={"Origin": "https://attacker.example"},
)
assert response.status_code == 200
assert captured == {
"email": "victim@test.com",
"frontend_origin": "https://ctms.example.com",
}
@pytest.mark.asyncio
async def test_root_returns_service_metadata(client_and_db):
client, _ = client_and_db
@@ -150,44 +391,41 @@ async def test_root_returns_service_metadata(client_and_db):
@pytest.mark.asyncio
async def test_login_blocked_before_approval(client_and_db):
async def test_registered_user_can_login_after_email_verification(client_and_db):
client, SessionLocal = client_and_db
payload = {
"email": "pending@test.com",
"email": "active-register@test.com",
"password": "Password123",
"full_name": "Pending User",
"full_name": "Active Registered User",
"clinical_department": "Safety",
}
await mark_register_email_verified(SessionLocal, payload["email"])
await client.post("/api/v1/auth/register", json=payload)
resp = await encrypted_login(client, payload["email"], payload["password"])
assert resp.status_code == 401
assert "账号未审核" in resp.json().get("detail", "")
assert resp.status_code == 200
assert resp.json()["access_token"]
@pytest.mark.asyncio
async def test_admin_can_approve_user(client_and_db):
async def test_admin_created_user_is_active_by_default(client_and_db):
client, SessionLocal = client_and_db
payload = {
"email": "approve@test.com",
"password": "Password123",
"full_name": "Approve Target",
"clinical_department": "Supply",
}
await client.post("/api/v1/auth/register", json=payload)
async with SessionLocal() as session:
user = await user_crud.get_by_email(session, payload["email"])
user_id = user.id
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
token = admin_login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
payload = {
"email": "admin-created@test.com",
"password": "Password123",
"full_name": "Admin Created",
"clinical_department": "Supply",
}
resp = await client.post(f"/api/v1/admin/users/{user_id}/approve", json={"action": "approve"}, headers=headers)
assert resp.status_code == 200
resp = await client.post("/api/v1/users/", json=payload, headers=headers)
assert resp.status_code == 201
assert resp.json()["status"] == "ACTIVE"
async with SessionLocal() as session:
refreshed = await user_crud.get_by_email(session, payload["email"])
assert refreshed.status == UserStatus.ACTIVE
assert refreshed.approved_by is not None
user = await user_crud.get_by_email(session, payload["email"])
assert user.status == UserStatus.ACTIVE
@pytest.mark.asyncio
@@ -248,6 +486,43 @@ async def test_admin_users_list_filters_by_keyword_and_status(client_and_db):
assert combined_data["items"][0]["email"] == "pending-filter@test.com"
@pytest.mark.asyncio
async def test_admin_delete_user_with_audit_history_returns_400(client_and_db):
client, SessionLocal = client_and_db
async with SessionLocal() as session:
user = User(
email="audited-delete@test.com",
password_hash=hash_password("Password123"),
full_name="Audited Delete",
clinical_department="Medical",
status=UserStatus.ACTIVE,
)
session.add(user)
await session.flush()
session.add(
AuditLog(
entity_type="user",
entity_id=user.id,
action="LOGIN",
operator_id=user.id,
operator_role="USER",
)
)
await session.commit()
user_id = user.id
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
token = admin_login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete(f"/api/v1/users/{user_id}", headers=headers)
assert resp.status_code == 400
assert resp.json()["detail"] == "该账号已有审计或权限访问记录,请停用账号以保留历史追溯"
async with SessionLocal() as session:
assert await user_crud.get_by_id(session, user_id) is not None
def test_register_request_does_not_expose_role_input():
assert "role" not in UserRegisterRequest.model_fields
@@ -313,17 +588,21 @@ 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)
async with SessionLocal() as session:
session.add(
User(
email="pending-dev-login@test.com",
password_hash=hash_password("Password123"),
full_name="Pending Dev Login",
clinical_department="Clinical",
status=UserStatus.PENDING,
)
)
await session.commit()
resp = await client.post(
"/api/v1/auth/dev-login",
json={"email": payload["email"], "password": payload["password"]},
json={"email": "pending-dev-login@test.com", "password": "Password123"},
)
finally:
settings.ENV = original_env