- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
513 lines
17 KiB
Python
513 lines
17 KiB
Python
"""DCAuth v2 + 短信验证码回归测试。
|
|
|
|
这些用例只依赖 SQLite 内存库与纯函数,不需要 MySQL / 真实短信渠道。
|
|
``tests/conftest.py`` 的全局 autouse fixture 默认连 MySQL,这里用同名 fixture
|
|
覆盖掉,保证在只装了 Python 依赖的环境里也能跑。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from web.backend.database import Base
|
|
from web.backend.errors import ApiError
|
|
from web.backend.models import (
|
|
AuditLog,
|
|
DeviceChallenge,
|
|
LoginSmsCode,
|
|
User,
|
|
UserDevice,
|
|
)
|
|
from web.backend.routers import auth as auth_router
|
|
from web.backend.routers.dcauth import authorize as authorize_endpoint
|
|
from web.backend.schemas import (
|
|
DeviceAuthorizeRequest,
|
|
LoginCompleteRequest,
|
|
LoginPrepareRequest,
|
|
LoginRequest,
|
|
)
|
|
from web.backend.security import hash_password
|
|
from web.backend.services import dc_auth_service as dc
|
|
from web.backend.services import login_sms_service as sms
|
|
|
|
DEVICE_ID_A = "D1-AAAA-BBBB-CCCC"
|
|
DEVICE_ID_B = "D1-DDDD-EEEE-FFFF"
|
|
PUBLIC_KEY_A = "public-key-a"
|
|
PUBLIC_KEY_B = "public-key-b"
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def setup_test_database():
|
|
"""覆盖 tests/conftest.py:本文件不需要 MySQL。"""
|
|
yield None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_tables_per_test():
|
|
"""覆盖 tests/conftest.py:每个用例使用独立 SQLite 内存库。"""
|
|
yield
|
|
|
|
|
|
@pytest.fixture()
|
|
def db() -> Session:
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[
|
|
User.__table__,
|
|
UserDevice.__table__,
|
|
DeviceChallenge.__table__,
|
|
LoginSmsCode.__table__,
|
|
AuditLog.__table__,
|
|
],
|
|
)
|
|
factory = sessionmaker(bind=engine)
|
|
session = factory()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
def _make_user(
|
|
db: Session, username: str = "测试用户", phone: str = "13800138000"
|
|
) -> User:
|
|
user = User(
|
|
username=username,
|
|
password_hash="not-used",
|
|
role="operation",
|
|
is_active=True,
|
|
phone=phone,
|
|
remark="",
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
def _b64url(raw: bytes) -> str:
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _keypair() -> tuple[Ed25519PrivateKey, str]:
|
|
private_key = Ed25519PrivateKey.generate()
|
|
public_key = private_key.public_key().public_bytes(
|
|
serialization.Encoding.Raw,
|
|
serialization.PublicFormat.Raw,
|
|
)
|
|
return private_key, _b64url(public_key)
|
|
|
|
|
|
def _authorize_payload(
|
|
private_key: Ed25519PrivateKey,
|
|
challenge: dict,
|
|
device_id: str = DEVICE_ID_A,
|
|
timestamp: int | None = None,
|
|
client_version: str = "2.0.0",
|
|
) -> dict:
|
|
ts = timestamp if timestamp is not None else int(datetime.now(UTC).timestamp())
|
|
canonical = dc.build_device_canonical(
|
|
"live_hub",
|
|
challenge["challengeId"],
|
|
challenge["challenge"],
|
|
device_id,
|
|
ts,
|
|
)
|
|
signature = private_key.sign(canonical.encode("utf-8"))
|
|
_, public_key = _keypair_from(private_key)
|
|
return {
|
|
"app": "live_hub",
|
|
"challengeId": challenge["challengeId"],
|
|
"challenge": challenge["challenge"],
|
|
"deviceId": device_id,
|
|
"publicKey": public_key,
|
|
"signature": _b64url(signature),
|
|
"timestamp": ts,
|
|
"clientVersion": client_version,
|
|
}
|
|
|
|
|
|
def _keypair_from(private_key: Ed25519PrivateKey) -> tuple[Ed25519PrivateKey, str]:
|
|
public_key = private_key.public_key().public_bytes(
|
|
serialization.Encoding.Raw,
|
|
serialization.PublicFormat.Raw,
|
|
)
|
|
return private_key, _b64url(public_key)
|
|
|
|
|
|
class TestCanonical:
|
|
def test_canonical_is_exact_v2_string(self):
|
|
canonical = dc.build_device_canonical(
|
|
"live_hub",
|
|
"challenge-id",
|
|
"challenge-value",
|
|
"D1-AAAA-BBBB-CCCC",
|
|
1700000000,
|
|
)
|
|
assert canonical == (
|
|
"dcauth.v2\n"
|
|
"live_hub\n"
|
|
"challenge-id\n"
|
|
"challenge-value\n"
|
|
"D1-AAAA-BBBB-CCCC\n"
|
|
"1700000000"
|
|
)
|
|
|
|
def test_client_version_major_parsing(self):
|
|
assert dc.parse_client_major("2.0.0") == 2
|
|
assert dc.parse_client_major("2") == 2
|
|
assert dc.parse_client_major("1.9.9") == 1
|
|
assert dc.parse_client_major("abc") is None
|
|
assert dc.parse_client_major("") is None
|
|
|
|
def test_client_version_below_2_raises_426(self):
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.ensure_supported_client_version("1.9.9")
|
|
assert exc.value.status_code == 426
|
|
assert exc.value.error_code == "client_outdated"
|
|
|
|
def test_ed25519_signature_roundtrip_and_tamper(self):
|
|
private_key, public_key = _keypair()
|
|
canonical = dc.build_device_canonical(
|
|
"live_hub", "cid", "chal", DEVICE_ID_A, 1700000000
|
|
)
|
|
signature = private_key.sign(canonical.encode("utf-8"))
|
|
dc.verify_device_signature(public_key, _b64url(signature), canonical)
|
|
|
|
tampered = canonical.replace("1700000000", "1700000001")
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.verify_device_signature(public_key, _b64url(signature), tampered)
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
class TestAuthorizeEndpoint:
|
|
def test_authorize_endpoint_returns_426(self, db: Session):
|
|
private_key, public_key = _keypair()
|
|
request = DeviceAuthorizeRequest(
|
|
app="live_hub",
|
|
challengeId="missing",
|
|
challenge="challenge",
|
|
deviceId=DEVICE_ID_A,
|
|
publicKey=public_key,
|
|
signature=_b64url(private_key.sign(b"whatever")),
|
|
timestamp=int(datetime.now(UTC).timestamp()),
|
|
clientVersion="1.0.0",
|
|
)
|
|
# 直接调用路由函数:版本校验发生在任何数据库查询之前。
|
|
with pytest.raises(ApiError) as exc:
|
|
authorize_endpoint(request, db)
|
|
assert exc.value.status_code == 426
|
|
assert exc.value.error_code == "client_outdated"
|
|
|
|
|
|
class TestChallengeLifecycle:
|
|
def test_challenge_single_consumption_and_ticket_lifecycle(self, db: Session):
|
|
user = _make_user(db)
|
|
challenge = dc.create_login_challenge(db, user.id, "http://localhost:8800")
|
|
private_key, _ = _keypair()
|
|
|
|
payload = _authorize_payload(private_key, challenge)
|
|
dc.authorize_device(db, payload)
|
|
|
|
row = dc.get_challenge(db, challenge["challengeId"])
|
|
assert row is not None
|
|
assert row.status == "authorized"
|
|
assert row.device_id == DEVICE_ID_A
|
|
assert row.client_version == "2.0.0"
|
|
assert row.authorized_at is not None
|
|
|
|
# 同一个 challenge 不能再被第二次授权。
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.authorize_device(db, payload)
|
|
assert exc.value.status_code == 409
|
|
|
|
# 轮询签发 ticket(只签发一次)。
|
|
first = dc.get_challenge_status(
|
|
db, challenge["challengeId"], challenge["pollToken"]
|
|
)
|
|
assert first["status"] == "authorized"
|
|
assert first["deviceTicket"]
|
|
second = dc.get_challenge_status(
|
|
db, challenge["challengeId"], challenge["pollToken"]
|
|
)
|
|
assert second["status"] == "ticket_issued"
|
|
|
|
# ticket 只能消费一次。
|
|
consumed = dc.consume_device_ticket(
|
|
db,
|
|
challenge["challengeId"],
|
|
challenge["pollToken"],
|
|
first["deviceTicket"],
|
|
)
|
|
assert consumed.status == "consumed"
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.consume_device_ticket(
|
|
db,
|
|
challenge["challengeId"],
|
|
challenge["pollToken"],
|
|
first["deviceTicket"],
|
|
)
|
|
assert exc.value.status_code == 401
|
|
|
|
def test_poll_token_hash_mismatch_is_forbidden(self, db: Session):
|
|
user = _make_user(db)
|
|
challenge = dc.create_login_challenge(db, user.id, "http://localhost:8800")
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.get_challenge_status(db, challenge["challengeId"], "wrong-token")
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
class TestDeviceBinding:
|
|
def test_mismatched_device_is_rejected(self, db: Session):
|
|
user = _make_user(db)
|
|
now = datetime.now(UTC)
|
|
db.add(
|
|
UserDevice(
|
|
user_id=user.id,
|
|
device_id=DEVICE_ID_A,
|
|
public_key=PUBLIC_KEY_A,
|
|
client_version="2.0.0",
|
|
status="active",
|
|
bound_at=now,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
challenge = DeviceChallenge(
|
|
challenge_id="cid-mismatch",
|
|
app="live_hub",
|
|
purpose="login",
|
|
challenge="chal",
|
|
poll_token_hash=dc.hash_token("poll"),
|
|
status="consumed",
|
|
user_id=user.id,
|
|
device_id=DEVICE_ID_B,
|
|
public_key=PUBLIC_KEY_B,
|
|
client_version="2.0.0",
|
|
expires_at=now + timedelta(minutes=1),
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
with pytest.raises(ApiError) as exc:
|
|
dc.ensure_device_binding(db, user, challenge)
|
|
assert exc.value.status_code == 401
|
|
assert exc.value.error_code == "device_mismatch"
|
|
assert exc.value.detail == "当前设备与账号绑定设备不一致,请联系管理员重置设备"
|
|
|
|
def test_first_device_is_bound_automatically(self, db: Session):
|
|
user = _make_user(db)
|
|
now = datetime.now(UTC)
|
|
challenge = DeviceChallenge(
|
|
challenge_id="cid-bind",
|
|
app="live_hub",
|
|
purpose="login",
|
|
challenge="chal",
|
|
poll_token_hash=dc.hash_token("poll"),
|
|
status="consumed",
|
|
user_id=user.id,
|
|
device_id=DEVICE_ID_A,
|
|
public_key=PUBLIC_KEY_A,
|
|
client_version="2.0.0",
|
|
expires_at=now + timedelta(minutes=1),
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
bound = dc.ensure_device_binding(db, user, challenge)
|
|
assert bound.status == "active"
|
|
assert bound.device_id == DEVICE_ID_A
|
|
assert bound.public_key == PUBLIC_KEY_A
|
|
|
|
|
|
class TestSmsCode:
|
|
def test_mock_provider_skips_daily_limit(self):
|
|
assert sms.should_enforce_login_sms_daily_limit("mock") is False
|
|
assert sms.should_enforce_login_sms_daily_limit("aliyun") is True
|
|
|
|
def test_five_wrong_attempts_lock_the_code(self, db: Session):
|
|
user = _make_user(db)
|
|
now = datetime.now(UTC)
|
|
row = LoginSmsCode(
|
|
user_id=user.id,
|
|
phone=user.phone,
|
|
purpose="login",
|
|
code_hash=sms.hash_sms_code("123456"),
|
|
expires_at=now + timedelta(minutes=5),
|
|
failed_attempt_count=0,
|
|
request_ip="",
|
|
created_at=now,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
|
|
for attempt in range(1, 6):
|
|
with pytest.raises(ApiError) as exc:
|
|
sms.verify_login_sms_code(db, user.id, "000000")
|
|
assert exc.value.error_code == (
|
|
"sms_code_locked" if attempt == 5 else "sms_code_invalid"
|
|
)
|
|
|
|
db.refresh(row)
|
|
assert row.failed_attempt_count == 5
|
|
assert row.consumed_at is not None
|
|
|
|
# 锁定后即使输入正确验证码也不能再用。
|
|
with pytest.raises(ApiError) as exc:
|
|
sms.verify_login_sms_code(db, user.id, "123456")
|
|
assert exc.value.error_code in {"sms_code_invalid", "sms_code_locked"}
|
|
|
|
def test_correct_code_is_consumed_once(self, db: Session):
|
|
user = _make_user(db)
|
|
now = datetime.now(UTC)
|
|
db.add(
|
|
LoginSmsCode(
|
|
user_id=user.id,
|
|
phone=user.phone,
|
|
purpose="login",
|
|
code_hash=sms.hash_sms_code("654321"),
|
|
expires_at=now + timedelta(minutes=5),
|
|
failed_attempt_count=0,
|
|
request_ip="",
|
|
created_at=now,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
assert sms.verify_login_sms_code(db, user.id, "654321") == user.phone
|
|
with pytest.raises(ApiError) as exc:
|
|
sms.verify_login_sms_code(db, user.id, "654321")
|
|
assert exc.value.error_code == "sms_code_invalid"
|
|
|
|
def test_mask_and_hash_helpers(self):
|
|
assert sms.mask_phone("13800138000") == "138****8000"
|
|
assert sms.hash_sms_code("123456") == sms.hash_sms_code("123456")
|
|
assert sms.hash_sms_code("123456") != "123456"
|
|
assert sms.normalize_phone(" 13800138000 ") == "13800138000"
|
|
|
|
|
|
def _request() -> Request:
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"http_version": "1.1",
|
|
"method": "POST",
|
|
"scheme": "http",
|
|
"path": "/api/auth/prepare",
|
|
"raw_path": b"/api/auth/prepare",
|
|
"query_string": b"",
|
|
"headers": [(b"origin", b"http://localhost:5174")],
|
|
"client": ("127.0.0.1", 12345),
|
|
"server": ("localhost", 8800),
|
|
"root_path": "",
|
|
}
|
|
)
|
|
|
|
|
|
class TestLoginFlow:
|
|
def test_prepare_complete_reuses_jwt_cookie_session(self, db: Session):
|
|
user = _make_user(db)
|
|
user.password_hash = hash_password("secret123")
|
|
db.commit()
|
|
|
|
now = datetime.now(UTC)
|
|
db.add(
|
|
LoginSmsCode(
|
|
user_id=user.id,
|
|
phone=user.phone,
|
|
purpose="login",
|
|
code_hash=sms.hash_sms_code("123456"),
|
|
expires_at=now + timedelta(minutes=5),
|
|
failed_attempt_count=0,
|
|
request_ip="",
|
|
created_at=now,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
prepared = auth_router.prepare_login(
|
|
req=LoginPrepareRequest(
|
|
username=user.username,
|
|
password="secret123",
|
|
sms_code="123456",
|
|
api_base="http://localhost:5174",
|
|
),
|
|
request=_request(),
|
|
db=db,
|
|
)
|
|
assert prepared.launch_url.startswith("dcauth://authorize?")
|
|
assert "app=live_hub" in prepared.launch_url
|
|
assert "api=http%3A%2F%2Flocalhost%3A5174" in prepared.launch_url
|
|
|
|
private_key, _ = _keypair()
|
|
dc.authorize_device(
|
|
db,
|
|
_authorize_payload(
|
|
private_key,
|
|
{
|
|
"challengeId": prepared.challenge_id,
|
|
"challenge": prepared.challenge,
|
|
},
|
|
),
|
|
)
|
|
status = dc.get_challenge_status(db, prepared.challenge_id, prepared.poll_token)
|
|
assert status["status"] == "authorized"
|
|
|
|
response = Response()
|
|
result = auth_router.complete_login(
|
|
req=LoginCompleteRequest(
|
|
challenge_id=prepared.challenge_id,
|
|
poll_token=prepared.poll_token,
|
|
device_ticket=status["deviceTicket"],
|
|
),
|
|
response=response,
|
|
db=db,
|
|
)
|
|
assert result.username == user.username
|
|
cookie = response.headers.get("set-cookie", "")
|
|
assert "access_token=" in cookie
|
|
assert "HttpOnly" in cookie
|
|
|
|
device = (
|
|
db.query(UserDevice)
|
|
.filter(UserDevice.user_id == user.id, UserDevice.status == "active")
|
|
.one()
|
|
)
|
|
assert device.device_id == DEVICE_ID_A
|
|
|
|
def test_legacy_login_only_allowed_without_phone(self, db: Session):
|
|
user = _make_user(db, phone="")
|
|
user.password_hash = hash_password("secret123")
|
|
db.commit()
|
|
|
|
response = Response()
|
|
result = auth_router.login(
|
|
req=LoginRequest(username=user.username, password="secret123"),
|
|
response=response,
|
|
db=db,
|
|
)
|
|
assert result.username == user.username
|
|
assert "access_token=" in response.headers.get("set-cookie", "")
|
|
|
|
user.phone = "13800138000"
|
|
db.commit()
|
|
with pytest.raises(ApiError) as exc:
|
|
auth_router.login(
|
|
req=LoginRequest(username=user.username, password="secret123"),
|
|
response=Response(),
|
|
db=db,
|
|
)
|
|
assert exc.value.status_code == 403
|
|
assert exc.value.error_code == "secure_login_required"
|