Files
nexus/Project/fonrey/TECH_STACK/登录管理技术方案.md

30 KiB
Raw Blame History

For AI assistants: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.

Fonrey 登录管理系统技术方案

版本: 2.0 | 项目: Fonrey 房产经纪管理系统 | 技术栈: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery
关联 PRD: Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md (v1.3)
关联数据模型: Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md
最后更新: 2026-04-25v2.0 补充服务层设计、HTMX 交互模式、Celery 任务、错误处理规范)

For AI assistants: Read this entire file before writing any code.
All decisions here are final. Do not suggest alternatives unless asked.


一、模块定位与架构边界

登录管理模块(accounts App负责多租户环境下的身份识别、认证、账号安全及凭据找回。

架构层级边界

层级 位置 说明
Tenant ID 验证 shared_appsPublic Schema 属于平台基础服务,在 public schema 下运行,无需租户切换
账号认证、找回密码等 租户 SchemaTenant Schema 通过请求域名 {tenant_slug}.fonrey.com 自动切换,django-tenants 中间件处理
Electron 客户端 前端 负责 Tenant ID 本地缓存、Session 管理、页面加载

模块依赖关系

accounts
    ├── 依赖 → org (Staff 实名绑定,单向依赖)
    ├── 依赖 → core.encryption (手机号加密)
    ├── 依赖 → core.cache (Redis 工具封装)
    ├── 依赖 → shared.tenants (Tenant ID 验证Public Schema)
    └── 被依赖 ← org (离职联动,通过 Service 层调用)

二、依赖与技术选型

依赖项 版本/方案 用途 说明
django.contrib.auth Django 内置 用户认证基础框架 扩展 AbstractBaseUser不直接使用 User 模型username 唯一性约束在租户 Schema 维度生效
django-tenants 已有 多租户隔离 UserAccount 在租户 SchemaTenant 验证接口在 shared_apps
PostgreSQL 已有 数据持久化 Schema 级别隔离租户数据
Redis 必须 多用途缓存 滑块验证 TokenTTL 3min、登录失败计数TTL 30min、密码重置频率限制
Celery 必须 异步任务队列 邮件发送异步处理,防止登录/找回接口超时(邮件发送可能耗时 > 500ms
Pillow 必须(若自研验证码) 图片处理 生成拼图背景图(抠出缺口)+ 拼图碎片,输出 Base64
django-ratelimit 或自定义中间件 必须 接口限流 Tenant 验证、登录、找回密码接口均需限流
electron-store 或 AES 加密文件 Electron 侧 本地持久化 加密存储 Tenant ID不存明文路径为 app.getPath('userData')
secrets (Python 标准库) Python 内置 Token 生成 使用 secrets.token_urlsafe(64) 生成密码重置 Token86 字符)

滑块验证码方案选型(待确认,见开放问题)

方案 优点 缺点
自研Pillow + 前端拖拽组件) 完全可控,无外部依赖,数据合规性好 需维护图库,需自己实现轨迹检测算法
第三方服务(极验 GeeTest / 网易易盾) 开箱即用,安全性更高 引入外部依赖,有数据合规风险,需评估

当前方案:暂按自研设计,后端负责人需在开发启动前确认最终选型。


三、目录结构

fonrey/apps/
└── accounts/                   # 账号认证管理(租户级 App
    ├── models.py               # UserAccount, LoginAttempt, PasswordResetToken, PasswordHistory
    ├── views/
    │   ├── auth.py             # 登录/登出视图HTMX 响应)
    │   ├── captcha.py          # 滑块验证码视图
    │   └── recovery.py         # 找回用户名/密码视图
    ├── urls.py
    ├── serializers.py          # API 序列化JSON 接口,供 Electron 前端使用)
    ├── forms.py                # 登录表单、找回密码表单
    ├── templates/
    │   └── accounts/
    │       ├── login.html              # 登录页(含滑块验证码区域)
    │       ├── tenant_verify.html      # Tenant 识别页(首次启动)
    │       ├── change_password.html    # 强制修改初始密码页
    │       ├── recover_username.html   # 找回用户名页
    │       ├── recover_password.html   # 找回密码(步骤 1身份验证
    │       └── reset_password.html     # 重置密码(步骤 2设置新密码
    └── services/
        ├── auth.py             # 认证逻辑:滑块验证、账号锁定、登录流程
        ├── captcha.py          # 验证码生成与校验Pillow 或第三方)
        ├── recovery.py         # 找回用户名/密码逻辑(含 Celery 任务触发)
        ├── password.py         # 密码复杂度校验、历史密码比对
        └── tenant.py           # Tenant 验证逻辑(属于 shared_appsPublic Schema

fonrey/shared/                  # Public Schema Appdjango-tenants shared_apps
└── tenants/
    ├── models.py               # TenantModel, Domain
    └── views.py                # tenant/verify/ 接口(在公共 Schema 下)

四、数据模型

数据模型完整定义已迁移至 Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md,本节仅保留技术实现视角的关键说明。

4.1 表归属汇总

表名 Schema 说明
user_accounts 租户 Schema 账号主表,username 唯一性在 Schema 维度生效
login_attempts 租户 Schema 登录审计,保留 ≥ 90 天
password_reset_tokens 租户 Schema 一次性重置令牌30 分钟过期
password_histories 租户 Schema 最近 3 次密码哈希,防重用

4.2 关键约束汇总

  • username 唯一性约束仅在当前租户 Schema 内生效(django-tenants 隔离机制),不同租户可以有相同 username
  • 密码存储使用 Django 默认 PBKDF2+SHA256make_password后端不得明文存储或传输
  • phone_enc 字段使用 core.encryption AES-256-GCM 加密存储;phone_hash SHA-256 哈希用于唯一性校验
  • locked_until 字段持久化锁定到期时间,防止 Redis 故障导致锁定状态丢失

五、服务层设计Service Layer

5.1 services/auth.py — 核心认证服务

# apps/accounts/services/auth.py

class AuthService:

    LOGIN_FAIL_LIMIT = 5          # 连续失败次数触发锁定
    LOCK_DURATION_MINUTES = 30    # 锁定时长(分钟)

    @classmethod
    def authenticate(cls, username: str, password: str, captcha_pass_token: str,
                     tenant_id: str, ip_address: str, user_agent: str) -> dict:
        """
        完整登录流程:
        1. 校验 captcha_pass_token一次性凭证Redis 查询后立即删除)
        2. 查询账号(不存在则记录审计日志,返回通用错误)
        3. 检查账号状态locked / disabled
        4. 校验密码
        5. 登录成功后:更新 last_login清零失败计数返回账号信息
        6. 失败时:递增失败计数,超限触发锁定

        Returns:
            {'success': True, 'user': UserAccount, 'is_initial_password': bool}
            {'success': False, 'error_code': str, 'error_message': str}
        """
        ...

    @classmethod
    def _check_lock_status(cls, user: 'UserAccount') -> bool:
        """检查账号锁定状态,自动解锁已到期的锁定"""
        ...

    @classmethod
    def _increment_fail_count(cls, tenant_id: str, username: str) -> int:
        """递增失败计数,返回当前计数;超限时触发账号锁定"""
        ...

    @classmethod
    def _trigger_lock(cls, user: 'UserAccount') -> None:
        """触发账号锁定status=locked, locked_until=now+30min"""
        ...

    @classmethod
    def unlock_account(cls, user: 'UserAccount') -> None:
        """管理员手动解锁账号"""
        ...

5.2 services/captcha.py — 验证码服务

# apps/accounts/services/captcha.py

class CaptchaService:

    CAPTCHA_TTL_SECONDS = 180        # 验证会话有效期3分钟
    PASS_TOKEN_TTL_SECONDS = 180     # 通过凭证有效期3分钟

    @classmethod
    def generate(cls) -> dict:
        """
        生成滑块拼图验证码。
        Returns:
            {
                'session_token': str,     # Redis Key uuid供前端提交时携带
                'background_b64': str,    # 背景图含缺口Base64
                'puzzle_b64': str,        # 拼图碎片 Base64
                'gap_y': int,             # 缺口 Y 坐标(前端定位碎片初始位置)
            }
        注意:缺口 X 坐标gap_x不返回给前端服务端保存在 Redis。
        """
        ...

    @classmethod
    def verify(cls, session_token: str, slide_x: int, trajectory: list) -> dict:
        """
        校验滑动结果。
        Args:
            session_token: generate() 返回的会话标识
            slide_x: 用户最终滑动距离px
            trajectory: 滑动轨迹,格式 [{'x': int, 'y': int, 't': int}, ...]
        Returns:
            {'pass': True, 'pass_token': str}   # 通过pass_token 用于登录接口
            {'pass': False, 'message': str}     # 失败,前端自动刷新拼图
        校验规则:
        1. 位置偏差abs(slide_x - gap_x) <= 5px
        2. 轨迹特征:存在加速→减速曲线,拒绝匀速/程序化轨迹
        """
        ...

5.3 services/recovery.py — 找回账号服务

# apps/accounts/services/recovery.py

class RecoveryService:

    RESET_LINK_EXPIRE_MINUTES = 30
    MAX_EMAILS_PER_HOUR = 3

    @classmethod
    def request_username_recovery(cls, email: str) -> None:
        """
        发起找回用户名。
        - 无论邮箱是否存在,统一返回「如该邮箱已绑定账号,您将收到邮件」
        - 邮箱存在时:触发 Celery 任务异步发送邮件
        - 限频:同一邮箱 1 小时内最多 3 次Redis 计数)
        """
        ...

    @classmethod
    def request_password_reset(cls, username: str, email: str) -> None:
        """
        发起找回密码(步骤 1        - 无论匹配结果,统一返回「如信息匹配,重置链接将发送至邮箱」(防枚举)
        - 匹配成功时:生成 PasswordResetToken触发 Celery 异步发送邮件
        - 限频:同一账号 1 小时内最多 3 次Redis 计数)
        """
        ...

    @classmethod
    def reset_password(cls, token_str: str, new_password: str) -> dict:
        """
        重置密码(步骤 2        Returns:
            {'success': True}
            {'success': False, 'error_code': 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'PASSWORD_REUSED'}
        操作顺序:
        1. 查询并校验 tokenis_used=False, expires_at > now
        2. 校验密码复杂度
        3. 校验历史密码(最近 3 次)
        4. 更新密码哈希is_initial_password=False
        5. 标记 token is_used=True
        6. 清除该账号所有有效 Session强制重新登录
        7. 写入 PasswordHistory
        """
        ...

5.4 services/password.py — 密码规则服务

# apps/accounts/services/password.py

class PasswordService:

    MIN_LENGTH = 8
    MAX_LENGTH = 32
    HISTORY_COUNT = 3  # 保留最近 N 条历史密码

    @classmethod
    def validate_complexity(cls, password: str) -> list[str]:
        """
        校验密码复杂度。
        Returns: 错误列表(空列表表示通过)
        规则:
        - 长度 8~32 位
        - 必须包含字母(区分大小写)
        - 必须包含数字
        """
        ...

    @classmethod
    def check_history(cls, user: 'UserAccount', new_password: str) -> bool:
        """
        检查新密码是否与最近 3 次历史密码重复。
        Returns: True允许使用/ False与历史重复
        """
        ...

    @classmethod
    def save_history(cls, user: 'UserAccount', password_hash: str) -> None:
        """
        保存新密码哈希至历史记录,超出 HISTORY_COUNT 时删除最旧记录。
        """
        ...

六、Celery 异步任务

# apps/accounts/tasks.py

from celery import shared_task

@shared_task(
    name='accounts.send_username_recovery_email',
    max_retries=3,
    default_retry_delay=60,  # 失败后 60 秒重试
)
def send_username_recovery_email(email: str, username: str, company_name: str) -> None:
    """
    发送找回用户名邮件。
    失败时自动重试最多 3 次3 次均失败则写入告警日志Sentry    邮件内容:用户名 + 发送时间 + 联系管理员说明。
    """
    ...


@shared_task(
    name='accounts.send_password_reset_email',
    max_retries=3,
    default_retry_delay=60,
)
def send_password_reset_email(email: str, reset_link: str, company_name: str,
                               expires_at: str) -> None:
    """
    发送密码重置链接邮件。
    失败时自动重试最多 3 次3 次均失败则写入告警日志Sentry    邮件内容重置链接30分钟有效+ 安全说明。
    """
    ...

重试策略邮件发送失败时不向前端返回错误用户已看到「邮件已发送」提示在后台静默重试3 次重试均失败后通过 Sentry 上报告警,管理员可在后台查看 Token 手动告知用户。


七、接口清单

接口 方法 Schema 位置 是否需要鉴权 限流规则 响应格式 说明
/api/auth/tenant/verify/ POST Publicshared 每 IP 每分钟 ≤ 10 次 JSON Tenant ID 验证
/api/auth/captcha/ GET Tenant JSON 获取滑块拼图验证码
/api/auth/captcha/verify/ POST Tenant JSON 提交滑动轨迹,返回一次性通过凭证
/api/auth/login/ POST Tenant 每 IP 每分钟 ≤ 20 次 JSON 账号密码登录
/api/auth/logout/ POST Tenant JSON 登出,使服务端 Session 失效
/api/auth/password/change/ POST Tenant JSON / HTMX 强制修改初始密码(登录后跳转)
/api/auth/recover/username/ POST Tenant 每邮箱每小时 ≤ 3 次 JSON / HTMX 发起找回用户名(发送邮件)
/api/auth/recover/password/request/ POST Tenant 每账号每小时 ≤ 3 次 JSON / HTMX 发起找回密码(发送重置链接邮件)
/api/auth/recover/password/reset/ POST Tenant Token 鉴权) JSON / HTMX 提交新密码,使用 PasswordResetToken 校验
/api/auth/login/phone/ POST Tenant JSON 预留v2 实现,手机验证码登录
/api/auth/wechat/qrcode/ GET Tenant JSON 预留v2 实现,获取微信二维码
/api/auth/wechat/callback/ POST Tenant JSON 预留v2 实现,微信扫码回调

7.1 Tenant 验证接口规范

POST /api/auth/tenant/verify/

Request Body:
{
  "tenant_id": "202500010001"   // 固定 12 位纯数字
}

Response 200 (验证通过):
{
  "valid": true,
  "tenant_name": "XX房产经纪有限公司",
  "tenant_logo_url": "https://cdn.fonrey.com/tenants/xxx/logo.png",
  "login_url": "https://xxx.fonrey.com/auth/login/"
}

Response 200 (验证失败):
{
  "valid": false,
  "error_code": "TENANT_NOT_FOUND",
  "message": "识别码无效"
}

失败响应统一返回 HTTP 200不区分「未找到」与「已禁用」防止枚举攻击。

7.2 登录接口规范

POST /api/auth/login/

Request Body:
{
  "username": "string",
  "password": "string",
  "captcha_pass_token": "string"   // 滑块验证通过后的一次性凭证UUID
}

Response 200 (登录成功):
{
  "success": true,
  "token": "...",
  "user": {
    "id": 1,
    "username": "...",
    "display_name": "...",
    "is_initial_password": false
  }
}

Response 200 (登录失败):
{
  "success": false,
  "error_code": "WRONG_CREDENTIALS" | "ACCOUNT_LOCKED" | "ACCOUNT_DISABLED" | "CAPTCHA_INVALID",
  "message": "...",
  "lock_remaining_seconds": 1800   // 仅 ACCOUNT_LOCKED 时返回
}

注意WRONG_CREDENTIALS 不区分「用户名错误」与「密码错误」,防止枚举攻击。

7.3 验证码接口规范

GET /api/auth/captcha/

Response 200:
{
  "session_token": "uuid-string",     // 提交验证时携带
  "background_b64": "data:image/png;base64,...",   // 带缺口的背景图
  "puzzle_b64": "data:image/png;base64,...",       // 拼图碎片
  "gap_y": 120,                       // 缺口 Y 坐标(用于定位碎片初始位置)
  "width": 320,                       // 背景图宽度px
  "height": 160                       // 背景图高度px
}

POST /api/auth/captcha/verify/

Request Body:
{
  "session_token": "uuid-string",
  "slide_x": 185,                     // 最终滑动距离px
  "trajectory": [
    {"x": 0, "y": 0, "t": 0},
    {"x": 20, "y": 1, "t": 80},
    {"x": 185, "y": 2, "t": 1200}
  ]
}

Response 200 (验证通过):
{
  "pass": true,
  "pass_token": "uuid-string"   // 一次性凭证TTL 3分钟登录时携带
}

Response 200 (验证失败):
{
  "pass": false,
  "message": "验证失败,请重新拖动"
}

八、前端交互模式HTMX + Alpine.js

8.1 页面结构说明

登录相关页面均为全页面渲染Server-Side RenderedElectron 客户端通过 BrowserWindow.loadURL() 加载完整 HTML。登录流程中的局部交互如验证码刷新、错误提示通过 HTMX 局部刷新实现。

8.2 登录页核心交互

<!-- 登录页accounts/login.html -->

<!-- 滑块验证码区域Alpine.js 管理状态) -->
<div x-data="captchaWidget()" x-init="loadCaptcha()">
    <!-- 背景图 + 拼图 -->
    <div class="captcha-container">
        <img :src="backgroundSrc" alt="验证图片">
        <div class="puzzle-piece"
             :style="`left: ${slideX}px; top: ${gapY}px`"
             :src="puzzleSrc">
        </div>
    </div>

    <!-- 滑块轨道 -->
    <div class="slider-track"
         @mousedown="startSlide($event)"
         @touchstart="startSlide($event)">
        <div class="slider-thumb" :class="{'verified': passed, 'shake': failed}">
            <span x-show="!passed && !failed">拖动完成拼图</span>
            <span x-show="passed" class="text-green-500">验证通过 ✓</span>
        </div>
    </div>

    <!-- 刷新按钮 -->
    <button @click="loadCaptcha()" type="button">🔄</button>
</div>

<!-- 登录表单HTMX 提交) -->
<form hx-post="/api/auth/login/"
      hx-target="#login-feedback"
      hx-swap="innerHTML"
      hx-indicator="#login-spinner">
    <input type="hidden" name="captcha_pass_token" x-bind:value="passToken">
    <input type="text" name="username" placeholder="请输入用户名">
    <input type="password" name="password" placeholder="请输入密码">
    <button type="submit" :disabled="!passed">登录</button>
</form>
<div id="login-feedback"></div>
<div id="login-spinner" class="htmx-indicator">登录中...</div>

Alpine.js 职责:管理验证码状态(加载中/通过/失败)、滑动轨迹记录、pass_token 绑定到表单隐藏字段。
HTMX 职责:表单提交、错误反馈局部渲染(hx-target="#login-feedback")。

8.3 HTMX 响应片段规范

登录接口在 HTMX 请求时(HX-Request: true Header返回 HTML 片段而非 JSONhx-target 局部替换:

登录成功(服务端返回 302 重定向HTMX 通过 HX-Redirect Header 处理):

# views/auth.py
if request.headers.get('HX-Request'):
    response = HttpResponse()
    response['HX-Redirect'] = '/dashboard/'  # 跳转首页
    return response

登录失败(返回错误提示 HTML 片段):

<!-- 服务端渲染的错误片段 -->
<div class="text-red-500 text-sm mt-2">
    用户名或密码错误,请重新输入
</div>

初始密码状态(登录成功但需修改密码):

if request.headers.get('HX-Request'):
    response = HttpResponse()
    response['HX-Redirect'] = '/auth/password/change/'
    return response

九、Redis Key 规范

用途 Key 格式 类型 TTL 说明
滑块验证会话(含缺口位置) captcha_session:{uuid} HASH 3 分钟 存储 gap_x, session_token;验证后立即删除
滑块验证通过凭证 captcha_pass:{uuid} STRING 3 分钟 登录接口验证后立即删除(单次有效)
登录失败计数 login_fail:{tenant_id}:{username} STRING 30 分钟 计数 ≥ 5 时触发锁定TTL 30 分钟自动清零
找回邮件发送频率 recover_email:{email} STRING 1 小时 记录已发送次数,上限 3 次/小时
密码重置 Token 生成频率 recover_reset:{user_id} STRING 1 小时 同一账号生成次数,上限 3 次/小时
Tenant ID 限流 tenant_verify_ip:{ip} STRING 1 分钟 计数 ≥ 10 时拒绝请求

故障恢复Redis 重启后,登录失败计数归零(用户可正常登录);账号锁定状态由 user_accounts.locked_until 持久化保证,不依赖 Redis。


十、安全机制设计

10.1 滑块拼图验证码

  • 图片生成Pillow 从预置图库随机抽取背景图,服务端随机生成缺口位置,抠出缺口并生成拼图碎片,两者分别以 Base64 返回前端
  • 缺口位置保护gap_x(水平位置)仅存于服务端 Redis不返回给前端;前端通过 slide_x 提交,服务端对比 gap_x 校验
  • 轨迹校验(双重判断):
    • 位置偏差abs(slide_x - gap_x) ≤ 5px
    • 轨迹特征:速度变化曲线存在加速→减速(人类滑动特征),拒绝匀速/程序化轨迹
  • 独立计数:验证码失败不计入账号密码错误次数,两者独立计数
  • 单次有效captcha_pass_token TTL 3 分钟,登录接口校验后立即删除

10.2 账号锁定机制

同一账号连续密码错误 ≥ 5 次:
    1. Redis `login_fail:{tenant_id}:{username}` 计数达到阈值
    2. 更新 user_accounts.status = 'locked'
    3. 设置 user_accounts.locked_until = now() + 30min
    4. 锁定状态下,登录接口直接返回 ACCOUNT_LOCKED不再校验密码

解锁条件(任一满足):
    A. locked_until 到期:应用层在下次登录时检测,自动恢复 status=active
    B. Tenant Admin 手动解锁:调用 AuthService.unlock_account()

10.3 密码安全

规则 说明
存储哈希 Django PBKDF2+SHA256make_password
传输安全 强制 HTTPS前端不加密密码HTTPS 层保证)
复杂度 长度 8~32 位,必须包含字母(区分大小写)+ 数字;建议特殊符号(非强制)
历史密码 不得与最近 3 次历史密码哈希相同(含系统固定初始密码)
Session 有效期 默认 8 小时;可由 Tenant Admin 在「系统设置」中调整

10.4 防枚举攻击设计

场景 防御措施
登录失败 不区分「用户名错误」与「密码错误」,统一返回 WRONG_CREDENTIALS
找回用户名/密码 无论邮箱/用户名是否存在,统一返回相同响应文案
Tenant ID 验证 不区分「租户不存在」与「租户已禁用」IP 限流每分钟 ≤ 10 次
密码重置 Token Token 使用 secrets.token_urlsafe(64) 生成86 字符),不可预测

10.5 密码重置流程安全要点

  • Token 由 secrets.token_urlsafe(64) 生成86 字符,全局唯一
  • 单次有效:使用后立即标记 is_used=True(先标记再执行,防止并发重放)
  • 有效期 30 分钟(expires_at = created_at + timedelta(minutes=30)
  • 重置成功后:清除该账号所有有效 Session强制重新登录
  • 重置成功后:is_initial_password = False,写入 PasswordHistory

十一、Electron 客户端约定

约定项 规格
Tenant ID 存储 electron-storeapp.getPath('userData') + AES 加密文件,不存明文
Session Token 存储 内存(global 变量)+ Chromium session Cookie不写入磁盘明文文件
登录页加载方式 主进程根据 Tenant ID 构建 https://{tenant_slug}.fonrey.com/auth/login/,通过 BrowserWindow.loadURL() 加载
多标签页 同一 BrowserWindow 内所有页面共享同一 Session Cookie
客户端登出 调用 POST /api/auth/logout/ 使服务端 Session 失效 + 清除 Chromium Session Cookie
窗口关闭 Session 保留(不自动登出),下次打开若 Session 未过期则直接进入系统
强制更新 客户端版本低于服务端 min_required_version 时,阻断登录流程,展示更新提示(详见发布管理模块 PRD
Tenant ID 缓存校验 非首次启动时,客户端向服务端发起缓存 Tenant ID 有效性校验(POST /api/auth/tenant/verify/);无效则清除缓存,重新显示 Tenant 识别界面

十二、多租户隔离要点

  • UserAccountLoginAttemptPasswordResetTokenPasswordHistory 均位于租户 Schema 内,数据完全隔离
  • username 唯一性约束在 Schema 维度生效,不同租户可以存在相同 username
  • Tenant 验证接口(/api/auth/tenant/verify/)位于 Public Schemashared_apps),查询 TenantModel
  • 登录等接口通过请求域名({tenant_slug}.fonrey.com)自动切换 Schemadjango-tenants 中间件处理,无需手动切换
  • 所有接口禁止跨租户数据访问ORM 查询范围自动限制在当前 Schema

十三、错误处理规范

13.1 标准错误码

Error Code HTTP Status 含义 前端显示文案
WRONG_CREDENTIALS 200 用户名或密码错误 「用户名或密码错误,请重新输入」
ACCOUNT_LOCKED 200 账号已锁定 「账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁」
ACCOUNT_DISABLED 200 账号已停用 「账号已停用,请联系您的管理员」
CAPTCHA_INVALID 200 验证码凭证无效/已过期 「验证码已失效,请重新验证」
CAPTCHA_FAIL 200 滑块位置/轨迹校验失败 「验证失败,请重新拖动」
TENANT_NOT_FOUND 200 Tenant ID 无效 「识别码无效,请联系您的系统管理员获取正确的识别码」
TOKEN_INVALID 200 重置 Token 无效或已使用 「链接已过期或已使用,请重新申请」
TOKEN_EXPIRED 200 重置 Token 已过期 「链接已过期,请重新申请」
PASSWORD_TOO_WEAK 200 密码不符合复杂度 逐条显示不满足的规则
PASSWORD_REUSED 200 密码与历史密码相同 「新密码不能与最近 3 次历史密码相同」

设计原则:所有登录相关接口统一返回 HTTP 200通过 error_code 字段区分业务错误,避免 HTTP 状态码暴露系统行为(防止通过 4xx/5xx 枚举账号状态)。

13.2 异常监控

  • 所有未预期异常5xx通过 Sentry 上报,含 tenant_idusername(脱敏)、堆栈信息
  • 邮件发送 Celery 任务 3 次重试失败后,上报 Sentry 告警并记录 task_id,管理员可在系统后台查询

十四、已知风险与缓解措施

风险 可能性 影响 缓解措施
滑块验证被机器模拟轨迹绕过 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹
Tenant ID 枚举攻击 限流(每 IP 每分钟 ≤ 10 次);响应不区分「未找到」与「已禁用」
密码重置 Token 泄露 单次有效 + 30 分钟过期 + HTTPS 传输
邮件发送失败 异步任务自动重试 3 次;失败写入 Sentry 告警;管理员可通过后台查看 Token 手动告知用户
多端并发登录 高(正常场景) 本期允许v2 可在 Token 引入版本号实现踢出策略
Redis 故障导致锁定状态丢失 locked_until 字段持久化至 PostgreSQLRedis 故障不影响锁定判断

十五、开放问题(开发启动前必须确认)

# 问题 负责人 截止
1 邮件服务商选型SendGrid / 阿里云邮件推送 / SMTP 自建? 后端负责人 + 运维 开发启动前
2 滑块验证码方案自研Pillow还是第三方极验 / 网易易盾)? 后端负责人 + 安全 开发启动前
3 Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? 产品经理 开发启动前
4 账号锁定后是否自动发邮件通知用户和/或管理员? 产品经理 开发启动前
5 历史密码校验范围:最近 3 次是否足够?是否增加「不得与用户名相同」规则? 产品经理 开发启动前

十六、明确禁止

  • 不得使用 Django 原生 User 模型,必须扩展 AbstractBaseUser
  • 不得在全局 Schema 创建 UserAccount 表(必须在租户 Schema 内)
  • 不得明文存储或传输密码
  • 不得在 LoginAttempt 记录中存储密码明文(含错误密码)
  • 不得在前端做密码哈希HTTPS 层保证传输安全)
  • 不得将 Session Token 写入 Electron 磁盘明文文件
  • 不得在找回账号/密码响应中区分「邮箱存在」与「邮箱不存在」(防止枚举)
  • PasswordResetToken 不得重复使用(is_used=True 后立即失效)
  • 登录失败响应不得区分「用户名错误」与「密码错误」
  • 不得将 gap_x(缺口水平位置)返回给前端(防止绕过验证)
  • 耗时超过 500ms 的操作(如邮件发送)必须通过 Celery 异步执行,不得在请求线程中同步等待