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

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
yml2213
2026-10-21 22:00:03 +08:00
parent f21362dfe3
commit 2f5a63ae27
24 changed files with 3049 additions and 130 deletions
+149
View File
@@ -8,12 +8,14 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Computed,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,6 +62,24 @@ class User(Base):
)
deleted_username: Mapped[str] = mapped_column(String(64), default="")
# ---- 安全登录(手机号 + 短信验证码 + DCAuth 设备签名)----
phone: Mapped[str] = mapped_column(
String(32), nullable=False, default="", server_default=""
)
phone_bound_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
phone_bound_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# MySQL 不支持部分唯一索引:用生成列把「空手机号」变成 NULL,
# 再对它建唯一索引,效果等同 PostgreSQL 的 `UNIQUE (phone) WHERE phone <> ''`。
phone_unique: Mapped[str | None] = mapped_column(
String(32),
Computed("NULLIF(phone, '')", persisted=True),
nullable=True,
)
__table_args__ = (Index("uq_users_phone_unique", "phone_unique", unique=True),)
# 客服被分配的账号
assigned_accounts: Mapped[list[Account]] = relationship(
"Account", back_populates="assigned_user", foreign_keys="Account.assigned_to"
@@ -680,6 +700,135 @@ class DouyuExternalOrderMetric(Base):
)
class LoginSmsCode(Base):
"""登录短信验证码。
只存 sha256(code),不存明文;一条记录只能消费一次,连续输错 5 次即作废。
"""
__tablename__ = "login_sms_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
phone: Mapped[str] = mapped_column(String(32), nullable=False, default="")
purpose: Mapped[str] = mapped_column(
String(16), nullable=False, default="login", server_default="login"
)
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
failed_attempt_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
request_ip: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
Index("ix_login_sms_codes_user_created", "user_id", "purpose", "created_at"),
Index("ix_login_sms_codes_phone_created", "phone", "created_at"),
)
class UserDevice(Base):
"""账号绑定的 DCAuth 设备(只存 deviceId 与 Ed25519 公钥)。
MySQL 用函数索引 ``UNIQUE ((CASE WHEN status='active' THEN user_id END))``
等价于 PostgreSQL 的部分唯一索引 ``UNIQUE (user_id) WHERE status='active'``。
不用生成列,是因为 MySQL 不允许“生成列依赖的基列”上挂 ON DELETE CASCADE 外键。
"""
__tablename__ = "user_devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
device_id: Mapped[str] = mapped_column(String(96), nullable=False)
public_key: Mapped[str] = mapped_column(String(128), nullable=False)
client_version: Mapped[str] = mapped_column(
String(32), nullable=False, default="", server_default=""
)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="active", server_default="active"
)
bound_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
revoked_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
__table_args__ = (
Index(
"uq_user_devices_active_user",
text("(CASE WHEN status = 'active' THEN user_id ELSE NULL END)"),
unique=True,
),
Index("ix_user_devices_device_id", "device_id"),
)
class DeviceChallenge(Base):
"""DCAuth challenge → authorized → ticket_issued → consumed 状态机。
所有状态推进都带 ``status`` 与 ``expires_at`` 条件做原子 UPDATE,
保证 challenge / ticket 只能被消费一次。
"""
__tablename__ = "device_challenges"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
challenge_id: Mapped[str] = mapped_column(String(64), nullable=False)
app: Mapped[str] = mapped_column(String(32), nullable=False)
purpose: Mapped[str] = mapped_column(
String(16), nullable=False, default="login", server_default="login"
)
challenge: Mapped[str] = mapped_column(String(128), nullable=False)
poll_token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", server_default="pending"
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
device_id: Mapped[str] = mapped_column(
String(96), nullable=False, default="", server_default=""
)
public_key: Mapped[str] = mapped_column(
String(128), nullable=False, default="", server_default=""
)
client_version: Mapped[str] = mapped_column(
String(32), nullable=False, default="", server_default=""
)
device_ticket_hash: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
authorized_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
ticket_issued_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
__table_args__ = (
UniqueConstraint("challenge_id", name="uq_device_challenges_challenge_id"),
Index("ix_device_challenges_expires", "expires_at"),
Index("ix_device_challenges_user", "user_id", "created_at"),
)
class HuyaAccount(Base):
"""虎牙账号"""