feat(auth): 新增手机号与 DCAuth 设备认证登录

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
yml2213
2026-10-21 22:00:03 +08:00
parent f21362dfe3
commit 2f5a63ae27
24 changed files with 3049 additions and 130 deletions
+13
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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:
+512
View File
@@ -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"
+1 -1
View File
@@ -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 = (
+26
View File
@@ -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},
)
+8
View File
@@ -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()
+149
View File
@@ -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):
"""虎牙账号"""
+123
View File
@@ -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
View File
@@ -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()),
}
+39
View File
@@ -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)
+28 -51
View File
@@ -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}")
+70
View File
@@ -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),
}
+517
View File
@@ -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
+333
View File
@@ -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,
)
+25 -1
View File
@@ -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 -3
View File
@@ -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);
}
);
+44
View File
@@ -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 ====================
+7
View File
@@ -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'),
};
+352 -54
View File
@@ -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>
);
+123 -2
View File
@@ -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>
);
}