feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
@@ -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):
|
||||
"""虎牙账号"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user