From 2f5a63ae27ac265c35fc91235e10320fca6c243a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 21 Oct 2026 22:00:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=96=B0=E5=A2=9E=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E4=B8=8E=20DCAuth=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置 --- .env.example | 13 + docker-compose.yml | 9 + tests/support/db.py | 24 +- tests/test_dc_auth_v2.py | 512 +++++++++++++++++ tests/test_migrations.py | 2 +- web/backend/errors.py | 26 + web/backend/main.py | 8 + .../20261021_0063_user_phone_device_auth.py | 301 ++++++++++ ...1021_0064_repair_user_phone_device_auth.py | 110 ++++ web/backend/models.py | 149 +++++ web/backend/routers/admin_users.py | 123 +++++ web/backend/routers/auth.py | 213 +++++++- web/backend/routers/dcauth.py | 39 ++ web/backend/routers/users.py | 79 +-- web/backend/schemas.py | 70 +++ web/backend/services/dc_auth_service.py | 517 ++++++++++++++++++ web/backend/services/login_sms_service.py | 333 +++++++++++ web/backend/services/user_profile_service.py | 30 + web/frontend/src/api/auth.ts | 26 +- web/frontend/src/api/client.ts | 13 +- web/frontend/src/api/types.ts | 44 ++ web/frontend/src/api/users.ts | 7 + web/frontend/src/pages/LoginPage.tsx | 406 ++++++++++++-- web/frontend/src/pages/UsersPage.tsx | 125 ++++- 24 files changed, 3049 insertions(+), 130 deletions(-) create mode 100644 tests/test_dc_auth_v2.py create mode 100644 web/backend/errors.py create mode 100644 web/backend/migrations/versions/20261021_0063_user_phone_device_auth.py create mode 100644 web/backend/migrations/versions/20261021_0064_repair_user_phone_device_auth.py create mode 100644 web/backend/routers/admin_users.py create mode 100644 web/backend/routers/dcauth.py create mode 100644 web/backend/services/dc_auth_service.py create mode 100644 web/backend/services/login_sms_service.py create mode 100644 web/backend/services/user_profile_service.py diff --git a/.env.example b/.env.example index 473a3eb..78bbd5c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2646e46..55a0580 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/tests/support/db.py b/tests/support/db.py index 4c5bb78..3f8ebde 100644 --- a/tests/support/db.py +++ b/tests/support/db.py @@ -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: diff --git a/tests/test_dc_auth_v2.py b/tests/test_dc_auth_v2.py new file mode 100644 index 0000000..a9d0b78 --- /dev/null +++ b/tests/test_dc_auth_v2.py @@ -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" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index b54c62d..926eaa6 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/errors.py b/web/backend/errors.py new file mode 100644 index 0000000..1e99308 --- /dev/null +++ b/web/backend/errors.py @@ -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}, + ) diff --git a/web/backend/main.py b/web/backend/main.py index 1366b49..6b197ef 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -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) diff --git a/web/backend/migrations/versions/20261021_0063_user_phone_device_auth.py b/web/backend/migrations/versions/20261021_0063_user_phone_device_auth.py new file mode 100644 index 0000000..2211a60 --- /dev/null +++ b/web/backend/migrations/versions/20261021_0063_user_phone_device_auth.py @@ -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) diff --git a/web/backend/migrations/versions/20261021_0064_repair_user_phone_device_auth.py b/web/backend/migrations/versions/20261021_0064_repair_user_phone_device_auth.py new file mode 100644 index 0000000..5e028d8 --- /dev/null +++ b/web/backend/migrations/versions/20261021_0064_repair_user_phone_device_auth.py @@ -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() diff --git a/web/backend/models.py b/web/backend/models.py index 28d8182..7cf01ac 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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): """虎牙账号""" diff --git a/web/backend/routers/admin_users.py b/web/backend/routers/admin_users.py new file mode 100644 index 0000000..ea15e09 --- /dev/null +++ b/web/backend/routers/admin_users.py @@ -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 "当前没有已绑定设备", + } diff --git a/web/backend/routers/auth.py b/web/backend/routers/auth.py index 091c346..91082ff 100644 --- a/web/backend/routers/auth.py +++ b/web/backend/routers/auth.py @@ -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()), } diff --git a/web/backend/routers/dcauth.py b/web/backend/routers/dcauth.py new file mode 100644 index 0000000..307b432 --- /dev/null +++ b/web/backend/routers/dcauth.py @@ -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) diff --git a/web/backend/routers/users.py b/web/backend/routers/users.py index 0f2c3ed..b18b7c6 100644 --- a/web/backend/routers/users.py +++ b/web/backend/routers/users.py @@ -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}") diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 7cea2db..f48af1d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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), } diff --git a/web/backend/services/dc_auth_service.py b/web/backend/services/dc_auth_service.py new file mode 100644 index 0000000..23c2254 --- /dev/null +++ b/web/backend/services/dc_auth_service.py @@ -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 diff --git a/web/backend/services/login_sms_service.py b/web/backend/services/login_sms_service.py new file mode 100644 index 0000000..8a9206c --- /dev/null +++ b/web/backend/services/login_sms_service.py @@ -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() diff --git a/web/backend/services/user_profile_service.py b/web/backend/services/user_profile_service.py new file mode 100644 index 0000000..8a4ced9 --- /dev/null +++ b/web/backend/services/user_profile_service.py @@ -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, + ) diff --git a/web/frontend/src/api/auth.ts b/web/frontend/src/api/auth.ts index 92c3a52..183171a 100644 --- a/web/frontend/src/api/auth.ts +++ b/web/frontend/src/api/auth.ts @@ -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('/auth/login', { username, password }), + sendSmsCode: (username: string, password: string) => + api.post('/auth/sms-code', { username, password }), + + prepareDeviceLogin: (data: DevicePreparePayload) => + api.post('/auth/prepare', data), + + completeDeviceLogin: (data: DeviceCompletePayload) => + api.post('/auth/complete', data), + + deviceChallengeStatus: (challengeId: string, pollToken: string) => + api.get( + `/dcauth/challenge/${encodeURIComponent(challengeId)}/status`, + { params: { pollToken } }, + ), + me: () => api.get('/auth/me'), logout: () => api.post('/auth/logout'), diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index be69ad6..73c368a 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -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); } ); diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 1d352da..b20ff7f 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 ==================== diff --git a/web/frontend/src/api/users.ts b/web/frontend/src/api/users.ts index 8c27428..9ed1bf7 100644 --- a/web/frontend/src/api/users.ts +++ b/web/frontend/src/api/users.ts @@ -10,5 +10,12 @@ export const userApi = { rename: (id: number, username: string) => api.patch(`/users/${id}/username`, { username }), delete: (id: number) => api.delete(`/users/${id}`), + bindPhone: (id: number, phone: string) => + api.post(`/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('/users/permissions/list'), }; diff --git a/web/frontend/src/pages/LoginPage.tsx b/web/frontend/src/pages/LoginPage.tsx index fe906ad..560120c 100644 --- a/web/frontend/src/pages/LoginPage.tsx +++ b/web/frontend/src/pages/LoginPage.tsx @@ -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('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(); const passwordRef = useRef(null); + const timerRef = useRef(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; 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 { + 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 ( -
- +
+ 直播运营后台 - + 当前版本 {versionText} -
- - } placeholder="用户名" /> - - - } placeholder="密码" ref={passwordRef} /> - - - + } + /> + + + + +
+ + + + 未绑定手机号的旧账号 + + + - - + + ) : ( + <> + +
+ + } + placeholder="用户名" + autoComplete="username" + /> + + + } + placeholder="密码" + autoComplete="current-password" + ref={passwordRef} + /> + + + + +
+ + + )}
); diff --git a/web/frontend/src/pages/UsersPage.tsx b/web/frontend/src/pages/UsersPage.tsx index ac7b4ec..03e5e31 100644 --- a/web/frontend/src/pages/UsersPage.tsx +++ b/web/frontend/src/pages/UsersPage.tsx @@ -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([]); const [useCustom, setUseCustom] = useState(false); const [permLoading, setPermLoading] = useState(false); + + // 手机号绑定 / 设备重置 + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [phoneUser, setPhoneUser] = useState(null); + const [phoneForm] = Form.useForm<{ phone: string }>(); + const [phoneSaving, setPhoneSaving] = useState(false); + const [resettingId, setResettingId] = useState(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 ? 启用 : 禁用, }, + { + title: '手机号', + dataIndex: 'phone', + width: 170, + render: (_: unknown, record: UserInfo) => ( + + {record.phone ? ( + {record.phone_masked || record.phone} + ) : ( + 未绑定 + )} + {canEdit && ( + + )} + + ), + }, + { + title: '绑定设备', + dataIndex: 'device_id', + width: 230, + render: (_: unknown, record: UserInfo) => + record.device_id ? ( + + {record.device_id_masked || record.device_id} + {canEdit && ( + handleResetDevice(record.id)} + > + + + )} + + ) : ( + 未绑定 + ), + }, { 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 */} + setPhoneModalOpen(false)} + onOk={handlePhoneSave} + okText="保存" + confirmLoading={phoneSaving} + width={420} + > +
+ + + + + +
); }