feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
@@ -123,5 +123,18 @@ OSS_MODE=local
|
||||
# OSS_LOCAL_ROOT=
|
||||
# OSS_PREFIX 是 P2 资料收集(collect)用的前缀,本功能由 scene 决定命名空间。
|
||||
|
||||
# ---- 登录短信(用户名 + 密码 + 短信验证码 + DCAuth 设备签名)----
|
||||
# mock(默认):只写日志、不真实发送、不限制每日次数;
|
||||
# aliyun:阿里云 Dysmsapi(RPC 签名),需要下面四项凭据。
|
||||
WORKER_SMS_PROVIDER=mock
|
||||
WORKER_SMS_ACCESS_KEY_ID=
|
||||
WORKER_SMS_ACCESS_KEY_SECRET=
|
||||
WORKER_SMS_SIGN_NAME=
|
||||
WORKER_SMS_TEMPLATE_CODE=
|
||||
# 验证码有效期 / 重发冷却 / 真实渠道每日上限
|
||||
WORKER_SMS_CODE_TTL_SECONDS=300
|
||||
WORKER_SMS_COOLDOWN_SECONDS=60
|
||||
WORKER_SMS_DAILY_LIMIT=10
|
||||
|
||||
# Uvicorn reload(开发模式设为 true,生产环境保持 false)
|
||||
UVICORN_RELOAD=false
|
||||
|
||||
@@ -46,6 +46,15 @@ services:
|
||||
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||
# CORS 允许的源(逗号分隔)
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
# 登录短信渠道(mock 只打日志;aliyun 走阿里云 Dysmsapi)
|
||||
- WORKER_SMS_PROVIDER=${WORKER_SMS_PROVIDER:-mock}
|
||||
- WORKER_SMS_ACCESS_KEY_ID=${WORKER_SMS_ACCESS_KEY_ID:-}
|
||||
- WORKER_SMS_ACCESS_KEY_SECRET=${WORKER_SMS_ACCESS_KEY_SECRET:-}
|
||||
- WORKER_SMS_SIGN_NAME=${WORKER_SMS_SIGN_NAME:-}
|
||||
- WORKER_SMS_TEMPLATE_CODE=${WORKER_SMS_TEMPLATE_CODE:-}
|
||||
- WORKER_SMS_CODE_TTL_SECONDS=${WORKER_SMS_CODE_TTL_SECONDS:-300}
|
||||
- WORKER_SMS_COOLDOWN_SECONDS=${WORKER_SMS_COOLDOWN_SECONDS:-60}
|
||||
- WORKER_SMS_DAILY_LIMIT=${WORKER_SMS_DAILY_LIMIT:-10}
|
||||
# Worker 已合并到本容器,仅通过回环地址访问。
|
||||
- YYB_WORKER_URL=http://127.0.0.1:8810
|
||||
- YYB_WORKER_KEY=${YYB_WORKER_KEY:-}
|
||||
|
||||
+23
-1
@@ -6,9 +6,10 @@ import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from sqlalchemy import Engine, create_engine, text
|
||||
from sqlalchemy import Engine, create_engine, inspect, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.schema import CreateColumn
|
||||
|
||||
|
||||
def get_mysql_credentials() -> dict[str, str]:
|
||||
@@ -134,10 +135,31 @@ def clear_tables(engine: Engine | None = None) -> None:
|
||||
conn.execute(text("SET FOREIGN_KEY_CHECKS = 1;"))
|
||||
|
||||
|
||||
def _add_missing_columns(engine: Engine) -> None:
|
||||
"""给“已存在但结构偏旧”的测试表补列。
|
||||
|
||||
``Base.metadata.create_all`` 只建缺失的表,不会给已有表加列;测试库复用旧结构时,
|
||||
新模型字段会在 INSERT 阶段报 Unknown column。这里只做加法(不动索引/数据),
|
||||
让测试库跟模型对齐,又不会像 drop_all 那样丢掉迁移里才有的索引。
|
||||
"""
|
||||
inspector = inspect(engine)
|
||||
with engine.begin() as conn:
|
||||
for table in Base.metadata.sorted_tables:
|
||||
if not inspector.has_table(table.name):
|
||||
continue
|
||||
existing = {column["name"] for column in inspector.get_columns(table.name)}
|
||||
for column in table.columns:
|
||||
if column.name in existing:
|
||||
continue
|
||||
ddl = CreateColumn(column).compile(dialect=engine.dialect)
|
||||
conn.execute(text(f"ALTER TABLE `{table.name}` ADD COLUMN {ddl}"))
|
||||
|
||||
|
||||
def init_test_schema(engine: Engine | None = None) -> None:
|
||||
"""确保测试库中所有表结构存在并清空所有数据(幂等高效)。"""
|
||||
eng = engine or get_test_engine()
|
||||
try:
|
||||
_add_missing_columns(eng)
|
||||
Base.metadata.create_all(eng)
|
||||
clear_tables(eng)
|
||||
except OperationalError as exc:
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"""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"
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261020_0062"
|
||||
HEAD_REVISION = "20261021_0064"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""统一的 API 业务异常。
|
||||
|
||||
前端需要区分「手机号未绑定」「设备不一致」「验证码锁定」等场景,
|
||||
FastAPI 默认的 ``{"detail": "..."}`` 无法携带机器可读的错误码,这里补一个
|
||||
``errorCode`` 字段;HTTP 状态码仍是标准语义(401/403/409/426/429)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status_code: int, detail: str, error_code: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.error_code = error_code
|
||||
|
||||
|
||||
async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail, "errorCode": exc.error_code},
|
||||
)
|
||||
@@ -15,14 +15,17 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from utils import setup_logger
|
||||
|
||||
from .database import init_db
|
||||
from .errors import ApiError, api_error_handler
|
||||
from .routers import (
|
||||
account_check,
|
||||
accounts,
|
||||
admin_users,
|
||||
audit,
|
||||
auth,
|
||||
automation,
|
||||
cookies,
|
||||
dashboard,
|
||||
dcauth,
|
||||
douyu,
|
||||
douyu_collab,
|
||||
files,
|
||||
@@ -105,6 +108,9 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 业务异常统一带 errorCode,前端据此区分手机号未绑定 / 设备不一致等场景。
|
||||
app.add_exception_handler(ApiError, api_error_handler)
|
||||
|
||||
# CORS(支持通过环境变量配置,逗号分隔;默认开发环境)
|
||||
_cors_env = os.getenv("CORS_ORIGINS", "")
|
||||
if _cors_env:
|
||||
@@ -146,6 +152,8 @@ app.add_middleware(SecurityHeadersMiddleware)
|
||||
# 注册路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(dcauth.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(account_check.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""账号安全登录:手机号、短信验证码、DCAuth 设备认证。
|
||||
|
||||
登录链路:用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名。
|
||||
旧账号未绑定手机号时仍可使用原密码登录;绑定手机号后强制走新链路。
|
||||
|
||||
MySQL 不支持 PostgreSQL 那种部分唯一索引,这里用“函数索引”实现同样的约束:
|
||||
- users.phone_unique = NULLIF(phone, ''),唯一索引 ⇒ 非空手机号唯一;
|
||||
- user_devices 上建 UNIQUE ((CASE WHEN status='active' THEN user_id END)) ⇒ 每个用户最多一个 active 设备。
|
||||
不用生成列,是因为 MySQL 禁止“生成列依赖的基列上带 ON DELETE CASCADE 的外键”。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261021_0063"
|
||||
down_revision: str | None = "20261020_0062"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
USERS = "users"
|
||||
SMS = "login_sms_codes"
|
||||
DEVICES = "user_devices"
|
||||
CHALLENGES = "device_challenges"
|
||||
|
||||
|
||||
def _table_names(bind) -> set[str]:
|
||||
return set(sa.inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _column_names(bind, table: str) -> set[str]:
|
||||
return {column["name"] for column in sa.inspect(bind).get_columns(table)}
|
||||
|
||||
|
||||
def _index_names(bind, table: str) -> set[str]:
|
||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table)}
|
||||
|
||||
|
||||
def _fk_names(bind, table: str) -> set[str]:
|
||||
return {
|
||||
fk.get("name", "")
|
||||
for fk in sa.inspect(bind).get_foreign_keys(table)
|
||||
if fk.get("name")
|
||||
}
|
||||
|
||||
|
||||
def _upgrade_users(bind) -> None:
|
||||
columns = _column_names(bind, USERS)
|
||||
indexes = _index_names(bind, USERS)
|
||||
dialect = bind.dialect.name
|
||||
|
||||
additions = []
|
||||
if "phone" not in columns:
|
||||
additions.append(
|
||||
sa.Column(
|
||||
"phone",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
)
|
||||
if "phone_bound_at" not in columns:
|
||||
additions.append(sa.Column("phone_bound_at", sa.DateTime(), nullable=True))
|
||||
if "phone_bound_by" not in columns:
|
||||
additions.append(sa.Column("phone_bound_by", sa.Integer(), nullable=True))
|
||||
if "phone_unique" not in columns:
|
||||
additions.append(
|
||||
sa.Column(
|
||||
"phone_unique",
|
||||
sa.String(length=32),
|
||||
sa.Computed("NULLIF(phone, '')", persisted=True),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
|
||||
fk_name = "fk_users_phone_bound_by"
|
||||
needs_fk = fk_name not in _fk_names(bind, USERS)
|
||||
needs_index = "uq_users_phone_unique" not in indexes
|
||||
|
||||
if dialect == "sqlite":
|
||||
# SQLite 不支持 ALTER TABLE ADD GENERATED COLUMN,走 batch 重建。
|
||||
with op.batch_alter_table(USERS) as batch:
|
||||
for column in additions:
|
||||
batch.add_column(column)
|
||||
if needs_fk:
|
||||
batch.create_foreign_key(
|
||||
fk_name,
|
||||
USERS,
|
||||
["phone_bound_by"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
if needs_index:
|
||||
batch.create_index(
|
||||
"uq_users_phone_unique", ["phone_unique"], unique=True
|
||||
)
|
||||
return
|
||||
|
||||
for column in additions:
|
||||
op.add_column(USERS, column)
|
||||
if needs_fk:
|
||||
op.create_foreign_key(
|
||||
fk_name,
|
||||
USERS,
|
||||
USERS,
|
||||
["phone_bound_by"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
if needs_index:
|
||||
op.create_index("uq_users_phone_unique", USERS, ["phone_unique"], unique=True)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tables = _table_names(bind)
|
||||
|
||||
if USERS in tables:
|
||||
_upgrade_users(bind)
|
||||
|
||||
if SMS not in tables:
|
||||
op.create_table(
|
||||
SMS,
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("phone", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"purpose", sa.String(length=16), nullable=False, server_default="login"
|
||||
),
|
||||
sa.Column("code_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"failed_attempt_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"request_ip", sa.String(length=64), nullable=False, server_default=""
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
[f"{USERS}.id"],
|
||||
name="fk_login_sms_codes_user_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_login_sms_codes_user_created",
|
||||
SMS,
|
||||
["user_id", "purpose", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_login_sms_codes_phone_created", SMS, ["phone", "created_at"]
|
||||
)
|
||||
|
||||
if DEVICES not in tables:
|
||||
op.create_table(
|
||||
DEVICES,
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=96), nullable=False),
|
||||
sa.Column("public_key", sa.String(length=128), nullable=False),
|
||||
sa.Column(
|
||||
"client_version",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), nullable=False, server_default="active"
|
||||
),
|
||||
sa.Column("bound_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("revoked_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
[f"{USERS}.id"],
|
||||
name="fk_user_devices_user_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["revoked_by"],
|
||||
[f"{USERS}.id"],
|
||||
name="fk_user_devices_revoked_by",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_user_devices_active_user",
|
||||
DEVICES,
|
||||
[sa.text("(CASE WHEN status = 'active' THEN user_id ELSE NULL END)")],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index("ix_user_devices_device_id", DEVICES, ["device_id"])
|
||||
|
||||
if CHALLENGES not in tables:
|
||||
op.create_table(
|
||||
CHALLENGES,
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("challenge_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("app", sa.String(length=32), nullable=False),
|
||||
sa.Column(
|
||||
"purpose", sa.String(length=16), nullable=False, server_default="login"
|
||||
),
|
||||
sa.Column("challenge", sa.String(length=128), nullable=False),
|
||||
sa.Column("poll_token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"device_id", sa.String(length=96), nullable=False, server_default=""
|
||||
),
|
||||
sa.Column(
|
||||
"public_key", sa.String(length=128), nullable=False, server_default=""
|
||||
),
|
||||
sa.Column(
|
||||
"client_version",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
sa.Column(
|
||||
"device_ticket_hash",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("authorized_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("ticket_issued_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("consumed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
[f"{USERS}.id"],
|
||||
name="fk_device_challenges_user_id",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"challenge_id", name="uq_device_challenges_challenge_id"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_device_challenges_expires", CHALLENGES, ["expires_at"])
|
||||
op.create_index(
|
||||
"ix_device_challenges_user", CHALLENGES, ["user_id", "created_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tables = _table_names(bind)
|
||||
|
||||
for table in (CHALLENGES, DEVICES, SMS):
|
||||
if table in tables:
|
||||
op.drop_table(table)
|
||||
|
||||
if USERS not in tables:
|
||||
return
|
||||
|
||||
dialect = bind.dialect.name
|
||||
indexes = _index_names(bind, USERS)
|
||||
fks = _fk_names(bind, USERS)
|
||||
|
||||
if dialect == "sqlite":
|
||||
with op.batch_alter_table(USERS) as batch:
|
||||
if "uq_users_phone_unique" in indexes:
|
||||
batch.drop_index("uq_users_phone_unique")
|
||||
if "fk_users_phone_bound_by" in fks:
|
||||
batch.drop_constraint("fk_users_phone_bound_by", type_="foreignkey")
|
||||
for column in (
|
||||
"phone_unique",
|
||||
"phone_bound_by",
|
||||
"phone_bound_at",
|
||||
"phone",
|
||||
):
|
||||
if column in _column_names(bind, USERS):
|
||||
batch.drop_column(column)
|
||||
return
|
||||
|
||||
if "uq_users_phone_unique" in indexes:
|
||||
op.drop_index("uq_users_phone_unique", table_name=USERS)
|
||||
if "fk_users_phone_bound_by" in fks:
|
||||
op.drop_constraint("fk_users_phone_bound_by", USERS, type_="foreignkey")
|
||||
columns = _column_names(bind, USERS)
|
||||
for column in ("phone_unique", "phone_bound_by", "phone_bound_at", "phone"):
|
||||
if column in columns:
|
||||
op.drop_column(USERS, column)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""修复 revision 20261021_0063 被旧实现占用导致的“空迁移”。
|
||||
|
||||
背景(2026-10-21):
|
||||
本地曾用同一个 revision id(``20261021_0063``)落地过一版已删除的设备认证实现,
|
||||
数据库里的 ``alembic_version`` 已经指向 0063,于是新写的 0063 被判为“已执行”,
|
||||
``users.phone_bound_at`` 等列没有创建,后端启动直接报
|
||||
``Unknown column 'users.phone_bound_at'``。
|
||||
|
||||
处理方式:
|
||||
1. 把旧实现留下的 ``users.phone``(varchar(20) NULL + 整列唯一)收敛到本次模型:
|
||||
去掉旧的整列唯一索引、NULL 回填成 ''、改成 varchar(32) NOT NULL DEFAULT '';
|
||||
唯一性交给 ``phone_unique``(它会把 '' 变成 NULL,空手机号可重复)。
|
||||
2. 幂等重放 0063 的全部建表/建列逻辑。所有步骤都带存在性判断,
|
||||
因此在“0063 正常执行过”的环境里再跑一次等于 no-op。
|
||||
|
||||
这样无论目标环境是:
|
||||
- 干净库(0062)→ 0063 正常执行,0064 no-op;
|
||||
- 被旧实现标记成 0063 的库 → 0064 补齐;
|
||||
最终结构都一致,不需要人工改 ``alembic_version``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261021_0064"
|
||||
down_revision: str | None = "20261021_0063"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _load_base_revision() -> ModuleType:
|
||||
path = Path(__file__).with_name("20261021_0063_user_phone_device_auth.py")
|
||||
spec = importlib.util.spec_from_file_location("live_hub_rev_0063", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"无法加载迁移文件: {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _normalize_legacy_phone_column(bind) -> None:
|
||||
"""把旧实现的 users.phone 收敛成本次模型的定义(仅 MySQL 需要)。"""
|
||||
if bind.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"]: column for column in inspector.get_columns("users")}
|
||||
phone = columns.get("phone")
|
||||
if phone is None:
|
||||
return
|
||||
|
||||
# 旧实现把 phone 整列设成唯一;回填 '' 之后多个空手机号会撞唯一索引,必须先摘掉。
|
||||
# 直接查 information_schema:get_indexes 与 get_unique_constraints 会重复报告同一个索引,
|
||||
# 连删两次会报 “Can't DROP ... check that column/key exists”。
|
||||
unique_indexes = (
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT DISTINCT INDEX_NAME
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'phone'
|
||||
AND non_unique = 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for name in unique_indexes:
|
||||
op.drop_index(name, table_name="users")
|
||||
|
||||
bind.execute(sa.text("UPDATE users SET phone = '' WHERE phone IS NULL"))
|
||||
|
||||
current_type = phone.get("type")
|
||||
needs_alter = (
|
||||
phone.get("nullable", True)
|
||||
or getattr(current_type, "length", None) != 32
|
||||
or str(current_type).lower() not in {"varchar(32)"}
|
||||
)
|
||||
if needs_alter:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"phone",
|
||||
existing_type=current_type,
|
||||
type_=sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_normalize_legacy_phone_column(bind)
|
||||
# 幂等重放 0063:已存在的列/表/索引会被跳过。
|
||||
_load_base_revision().upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_load_base_revision().downgrade()
|
||||
@@ -8,12 +8,14 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Computed,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -60,6 +62,24 @@ class User(Base):
|
||||
)
|
||||
deleted_username: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
# ---- 安全登录(手机号 + 短信验证码 + DCAuth 设备签名)----
|
||||
phone: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="", server_default=""
|
||||
)
|
||||
phone_bound_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
phone_bound_by: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# MySQL 不支持部分唯一索引:用生成列把「空手机号」变成 NULL,
|
||||
# 再对它建唯一索引,效果等同 PostgreSQL 的 `UNIQUE (phone) WHERE phone <> ''`。
|
||||
phone_unique: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
Computed("NULLIF(phone, '')", persisted=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
__table_args__ = (Index("uq_users_phone_unique", "phone_unique", unique=True),)
|
||||
|
||||
# 客服被分配的账号
|
||||
assigned_accounts: Mapped[list[Account]] = relationship(
|
||||
"Account", back_populates="assigned_user", foreign_keys="Account.assigned_to"
|
||||
@@ -680,6 +700,135 @@ class DouyuExternalOrderMetric(Base):
|
||||
)
|
||||
|
||||
|
||||
class LoginSmsCode(Base):
|
||||
"""登录短信验证码。
|
||||
|
||||
只存 sha256(code),不存明文;一条记录只能消费一次,连续输错 5 次即作废。
|
||||
"""
|
||||
|
||||
__tablename__ = "login_sms_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
phone: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
purpose: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="login", server_default="login"
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
failed_attempt_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
request_ip: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="", server_default=""
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_login_sms_codes_user_created", "user_id", "purpose", "created_at"),
|
||||
Index("ix_login_sms_codes_phone_created", "phone", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class UserDevice(Base):
|
||||
"""账号绑定的 DCAuth 设备(只存 deviceId 与 Ed25519 公钥)。
|
||||
|
||||
MySQL 用函数索引 ``UNIQUE ((CASE WHEN status='active' THEN user_id END))``
|
||||
等价于 PostgreSQL 的部分唯一索引 ``UNIQUE (user_id) WHERE status='active'``。
|
||||
不用生成列,是因为 MySQL 不允许“生成列依赖的基列”上挂 ON DELETE CASCADE 外键。
|
||||
"""
|
||||
|
||||
__tablename__ = "user_devices"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
device_id: Mapped[str] = mapped_column(String(96), nullable=False)
|
||||
public_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
client_version: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="", server_default=""
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
bound_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
revoked_by: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
UTCDateTime, default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_user_devices_active_user",
|
||||
text("(CASE WHEN status = 'active' THEN user_id ELSE NULL END)"),
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_user_devices_device_id", "device_id"),
|
||||
)
|
||||
|
||||
|
||||
class DeviceChallenge(Base):
|
||||
"""DCAuth challenge → authorized → ticket_issued → consumed 状态机。
|
||||
|
||||
所有状态推进都带 ``status`` 与 ``expires_at`` 条件做原子 UPDATE,
|
||||
保证 challenge / ticket 只能被消费一次。
|
||||
"""
|
||||
|
||||
__tablename__ = "device_challenges"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
challenge_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
app: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="login", server_default="login"
|
||||
)
|
||||
challenge: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
poll_token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="pending", server_default="pending"
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
device_id: Mapped[str] = mapped_column(
|
||||
String(96), nullable=False, default="", server_default=""
|
||||
)
|
||||
public_key: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="", server_default=""
|
||||
)
|
||||
client_version: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="", server_default=""
|
||||
)
|
||||
device_ticket_hash: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="", server_default=""
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
|
||||
authorized_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
ticket_issued_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True
|
||||
)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
UTCDateTime, default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("challenge_id", name="uq_device_challenges_challenge_id"),
|
||||
Index("ix_device_challenges_expires", "expires_at"),
|
||||
Index("ix_device_challenges_user", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class HuyaAccount(Base):
|
||||
"""虎牙账号"""
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""管理端账号安全设置:绑定手机号、重置绑定设备。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import require_permission
|
||||
from ..errors import ApiError
|
||||
from ..models import AuditLog, User, UserDevice
|
||||
from ..schemas import UserInfo, UserPhoneBindRequest
|
||||
from ..services.dc_auth_service import mask_device_id
|
||||
from ..services.login_sms_service import mask_phone, normalize_phone
|
||||
from ..services.user_profile_service import to_user_info
|
||||
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["用户管理"])
|
||||
|
||||
|
||||
def _get_user(db: Session, user_id: int) -> User:
|
||||
user = db.query(User).filter(User.id == user_id, User.deleted_at.is_(None)).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def _active_device(db: Session, user_id: int) -> UserDevice | None:
|
||||
return (
|
||||
db.query(UserDevice)
|
||||
.filter(UserDevice.user_id == user_id, UserDevice.status == "active")
|
||||
.order_by(UserDevice.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/phone", response_model=UserInfo)
|
||||
def bind_user_phone(
|
||||
user_id: int,
|
||||
req: UserPhoneBindRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:edit")),
|
||||
):
|
||||
user = _get_user(db, user_id)
|
||||
phone = normalize_phone(req.phone)
|
||||
if not phone:
|
||||
raise ApiError(400, "请输入手机号", "phone_required")
|
||||
|
||||
existing = db.query(User).filter(User.phone == phone, User.id != user.id).first()
|
||||
if existing is not None:
|
||||
raise ApiError(409, "该手机号已绑定其他用户", "phone_exists")
|
||||
|
||||
now = datetime.now(UTC)
|
||||
user.phone = phone
|
||||
user.phone_bound_at = now
|
||||
user.phone_bound_by = current.id
|
||||
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:phone_bind",
|
||||
target=user.username,
|
||||
detail=f"phone={mask_phone(phone)}",
|
||||
)
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise ApiError(409, "该手机号已绑定其他用户", "phone_exists") from exc
|
||||
|
||||
db.refresh(user)
|
||||
return to_user_info(user, _active_device(db, user.id))
|
||||
|
||||
|
||||
@router.post("/{user_id}/device/reset")
|
||||
def reset_user_device(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:edit")),
|
||||
):
|
||||
user = _get_user(db, user_id)
|
||||
now = datetime.now(UTC)
|
||||
active = _active_device(db, user.id)
|
||||
revoked = (
|
||||
db.query(UserDevice)
|
||||
.filter(UserDevice.user_id == user.id, UserDevice.status == "active")
|
||||
.update(
|
||||
{
|
||||
UserDevice.status: "revoked",
|
||||
UserDevice.revoked_at: now,
|
||||
UserDevice.revoked_by: current.id,
|
||||
UserDevice.updated_at: now,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:device_reset",
|
||||
target=user.username,
|
||||
detail=(
|
||||
f"revoked={revoked}; device={mask_device_id(active.device_id)}"
|
||||
if active is not None
|
||||
else "revoked=0"
|
||||
),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"reset": revoked > 0,
|
||||
"revoked": revoked,
|
||||
"message": "已重置设备,下次登录将重新绑定"
|
||||
if revoked
|
||||
else "当前没有已绑定设备",
|
||||
}
|
||||
+196
-17
@@ -1,16 +1,42 @@
|
||||
"""认证路由"""
|
||||
"""认证路由:安全登录(短信 + DCAuth v2)与旧账号密码兜底。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import AuditLog, User
|
||||
from ..errors import ApiError
|
||||
from ..models import AuditLog, DeviceChallenge, User
|
||||
from ..permissions import ROLE_LABELS, get_user_permissions
|
||||
from ..schemas import LoginRequest, TokenResponse
|
||||
from ..schemas import (
|
||||
LoginCompleteRequest,
|
||||
LoginPrepareRequest,
|
||||
LoginPrepareResponse,
|
||||
LoginRequest,
|
||||
SmsCodeRequest,
|
||||
SmsCodeResponse,
|
||||
TokenResponse,
|
||||
)
|
||||
from ..security import ACCESS_TOKEN_EXPIRE_HOURS, create_access_token, verify_password
|
||||
from ..services.dc_auth_service import (
|
||||
APP_ID,
|
||||
CHALLENGE_PURPOSE,
|
||||
consume_device_ticket,
|
||||
create_login_challenge,
|
||||
ensure_device_binding,
|
||||
get_challenge,
|
||||
mask_device_id,
|
||||
normalize_api_base,
|
||||
)
|
||||
from ..services.login_sms_service import (
|
||||
mask_phone,
|
||||
send_login_sms_code,
|
||||
verify_login_sms_code,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
@@ -18,18 +44,38 @@ router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == req.username).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="账号已禁用,请联系管理员")
|
||||
def _find_user(db: Session, username_value: str) -> User | None:
|
||||
"""用户名只 trim,不做大小写折叠、不限制字符集。
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
perms = get_user_permissions(user)
|
||||
MySQL 默认排序规则大小写不敏感,因此查出来后还要做一次 Python 侧精确比对,
|
||||
避免 ``Admin`` 命中 ``admin``。
|
||||
"""
|
||||
username = str(username_value or "").strip()
|
||||
if not username:
|
||||
return None
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is not None and user.username != username:
|
||||
return None
|
||||
return user
|
||||
|
||||
# 设置 httpOnly cookie(前端无需 JS 读取 token,防 XSS 窃取)
|
||||
|
||||
def _authenticate(db: Session, username: str, password: str) -> User:
|
||||
user = _find_user(db, username)
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
raise ApiError(401, "用户名或密码错误", "invalid_credentials")
|
||||
if not user.is_active or user.deleted_at is not None:
|
||||
raise ApiError(403, "账号已禁用,请联系管理员", "user_disabled")
|
||||
return user
|
||||
|
||||
|
||||
def _request_origin(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin:
|
||||
return origin
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _set_auth_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
@@ -40,9 +86,28 @@ def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
path="/",
|
||||
)
|
||||
|
||||
# 审计
|
||||
|
||||
def _issue_session(
|
||||
db: Session,
|
||||
response: Response,
|
||||
user: User,
|
||||
method: str,
|
||||
device_id: str = "",
|
||||
) -> TokenResponse:
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
_set_auth_cookie(response, token)
|
||||
|
||||
detail = f"method={method}"
|
||||
if device_id:
|
||||
detail += f"; device={mask_device_id(device_id)}"
|
||||
db.add(
|
||||
AuditLog(user_id=user.id, username=user.username, action="login", target="auth")
|
||||
AuditLog(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login",
|
||||
target="auth",
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -50,10 +115,122 @@ def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
access_token=token,
|
||||
role=user.role,
|
||||
username=user.username,
|
||||
permissions=perms,
|
||||
permissions=get_user_permissions(user),
|
||||
)
|
||||
|
||||
|
||||
def _require_phone(user: User) -> str:
|
||||
phone = str(user.phone or "").strip()
|
||||
if not phone:
|
||||
raise ApiError(
|
||||
409,
|
||||
"该账号尚未绑定手机号,请联系管理员",
|
||||
"phone_unbound",
|
||||
)
|
||||
return phone
|
||||
|
||||
|
||||
@router.post("/sms-code", response_model=SmsCodeResponse)
|
||||
def send_sms_code(
|
||||
req: SmsCodeRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _authenticate(db, req.username, req.password)
|
||||
phone = _require_phone(user)
|
||||
request_ip = request.client.host if request.client else ""
|
||||
result = send_login_sms_code(db, user, phone, request_ip=request_ip)
|
||||
return SmsCodeResponse(
|
||||
sent=True,
|
||||
phone_masked=result["phoneMasked"],
|
||||
expires_in_seconds=result["expiresInSeconds"],
|
||||
provider=result["provider"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/prepare", response_model=LoginPrepareResponse)
|
||||
def prepare_login(
|
||||
req: LoginPrepareRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _authenticate(db, req.username, req.password)
|
||||
phone = _require_phone(user)
|
||||
|
||||
# 先校验并消费短信码,再创建 challenge(与参考实现一致)。
|
||||
verify_login_sms_code(db, user.id, req.sms_code)
|
||||
|
||||
api_base = normalize_api_base(req.api_base or _request_origin(request))
|
||||
challenge = create_login_challenge(db, user.id, api_base)
|
||||
return LoginPrepareResponse(
|
||||
challenge_id=challenge["challengeId"],
|
||||
challenge=challenge["challenge"],
|
||||
expires_at=challenge["expiresAt"],
|
||||
poll_token=challenge["pollToken"],
|
||||
launch_url=challenge["launchUrl"],
|
||||
phone_masked=mask_phone(phone),
|
||||
app=challenge["app"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=TokenResponse)
|
||||
def complete_login(
|
||||
req: LoginCompleteRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
challenge = get_challenge(db, req.challenge_id)
|
||||
if (
|
||||
challenge is None
|
||||
or challenge.user_id is None
|
||||
or challenge.app != APP_ID
|
||||
or challenge.purpose != CHALLENGE_PURPOSE
|
||||
):
|
||||
raise ApiError(
|
||||
404,
|
||||
"设备认证会话不存在,请重新登录",
|
||||
"challenge_not_found",
|
||||
)
|
||||
|
||||
user = (
|
||||
db.query(User)
|
||||
.filter(User.id == challenge.user_id, User.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if user is None or not user.is_active:
|
||||
raise ApiError(401, "账号已不可用,请重新登录", "user_disabled")
|
||||
|
||||
consumed: DeviceChallenge = consume_device_ticket(
|
||||
db,
|
||||
req.challenge_id,
|
||||
req.poll_token,
|
||||
req.device_ticket,
|
||||
)
|
||||
if consumed.user_id != user.id:
|
||||
raise ApiError(401, "设备认证票据无效", "ticket_invalid")
|
||||
|
||||
ensure_device_binding(db, user, consumed)
|
||||
return _issue_session(db, response, user, "sms_device", consumed.device_id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(
|
||||
req: LoginRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""旧账号兜底:仅当用户未绑定手机号时允许密码单因素登录。"""
|
||||
user = _authenticate(db, req.username, req.password)
|
||||
if str(user.phone or "").strip():
|
||||
raise ApiError(
|
||||
403,
|
||||
"该账号已启用安全登录,请使用用户名 + 密码 + 短信验证码 + 本机验证",
|
||||
"secure_login_required",
|
||||
)
|
||||
|
||||
return _issue_session(db, response, user, "password_legacy")
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
@@ -64,6 +241,8 @@ def me(current_user: User = Depends(get_current_user)):
|
||||
"is_active": current_user.is_active,
|
||||
"remark": current_user.remark,
|
||||
"permissions": get_user_permissions(current_user),
|
||||
"phone_masked": mask_phone(current_user.phone),
|
||||
"phone_bound": bool(str(current_user.phone or "").strip()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""DCAuth v2 客户端协议入口。
|
||||
|
||||
客户端(DCAuth.exe / dcauth)把 authorize 路径硬编码为
|
||||
``POST {apiBase}/api/dcauth/authorize``,因此这里的路径不能改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..schemas import DeviceAuthorizeRequest
|
||||
from ..services.dc_auth_service import (
|
||||
authorize_device,
|
||||
get_challenge_status,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/dcauth", tags=["设备认证"])
|
||||
|
||||
|
||||
@router.post("/authorize")
|
||||
def authorize(
|
||||
payload: DeviceAuthorizeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
result = authorize_device(db, payload.model_dump(by_alias=True))
|
||||
return {"success": True, **result}
|
||||
|
||||
|
||||
@router.get("/challenge/{challenge_id}/status")
|
||||
def challenge_status(
|
||||
challenge_id: str,
|
||||
poll_token: str | None = Query(None, alias="pollToken"),
|
||||
header_poll_token: str | None = Header(None, alias="X-Poll-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
token = header_poll_token or poll_token or ""
|
||||
return get_challenge_status(db, challenge_id, token)
|
||||
@@ -9,35 +9,44 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import require_permission
|
||||
from ..models import Account, AuditLog, HuyaAccount, User
|
||||
from ..permissions import PERMISSIONS, get_user_permissions
|
||||
from ..models import Account, AuditLog, HuyaAccount, User, UserDevice
|
||||
from ..permissions import PERMISSIONS
|
||||
from ..schemas import UserCreate, UserInfo, UserRename, UserUpdate
|
||||
from ..security import hash_password
|
||||
from ..services.user_profile_service import to_user_info
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||
|
||||
|
||||
def _active_device(db: Session, user_id: int) -> UserDevice | None:
|
||||
return (
|
||||
db.query(UserDevice)
|
||||
.filter(UserDevice.user_id == user_id, UserDevice.status == "active")
|
||||
.order_by(UserDevice.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserInfo])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("user:view")),
|
||||
):
|
||||
users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all()
|
||||
result = []
|
||||
for u in users:
|
||||
result.append(
|
||||
UserInfo(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
remark=u.remark or "",
|
||||
created_at=u.created_at,
|
||||
permissions=get_user_permissions(u),
|
||||
custom_permissions=u.custom_permissions,
|
||||
devices: dict[int, UserDevice] = {}
|
||||
if users:
|
||||
device_rows = (
|
||||
db.query(UserDevice)
|
||||
.filter(
|
||||
UserDevice.user_id.in_([user.id for user in users]),
|
||||
UserDevice.status == "active",
|
||||
)
|
||||
.order_by(UserDevice.id.desc())
|
||||
.all()
|
||||
)
|
||||
return result
|
||||
for device in device_rows:
|
||||
devices.setdefault(device.user_id, device)
|
||||
return [to_user_info(user, devices.get(user.id)) for user in users]
|
||||
|
||||
|
||||
@router.post("", response_model=UserInfo)
|
||||
@@ -70,15 +79,7 @@ def create_user(
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
return to_user_info(user, _active_device(db, user.id))
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserInfo)
|
||||
@@ -118,15 +119,7 @@ def update_user(
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
return to_user_info(user, _active_device(db, user.id))
|
||||
|
||||
|
||||
@router.patch("/{user_id}/username", response_model=UserInfo)
|
||||
@@ -145,15 +138,7 @@ def rename_user(
|
||||
if len(username) < 2:
|
||||
raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
|
||||
if username == user.username:
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
return to_user_info(user, _active_device(db, user.id))
|
||||
if db.query(User.id).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
@@ -173,15 +158,7 @@ def rename_user(
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="用户名已存在") from exc
|
||||
db.refresh(user)
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
return to_user_info(user, _active_device(db, user.id))
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
|
||||
@@ -53,6 +53,62 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class SmsCodeRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class SmsCodeResponse(BaseModel):
|
||||
sent: bool = True
|
||||
phone_masked: str = Field(..., serialization_alias="phoneMasked")
|
||||
expires_in_seconds: int = Field(..., serialization_alias="expiresInSeconds")
|
||||
provider: str = "mock"
|
||||
|
||||
|
||||
class LoginPrepareRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
sms_code: str = Field(..., alias="smsCode")
|
||||
api_base: str | None = Field(None, alias="apiBase")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class LoginPrepareResponse(BaseModel):
|
||||
challenge_id: str = Field(..., serialization_alias="challengeId")
|
||||
challenge: str
|
||||
expires_at: str = Field(..., serialization_alias="expiresAt")
|
||||
poll_token: str = Field(..., serialization_alias="pollToken")
|
||||
launch_url: str = Field(..., serialization_alias="launchUrl")
|
||||
phone_masked: str = Field(..., serialization_alias="phoneMasked")
|
||||
app: str = "live_hub"
|
||||
|
||||
|
||||
class LoginCompleteRequest(BaseModel):
|
||||
challenge_id: str = Field(..., alias="challengeId")
|
||||
poll_token: str = Field(..., alias="pollToken")
|
||||
device_ticket: str = Field(..., alias="deviceTicket")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class DeviceAuthorizeRequest(BaseModel):
|
||||
app: str
|
||||
challenge_id: str = Field(..., alias="challengeId")
|
||||
challenge: str
|
||||
device_id: str = Field(..., alias="deviceId")
|
||||
public_key: str = Field(..., alias="publicKey")
|
||||
signature: str
|
||||
timestamp: int | str
|
||||
client_version: str = Field(..., alias="clientVersion")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class UserPhoneBindRequest(BaseModel):
|
||||
phone: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -70,6 +126,13 @@ class UserInfo(BaseModel):
|
||||
created_at: datetime | None = None
|
||||
permissions: list[str] = []
|
||||
custom_permissions: list[str] | None = None
|
||||
phone: str = ""
|
||||
phone_masked: str = ""
|
||||
phone_bound_at: datetime | None = None
|
||||
device_id: str = ""
|
||||
device_id_masked: str = ""
|
||||
device_bound_at: datetime | None = None
|
||||
device_last_used_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -84,6 +147,13 @@ class UserInfo(BaseModel):
|
||||
"created_at": _isoformat(self.created_at),
|
||||
"permissions": self.permissions,
|
||||
"custom_permissions": self.custom_permissions,
|
||||
"phone": self.phone,
|
||||
"phone_masked": self.phone_masked,
|
||||
"phone_bound_at": _isoformat(self.phone_bound_at),
|
||||
"device_id": self.device_id,
|
||||
"device_id_masked": self.device_id_masked,
|
||||
"device_bound_at": _isoformat(self.device_bound_at),
|
||||
"device_last_used_at": _isoformat(self.device_last_used_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""DCAuth v2 设备认证(无机器码)。
|
||||
|
||||
协议要点:
|
||||
- 客户端 POST {origin}/api/dcauth/authorize;
|
||||
- 签名原文固定为 ``dcauth.v2\\n{app}\\n{challengeId}\\n{challenge}\\n{deviceId}\\n{timestamp}``;
|
||||
- Ed25519 验签,公钥 32 字节、签名 64 字节,base64url 无填充;
|
||||
- challenge 120 秒有效,状态 pending → authorized → ticket_issued → consumed,
|
||||
每次推进都用带 status/expires_at 条件的原子 UPDATE,保证只能消费一次。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..errors import ApiError
|
||||
from ..models import DeviceChallenge, User, UserDevice
|
||||
|
||||
APP_ID = "live_hub"
|
||||
CHALLENGE_PURPOSE = "login"
|
||||
CHALLENGE_TTL_SECONDS = 120
|
||||
MAX_CLOCK_SKEW_SECONDS = 300
|
||||
MAX_AUTHORIZE_PER_MINUTE = 30
|
||||
|
||||
_DEVICE_ID_RE = re.compile(r"^D1-[A-Z0-9-]{8,64}$")
|
||||
_BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
_rate_lock = threading.Lock()
|
||||
_rate_buckets: dict[str, deque[float]] = {}
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def build_device_canonical(
|
||||
app: str,
|
||||
challenge_id: str,
|
||||
challenge: str,
|
||||
device_id: str,
|
||||
timestamp: int,
|
||||
) -> str:
|
||||
"""DCAuth v2 签名原文(顺序、分隔符都不能变)。"""
|
||||
return "\n".join(
|
||||
[
|
||||
"dcauth.v2",
|
||||
app,
|
||||
challenge_id,
|
||||
challenge,
|
||||
device_id,
|
||||
str(timestamp),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def hash_token(value: str) -> str:
|
||||
return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def mask_device_id(value: str | None) -> str:
|
||||
device_id = str(value or "").strip().upper()
|
||||
if len(device_id) <= 8:
|
||||
return device_id
|
||||
return f"{device_id[:6]}-****-{device_id[-4:]}"
|
||||
|
||||
|
||||
def parse_client_major(client_version: str | None) -> int | None:
|
||||
major = str(client_version or "").strip().split(".", 1)[0]
|
||||
if not major.isdigit():
|
||||
return None
|
||||
return int(major)
|
||||
|
||||
|
||||
def ensure_supported_client_version(client_version: str | None) -> None:
|
||||
major = parse_client_major(client_version)
|
||||
if major is None or major < 2:
|
||||
raise ApiError(
|
||||
426,
|
||||
"DCAuth 客户端版本过低,请升级到 v2.0.0 或更高版本",
|
||||
"client_outdated",
|
||||
)
|
||||
|
||||
|
||||
def decode_base64url(value: str) -> bytes:
|
||||
"""严格解码 base64url(不接受 padding、空格或标准 base64 字符)。"""
|
||||
if not value or not _BASE64URL_RE.fullmatch(value):
|
||||
raise ApiError(
|
||||
400,
|
||||
"设备公钥或签名编码无效",
|
||||
"signature_invalid",
|
||||
)
|
||||
padding = "=" * (-len(value) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ApiError(
|
||||
400,
|
||||
"设备公钥或签名编码无效",
|
||||
"signature_invalid",
|
||||
) from exc
|
||||
|
||||
|
||||
def verify_device_signature(
|
||||
public_key: str,
|
||||
signature: str,
|
||||
canonical: str,
|
||||
) -> None:
|
||||
public_key_bytes = decode_base64url(public_key)
|
||||
signature_bytes = decode_base64url(signature)
|
||||
if len(public_key_bytes) != 32 or len(signature_bytes) != 64:
|
||||
raise ApiError(
|
||||
400,
|
||||
"设备公钥或签名格式不正确",
|
||||
"signature_invalid",
|
||||
)
|
||||
try:
|
||||
key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
||||
key.verify(signature_bytes, canonical.encode("utf-8"))
|
||||
except (InvalidSignature, ValueError) as exc:
|
||||
raise ApiError(401, "设备签名校验失败", "signature_invalid") from exc
|
||||
|
||||
|
||||
def normalize_api_base(value: str | None) -> str:
|
||||
"""apiBase 必须是 scheme://host,不能带路径、查询串或用户信息。"""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raise ApiError(400, "缺少设备认证 API 地址", "api_base_invalid")
|
||||
candidate = raw.rstrip("/")
|
||||
parts = urlsplit(candidate)
|
||||
if parts.scheme.lower() not in ("http", "https") or not parts.netloc:
|
||||
raise ApiError(400, "设备认证 API 地址必须是 http(s) 地址", "api_base_invalid")
|
||||
if parts.username or parts.password or parts.path or parts.query or parts.fragment:
|
||||
raise ApiError(
|
||||
400,
|
||||
"设备认证 API 地址只能包含 scheme://host,不能带路径或查询参数",
|
||||
"api_base_invalid",
|
||||
)
|
||||
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
||||
|
||||
|
||||
def build_launch_url(
|
||||
api_base: str,
|
||||
challenge_id: str,
|
||||
challenge: str,
|
||||
) -> str:
|
||||
query = urlencode(
|
||||
{
|
||||
"app": APP_ID,
|
||||
"challenge_id": challenge_id,
|
||||
"challenge": challenge,
|
||||
"api": api_base,
|
||||
},
|
||||
quote_via=quote,
|
||||
)
|
||||
return f"dcauth://authorize?{query}"
|
||||
|
||||
|
||||
def check_authorize_rate_limit(challenge_id: str) -> None:
|
||||
"""按 challengeId 限流(内存实现,单进程 30 次/分钟)。"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - 60.0
|
||||
with _rate_lock:
|
||||
bucket = _rate_buckets.setdefault(challenge_id, deque())
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= MAX_AUTHORIZE_PER_MINUTE:
|
||||
raise ApiError(429, "设备认证请求过于频繁,请稍后再试", "rate_limited")
|
||||
bucket.append(now)
|
||||
if len(_rate_buckets) > 5000:
|
||||
stale = [
|
||||
key
|
||||
for key, hits in _rate_buckets.items()
|
||||
if not hits or hits[-1] <= cutoff
|
||||
]
|
||||
for key in stale:
|
||||
_rate_buckets.pop(key, None)
|
||||
|
||||
|
||||
def create_login_challenge(db: Session, user_id: int, api_base: str) -> dict:
|
||||
now = utcnow()
|
||||
expires_at = now + timedelta(seconds=CHALLENGE_TTL_SECONDS)
|
||||
challenge_id = str(uuid4())
|
||||
challenge_value = secrets.token_urlsafe(32)
|
||||
poll_token = secrets.token_urlsafe(32)
|
||||
|
||||
row = DeviceChallenge(
|
||||
challenge_id=challenge_id,
|
||||
app=APP_ID,
|
||||
purpose=CHALLENGE_PURPOSE,
|
||||
challenge=challenge_value,
|
||||
poll_token_hash=hash_token(poll_token),
|
||||
status="pending",
|
||||
user_id=user_id,
|
||||
expires_at=expires_at,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"challengeId": challenge_id,
|
||||
"challenge": challenge_value,
|
||||
"expiresAt": expires_at.isoformat(),
|
||||
"pollToken": poll_token,
|
||||
"launchUrl": build_launch_url(api_base, challenge_id, challenge_value),
|
||||
"app": APP_ID,
|
||||
}
|
||||
|
||||
|
||||
def get_challenge(db: Session, challenge_id: str) -> DeviceChallenge | None:
|
||||
return (
|
||||
db.query(DeviceChallenge)
|
||||
.filter(DeviceChallenge.challenge_id == challenge_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _parse_timestamp(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ApiError(400, "设备时间戳格式不正确", "clock_invalid")
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text or not re.fullmatch(r"-?\d+", text):
|
||||
raise ApiError(400, "设备时间戳格式不正确", "clock_invalid")
|
||||
return int(text)
|
||||
|
||||
|
||||
def authorize_device(db: Session, payload: dict) -> dict:
|
||||
"""校验并原子推进 challenge:pending → authorized。"""
|
||||
app = str(payload.get("app") or "").strip()
|
||||
challenge_id = str(payload.get("challengeId") or "").strip()
|
||||
challenge_value = str(payload.get("challenge") or "").strip()
|
||||
device_id = str(payload.get("deviceId") or "").strip().upper()
|
||||
public_key = str(payload.get("publicKey") or "").strip()
|
||||
signature = str(payload.get("signature") or "").strip()
|
||||
client_version = str(payload.get("clientVersion") or "").strip()[:32]
|
||||
raw_timestamp = payload.get("timestamp")
|
||||
|
||||
if (
|
||||
not challenge_id
|
||||
or not challenge_value
|
||||
or not device_id
|
||||
or not public_key
|
||||
or not signature
|
||||
):
|
||||
raise ApiError(400, "设备认证参数不完整", "payload_invalid")
|
||||
if raw_timestamp is None or raw_timestamp == "":
|
||||
raise ApiError(400, "设备认证参数不完整", "payload_invalid")
|
||||
|
||||
check_authorize_rate_limit(challenge_id)
|
||||
|
||||
if app != APP_ID:
|
||||
raise ApiError(400, "设备认证应用不匹配", "app_mismatch")
|
||||
ensure_supported_client_version(client_version)
|
||||
if not _DEVICE_ID_RE.fullmatch(device_id):
|
||||
raise ApiError(400, "设备码格式不正确", "device_id_invalid")
|
||||
|
||||
timestamp = _parse_timestamp(raw_timestamp)
|
||||
now = utcnow()
|
||||
if abs(now.timestamp() - timestamp) > MAX_CLOCK_SKEW_SECONDS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"设备时间与服务器偏差过大,请校准系统时间",
|
||||
"clock_invalid",
|
||||
)
|
||||
|
||||
challenge = get_challenge(db, challenge_id)
|
||||
if (
|
||||
not challenge
|
||||
or challenge.app != APP_ID
|
||||
or challenge.purpose != CHALLENGE_PURPOSE
|
||||
):
|
||||
raise ApiError(404, "设备认证会话不存在", "challenge_not_found")
|
||||
if not hmac.compare_digest(challenge.challenge, challenge_value):
|
||||
raise ApiError(400, "设备认证 challenge 不匹配", "challenge_mismatch")
|
||||
if challenge.status != "pending" or challenge.expires_at <= now:
|
||||
raise ApiError(
|
||||
409,
|
||||
"设备认证会话已失效,请重新登录",
|
||||
"challenge_expired",
|
||||
)
|
||||
|
||||
canonical = build_device_canonical(
|
||||
app=APP_ID,
|
||||
challenge_id=challenge_id,
|
||||
challenge=challenge_value,
|
||||
device_id=device_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
verify_device_signature(public_key, signature, canonical)
|
||||
|
||||
updated = (
|
||||
db.query(DeviceChallenge)
|
||||
.filter(
|
||||
DeviceChallenge.challenge_id == challenge_id,
|
||||
DeviceChallenge.app == APP_ID,
|
||||
DeviceChallenge.status == "pending",
|
||||
DeviceChallenge.expires_at > now,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
DeviceChallenge.status: "authorized",
|
||||
DeviceChallenge.device_id: device_id,
|
||||
DeviceChallenge.public_key: public_key,
|
||||
DeviceChallenge.client_version: client_version,
|
||||
DeviceChallenge.authorized_at: now,
|
||||
DeviceChallenge.updated_at: now,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if updated != 1:
|
||||
db.rollback()
|
||||
raise ApiError(
|
||||
409,
|
||||
"设备认证会话已被使用,请重新登录",
|
||||
"challenge_used",
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "authorized", "deviceId": device_id}
|
||||
|
||||
|
||||
def get_challenge_status(
|
||||
db: Session,
|
||||
challenge_id: str,
|
||||
poll_token: str,
|
||||
) -> dict:
|
||||
challenge_id = str(challenge_id or "").strip()
|
||||
poll_token = str(poll_token or "").strip()
|
||||
if not challenge_id or not poll_token:
|
||||
raise ApiError(400, "缺少设备认证参数", "poll_invalid")
|
||||
|
||||
challenge = get_challenge(db, challenge_id)
|
||||
if not challenge:
|
||||
return {"status": "rejected", "message": "设备认证会话不存在"}
|
||||
if not hmac.compare_digest(challenge.poll_token_hash, hash_token(poll_token)):
|
||||
raise ApiError(403, "设备认证轮询密钥无效", "poll_forbidden")
|
||||
|
||||
now = utcnow()
|
||||
if challenge.expires_at <= now and challenge.status != "consumed":
|
||||
return {"status": "expired", "message": "设备认证会话已过期"}
|
||||
|
||||
if challenge.status == "authorized":
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
issued = (
|
||||
db.query(DeviceChallenge)
|
||||
.filter(
|
||||
DeviceChallenge.challenge_id == challenge_id,
|
||||
DeviceChallenge.status == "authorized",
|
||||
DeviceChallenge.expires_at > now,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
DeviceChallenge.status: "ticket_issued",
|
||||
DeviceChallenge.device_ticket_hash: hash_token(ticket),
|
||||
DeviceChallenge.ticket_issued_at: now,
|
||||
DeviceChallenge.updated_at: now,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if issued == 1:
|
||||
db.commit()
|
||||
return {
|
||||
"status": "authorized",
|
||||
"deviceTicket": ticket,
|
||||
"deviceId": challenge.device_id,
|
||||
}
|
||||
db.rollback()
|
||||
db.expire_all()
|
||||
latest = get_challenge(db, challenge_id)
|
||||
return {"status": latest.status if latest else "pending"}
|
||||
|
||||
return {
|
||||
"status": challenge.status,
|
||||
"deviceId": challenge.device_id or "",
|
||||
}
|
||||
|
||||
|
||||
def consume_device_ticket(
|
||||
db: Session,
|
||||
challenge_id: str,
|
||||
poll_token: str,
|
||||
device_ticket: str,
|
||||
) -> DeviceChallenge:
|
||||
challenge_id = str(challenge_id or "").strip()
|
||||
poll_token = str(poll_token or "").strip()
|
||||
device_ticket = str(device_ticket or "").strip()
|
||||
if not challenge_id or not poll_token or not device_ticket:
|
||||
raise ApiError(400, "缺少设备认证票据", "ticket_required")
|
||||
|
||||
now = utcnow()
|
||||
updated = (
|
||||
db.query(DeviceChallenge)
|
||||
.filter(
|
||||
DeviceChallenge.challenge_id == challenge_id,
|
||||
DeviceChallenge.poll_token_hash == hash_token(poll_token),
|
||||
DeviceChallenge.device_ticket_hash == hash_token(device_ticket),
|
||||
DeviceChallenge.status == "ticket_issued",
|
||||
DeviceChallenge.expires_at > now,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
DeviceChallenge.status: "consumed",
|
||||
DeviceChallenge.consumed_at: now,
|
||||
DeviceChallenge.updated_at: now,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if updated != 1:
|
||||
db.rollback()
|
||||
raise ApiError(
|
||||
401,
|
||||
"设备认证票据无效或已使用,请重新登录",
|
||||
"ticket_invalid",
|
||||
)
|
||||
db.commit()
|
||||
consumed = get_challenge(db, challenge_id)
|
||||
if consumed is None:
|
||||
raise ApiError(
|
||||
401,
|
||||
"设备认证票据无效或已使用,请重新登录",
|
||||
"ticket_invalid",
|
||||
)
|
||||
return consumed
|
||||
|
||||
|
||||
def _active_device(db: Session, user_id: int) -> UserDevice | None:
|
||||
return (
|
||||
db.query(UserDevice)
|
||||
.filter(UserDevice.user_id == user_id, UserDevice.status == "active")
|
||||
.order_by(UserDevice.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _device_mismatch() -> ApiError:
|
||||
return ApiError(
|
||||
401,
|
||||
"当前设备与账号绑定设备不一致,请联系管理员重置设备",
|
||||
"device_mismatch",
|
||||
)
|
||||
|
||||
|
||||
def ensure_device_binding(
|
||||
db: Session,
|
||||
user: User,
|
||||
challenge: DeviceChallenge,
|
||||
) -> UserDevice:
|
||||
"""首台设备自动绑定;已绑定设备必须 device_id + public_key 同时一致。"""
|
||||
now = utcnow()
|
||||
current = _active_device(db, user.id)
|
||||
if current is not None:
|
||||
if not hmac.compare_digest(
|
||||
str(current.device_id or ""), str(challenge.device_id or "")
|
||||
) or not hmac.compare_digest(
|
||||
str(current.public_key or ""), str(challenge.public_key or "")
|
||||
):
|
||||
raise _device_mismatch()
|
||||
current.last_used_at = now
|
||||
current.updated_at = now
|
||||
db.commit()
|
||||
return current
|
||||
|
||||
bound = UserDevice(
|
||||
user_id=user.id,
|
||||
device_id=challenge.device_id,
|
||||
public_key=challenge.public_key,
|
||||
client_version=challenge.client_version,
|
||||
status="active",
|
||||
bound_at=now,
|
||||
last_used_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(bound)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发首绑:唯一索引只允许一个 active 设备,回查后按同一规则比较。
|
||||
db.rollback()
|
||||
concurrent = _active_device(db, user.id)
|
||||
if (
|
||||
concurrent is None
|
||||
or not hmac.compare_digest(
|
||||
str(concurrent.device_id or ""), str(challenge.device_id or "")
|
||||
)
|
||||
or not hmac.compare_digest(
|
||||
str(concurrent.public_key or ""), str(challenge.public_key or "")
|
||||
)
|
||||
):
|
||||
raise _device_mismatch()
|
||||
concurrent.last_used_at = now
|
||||
concurrent.updated_at = now
|
||||
db.commit()
|
||||
return concurrent
|
||||
|
||||
db.refresh(bound)
|
||||
return bound
|
||||
@@ -0,0 +1,333 @@
|
||||
"""登录短信验证码:发送渠道、频率限制与校验。
|
||||
|
||||
渠道通过环境变量切换(沿用订单后台命名):
|
||||
- ``WORKER_SMS_PROVIDER=mock``(默认):只打日志,不做每日限额;
|
||||
- ``WORKER_SMS_PROVIDER=aliyun``:阿里云 Dysmsapi RPC 签名调用。
|
||||
|
||||
验证码只存 sha256,5 分钟有效、60 秒冷却、同一码错 5 次作废、成功即消费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..errors import ApiError
|
||||
from ..models import LoginSmsCode, User
|
||||
|
||||
SMS_PURPOSE = "login"
|
||||
MAX_VERIFY_ATTEMPTS = 5
|
||||
_CODE_RE = re.compile(r"^\d{6}$")
|
||||
_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
|
||||
_CHINA_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmsConfig:
|
||||
provider: str
|
||||
access_key_id: str
|
||||
access_key_secret: str
|
||||
sign_name: str
|
||||
template_code: str
|
||||
code_ttl_seconds: int
|
||||
cooldown_seconds: int
|
||||
daily_limit: int
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(str(os.getenv(name, "")).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_sms_config() -> SmsConfig:
|
||||
return SmsConfig(
|
||||
provider=str(os.getenv("WORKER_SMS_PROVIDER", "mock")).strip().lower()
|
||||
or "mock",
|
||||
access_key_id=str(os.getenv("WORKER_SMS_ACCESS_KEY_ID", "")).strip(),
|
||||
access_key_secret=str(os.getenv("WORKER_SMS_ACCESS_KEY_SECRET", "")).strip(),
|
||||
sign_name=str(os.getenv("WORKER_SMS_SIGN_NAME", "")).strip(),
|
||||
template_code=str(os.getenv("WORKER_SMS_TEMPLATE_CODE", "")).strip(),
|
||||
code_ttl_seconds=max(60, _env_int("WORKER_SMS_CODE_TTL_SECONDS", 300)),
|
||||
cooldown_seconds=max(10, _env_int("WORKER_SMS_COOLDOWN_SECONDS", 60)),
|
||||
daily_limit=max(1, _env_int("WORKER_SMS_DAILY_LIMIT", 10)),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def normalize_phone(value: str | None) -> str:
|
||||
phone = str(value or "").strip()
|
||||
if not phone:
|
||||
return ""
|
||||
if not _PHONE_RE.fullmatch(phone):
|
||||
raise ApiError(400, "手机号格式不正确", "phone_invalid")
|
||||
return phone
|
||||
|
||||
|
||||
def mask_phone(value: str | None) -> str:
|
||||
phone = str(value or "").strip()
|
||||
if not _PHONE_RE.fullmatch(phone):
|
||||
return phone
|
||||
return f"{phone[:3]}****{phone[-4:]}"
|
||||
|
||||
|
||||
def hash_sms_code(code: str) -> str:
|
||||
return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_sms_code() -> str:
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def should_enforce_login_sms_daily_limit(provider: str | None) -> bool:
|
||||
return str(provider or "mock").strip().lower() != "mock"
|
||||
|
||||
|
||||
def china_day_range(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
current = (now or utcnow()).astimezone(_CHINA_TZ)
|
||||
start_local = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
start_local.astimezone(UTC),
|
||||
(start_local + timedelta(days=1)).astimezone(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_sms(config: SmsConfig, phone: str, code: str, ttl_seconds: int) -> None:
|
||||
if config.provider != "mock":
|
||||
_send_via_aliyun(config, phone, code)
|
||||
return
|
||||
logger.info(
|
||||
"[mock短信] 向 {} 发送登录验证码:{}({} 秒内有效,不真实发送)",
|
||||
mask_phone(phone),
|
||||
code,
|
||||
ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _percent_encode(value: str) -> str:
|
||||
return quote(str(value), safe="~")
|
||||
|
||||
|
||||
def _aliyun_signature(params: dict[str, str], secret: str) -> str:
|
||||
canonical = "&".join(
|
||||
f"{_percent_encode(key)}={_percent_encode(value)}"
|
||||
for key, value in sorted(params.items())
|
||||
)
|
||||
string_to_sign = f"GET&%2F&{_percent_encode(canonical)}"
|
||||
digest = hmac.new(
|
||||
f"{secret}&".encode(),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
return base64.b64encode(digest).decode("utf-8")
|
||||
|
||||
|
||||
def _send_via_aliyun(config: SmsConfig, phone: str, code: str) -> None:
|
||||
if not (
|
||||
config.access_key_id
|
||||
and config.access_key_secret
|
||||
and config.sign_name
|
||||
and config.template_code
|
||||
):
|
||||
raise ApiError(
|
||||
503,
|
||||
"短信渠道未配置,请联系管理员",
|
||||
"sms_not_configured",
|
||||
)
|
||||
|
||||
params = {
|
||||
"AccessKeyId": config.access_key_id,
|
||||
"Action": "SendSms",
|
||||
"Format": "JSON",
|
||||
"PhoneNumbers": phone,
|
||||
"RegionId": "cn-hangzhou",
|
||||
"SignName": config.sign_name,
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": str(uuid4()),
|
||||
"SignatureVersion": "1.0",
|
||||
"TemplateCode": config.template_code,
|
||||
"TemplateParam": json.dumps(
|
||||
{"code": code}, separators=(",", ":"), ensure_ascii=False
|
||||
),
|
||||
"Timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"Version": "2017-05-25",
|
||||
}
|
||||
params["Signature"] = _aliyun_signature(params, config.access_key_secret)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://dysmsapi.aliyuncs.com/",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc: # 外部渠道统一兜底
|
||||
logger.exception("阿里云短信发送异常: {}", exc)
|
||||
raise ApiError(502, "短信发送失败,请稍后再试", "sms_send_failed") from exc
|
||||
|
||||
if response.status_code != 200 or str(payload.get("Code", "")) != "OK":
|
||||
logger.error(
|
||||
"阿里云短信发送失败: status={} code={} message={}",
|
||||
response.status_code,
|
||||
payload.get("Code"),
|
||||
payload.get("Message"),
|
||||
)
|
||||
raise ApiError(502, "短信发送失败,请稍后再试", "sms_send_failed")
|
||||
|
||||
|
||||
def _latest_code(db: Session, user_id: int) -> LoginSmsCode | None:
|
||||
return (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.user_id == user_id,
|
||||
LoginSmsCode.purpose == SMS_PURPOSE,
|
||||
)
|
||||
.order_by(LoginSmsCode.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def send_login_sms_code(
|
||||
db: Session,
|
||||
user: User,
|
||||
phone: str,
|
||||
request_ip: str = "",
|
||||
) -> dict:
|
||||
config = get_sms_config()
|
||||
now = utcnow()
|
||||
|
||||
latest = _latest_code(db, user.id)
|
||||
if (
|
||||
latest is not None
|
||||
and latest.consumed_at is None
|
||||
and latest.created_at > now - timedelta(seconds=config.cooldown_seconds)
|
||||
):
|
||||
raise ApiError(429, "验证码发送过于频繁,请稍后再试", "sms_cooldown")
|
||||
|
||||
if should_enforce_login_sms_daily_limit(config.provider):
|
||||
start, end = china_day_range(now)
|
||||
sent_today = (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.user_id == user.id,
|
||||
LoginSmsCode.purpose == SMS_PURPOSE,
|
||||
LoginSmsCode.created_at >= start,
|
||||
LoginSmsCode.created_at < end,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if sent_today >= config.daily_limit:
|
||||
raise ApiError(
|
||||
429,
|
||||
"今日验证码发送次数已达上限,请明天再试",
|
||||
"sms_daily_limit",
|
||||
)
|
||||
|
||||
code = generate_sms_code()
|
||||
_dispatch_sms(config, phone, code, config.code_ttl_seconds)
|
||||
|
||||
row = LoginSmsCode(
|
||||
user_id=user.id,
|
||||
phone=phone,
|
||||
purpose=SMS_PURPOSE,
|
||||
code_hash=hash_sms_code(code),
|
||||
expires_at=now + timedelta(seconds=config.code_ttl_seconds),
|
||||
failed_attempt_count=0,
|
||||
request_ip=str(request_ip or "").strip()[:64],
|
||||
created_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"sent": True,
|
||||
"phoneMasked": mask_phone(phone),
|
||||
"expiresInSeconds": config.code_ttl_seconds,
|
||||
"provider": config.provider,
|
||||
}
|
||||
|
||||
|
||||
def verify_login_sms_code(db: Session, user_id: int, code_value: str | None) -> str:
|
||||
"""校验并消费验证码;返回验证码绑定的手机号。"""
|
||||
code = str(code_value or "").strip()
|
||||
if not code:
|
||||
raise ApiError(400, "请输入短信验证码", "sms_code_required")
|
||||
if not _CODE_RE.fullmatch(code):
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
now = utcnow()
|
||||
latest = _latest_code(db, user_id)
|
||||
if latest is None or latest.consumed_at is not None or latest.expires_at <= now:
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
if latest.failed_attempt_count >= MAX_VERIFY_ATTEMPTS:
|
||||
_mark_code_consumed(db, latest.id, now)
|
||||
raise ApiError(
|
||||
400,
|
||||
"验证码错误次数过多,请重新获取验证码",
|
||||
"sms_code_locked",
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(str(latest.code_hash or ""), hash_sms_code(code)):
|
||||
db.query(LoginSmsCode).filter(
|
||||
LoginSmsCode.id == latest.id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
).update(
|
||||
{LoginSmsCode.failed_attempt_count: LoginSmsCode.failed_attempt_count + 1},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(latest)
|
||||
if latest.failed_attempt_count >= MAX_VERIFY_ATTEMPTS:
|
||||
_mark_code_consumed(db, latest.id, now)
|
||||
raise ApiError(
|
||||
400,
|
||||
"验证码错误次数过多,请重新获取验证码",
|
||||
"sms_code_locked",
|
||||
)
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
consumed = (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.id == latest.id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
LoginSmsCode.failed_attempt_count < MAX_VERIFY_ATTEMPTS,
|
||||
)
|
||||
.update(
|
||||
{LoginSmsCode.consumed_at: now},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
if consumed != 1:
|
||||
raise ApiError(400, "验证码已失效,请重新获取", "sms_code_invalid")
|
||||
return str(latest.phone or "")
|
||||
|
||||
|
||||
def _mark_code_consumed(db: Session, code_id: int, now: datetime) -> None:
|
||||
db.query(LoginSmsCode).filter(
|
||||
LoginSmsCode.id == code_id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
).update(
|
||||
{LoginSmsCode.consumed_at: now},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""用户信息序列化(列表 / 创建 / 编辑 / 管理端接口共用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import User, UserDevice
|
||||
from ..permissions import get_user_permissions
|
||||
from ..schemas import UserInfo
|
||||
from .dc_auth_service import mask_device_id
|
||||
from .login_sms_service import mask_phone
|
||||
|
||||
|
||||
def to_user_info(user: User, device: UserDevice | None = None) -> UserInfo:
|
||||
device_id = str(device.device_id or "") if device is not None else ""
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
created_at=user.created_at,
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
phone=str(user.phone or ""),
|
||||
phone_masked=mask_phone(user.phone),
|
||||
phone_bound_at=user.phone_bound_at,
|
||||
device_id=device_id,
|
||||
device_id_masked=mask_device_id(device_id),
|
||||
device_bound_at=device.bound_at if device is not None else None,
|
||||
device_last_used_at=device.last_used_at if device is not None else None,
|
||||
)
|
||||
@@ -1,10 +1,34 @@
|
||||
import api from './client';
|
||||
import type { CurrentUser, LoginResult, MessageResponse } from './types';
|
||||
import type {
|
||||
CurrentUser,
|
||||
DeviceChallengeStatus,
|
||||
DeviceCompletePayload,
|
||||
DevicePreparePayload,
|
||||
DevicePrepareResult,
|
||||
LoginResult,
|
||||
MessageResponse,
|
||||
SmsCodeResult,
|
||||
} from './types';
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<LoginResult, LoginResult>('/auth/login', { username, password }),
|
||||
|
||||
sendSmsCode: (username: string, password: string) =>
|
||||
api.post<SmsCodeResult, SmsCodeResult>('/auth/sms-code', { username, password }),
|
||||
|
||||
prepareDeviceLogin: (data: DevicePreparePayload) =>
|
||||
api.post<DevicePrepareResult, DevicePrepareResult>('/auth/prepare', data),
|
||||
|
||||
completeDeviceLogin: (data: DeviceCompletePayload) =>
|
||||
api.post<LoginResult, LoginResult>('/auth/complete', data),
|
||||
|
||||
deviceChallengeStatus: (challengeId: string, pollToken: string) =>
|
||||
api.get<DeviceChallengeStatus, DeviceChallengeStatus>(
|
||||
`/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
|
||||
{ params: { pollToken } },
|
||||
),
|
||||
|
||||
me: () => api.get<CurrentUser, CurrentUser>('/auth/me'),
|
||||
|
||||
logout: () => api.post<MessageResponse, MessageResponse>('/auth/logout'),
|
||||
|
||||
@@ -10,9 +10,13 @@ const api = axios.create({
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const requestUrl: string = error.config?.url ?? '';
|
||||
// 登录链路(含 DCAuth 轮询)里的 401 是业务错误,不能当成“登录态失效”跳转。
|
||||
const isAuthFlow =
|
||||
['/auth/login', '/auth/sms-code', '/auth/prepare', '/auth/complete'].includes(requestUrl) ||
|
||||
requestUrl.startsWith('/dcauth/');
|
||||
// 仅在“已登录后 token 失效”时清除登录态并跳转登录页。
|
||||
// 登录接口本身返回 401(用户名/密码错误)时不应跳转,否则会吞掉错误提示。
|
||||
if (error.response?.status === 401 && error.config?.url !== '/auth/login') {
|
||||
if (error.response?.status === 401 && !isAuthFlow) {
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
@@ -25,7 +29,10 @@ api.interceptors.response.use(
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
msg = JSON.stringify(detail);
|
||||
}
|
||||
return Promise.reject(new Error(msg));
|
||||
const wrapped = new Error(msg) as Error & { errorCode?: string; status?: number };
|
||||
wrapped.errorCode = error.response?.data?.errorCode;
|
||||
wrapped.status = error.response?.status;
|
||||
return Promise.reject(wrapped);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -272,6 +272,43 @@ export interface CurrentUser {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface SmsCodeResult {
|
||||
sent: boolean;
|
||||
phoneMasked: string;
|
||||
expiresInSeconds: number;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface DevicePreparePayload {
|
||||
username: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export interface DevicePrepareResult {
|
||||
challengeId: string;
|
||||
challenge: string;
|
||||
expiresAt: string;
|
||||
pollToken: string;
|
||||
launchUrl: string;
|
||||
phoneMasked: string;
|
||||
app: string;
|
||||
}
|
||||
|
||||
export interface DeviceChallengeStatus {
|
||||
status: string;
|
||||
deviceTicket?: string;
|
||||
deviceId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeviceCompletePayload {
|
||||
challengeId: string;
|
||||
pollToken: string;
|
||||
deviceTicket: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -280,6 +317,13 @@ export interface UserInfo {
|
||||
remark: string;
|
||||
permissions: string[];
|
||||
custom_permissions?: string[] | null;
|
||||
phone: string;
|
||||
phone_masked: string;
|
||||
phone_bound_at?: string | null;
|
||||
device_id: string;
|
||||
device_id_masked: string;
|
||||
device_bound_at?: string | null;
|
||||
device_last_used_at?: string | null;
|
||||
}
|
||||
|
||||
// ==================== Account ====================
|
||||
|
||||
@@ -10,5 +10,12 @@ export const userApi = {
|
||||
rename: (id: number, username: string) =>
|
||||
api.patch<UserInfo, UserInfo>(`/users/${id}/username`, { username }),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
|
||||
bindPhone: (id: number, phone: string) =>
|
||||
api.post<UserInfo, UserInfo>(`/admin/users/${id}/phone`, { phone }),
|
||||
resetDevice: (id: number) =>
|
||||
api.post<
|
||||
{ success: boolean; reset: boolean; revoked: number; message: string },
|
||||
{ success: boolean; reset: boolean; revoked: number; message: string }
|
||||
>(`/admin/users/${id}/device/reset`),
|
||||
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Card, Divider, Form, Input, Space, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LockOutlined,
|
||||
MobileOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '../api/modules';
|
||||
import type { DevicePrepareResult, LoginResult } from '../api/modules';
|
||||
import { setAuth, type AuthUser } from '../store/auth';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { useTheme } from '../store/useTheme';
|
||||
@@ -12,78 +18,370 @@ import { useAppInfo } from '../hooks/useAppInfo';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type LoginMode = 'secure' | 'legacy';
|
||||
type LoginValues = { username: string; password: string; smsCode?: string };
|
||||
|
||||
function errorCodeOf(error: unknown): string {
|
||||
return (error as { errorCode?: string } | null)?.errorCode || '';
|
||||
}
|
||||
|
||||
function launchProtocol(url: string) {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.setTimeout(() => anchor.remove(), 1000);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
|
||||
const [mode, setMode] = useState<LoginMode>('secure');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [phoneMasked, setPhoneMasked] = useState('');
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [launchUrl, setLaunchUrl] = useState('');
|
||||
const [form] = Form.useForm<LoginValues>();
|
||||
const passwordRef = useRef<InputRef>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const appInfo = useAppInfo();
|
||||
const versionText = appInfo?.version ? `v${appInfo.version}` : 'v--';
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
setCountdown(seconds);
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setCountdown((current) => {
|
||||
if (current <= 1) {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return current - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function switchMode(next: LoginMode) {
|
||||
if (loading || waiting) return;
|
||||
setMode(next);
|
||||
form.setFieldValue('smsCode', '');
|
||||
setWaiting(false);
|
||||
setLaunchUrl('');
|
||||
setPhoneMasked('');
|
||||
}
|
||||
|
||||
function applyLoginResult(result: LoginResult) {
|
||||
const user: AuthUser = {
|
||||
id: 0,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
permissions: result.permissions,
|
||||
};
|
||||
setAuth(user);
|
||||
message.success('登录成功');
|
||||
onLogin?.();
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
let values: Pick<LoginValues, 'username' | 'password'>;
|
||||
try {
|
||||
const result = await authApi.login(values.username, values.password);
|
||||
const user: AuthUser = {
|
||||
id: 0,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
permissions: result.permissions,
|
||||
};
|
||||
setAuth(user);
|
||||
message.success('登录成功');
|
||||
onLogin?.(); // 触发 App 重渲染
|
||||
navigate('/', { replace: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = getErrorMessage(e) || '登录失败';
|
||||
message.error(msg);
|
||||
// 密码错误(401)时清空密码并自动聚焦,方便快速重试
|
||||
if (msg.includes('密码') || msg.includes('用户名')) {
|
||||
form.setFieldValue('password', '');
|
||||
passwordRef.current?.focus();
|
||||
values = await form.validateFields(['username', 'password']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const result = await authApi.sendSmsCode(values.username.trim(), values.password);
|
||||
setPhoneMasked(result.phoneMasked);
|
||||
startCountdown(60);
|
||||
message.success(`验证码已发送至 ${result.phoneMasked}`);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'phone_unbound') {
|
||||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||||
} else {
|
||||
message.error(getErrorMessage(error) || '验证码发送失败');
|
||||
}
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSecureLogin(values: LoginValues) {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setLoading(true);
|
||||
setWaiting(false);
|
||||
try {
|
||||
const prepared = await authApi.prepareDeviceLogin({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
smsCode: (values.smsCode || '').trim(),
|
||||
apiBase: window.location.origin,
|
||||
});
|
||||
setPhoneMasked(prepared.phoneMasked);
|
||||
setLaunchUrl(prepared.launchUrl);
|
||||
setWaiting(true);
|
||||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认');
|
||||
launchProtocol(prepared.launchUrl);
|
||||
|
||||
const completed = await waitForDevice(prepared);
|
||||
applyLoginResult(completed);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'phone_unbound') {
|
||||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||||
} else if (code === 'device_mismatch') {
|
||||
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备');
|
||||
} else if (code === 'challenge_expired' || code === 'challenge_rejected') {
|
||||
message.error('本机验证已过期,请重新发起登录');
|
||||
} else if (code === 'device_timeout') {
|
||||
message.error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起');
|
||||
} else {
|
||||
const text = getErrorMessage(error) || '登录失败';
|
||||
message.error(text);
|
||||
if (text.includes('密码') || text.includes('用户名')) {
|
||||
form.setFieldValue('password', '');
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setLoading(false);
|
||||
setWaiting(false);
|
||||
setLaunchUrl('');
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDevice(prepared: DevicePrepareResult): Promise<LoginResult> {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
await sleep(1000);
|
||||
const status = await authApi.deviceChallengeStatus(
|
||||
prepared.challengeId,
|
||||
prepared.pollToken,
|
||||
);
|
||||
if (status.status === 'authorized' && status.deviceTicket) {
|
||||
return authApi.completeDeviceLogin({
|
||||
challengeId: prepared.challengeId,
|
||||
pollToken: prepared.pollToken,
|
||||
deviceTicket: status.deviceTicket,
|
||||
});
|
||||
}
|
||||
if (status.status === 'expired' || status.status === 'rejected') {
|
||||
const error = new Error(status.message || '本机验证已过期,请重新登录') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
error.errorCode = status.status === 'expired' ? 'challenge_expired' : 'challenge_rejected';
|
||||
throw error;
|
||||
}
|
||||
if (status.status === 'ticket_issued' || status.status === 'consumed') {
|
||||
const error = new Error('本机验证会话已被使用,请重新发起登录') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
error.errorCode = 'challenge_used';
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth');
|
||||
}
|
||||
}
|
||||
const timeout = new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
timeout.errorCode = 'device_timeout';
|
||||
throw timeout;
|
||||
}
|
||||
|
||||
async function handleLegacyLogin(values: LoginValues) {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authApi.login(values.username.trim(), values.password);
|
||||
applyLoginResult(result);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'secure_login_required') {
|
||||
message.warning('该账号已启用安全登录,请使用短信验证码 + 本机验证登录');
|
||||
} else {
|
||||
message.error(getErrorMessage(error) || '登录失败');
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Card style={{ width: 380, boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)' }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: 420,
|
||||
boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
直播运营后台
|
||||
</Title>
|
||||
<Text type="secondary" style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: -20,
|
||||
marginBottom: 24,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: -20,
|
||||
marginBottom: 24,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
当前版本 {versionText}
|
||||
</Text>
|
||||
<Form form={form} onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" ref={passwordRef} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
|
||||
{mode === 'secure' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="安全登录"
|
||||
description="用户名密码验证后,还需短信验证码和本机 DCAuth 设备签名。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{waiting ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="等待本机确认"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<span>请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth。</span>
|
||||
{launchUrl ? (
|
||||
<Text code copyable={{ text: launchUrl }} style={{ wordBreak: 'break-all' }}>
|
||||
{launchUrl}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} onFinish={handleSecureLogin} size="large" requiredMark={false}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名(中文 / 英文 / 数字)"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="smsCode"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
placeholder={phoneMasked ? `6 位验证码(${phoneMasked})` : '6 位短信验证码'}
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
htmlType="button"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0 || loading}
|
||||
onClick={() => void sendCode()}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
安全登录 · 拉起本机验证
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider plain style={{ margin: '4px 0 12px' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未绑定手机号的旧账号
|
||||
</Text>
|
||||
</Divider>
|
||||
<Button type="link" block onClick={() => switchMode('legacy')} disabled={loading}>
|
||||
旧账号仅密码登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="旧账号仅密码登录"
|
||||
description="仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form form={form} onFinish={handleLegacyLogin} size="large" requiredMark={false}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
密码登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Button type="link" block onClick={() => switchMode('secure')} disabled={loading}>
|
||||
<MobileOutlined /> 返回安全登录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { userApi, type UserInfo } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
@@ -59,6 +59,13 @@ export default function UsersPage() {
|
||||
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [permLoading, setPermLoading] = useState(false);
|
||||
|
||||
// 手机号绑定 / 设备重置
|
||||
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
|
||||
const [phoneUser, setPhoneUser] = useState<UserInfo | null>(null);
|
||||
const [phoneForm] = Form.useForm<{ phone: string }>();
|
||||
const [phoneSaving, setPhoneSaving] = useState(false);
|
||||
const [resettingId, setResettingId] = useState<number | null>(null);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canAssignPerm = can('user:assign_permissions');
|
||||
@@ -136,6 +143,42 @@ export default function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 手机号 / 设备 ----
|
||||
const handlePhoneClick = (user: UserInfo) => {
|
||||
setPhoneUser(user);
|
||||
phoneForm.setFieldsValue({ phone: user.phone || '' });
|
||||
setPhoneModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePhoneSave = async () => {
|
||||
if (!phoneUser) return;
|
||||
try {
|
||||
const values = await phoneForm.validateFields();
|
||||
setPhoneSaving(true);
|
||||
await userApi.bindPhone(phoneUser.id, values.phone.trim());
|
||||
message.success('手机号已更新');
|
||||
setPhoneModalOpen(false);
|
||||
loadUsers();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setPhoneSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDevice = async (id: number) => {
|
||||
setResettingId(id);
|
||||
try {
|
||||
const result = await userApi.resetDevice(id);
|
||||
message.success(result.message || (result.reset ? '已重置设备' : '当前没有已绑定设备'));
|
||||
loadUsers();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setResettingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 权限管理 ----
|
||||
const handlePermClick = async (user: UserInfo) => {
|
||||
setPermUser(user);
|
||||
@@ -209,6 +252,55 @@ export default function UsersPage() {
|
||||
width: 80,
|
||||
render: (active: boolean) => active ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 170,
|
||||
render: (_: unknown, record: UserInfo) => (
|
||||
<Space size={4}>
|
||||
{record.phone ? (
|
||||
<span>{record.phone_masked || record.phone}</span>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button size="small" type="link" onClick={() => handlePhoneClick(record)}>
|
||||
{record.phone ? '修改' : '绑定'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定设备',
|
||||
dataIndex: 'device_id',
|
||||
width: 230,
|
||||
render: (_: unknown, record: UserInfo) =>
|
||||
record.device_id ? (
|
||||
<Space size={4} wrap>
|
||||
<span style={{ fontFamily: 'monospace' }}>{record.device_id_masked || record.device_id}</span>
|
||||
{canEdit && (
|
||||
<Popconfirm
|
||||
title="确认重置设备?"
|
||||
description="重置后该账号下次登录会重新绑定当前设备。"
|
||||
onConfirm={() => handleResetDevice(record.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
icon={<ReloadOutlined />}
|
||||
loading={resettingId === record.id}
|
||||
>
|
||||
重置设备
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -250,7 +342,7 @@ export default function UsersPage() {
|
||||
loading={loading}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 844 }}
|
||||
scroll={{ x: 1240 }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
@@ -378,6 +470,35 @@ export default function UsersPage() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 绑定 / 修改手机号 Modal */}
|
||||
<Modal
|
||||
title={phoneUser ? `绑定手机号 - ${phoneUser.username}` : '绑定手机号'}
|
||||
open={phoneModalOpen}
|
||||
onCancel={() => setPhoneModalOpen(false)}
|
||||
onOk={handlePhoneSave}
|
||||
okText="保存"
|
||||
confirmLoading={phoneSaving}
|
||||
width={420}
|
||||
>
|
||||
<Form form={phoneForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="11 位手机号" maxLength={11} autoComplete="tel" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="绑定后该账号必须使用「密码 + 短信验证码 + 本机 DCAuth 设备」登录。"
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user