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:
fawney19
2026-03-17 16:34:09 +08:00
parent d480aa11f3
commit c4bb6b8161
58 changed files with 4654 additions and 508 deletions

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.api.auth.routes import AuthRefreshAdapter
from src.config import config
@pytest.mark.asyncio
async def test_auth_refresh_adapter_skips_rotation_for_grace_window_token() -> None:
adapter = AuthRefreshAdapter()
created_at = datetime.now(timezone.utc)
user = SimpleNamespace(
id="user-1",
role=SimpleNamespace(value="user"),
created_at=created_at,
is_active=True,
is_deleted=False,
)
session = SimpleNamespace(id="session-1", client_device_id="device-1")
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = user
request = SimpleNamespace(
headers={"content-length": "0", "user-agent": "pytest"},
cookies={config.auth_refresh_cookie_name: "refresh-old"},
query_params={},
state=SimpleNamespace(),
)
context = SimpleNamespace(db=db, request=request)
with (
patch(
"src.api.auth.routes.AuthService.verify_token",
new=AsyncMock(
return_value={
"user_id": "user-1",
"session_id": "session-1",
"created_at": created_at.isoformat(),
}
),
),
patch(
"src.api.auth.routes.AuthService.token_identity_matches_user",
return_value=True,
),
patch(
"src.api.auth.routes.SessionService.extract_client_device_id",
return_value="device-1",
),
patch(
"src.api.auth.routes.SessionService.validate_refresh_session",
return_value=(session, True),
),
patch("src.api.auth.routes.SessionService.assert_session_device_matches"),
patch(
"src.api.auth.routes.AuthService.create_access_token",
return_value="access-new",
),
patch("src.api.auth.routes.AuthService.create_refresh_token") as mock_create_refresh,
patch("src.api.auth.routes.SessionService.rotate_refresh_token") as mock_rotate,
patch(
"src.api.auth.routes.get_client_ip",
return_value="127.0.0.1",
),
patch(
"src.api.auth.routes.get_user_agent",
return_value="pytest-agent",
),
):
result = await adapter.handle(context)
assert result == {
"access_token": "access-new",
"token_type": "bearer",
"expires_in": 86400,
}
mock_create_refresh.assert_not_called()
mock_rotate.assert_not_called()
db.commit.assert_called_once()
assert context.request.state.tx_committed_by_route is True