mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(auth): 重构认证系统,引入 session 会话管理
- 新增 user_sessions 数据库表及 Alembic 迁移 - 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理) - 认证流程改用 refresh token cookie + access token 双令牌模式 - 前端实现自动静默刷新、跨标签页同步及设备指纹 - 用户设置页新增会话管理和密码修改功能 - 管理员用户管理新增强制登出和会话查看 - 密码策略增强,支持强度校验和泄露检测 - OAuth 登录流程适配新会话机制 - 新增完整的单元测试和 API 测试覆盖 Closes #232 Co-authored-by: LewisPen <LewisPen@nyadoo.com>
This commit is contained in:
@@ -96,6 +96,69 @@ def test_list_users_uses_wallet_batch_lookup(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert batch_getter.call_args.args[1] == ["user-1", "user-2"]
|
||||
|
||||
|
||||
def test_list_user_sessions_route_returns_sessions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
sessions = [
|
||||
{
|
||||
"id": "session-1",
|
||||
"device_label": "Chrome / macOS",
|
||||
"device_type": "desktop",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"is_current": False,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._list_user_sessions_sync",
|
||||
lambda user_id: (
|
||||
sessions,
|
||||
{"action": "list_user_sessions", "target_user_id": user_id},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.get("/api/admin/users/user-1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == sessions
|
||||
|
||||
|
||||
def test_revoke_user_session_route_returns_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._revoke_user_session_sync",
|
||||
lambda user_id, session_id, admin_user_id: (
|
||||
{"message": f"{user_id}:{session_id}:revoked"},
|
||||
{"action": "revoke_user_session", "target_user_id": user_id, "session_id": session_id},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/users/user-1/sessions/session-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "user-1:session-1:revoked"}
|
||||
|
||||
|
||||
def test_revoke_all_user_sessions_route_returns_count(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._revoke_all_user_sessions_sync",
|
||||
lambda user_id, admin_user_id: (
|
||||
{"message": "done", "revoked_count": 2},
|
||||
{"action": "revoke_all_user_sessions", "target_user_id": user_id, "revoked_count": 2},
|
||||
),
|
||||
)
|
||||
|
||||
response = client.delete("/api/admin/users/user-1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "done", "revoked_count": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -103,14 +166,12 @@ async def test_create_user_adapter_preserves_empty_restriction_lists(
|
||||
db = MagicMock()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _create_user_sync(request: Any, role: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
captured["allowed_providers"] = request.allowed_providers
|
||||
captured["allowed_api_formats"] = request.allowed_api_formats
|
||||
captured["allowed_models"] = request.allowed_models
|
||||
def _fake_create_user_sync(request: Any, role: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
captured["request"] = request
|
||||
captured["role"] = role
|
||||
return {"id": "user-3"}, {}
|
||||
return {"id": "user-3"}, {"action": "create_user", "target_user_id": "user-3"}
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes._create_user_sync", _create_user_sync)
|
||||
monkeypatch.setattr("src.api.admin.users.routes._create_user_sync", _fake_create_user_sync)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
@@ -131,6 +192,6 @@ async def test_create_user_adapter_preserves_empty_restriction_lists(
|
||||
result = await AdminCreateUserAdapter().handle(context)
|
||||
|
||||
assert result == {"id": "user-3"}
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
assert captured["request"].allowed_providers == []
|
||||
assert captured["request"].allowed_api_formats == []
|
||||
assert captured["request"].allowed_models == []
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.auth.routes import _logout_with_refresh_cookie_fallback
|
||||
from src.api.auth.routes import router as auth_router
|
||||
from src.config import config
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_auth_app(
|
||||
db: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
pipeline_result: Any = None,
|
||||
pipeline_exception: Exception | None = None,
|
||||
) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
async def _fake_pipeline_run(
|
||||
*, adapter: Any, http_request: object, db: MagicMock, mode: object
|
||||
) -> Any:
|
||||
_ = adapter, http_request, db, mode
|
||||
if pipeline_exception is not None:
|
||||
raise pipeline_exception
|
||||
return pipeline_result
|
||||
|
||||
monkeypatch.setattr("src.api.auth.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_login_route_sets_refresh_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"access_token": "access-1",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"user_id": "user-1",
|
||||
"username": "tester",
|
||||
"role": "user",
|
||||
"_refresh_token": "refresh-1",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/login", json={"email": "user@example.com", "password": "pw"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_token": "access-1",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"user_id": "user-1",
|
||||
"username": "tester",
|
||||
"role": "user",
|
||||
}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "refresh-1" in set_cookie
|
||||
assert "HttpOnly" in set_cookie
|
||||
|
||||
|
||||
def test_refresh_route_sets_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_result={
|
||||
"access_token": "access-2",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
"_refresh_token": "refresh-2",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_token": "access-2",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 86400,
|
||||
}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "refresh-2" in set_cookie
|
||||
assert "HttpOnly" in set_cookie
|
||||
|
||||
|
||||
def test_refresh_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="登录会话已失效,请重新登录"),
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["message"] == "登录会话已失效,请重新登录"
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
def test_logout_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="缺少认证令牌"),
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/logout")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["message"] == "缺少认证令牌"
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
def test_logout_route_uses_refresh_cookie_fallback_on_auth_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_auth_app(
|
||||
db,
|
||||
monkeypatch,
|
||||
pipeline_exception=HTTPException(status_code=401, detail="Token已过期"),
|
||||
)
|
||||
|
||||
async def _fake_fallback(_request: object, _db: MagicMock) -> dict[str, Any]:
|
||||
return {"message": "登出成功", "success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes._logout_with_refresh_cookie_fallback",
|
||||
_fake_fallback,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/logout",
|
||||
cookies={config.auth_refresh_cookie_name: "refresh-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "登出成功", "success": True}
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert config.auth_refresh_cookie_name in set_cookie
|
||||
assert "Max-Age=0" in set_cookie
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_with_refresh_cookie_fallback_revokes_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
user = SimpleNamespace(id="user-1", email="user@example.com")
|
||||
session = SimpleNamespace(id="session-1", is_revoked=False, is_expired=False)
|
||||
db.query.return_value.filter.return_value.first.return_value = user
|
||||
request = SimpleNamespace(
|
||||
cookies={config.auth_refresh_cookie_name: "refresh-1"},
|
||||
headers={},
|
||||
query_params={},
|
||||
state=SimpleNamespace(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.AuthService.verify_token",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"user_id": "user-1",
|
||||
"session_id": "session-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.extract_client_device_id",
|
||||
lambda _request: "device-1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.get_session_for_user",
|
||||
lambda *_args, **_kwargs: session,
|
||||
)
|
||||
assert_session_device_matches = MagicMock()
|
||||
revoke_session = MagicMock()
|
||||
log_event = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.assert_session_device_matches",
|
||||
assert_session_device_matches,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.SessionService.revoke_session",
|
||||
revoke_session,
|
||||
)
|
||||
monkeypatch.setattr("src.api.auth.routes.AuditService.log_event", log_event)
|
||||
monkeypatch.setattr("src.api.auth.routes.get_client_ip", lambda _request: "127.0.0.1")
|
||||
monkeypatch.setattr(
|
||||
"src.api.auth.routes.get_user_agent",
|
||||
lambda _request: "pytest-agent",
|
||||
)
|
||||
|
||||
result = await _logout_with_refresh_cookie_fallback(request, db)
|
||||
|
||||
assert result == {"message": "登出成功", "success": True}
|
||||
assert_session_device_matches.assert_called_once_with(session, "device-1")
|
||||
revoke_session.assert_called_once()
|
||||
log_event.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
assert request.state.tx_committed_by_route is True
|
||||
+226
-20
@@ -8,6 +8,7 @@ API Pipeline 测试
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -227,6 +228,35 @@ class TestPipelineAuditLogging:
|
||||
assert call_kwargs["status_code"] == 500
|
||||
assert call_kwargs["error_message"] == "Internal error"
|
||||
|
||||
def test_record_audit_event_commits_when_route_already_committed(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
mock_context = MagicMock()
|
||||
mock_context.db = MagicMock()
|
||||
mock_context.user = MagicMock()
|
||||
mock_context.user.id = "user-123"
|
||||
mock_context.api_key = None
|
||||
mock_context.request_id = "req-123"
|
||||
mock_context.client_ip = "127.0.0.1"
|
||||
mock_context.user_agent = "test-agent"
|
||||
mock_context.request = MagicMock()
|
||||
mock_context.request.method = "POST"
|
||||
mock_context.request.url.path = "/api/auth/refresh"
|
||||
mock_context.request.state = SimpleNamespace(tx_committed_by_route=True)
|
||||
mock_context.start_time = 1000.0
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "test-adapter"
|
||||
mock_adapter.audit_log_enabled = True
|
||||
mock_adapter.audit_success_event = None
|
||||
mock_adapter.audit_failure_event = None
|
||||
|
||||
with patch.object(pipeline.audit_service, "log_event") as mock_log:
|
||||
pipeline._record_audit_event(mock_context, mock_adapter, success=True, status_code=200)
|
||||
|
||||
mock_log.assert_called_once()
|
||||
mock_context.db.commit.assert_called_once()
|
||||
|
||||
def test_record_audit_event_no_db(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试没有数据库会话时跳过审计"""
|
||||
mock_context = MagicMock()
|
||||
@@ -755,6 +785,8 @@ class TestPipelineAdminAuth:
|
||||
async def test_authenticate_admin_success(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试管理员认证成功"""
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-123"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
@@ -765,28 +797,46 @@ class TestPipelineAdminAuth:
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "Bearer valid-token"}
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-123",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "admin-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-123",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
assert mock_request.state.user_id == "admin-123"
|
||||
assert mock_request.state.user_session_id == "session-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试 bearer (小写) 前缀也能正确解析"""
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-123"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
@@ -797,24 +847,79 @@ class TestPipelineAdminAuth:
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "bearer valid-token"}
|
||||
mock_request.headers = {
|
||||
"authorization": "bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-123",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
|
||||
) as mock_verify:
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "admin-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-123",
|
||||
},
|
||||
) as mock_verify,
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
mock_verify.assert_awaited_once_with("valid-token", token_type="access")
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_rejects_legacy_token_without_session_id(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-legacy",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.role = UserRole.ADMIN
|
||||
mock_user.email = "admin@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
|
||||
),
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"token_identity_matches_user",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException, match="登录会话已失效,请重新登录"):
|
||||
await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
|
||||
class TestPipelineUserAuth:
|
||||
"""测试普通用户 JWT 认证"""
|
||||
@@ -827,6 +932,8 @@ class TestPipelineUserAuth:
|
||||
async def test_authenticate_user_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试 bearer (小写) 前缀也能正确解析"""
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-456"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
@@ -836,20 +943,119 @@ class TestPipelineUserAuth:
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "bearer valid-token"}
|
||||
mock_request.headers = {
|
||||
"authorization": "bearer valid-token",
|
||||
"X-Client-Device-Id": "device-user-456",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_id": "user-123", "created_at": created_at.isoformat()},
|
||||
) as mock_verify:
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "user-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-456",
|
||||
},
|
||||
) as mock_verify,
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
mock_verify.assert_awaited_once_with("valid-token", token_type="access")
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
assert mock_request.state.user_session_id == "session-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_rejects_legacy_token_without_session_id(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
"""历史 JWT 无 session_id 时应拒绝。"""
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-user-legacy",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_id": "user-123", "created_at": created_at.isoformat()},
|
||||
),
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"token_identity_matches_user",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException, match="登录会话已失效,请重新登录"):
|
||||
await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_requires_device_id(self, pipeline: ApiRequestPipeline) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "Bearer valid-token"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-456"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "user-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-456",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"token_identity_matches_user",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException, match="缺少或无效的设备标识"):
|
||||
await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
Reference in New Issue
Block a user