chore: sync local project changes

This commit is contained in:
Shen Wei
2026-04-27 16:26:07 +08:00
parent dfcf7de003
commit 5854781fa8
144 changed files with 12849 additions and 12330 deletions

View File

@@ -1,711 +1,281 @@
> **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 任务、错误处理规范)
# Fonrey 登录管理技术方案
> **For AI assistants**: Read this entire file before writing any code.
> All decisions here are final. Do not suggest alternatives unless asked.
**版本**: 3.1
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`
**关联数据模型**: `DATA_MODEL/DATA_MODEL_LOGIN.md`(本方案不重复 DDL
**最后更新**: 2026-04-27
---
## 一、模块定位与架构边界
## 一、文档定位与边界
登录管理模块(`accounts` App负责多租户环境下的身份识别、认证、账号安全及凭据找回。
本文件仅定义登录模块的:
### 架构层级边界
1. 模块边界与服务职责
2. API 端点设计(页面 / HTMX / JSON
3. 登录安全策略(验证码、锁定、会话、找回)
4. 缓存与异步任务策略
5. 错误码与测试映射
| 层级 | 位置 | 说明 |
|------|------|------|
| Tenant ID 验证 | `shared_apps`Public 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 层调用)
```
> 不在本文件展开表字段、索引、DDL。数据结构以 `DATA_MODEL_LOGIN.md` 为唯一权威。
---
## 二、依赖与技术选型
## 二、范围定义(以 P0 为准)
| 依赖项 | 版本/方案 | 用途 | 说明 |
|--------|-----------|------|------|
| `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 字符) |
### 2.1 P0 必须覆盖
### 滑块验证码方案选型(待确认,见开放问题
- Tenant ID 校验Public Schema
- 用户名 + 密码登录Tenant Schema
- 验证码挑战与一次性 pass token
- 连续失败锁定与解锁机制
- 找回用户名 / 重置密码
- 首次登录强制改密
- 安全登出与会话销毁
| 方案 | 优点 | 缺点 |
|------|------|------|
| 自研Pillow + 前端拖拽组件) | 完全可控,无外部依赖,数据合规性好 | 需维护图库,需自己实现轨迹检测算法 |
| 第三方服务(极验 GeeTest / 网易易盾) | 开箱即用,安全性更高 | 引入外部依赖,有数据合规风险,需评估 |
### 2.2 预留(非本期强制)
**当前方案**:暂按自研设计,后端负责人需在开发启动前确认最终选型。
- MFAOTP / 短信)
- 企业 SSOOAuth2 / SAML
- 设备指纹与风险评分
---
## 三、目录结构
## 三、模块架构边界
```
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
## 3.1 模块职责(`apps/account`
fonrey/shared/ # Public Schema Appdjango-tenants shared_apps
└── tenants/
├── models.py # TenantModel, Domain
└── views.py # tenant/verify/ 接口(在公共 Schema 下)
```
- 登录认证入口与 Session 建立
- 密码策略、锁定策略、登录防刷策略执行
- 找回流程与一次性重置令牌管理
- 登录/登出/失败审计事件写入
## 3.2 多租户分层职责
| 层级 | Schema | 职责 |
|---|---|---|
| Tenant ID 校验 | Public | 校验租户标识、域名映射、租户状态 |
| 登录认证 | Tenant | 账号鉴权、失败计数、会话建立 |
| 密码找回 | Tenant | 身份确认、令牌签发与核销 |
## 3.3 外部依赖
| 依赖模块 | 用途 |
|---|---|
| `apps/org` | 员工状态联动(离职/冻结禁止登录) |
| `core/encryption.py` | 手机号等敏感信息加密与脱敏 |
| `core/cache.py` | 验证码票据、失败计数、频控缓存 |
| `Celery` | 找回消息异步发送、安全日报任务 |
---
## 四、数据模型
## 四、API 设计原则
> **数据模型完整定义已迁移至** `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+SHA256``make_password`**后端不得明文存储或传输**
- `phone_enc` 字段使用 `core.encryption` AES-256-GCM 加密存储;`phone_hash` SHA-256 哈希用于唯一性校验
- `locked_until` 字段持久化锁定到期时间,防止 Redis 故障导致锁定状态丢失
1. 登录链路固定三段Tenant 校验 → 验证码 → 账号认证。
2. 最小暴露原则:账号不存在与密码错误统一返回。
3. 认证状态以数据库为准Redis 仅做加速与频控。
4. 安全相关写操作后立即失效缓存,不依赖 TTL。
5. HTMX 返回片段JSON 返回结构化错误体。
---
## 五、服务层设计Service Layer
## 五、端点清单(核心
### 5.1 `services/auth.py` — 核心认证服务
## 5.1 页面路由SSR
```python
# apps/accounts/services/auth.py
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/account/tenant/verify/` | GET | 否 | 首次租户识别页 |
| `/account/login/` | GET | 否 | 登录页 |
| `/account/change-password/` | GET | 是 | 首登强制改密页 |
| `/account/recover-username/` | GET | 否 | 找回用户名页 |
| `/account/recover-password/` | GET | 否 | 找回密码页 |
class AuthService:
## 5.2 HTMX 片段端点
LOGIN_FAIL_LIMIT = 5 # 连续失败次数触发锁定
LOCK_DURATION_MINUTES = 30 # 锁定时长(分钟)
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/account/fragments/login-form/` | GET | 登录表单局刷 | HTML 片段 |
| `/account/fragments/captcha/` | GET | 验证码区块刷新 | HTML 片段 |
| `/account/fragments/recover-step/` | GET | 找回步骤局刷 | HTML 片段 |
@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. 失败时:递增失败计数,超限触发锁定
## 5.3 JSON APIP0
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` — 验证码服务
```python
# 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` — 找回账号服务
```python
# 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` — 密码规则服务
```python
# 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 时删除最旧记录。
"""
...
```
| 端点 | 方法 | 说明 |
|---|---|---|
| `/api/account/tenant/verify/` | POST | 校验 tenant_id 与可用性 |
| `/api/account/captcha/generate/` | POST | 生成验证码 challenge |
| `/api/account/captcha/verify/` | POST | 校验 challenge 并签发 pass token |
| `/api/account/login/` | POST | 登录并建立 session |
| `/api/account/logout/` | POST | 登出并销毁 session |
| `/api/account/password/change/` | POST | 登录态改密 |
| `/api/account/username/recover/` | POST | 找回用户名 |
| `/api/account/password/recover/request/` | POST | 申请重置密码令牌 |
| `/api/account/password/recover/reset/` | POST | 使用令牌重置密码 |
---
## 六、Celery 异步任务
## 六、关键 API 规范(请求/响应)
```python
# apps/accounts/tasks.py
## 6.1 登录
from celery import shared_task
`POST /api/account/login/`
@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:
```json
{
"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": "识别码无效"
"tenant_id": "fonrey-sh",
"username": "agent_001",
"password": "******",
"captcha_pass_token": "token"
}
```
> 失败响应统一返回 HTTP 200不区分「未找到」与「已禁用」防止枚举攻击。
成功 `200`
### 7.2 登录接口规范
```
POST /api/auth/login/
Request Body:
```json
{
"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 时返回
"message": "登录成功",
"redirect_url": "/home/"
}
```
> **注意**`WRONG_CREDENTIALS` 不区分「用户名错误」与「密码错误」,防止枚举攻击。
失败码:`ACCOUNT_LOGIN_INVALID_CREDENTIAL` / `ACCOUNT_LOCKED` / `ACCOUNT_CAPTCHA_INVALID`
### 7.3 验证码接口规范
## 6.2 申请重置密码
```
GET /api/auth/captcha/
`POST /api/account/password/recover/request/`
Response 200:
```json
{
"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": "验证失败,请重新拖动"
"tenant_id": "fonrey-sh",
"username": "agent_001",
"contact": "138****0000"
}
```
---
## 八、前端交互模式HTMX + Alpine.js
### 8.1 页面结构说明
登录相关页面均为**全页面渲染**Server-Side RenderedElectron 客户端通过 `BrowserWindow.loadURL()` 加载完整 HTML。登录流程中的局部交互如验证码刷新、错误提示通过 HTMX 局部刷新实现。
### 8.2 登录页核心交互
```html
<!-- 登录页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 片段而非 JSON`hx-target` 局部替换:
**登录成功**(服务端返回 302 重定向HTMX 通过 `HX-Redirect` Header 处理):
```python
# views/auth.py
if request.headers.get('HX-Request'):
response = HttpResponse()
response['HX-Redirect'] = '/dashboard/' # 跳转首页
return response
```
**登录失败**(返回错误提示 HTML 片段):
```html
<!-- 服务端渲染的错误片段 -->
<div class="text-red-500 text-sm mt-2">
用户名或密码错误,请重新输入
</div>
```
**初始密码状态**(登录成功但需修改密码):
```python
if request.headers.get('HX-Request'):
response = HttpResponse()
response['HX-Redirect'] = '/auth/password/change/'
return response
```
规则:
- 频率限制(建议 5 次/小时)
- 统一返回文案,避免枚举账号存在性
---
## 九、Redis Key 规范
## 七、HTMX 交互约定
| 用途 | 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 时拒绝请求 |
## 7.1 Header 约定
> **故障恢复**Redis 重启后,登录失败计数归零(用户可正常登录);账号锁定状态由 `user_accounts.locked_until` 持久化保证,不依赖 Redis。
- 请求头:`HX-Request: true`
- 成功触发:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败触发:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 登录成功:`HX-Redirect: /home/`
## 7.2 模板分片命名
- `templates/account/fragments/login_form.html`
- `templates/account/fragments/captcha_panel.html`
- `templates/account/fragments/recover_step.html`
---
## 十、安全机制设计
## 八、权限与数据范围
### 10.1 滑块拼图验证码
## 8.1 访问控制
- **图片生成**`Pillow` 从预置图库随机抽取背景图,服务端随机生成缺口位置,抠出缺口并生成拼图碎片,两者分别以 Base64 返回前端
- **缺口位置保护**`gap_x`(水平位置)仅存于服务端 Redis**不返回给前端**;前端通过 `slide_x` 提交,服务端对比 `gap_x` 校验
- **轨迹校验**(双重判断):
- **位置偏差**`abs(slide_x - gap_x) ≤ 5px`
- **轨迹特征**:速度变化曲线存在加速→减速(人类滑动特征),拒绝匀速/程序化轨迹
- **独立计数**:验证码失败**不计入**账号密码错误次数,两者独立计数
- **单次有效**`captcha_pass_token` TTL 3 分钟,登录接口校验后立即删除
- 匿名可访问:租户校验、登录、找回相关端点
- 登录后访问:改密、登出
- 首登未改密用户仅允许访问改密页面
### 10.2 账号锁定机制
## 8.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+SHA256``make_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`
登录与找回相关操作至少记录:
- tenant_schema
- username或脱敏标识
- ip / user_agent
- result_code
- created_at
---
## 十一、Electron 客户端约定
## 九、异步任务与缓存策略
| 约定项 | 规格 |
|--------|------|
| Tenant ID 存储 | `electron-store``app.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 识别界面 |
## 9.1 Celery 任务
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `account_send_recover_message_task` | 找回请求成功后 | 异步发送邮件/短信 |
| `account_security_digest_task` | 定时任务 | 汇总锁定、失败、异常登录统计 |
## 9.2 Redis Key 规范
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:account:captcha:{challenge_id}` | 180s | 验证码挑战态 |
| `{schema}:account:captcha:pass:{token}` | 180s | 验证通过一次性票据 |
| `{schema}:account:login_fail:{username}` | 1800s | 登录失败计数 |
| `{schema}:account:recover:rate:{username}` | 3600s | 找回频控 |
---
## 十二、多租户隔离要点
## 十、性能与可靠性约束
- `UserAccount``LoginAttempt``PasswordResetToken``PasswordHistory` 均位于**租户 Schema 内**,数据完全隔离
- `username` 唯一性约束在 Schema 维度生效,不同租户可以存在相同 username
- Tenant 验证接口(`/api/auth/tenant/verify/`)位于 **Public Schema**`shared_apps`),查询 `TenantModel`
- 登录等接口通过请求域名(`{tenant_slug}.fonrey.com`)自动切换 Schema`django-tenants` 中间件处理,**无需手动切换**
- 所有接口禁止跨租户数据访问ORM 查询范围自动限制在当前 Schema
- 登录链路接口目标:`p95 < 300ms`(不含外部消息发送)
- 找回请求接口目标:`p95 < 400ms`(消息发送异步化)
- 缓存故障时系统应降级到 DB 校验,但保留频控硬兜底
- 验证码组件不可用时,应返回明确错误并禁止跳过登录防护
---
## 十三、错误处理规范
## 十一、安全与合规
### 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_id``username`(脱敏)、堆栈信息
- 邮件发送 Celery 任务 3 次重试失败后,上报 Sentry 告警并记录 `task_id`,管理员可在系统后台查询
1. 密码仅允许 Django 安全哈希PBKDF2 / Argon2
2. 连续失败 N 次锁定(建议 5 次30 分钟)。
3. 重置令牌一次性使用,过期失效,不可复用。
4. 登出必须销毁会话,旧页面刷新需重定向登录。
5. 敏感字段仅脱敏返回,禁止明文日志输出。
---
## 十四、已知风险与缓解措施
## 十二、错误码建议
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|---------|
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹 |
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分「未找到」与「已禁用」 |
| 密码重置 Token 泄露 | 低 | 高 | 单次有效 + 30 分钟过期 + HTTPS 传输 |
| 邮件发送失败 | 中 | 中 | 异步任务自动重试 3 次;失败写入 Sentry 告警;管理员可通过后台查看 Token 手动告知用户 |
| 多端并发登录 | 高(正常场景) | 低 | 本期允许v2 可在 Token 引入版本号实现踢出策略 |
| Redis 故障导致锁定状态丢失 | 低 | 中 | `locked_until` 字段持久化至 PostgreSQLRedis 故障不影响锁定判断 |
| code | HTTP | 场景 |
|---|---|---|
| `ACCOUNT_TENANT_INVALID` | 400 | tenant 无效或停用 |
| `ACCOUNT_CAPTCHA_INVALID` | 400 | 验证码失败/过期 |
| `ACCOUNT_LOGIN_INVALID_CREDENTIAL` | 401 | 账号或密码错误 |
| `ACCOUNT_LOCKED` | 423 | 账号锁定中 |
| `ACCOUNT_PASSWORD_WEAK` | 400 | 新密码不满足复杂度 |
| `ACCOUNT_RESET_TOKEN_INVALID` | 400 | 重置令牌无效/已使用 |
---
## 十五、开放问题(开发启动前必须确认
## 十三、测试映射P0
| # | 问题 | 负责人 | 截止 |
|---|------|--------|------|
| 1 | 邮件服务商选型SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
| 2 | 滑块验证码方案自研Pillow还是第三方极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
| 3 | Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
| 4 | 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
| 5 | 历史密码校验范围:最近 3 次是否足够?是否增加「不得与用户名相同」规则? | 产品经理 | 开发启动前 |
| 场景 | 最低覆盖 |
|---|---|
| Tenant 校验 | 有效/无效/停用租户 |
| 登录链路 | 验证码通过、失败锁定、解锁后登录 |
| 找回流程 | 频控、令牌一次性、过期处理 |
| 首登改密 | 未改密不可进入业务页 |
| 登出 | 会话销毁、回退重定向 |
测试文件:`tests/integration/account/test_us_account.py`
---
## 十六、明确禁止
## 十四、落地顺序建议
- ❌ 不得使用 Django 原生 `User` 模型,必须扩展 `AbstractBaseUser`
- ❌ 不得在全局 Schema 创建 `UserAccount` 表(必须在租户 Schema 内)
- ❌ 不得明文存储或传输密码
- ❌ 不得在 `LoginAttempt` 记录中存储密码明文(含错误密码)
- ❌ 不得在前端做密码哈希HTTPS 层保证传输安全)
- ❌ 不得将 Session Token 写入 Electron 磁盘明文文件
- ❌ 不得在找回账号/密码响应中区分「邮箱存在」与「邮箱不存在」(防止枚举)
-`PasswordResetToken` 不得重复使用(`is_used=True` 后立即失效)
- ❌ 登录失败响应不得区分「用户名错误」与「密码错误」
- ❌ 不得将 `gap_x`(缺口水平位置)返回给前端(防止绕过验证)
- ❌ 耗时超过 500ms 的操作(如邮件发送)必须通过 Celery 异步执行,不得在请求线程中同步等待
1. 先实现 tenant 校验 + 登录主链路
2. 再接验证码 + 失败锁定
3. 再实现找回用户名/密码
4. 最后补安全摘要任务与审计报表
---
## 十五、文档同步规则
- 登录数据结构调整:同步 `DATA_MODEL_LOGIN.md`
- 安全策略或门禁调整:同步 `权限管理系统技术方案.md`
- API 变更:同步本文件与登录 PRD 验收条目