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

304 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Fonrey 登录管理系统技术方案
**版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery
**关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **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_apps`(公共 Schema | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 |
| 账号认证、找回密码等 | 租户 SchemaTenant Schema | 通过请求域名 `{tenant_slug}.fonrey.com` 自动切换,`django-tenants` 中间件处理 |
| Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session Token 管理、页面加载 |
---
## 二、依赖与技术选型
| 依赖项 | 版本/方案 | 用途 | 说明 |
|--------|-----------|------|------|
| `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`**不直接使用** `User` 模型username 唯一性约束在租户 Schema 维度生效,而非全局 |
| `django-tenants` | 已有 | 多租户隔离 | `UserAccount` 在租户 SchemaTenant 验证接口在 `shared_apps` |
| `PostgreSQL` | 已有 | 数据持久化 | Schema 级别隔离租户数据 |
| `Redis` | 必须 | 多用途缓存 | 滑块验证 TokenTTL 3min、登录失败计数TTL 30min、密码重置 Token 缓存 |
| `Celery` | 必须 | 异步任务队列 | 邮件发送异步处理,防止登录/找回接口超时 |
| `Pillow` | 必须(若自研验证码) | 图片处理 | 生成拼图背景图(抠出缺口)+ 拼图碎片,输出 Base64 |
| `django-ratelimit` 或自定义中间件 | 必须 | 接口限流 | Tenant 验证、登录、找回密码接口均需限流 |
| `electron-store` 或 AES 加密文件 | Electron 侧 | 本地持久化 | 加密存储 Tenant ID不存明文路径为 `app.getPath('userData')` |
| `secrets` (Python 标准库) | Python 内置 | Token 生成 | 使用 `secrets.token_urlsafe(32)` 生成密码重置 Token |
### 滑块验证码方案选型(待确认,见开放问题)
| 方案 | 优点 | 缺点 |
|------|------|------|
| 自研Pillow + 前端拖拽组件) | 完全可控,无外部依赖,数据合规性好 | 需维护图库,需自己实现轨迹检测算法 |
| 第三方服务(极验 GeeTest / 网易易盾) | 开箱即用,安全性更高 | 引入外部依赖,有数据合规风险,需评估 |
**当前方案**:暂按自研设计,后端负责人需在开发启动前确认最终选型。
---
## 三、目录结构
```
fonrey/apps/
└── accounts/ # 账号认证管理(租户级 App
├── models.py # UserAccount, LoginAttempt, PasswordResetToken
├── views.py # 登录/登出/找回账号/找回密码视图
├── urls.py
├── serializers.py # API 序列化JSON 接口)
└── services/
├── auth.py # 认证逻辑(验证码校验、账号锁定判断)
├── recovery.py # 找回密码/用户名逻辑(含邮件发送 Celery 任务)
└── tenant.py # Tenant 验证逻辑(属于 shared_apps公共 Schema
```
---
## 四、数据模型
### 4.1 `UserAccount`(核心账号表,位于租户 Schema
```python
class UserAccount(AbstractBaseUser):
id = BigAutoField(primary_key=True)
username = CharField(max_length=30) # 同租户内唯一普通员工为手机号Tenant Admin 为自定义字符串
email = EmailField(null=True, blank=True) # 同租户唯一,为空则无法自助找回密码
phone = CharField(max_length=11, null=True) # 加密存储core.encryption普通员工必填
staff = OneToOneField('org.Staff', null=True, on_delete=SET_NULL) # 实名绑定;普通员工必须
is_tenant_admin = BooleanField(default=False)
status = CharField(max_length=10) # active / disabled / locked
is_initial_password = BooleanField(default=True) # True → 登录后强制跳转修改密码
last_login = DateTimeField(null=True)
created_at = DateTimeField(auto_now_add=True)
created_by = ForeignKey('self', null=True, on_delete=SET_NULL)
USERNAME_FIELD = 'username'
class Meta:
unique_together = [('username',)] # Schema 内唯一,跨租户不冲突
```
**关键约束**
- `username` 唯一性约束仅在当前租户 Schema 内生效(`django-tenants` 隔离机制),不同租户可以有相同 username
- 密码存储使用 Django 默认 `PBKDF2+SHA256``make_password`**后端不得明文存储或传输**
- `phone` 字段使用 `core.encryption` 加密存储
### 4.2 `LoginAttempt`(登录审计,位于租户 Schema
```python
class LoginAttempt(Model):
username = CharField(max_length=30)
ip_address = GenericIPAddressField()
success = BooleanField()
failure_reason = CharField(max_length=30, null=True)
# 可选值wrong_password / wrong_captcha / account_locked / account_disabled
attempted_at = DateTimeField(auto_now_add=True)
```
**保留策略**:合规审计数据,**最少保留 90 天**,不得提前清理。
### 4.3 `PasswordResetToken`(密码重置令牌,位于租户 Schema
```python
class PasswordResetToken(Model):
user = ForeignKey(UserAccount, on_delete=CASCADE)
token = CharField(max_length=64) # secrets.token_urlsafe(32) 生成
expires_at = DateTimeField() # created_at + 30 分钟
is_used = BooleanField(default=False)
created_at = DateTimeField(auto_now_add=True)
```
**安全约束**
- Token 单次有效(使用后立即设 `is_used=True`
- 有效期 30 分钟,过期后拒绝使用
- 同一账号 1 小时内最多生成 3 个有效 Token服务端计数
---
## 五、Redis Key 规范
| 用途 | Key 格式 | TTL | 说明 |
|------|----------|-----|------|
| 滑块验证会话 Token | `captcha_token:{uuid}` | 3 分钟 | 前端拖动完成后服务端生成一次性通过凭证 |
| 登录失败计数 | `login_fail:{tenant_id}:{username}` | 30 分钟 | 计数 ≥ 5 时锁定账号TTL 30 分钟自动解锁 |
| 找回邮件发送频率 | `recover_email:{email}` | 1 小时 | 记录已发送次数,上限 3 次/小时 |
| Tenant ID 限流 | `tenant_verify_ip:{ip}` | 1 分钟 | 计数 ≥ 10 时拒绝请求 |
---
## 六、接口清单
| 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 限流规则 | 说明 |
|------|------|------------|------------|---------|------|
| `/api/auth/tenant/verify/` | POST | Publicshared | 否 | 每 IP 每分钟 ≤ 10 次 | Tenant ID 验证 |
| `/api/auth/captcha/` | GET | Tenant | 否 | — | 获取滑块拼图验证码(背景图 Base64 + 碎片图 Base64 + 验证 Token |
| `/api/auth/captcha/verify/` | POST | Tenant | 否 | — | 提交滑动轨迹 + 位置,返回一次性通过凭证 |
| `/api/auth/login/` | POST | Tenant | 否 | 每 IP 每分钟 ≤ 20 次 | 账号密码登录 |
| `/api/auth/logout/` | POST | Tenant | 是 | — | 登出,使服务端 Session 失效 |
| `/api/auth/recover/username/` | POST | Tenant | 否 | 每邮箱每小时 ≤ 3 次 | 发起找回用户名(发送邮件) |
| `/api/auth/recover/password/request/` | POST | Tenant | 否 | 每账号每小时 ≤ 3 次 | 发起找回密码(发送重置链接邮件) |
| `/api/auth/recover/password/reset/` | POST | Tenant | 否Token 鉴权) | — | 提交新密码,使用 PasswordResetToken 校验 |
| `/api/auth/login/phone/` | POST | Tenant | 否 | — | **预留**v2 实现,手机验证码登录 |
| `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | — | **预留**v2 实现,获取微信二维码 |
| `/api/auth/wechat/callback/` | POST | Tenant | 否 | — | **预留**v2 实现,微信扫码回调 |
### Tenant 验证接口 Request/Response 规范
```
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不区分"未找到"与"已禁用",防止枚举攻击。
### 登录接口核心逻辑
```
POST /api/auth/login/
Request Body:
{
"username": "string",
"password": "string",
"captcha_token": "string", // 滑块验证通过后的一次性凭证
"captcha_pass_token": "string"
}
Response 200 (成功):
{
"token": "...",
"user": {
"id": 1,
"username": "...",
"display_name": "...",
"is_initial_password": false
}
}
```
---
## 七、安全机制设计
### 7.1 滑块拼图验证码
- **图片生成**`Pillow` 从预置图库随机抽取背景图,服务端随机生成缺口位置,抠出缺口并生成拼图碎片,两者分别以 Base64 返回前端
- **轨迹校验**:前端记录滑动过程的坐标序列 + 时间戳,提交至 `/api/auth/captcha/verify/`;服务端综合校验:
- **位置偏差**:碎片最终位置与缺口中心偏差 ≤ ±5px
- **轨迹特征**:存在加速→减速的非线性运动曲线;拒绝匀速/程序化轨迹
- **独立性**:验证码失败**不计入**账号密码错误次数,两者独立计数
- **有效期**:通过凭证(`captcha_pass_token`TTL 3 分钟,单次有效
### 7.2 账号锁定机制
- 同一账号(`login_fail:{tenant_id}:{username}`)连续密码错误 ≥ 5 次:
- 账号状态置为 `locked`,持续 30 分钟
- Redis TTL 30 分钟到期后自动恢复,同时 `status` 更新为 `active`
- Tenant Admin 可在管理界面手动解锁(提前恢复)
### 7.3 密码安全
| 规则 | 说明 |
|------|------|
| 存储哈希 | Django `PBKDF2+SHA256``make_password` |
| 传输安全 | 强制 HTTPS前端**不加密**密码HTTPS 层保证) |
| 复杂度 | 长度 8~32 位,必须包含字母(区分大小写)+ 数字;建议特殊符号(非强制) |
| 历史密码 | 不得与最近 3 次历史密码相同(含系统固定初始密码 `Fonrey@2025` |
| Session 有效期 | 默认 8 小时;可由 Tenant Admin 在「系统设置」中调整 |
### 7.4 密码重置流程安全要点
- Token 由 `secrets.token_urlsafe(32)` 生成64 字符,全局唯一
- 单次有效:使用后立即标记 `is_used=True`
- 有效期 30 分钟(`expires_at = created_at + timedelta(minutes=30)`
- 重置成功后:清除该账号所有有效 Session强制重新登录
- 重置成功后:`is_initial_password = False`
---
## 八、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` 时,阻断登录流程,展示更新提示 |
---
## 九、多租户隔离要点
- `UserAccount``LoginAttempt``PasswordResetToken` 均位于**租户 Schema 内**,数据完全隔离
- `username` 唯一性约束在 Schema 维度生效,不同租户可以存在相同 username
- Tenant 验证接口(`/api/auth/tenant/verify/`)位于 **Public Schema**`shared_apps`),查询 `TenantModel`
- 登录等接口通过请求域名(`{tenant_slug}.fonrey.com`)自动切换 Schema`django-tenants` 中间件处理,**无需手动切换**
---
## 十、已知风险与缓解措施
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|---------|
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹 |
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分"未找到"与"已禁用" |
| 密码重置 Token 泄露 | 低 | 高 | 单次有效 + 30 分钟过期 + HTTPS 传输 |
| 邮件发送失败 | 中 | 中 | 异步任务失败写入告警日志;管理员可通过后台查看 Token 手动告知用户 |
| 多端并发登录 | 高(正常场景) | 低 | 本期允许v2 可在 Token 引入版本号实现踢出策略 |
---
## 十一、开放问题(开发启动前必须确认)
| 问题 | 负责人 | 截止 |
|------|--------|------|
| 邮件服务商选型SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
| 滑块验证码方案自研Pillow还是第三方极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
| Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
| 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
| 历史密码校验范围:最近 3 次是否足够?是否增加"不得与用户名相同"规则? | 产品经理 | 开发启动前 |
---
## 十二、明确禁止
- ❌ 不得使用 Django 原生 `User` 模型,必须扩展 `AbstractBaseUser`
- ❌ 不得在全局 Schema 创建 `UserAccount` 表(必须在租户 Schema 内)
- ❌ 不得明文存储或传输密码
- ❌ 不得在 `LoginAttempt` 记录中存储密码明文(含错误密码)
- ❌ 不得在前端做密码哈希HTTPS 层保证传输安全)
- ❌ 不得将 Session Token 写入 Electron 磁盘明文文件
- ❌ 不得在找回账号/密码响应中区分"邮箱存在"与"邮箱不存在"(防止枚举)
-`PasswordResetToken` 不得重复使用(`is_used=True` 后立即失效)
- ❌ 登录失败响应不得区分"用户名错误"与"密码错误"