100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
|
|
"""add agent MFA fields
|
||
|
|
|
||
|
|
Revision ID: 023_mfa_fields
|
||
|
|
Revises: 012_sync_remaining_fields
|
||
|
|
Create Date: 2026-06-21
|
||
|
|
|
||
|
|
Phase 2.1 task #17: pyotp TOTP 服务 + User MFA 字段
|
||
|
|
- 新增 mfa_secret 字段(存储 TOTP secret,绑定时生成,首次验证前不算启用)
|
||
|
|
- 新增 mfa_enabled 字段(是否启用 MFA,默认 False)
|
||
|
|
- 新增 mfa_bound_at 字段(首次绑定完成时间,可空)
|
||
|
|
- 新增 mfa_last_verified_at 字段(最近一次验证成功时间,可空)
|
||
|
|
|
||
|
|
为什么需要独立字段而非复用早期 otp_*:
|
||
|
|
Phase 2.1 的 MFA 是面向全员(员工 + 坐席)的统一二次认证方案,
|
||
|
|
与早期仅供 admin 强制 OTP 的 otp_secret / otp_enabled 是两套体系。
|
||
|
|
字段独立便于后续维护 + 迁移路径清晰。
|
||
|
|
|
||
|
|
为什么不破坏现有坐席:
|
||
|
|
- mfa_secret 默认为 NULL,允许已注册坐席不绑定
|
||
|
|
- mfa_enabled 用 server_default=text('false')(字符串 false,不是 Python False),
|
||
|
|
否则 Alembic 会写入整数 0 在 PG 里被解读为 truthy
|
||
|
|
"""
|
||
|
|
from alembic import op
|
||
|
|
import sqlalchemy as sa
|
||
|
|
|
||
|
|
|
||
|
|
# revision identifiers
|
||
|
|
revision = '023_mfa_fields'
|
||
|
|
down_revision = '012_sync_remaining_fields'
|
||
|
|
branch_labels = None
|
||
|
|
depends_on = None
|
||
|
|
|
||
|
|
|
||
|
|
def upgrade() -> None:
|
||
|
|
"""添加 4 个 MFA 字段到 agents 表"""
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# mfa_secret: TOTP 共享密钥(base32,绑定时生成)
|
||
|
|
# 可空,默认 None — 用户没绑定时就是空
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
op.add_column(
|
||
|
|
'agents',
|
||
|
|
sa.Column(
|
||
|
|
'mfa_secret',
|
||
|
|
sa.String(32),
|
||
|
|
nullable=True,
|
||
|
|
comment='MFA TOTP 共享密钥(base32,绑定时生成)',
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# mfa_enabled: 是否启用 MFA
|
||
|
|
# 非空,默认 False
|
||
|
|
# server_default 必须用 text('false') 字符串形式(PG 把 false 解析为布尔 false)
|
||
|
|
# 直接传 sa.text('False') 或 Python False 会被 SQLAlchemy 当成 truthy 写出 '1'
|
||
|
|
# 详见 memory: feedback-adopted-default-bug.md
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
op.add_column(
|
||
|
|
'agents',
|
||
|
|
sa.Column(
|
||
|
|
'mfa_enabled',
|
||
|
|
sa.Boolean(),
|
||
|
|
nullable=False,
|
||
|
|
server_default=sa.text('false'),
|
||
|
|
comment='MFA 是否启用(False/True)',
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# mfa_bound_at: 首次绑定完成时间(可空)
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
op.add_column(
|
||
|
|
'agents',
|
||
|
|
sa.Column(
|
||
|
|
'mfa_bound_at',
|
||
|
|
sa.DateTime(timezone=True),
|
||
|
|
nullable=True,
|
||
|
|
comment='MFA 首次绑定完成时间',
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# mfa_last_verified_at: 最近一次验证成功时间(可空,审计用)
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
op.add_column(
|
||
|
|
'agents',
|
||
|
|
sa.Column(
|
||
|
|
'mfa_last_verified_at',
|
||
|
|
sa.DateTime(timezone=True),
|
||
|
|
nullable=True,
|
||
|
|
comment='MFA 最近一次验证成功时间',
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def downgrade() -> None:
|
||
|
|
"""删除 4 个 MFA 字段(按添加的逆序)"""
|
||
|
|
op.drop_column('agents', 'mfa_last_verified_at')
|
||
|
|
op.drop_column('agents', 'mfa_bound_at')
|
||
|
|
op.drop_column('agents', 'mfa_enabled')
|
||
|
|
op.drop_column('agents', 'mfa_secret')
|