mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
- 新增 user_sessions 数据库表及 Alembic 迁移 - 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理) - 认证流程改用 refresh token cookie + access token 双令牌模式 - 前端实现自动静默刷新、跨标签页同步及设备指纹 - 用户设置页新增会话管理和密码修改功能 - 管理员用户管理新增强制登出和会话查看 - 密码策略增强,支持强度校验和泄露检测 - OAuth 登录流程适配新会话机制 - 新增完整的单元测试和 API 测试覆盖 Closes #232 Co-authored-by: LewisPen <LewisPen@nyadoo.com>
221 lines
7.0 KiB
Python
221 lines
7.0 KiB
Python
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
|