393 lines
14 KiB
Python
393 lines
14 KiB
Python
|
|
# =============================================================================
|
||
|
|
# 三端认证重构 AUTH-03 — 统一 OTP 路由测试
|
||
|
|
# =============================================================================
|
||
|
|
# 验证 otp.py 的 6 个端点:
|
||
|
|
# GET /api/auth/otp-status — 查询绑定状态
|
||
|
|
# POST /api/auth/otp-bind — 生成 secret + 二维码
|
||
|
|
# POST /api/auth/otp-verify — 验证 OTP(写 Redis 30 分钟)
|
||
|
|
# POST /api/auth/otp-unbind — 用户主动关闭 OTP
|
||
|
|
# POST /api/auth/otp-admin-reset/{id} — 管理员重置
|
||
|
|
# GET /api/auth/otp-admin-users — 管理员查看全部坐席状态
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
import pyotp
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from app.models.agent import Agent
|
||
|
|
from app.services.mfa_service import MFA_VERIFIED_TTL_SECONDS
|
||
|
|
from tests.conftest import create_test_agent
|
||
|
|
|
||
|
|
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
# 辅助函数
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
async def _login_and_get_token(client, user_id: str, name: str, otp_code: str = None) -> str:
|
||
|
|
"""调用 /agents/login 获取 token
|
||
|
|
|
||
|
|
Args:
|
||
|
|
client: 测试客户端
|
||
|
|
user_id: 用户 ID
|
||
|
|
name: 用户名
|
||
|
|
otp_code: 可选的 OTP 验证码(当登录返回 require_otp: true 时需要提供)
|
||
|
|
"""
|
||
|
|
payload = {"user_id": user_id, "name": name}
|
||
|
|
if otp_code:
|
||
|
|
payload["otp_code"] = otp_code
|
||
|
|
|
||
|
|
response = await client.post(
|
||
|
|
"/agents/login",
|
||
|
|
json=payload,
|
||
|
|
)
|
||
|
|
assert response.status_code == 200, f"登录失败: {response.text}"
|
||
|
|
body = response.json()
|
||
|
|
assert body.get("code") == 0, f"登录业务码非 0: {body}"
|
||
|
|
|
||
|
|
# 如果返回 require_otp,说明需要 OTP 验证
|
||
|
|
if body["data"].get("require_otp"):
|
||
|
|
# 需要先绑定/启用 OTP,这里返回 None 表示需要 OTP
|
||
|
|
return None
|
||
|
|
|
||
|
|
assert "token" in body["data"], f"登录响应没有 token: {body}"
|
||
|
|
return body["data"]["token"]
|
||
|
|
|
||
|
|
|
||
|
|
def _bearer(token: str) -> dict:
|
||
|
|
return {"Authorization": f"Bearer {token}"}
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed_admin_role(db_session, employee_id: str) -> str:
|
||
|
|
"""为用户分配 admin 角色"""
|
||
|
|
from app.models.role import Role
|
||
|
|
from app.models.user_role import UserRole
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
# 1. 确保 admin 角色存在
|
||
|
|
stmt = select(Role).where(Role.name == "admin")
|
||
|
|
role = (await db_session.execute(stmt)).scalars().first()
|
||
|
|
if not role:
|
||
|
|
role = Role(
|
||
|
|
id=str(uuid.uuid4()),
|
||
|
|
name="admin",
|
||
|
|
display_name="管理员",
|
||
|
|
is_default=False,
|
||
|
|
permissions=[],
|
||
|
|
)
|
||
|
|
db_session.add(role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 2. 建立关联
|
||
|
|
stmt = select(UserRole).where(
|
||
|
|
UserRole.employee_id == employee_id,
|
||
|
|
UserRole.role_id == role.id,
|
||
|
|
)
|
||
|
|
existing = (await db_session.execute(stmt)).scalars().first()
|
||
|
|
if not existing:
|
||
|
|
user_role = UserRole(
|
||
|
|
id=str(uuid.uuid4()),
|
||
|
|
employee_id=employee_id,
|
||
|
|
role_id=role.id,
|
||
|
|
source="manual",
|
||
|
|
assigned_at=datetime.now(),
|
||
|
|
)
|
||
|
|
db_session.add(user_role)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
return role.id
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 1. GET /api/auth/otp-status
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPSatus:
|
||
|
|
"""GET /api/auth/otp-status 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_new_user_status_unbound(self, client, db_session):
|
||
|
|
"""全新用户 → bound=false, enabled=false"""
|
||
|
|
agent = create_test_agent(user_id="alice_001", name="Alice")
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
token = await _login_and_get_token(client, "alice_001", "Alice")
|
||
|
|
resp = await client.get("/auth/otp-status", headers=_bearer(token))
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
data = body["data"]
|
||
|
|
assert data["bound"] is False
|
||
|
|
assert data["enabled"] is False
|
||
|
|
assert data["verified"] is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bound_user_status(self, client, db_session):
|
||
|
|
"""已绑定用户 → bound=true, enabled=true
|
||
|
|
|
||
|
|
注意:已绑定用户的 mfa_enabled=True, mfa_secret 有值
|
||
|
|
这样的用户登录时会返回 require_otp: true
|
||
|
|
"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
agent = create_test_agent(user_id="bob_001", name="Bob")
|
||
|
|
# 直接设置已绑定状态(mfa_enabled=True, mfa_secret 有值)
|
||
|
|
agent.mfa_secret = pyotp.random_base32()
|
||
|
|
agent.mfa_enabled = True
|
||
|
|
agent.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 使用 OTP 验证码登录获取 token
|
||
|
|
otp_code = pyotp.TOTP(agent.mfa_secret).now()
|
||
|
|
token = await _login_and_get_token(client, "bob_001", "Bob", otp_code)
|
||
|
|
|
||
|
|
# 查询状态
|
||
|
|
resp = await client.get("/auth/otp-status", headers=_bearer(token))
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
data = body["data"]
|
||
|
|
assert data["bound"] is True
|
||
|
|
assert data["enabled"] is True
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 2. POST /api/auth/otp-bind
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPBind:
|
||
|
|
"""POST /api/auth/otp-bind 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bind_returns_secret_and_qrcode(self, client, db_session):
|
||
|
|
"""返回 secret + otpauth_url + qr_code_base64"""
|
||
|
|
agent = create_test_agent(user_id="carol_001", name="Carol")
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
token = await _login_and_get_token(client, "carol_001", "Carol")
|
||
|
|
resp = await client.post("/auth/otp-bind", headers=_bearer(token))
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
data = body["data"]
|
||
|
|
assert "secret" in data
|
||
|
|
assert "otpauth_url" in data
|
||
|
|
assert "qr_code_base64" in data
|
||
|
|
assert len(data["secret"]) == 32
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_bind_already_enabled_rejected(self, client, db_session):
|
||
|
|
"""已启用则拒绝重新绑定
|
||
|
|
|
||
|
|
已绑定 MFA 的用户需要通过 otp-verify 流程来获取 token
|
||
|
|
"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
agent = create_test_agent(user_id="dave_001", name="Dave")
|
||
|
|
secret = pyotp.random_base32()
|
||
|
|
agent.mfa_secret = secret
|
||
|
|
agent.mfa_enabled = True
|
||
|
|
agent.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 使用 OTP 验证码登录
|
||
|
|
otp_code = pyotp.TOTP(secret).now()
|
||
|
|
token = await _login_and_get_token(client, "dave_001", "Dave", otp_code)
|
||
|
|
resp = await client.post("/auth/otp-bind", headers=_bearer(token))
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] != 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 3. POST /api/auth/otp-verify
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPVerify:
|
||
|
|
"""POST /api/auth/otp-verify 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_verify_correct_code(self, client, db_session):
|
||
|
|
"""正确码 → verified=True + 返回 expires_in"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
agent = create_test_agent(user_id="eve_001", name="Eve")
|
||
|
|
secret = pyotp.random_base32()
|
||
|
|
agent.mfa_secret = secret
|
||
|
|
agent.mfa_enabled = True
|
||
|
|
agent.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 使用 OTP 验证码登录
|
||
|
|
otp_code = pyotp.TOTP(secret).now()
|
||
|
|
token = await _login_and_get_token(client, "eve_001", "Eve", otp_code)
|
||
|
|
|
||
|
|
# 验证 OTP(使用新的验证码)
|
||
|
|
new_otp_code = pyotp.TOTP(secret).now()
|
||
|
|
resp = await client.post(
|
||
|
|
"/auth/otp-verify",
|
||
|
|
headers=_bearer(token),
|
||
|
|
json={"otp_code": new_otp_code},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
data = body["data"]
|
||
|
|
assert data["verified"] is True
|
||
|
|
assert data["expires_in"] == MFA_VERIFIED_TTL_SECONDS
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_verify_wrong_code(self, client, db_session):
|
||
|
|
"""错误码 → verified=False"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
agent = create_test_agent(user_id="frank_001", name="Frank")
|
||
|
|
secret = pyotp.random_base32()
|
||
|
|
agent.mfa_secret = secret
|
||
|
|
agent.mfa_enabled = True
|
||
|
|
agent.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 使用 OTP 验证码登录
|
||
|
|
otp_code = pyotp.TOTP(secret).now()
|
||
|
|
token = await _login_and_get_token(client, "frank_001", "Frank", otp_code)
|
||
|
|
|
||
|
|
# 使用错误的 OTP 验证
|
||
|
|
resp = await client.post(
|
||
|
|
"/auth/otp-verify",
|
||
|
|
headers=_bearer(token),
|
||
|
|
json={"otp_code": "000000"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
assert body["data"]["verified"] is False
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 4. POST /api/auth/otp-unbind
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPUnbind:
|
||
|
|
"""POST /api/auth/otp-unbind 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unbind_correct_code(self, client, db_session):
|
||
|
|
"""正确 OTP → 清空 secret + enabled=False"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
agent = create_test_agent(user_id="grace_001", name="Grace")
|
||
|
|
secret = pyotp.random_base32()
|
||
|
|
agent.mfa_secret = secret
|
||
|
|
agent.mfa_enabled = True
|
||
|
|
agent.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(agent)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# 使用 OTP 验证码登录
|
||
|
|
otp_code = pyotp.TOTP(secret).now()
|
||
|
|
token = await _login_and_get_token(client, "grace_001", "Grace", otp_code)
|
||
|
|
|
||
|
|
# 解绑 OTP
|
||
|
|
resp = await client.post(
|
||
|
|
"/auth/otp-unbind",
|
||
|
|
headers=_bearer(token),
|
||
|
|
json={"otp_code": otp_code},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
assert body["data"]["success"] is True
|
||
|
|
|
||
|
|
# DB 验证
|
||
|
|
stmt = select(Agent).where(Agent.user_id == "grace_001")
|
||
|
|
db_agent = (await db_session.execute(stmt)).scalars().first()
|
||
|
|
assert db_agent.mfa_secret is None
|
||
|
|
assert db_agent.mfa_enabled is False
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 5. POST /api/auth/otp-admin-reset/{employee_id}
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPAdminReset:
|
||
|
|
"""POST /api/auth/otp-admin-reset/{employee_id} 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_admin_reset_target_user(self, client, db_session):
|
||
|
|
"""管理员重置 → 目标用户清空"""
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
# 目标用户
|
||
|
|
target = create_test_agent(user_id="henry_001", name="Henry")
|
||
|
|
target.mfa_secret = pyotp.random_base32()
|
||
|
|
target.mfa_enabled = True
|
||
|
|
target.mfa_bound_at = datetime.now()
|
||
|
|
db_session.add(target)
|
||
|
|
|
||
|
|
# 管理员
|
||
|
|
admin = create_test_agent(user_id="admin_001", name="Admin")
|
||
|
|
db_session.add(admin)
|
||
|
|
await db_session.flush()
|
||
|
|
await _seed_admin_role(db_session, "admin_001")
|
||
|
|
|
||
|
|
admin_token = await _login_and_get_token(client, "admin_001", "Admin")
|
||
|
|
resp = await client.post(
|
||
|
|
f"/auth/otp-admin-reset/henry_001",
|
||
|
|
headers=_bearer(admin_token),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
assert body["data"]["success"] is True
|
||
|
|
|
||
|
|
# DB 验证
|
||
|
|
stmt = select(Agent).where(Agent.user_id == "henry_001")
|
||
|
|
db_target = (await db_session.execute(stmt)).scalars().first()
|
||
|
|
assert db_target.mfa_secret is None
|
||
|
|
assert db_target.mfa_enabled is False
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 6. GET /api/auth/otp-admin-users
|
||
|
|
# =============================================================================
|
||
|
|
class TestOTPAdminUsers:
|
||
|
|
"""GET /api/auth/otp-admin-users 测试"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_admin_list_users(self, client, db_session):
|
||
|
|
"""管理员查看 → 返回所有用户"""
|
||
|
|
# 添加两个用户
|
||
|
|
agent1 = create_test_agent(user_id="user_001", name="User1")
|
||
|
|
agent2 = create_test_agent(user_id="user_002", name="User2")
|
||
|
|
db_session.add(agent1)
|
||
|
|
db_session.add(agent2)
|
||
|
|
|
||
|
|
# 管理员
|
||
|
|
admin = create_test_agent(user_id="admin_002", name="Admin2")
|
||
|
|
db_session.add(admin)
|
||
|
|
await db_session.flush()
|
||
|
|
await _seed_admin_role(db_session, "admin_002")
|
||
|
|
|
||
|
|
admin_token = await _login_and_get_token(client, "admin_002", "Admin2")
|
||
|
|
resp = await client.get("/auth/otp-admin-users", headers=_bearer(admin_token))
|
||
|
|
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
# 应该包含所有用户
|
||
|
|
users = body["data"]
|
||
|
|
assert len(users) >= 2
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
pytest.main([__file__, "-v"])
|