Sync: add identity and trust notes
This commit is contained in:
470
Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md
Normal file
470
Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Fonrey — 登录与账号认证数据模型(DATA_MODEL_LOGIN)
|
||||
|
||||
> **所属系统**: Fonrey 房产经纪管理系统
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
|
||||
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
|
||||
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、领域概览(Domain Overview)
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一)和普通员工账号(username 固定为手机号)。
|
||||
- **LoginAttempt(登录尝试记录)**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
|
||||
- **PasswordResetToken(密码重置令牌)**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
|
||||
- **PasswordHistory(历史密码记录)**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
|
||||
|
||||
### 关键业务规则
|
||||
|
||||
1. **账号与员工强绑定**:每个登录账号 **必须** 与 `org.Staff` 中的员工档案 1:1 绑定(Tenant Admin 例外,可不绑定)。
|
||||
2. **用户名规则差异化**:
|
||||
- Tenant Admin:由平台运营自定义(字母开头,6~30 位,含字母/数字/下划线)
|
||||
- 普通员工:**固定为员工手机号**(11 位数字),创建后不可变更
|
||||
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
|
||||
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。
|
||||
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
|
||||
6. **不支持自助注册**:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 二、实体关系
|
||||
|
||||
```
|
||||
UserAccount
|
||||
│
|
||||
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
|
||||
├── 1:N ── LoginAttempt (登录审计记录)
|
||||
├── 1:N ── PasswordResetToken (密码重置令牌)
|
||||
├── 1:N ── PasswordHistory (历史密码记录)
|
||||
└── M:1 ── UserAccount.created_by (创建人自引用)
|
||||
```
|
||||
|
||||
### Schema 归属
|
||||
|
||||
| 表 | Schema 位置 | 说明 |
|
||||
|----|------------|------|
|
||||
| `user_accounts` | 租户 Schema | 账号数据按租户隔离,username 唯一性在 Schema 维度生效 |
|
||||
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
|
||||
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
|
||||
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
|
||||
|
||||
> **注意**:Tenant ID 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。
|
||||
|
||||
---
|
||||
|
||||
## 三、Schema 定义
|
||||
|
||||
### 3.1 `user_accounts` — 账号主表(租户 Schema)
|
||||
|
||||
**表说明**:系统登录主体,每个租户内独立隔离,`username` 唯一性约束在 Schema 维度生效。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名;普通员工为手机号(11 位数字);Tenant Admin 为自定义字符串;创建后不可更改 |
|
||||
| `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` |
|
||||
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
|
||||
| `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 |
|
||||
| `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 |
|
||||
| `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定(1:1);普通员工必须有值;Tenant Admin 可为空 |
|
||||
| `is_tenant_admin` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否为该租户的超级管理账号;每个租户最多 1 个(应用层约束) |
|
||||
| `status` | `VARCHAR(10)` | `NOT NULL`, `CHECK(status IN ('active','disabled','locked'))` | `'active'` | 账号状态;`locked` 为密码错误锁定,30 分钟自动恢复 |
|
||||
| `is_initial_password` | `BOOLEAN` | `NOT NULL` | `TRUE` | 初始密码标记;True 时登录成功后强制跳转修改密码页,不可跳过 |
|
||||
| `last_login` | `TIMESTAMPTZ` | `NULL` | `NULL` | 最后登录时间 |
|
||||
| `locked_until` | `TIMESTAMPTZ` | `NULL` | `NULL` | 锁定到期时间;到期后应用层将 status 恢复 active |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 账号创建时间 |
|
||||
| `updated_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 最后更新时间(触发器维护) |
|
||||
| `created_by` | `BIGINT` | `FK → user_accounts.id`, `NULL` | `NULL` | 创建人;普通员工由 Tenant Admin 创建;Tenant Admin 由平台运营创建(可为 NULL) |
|
||||
|
||||
#### 唯一性约束
|
||||
|
||||
```sql
|
||||
UNIQUE (username) -- Schema 内唯一,跨租户不冲突(django-tenants 机制保障)
|
||||
UNIQUE (email) -- 同租户内邮箱唯一(可为 NULL,NULL 不参与唯一性校验)
|
||||
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
|
||||
UNIQUE (staff_id) -- 员工档案 1:1 绑定
|
||||
```
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username);
|
||||
CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL;
|
||||
CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL;
|
||||
CREATE INDEX idx_user_accounts_status ON user_accounts (status);
|
||||
CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
# apps/accounts/models.py
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UserAccountManager(BaseUserManager):
|
||||
def create_user(self, username, password, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError("username 不能为空")
|
||||
user = self.model(username=username, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
|
||||
class UserAccount(AbstractBaseUser):
|
||||
"""
|
||||
租户级用户账号。
|
||||
- 普通员工:username 固定为手机号(11 位数字)
|
||||
- Tenant Admin:username 由平台运营自定义(字母开头,6~30 位)
|
||||
注意:此表位于租户 Schema,username 唯一性约束在 Schema 维度生效。
|
||||
"""
|
||||
username = models.CharField(max_length=30)
|
||||
email = models.EmailField(null=True, blank=True)
|
||||
phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文
|
||||
phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引
|
||||
staff = models.OneToOneField(
|
||||
'org.Staff',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='account',
|
||||
)
|
||||
is_tenant_admin = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')],
|
||||
default='active',
|
||||
)
|
||||
is_initial_password = models.BooleanField(default=True)
|
||||
last_login = models.DateTimeField(null=True, blank=True)
|
||||
locked_until = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_by = models.ForeignKey(
|
||||
'self',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_accounts',
|
||||
)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
objects = UserAccountManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_accounts'
|
||||
# Schema 内唯一约束
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})"
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""检查账号是否处于锁定状态(含自动过期判断)"""
|
||||
from django.utils import timezone
|
||||
if self.status == 'locked':
|
||||
if self.locked_until and timezone.now() >= self.locked_until:
|
||||
# 锁定已到期,应用层自动恢复(实际由 service 层处理)
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema)
|
||||
|
||||
**表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
|
||||
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6) |
|
||||
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-Agent(Electron 版本信息) |
|
||||
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
|
||||
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
|
||||
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
|
||||
|
||||
**`failure_reason` 枚举值**:
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `wrong_password` | 用户名或密码错误 |
|
||||
| `wrong_captcha` | 行为验证码失败 |
|
||||
| `account_locked` | 账号已锁定 |
|
||||
| `account_disabled` | 账号已停用 |
|
||||
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_login_attempts_username ON login_attempts (username);
|
||||
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address);
|
||||
CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC);
|
||||
-- 复合索引:按账号查询最近失败记录(锁定判断场景)
|
||||
CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class LoginAttempt(models.Model):
|
||||
"""
|
||||
登录尝试审计记录。
|
||||
- 合规保留周期:≥ 90 天
|
||||
- 注意:failure_reason 不得存储密码明文(含错误密码)
|
||||
"""
|
||||
FAILURE_REASONS = [
|
||||
('wrong_password', '用户名或密码错误'),
|
||||
('wrong_captcha', '行为验证码失败'),
|
||||
('account_locked', '账号已锁定'),
|
||||
('account_disabled', '账号已停用'),
|
||||
('tenant_not_found', '租户不存在'),
|
||||
]
|
||||
|
||||
username = models.CharField(max_length=30)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(null=True, blank=True)
|
||||
success = models.BooleanField()
|
||||
failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS)
|
||||
attempted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'login_attempts'
|
||||
indexes = [
|
||||
models.Index(fields=['username']),
|
||||
models.Index(fields=['ip_address']),
|
||||
models.Index(fields=['-attempted_at']),
|
||||
models.Index(fields=['username', 'success', '-attempted_at'],
|
||||
name='idx_login_attempts_fail_check'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema)
|
||||
|
||||
**表说明**:用于通过邮件找回密码的一次性令牌。单次有效,30 分钟过期。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成(86 字符),全局唯一 |
|
||||
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟`) |
|
||||
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True,防止重放攻击 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
|
||||
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordResetToken(models.Model):
|
||||
"""
|
||||
密码重置令牌。
|
||||
安全约束:
|
||||
- Token 单次有效(is_used=True 后立即失效)
|
||||
- 有效期 30 分钟
|
||||
- 同一账号 1 小时内最多生成 3 个(服务层限频,Redis 计数)
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
|
||||
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
|
||||
expires_at = models.DateTimeField()
|
||||
is_used = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_reset_tokens'
|
||||
indexes = [
|
||||
models.Index(fields=['user_id']),
|
||||
]
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
from django.utils import timezone
|
||||
return not self.is_used and timezone.now() < self.expires_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `password_histories` — 历史密码记录表(租户 Schema)
|
||||
|
||||
**表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordHistory(models.Model):
|
||||
"""
|
||||
历史密码记录,每个账号保留最近 N 条(默认 3 条)。
|
||||
新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025)。
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories')
|
||||
password_hash = models.CharField(max_length=128)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_histories'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Redis 缓存结构(辅助状态,非持久化)
|
||||
|
||||
以下 Redis Key 不存入 PostgreSQL,属于运行时状态,需与数据库状态保持最终一致:
|
||||
|
||||
| Key 格式 | 类型 | TTL | 说明 |
|
||||
|----------|------|-----|------|
|
||||
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token;验证通过后生成 `captcha_pass_token` |
|
||||
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
|
||||
| `login_fail:{tenant_id}:{username}` | STRING(计数) | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定;TTL 30 分钟自动清零 |
|
||||
| `recover_email:{email}` | STRING(计数) | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
|
||||
| `recover_reset:{account_id}` | STRING(计数) | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
|
||||
| `tenant_verify_ip:{ip}` | STRING(计数) | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
|
||||
|
||||
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化,Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 五、账号创建流程与状态机
|
||||
|
||||
### 5.1 账号状态机
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 账号生命周期状态机 │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
[创建账号]
|
||||
│ is_initial_password=True, status=active
|
||||
▼
|
||||
[初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页]
|
||||
│ │ 修改成功
|
||||
│ ▼
|
||||
│ [正常使用态]
|
||||
│ status=active
|
||||
│ is_initial_password=False
|
||||
│
|
||||
├── 密码错误 ≥ 5 次 ──────────────────► [锁定态]
|
||||
│ status=locked
|
||||
│ locked_until = now+30min
|
||||
│ │
|
||||
│ ┌───────────────┤
|
||||
│ │ 30分钟到期 │ 管理员手动解锁
|
||||
│ ▼ ▼
|
||||
│ [正常使用态] ◄─── [管理员操作]
|
||||
│
|
||||
└── 员工离职 / 管理员停用 ──► [停用态]
|
||||
status=disabled
|
||||
│
|
||||
员工复职/管理员恢复
|
||||
│
|
||||
▼
|
||||
[正常使用态]
|
||||
```
|
||||
|
||||
### 5.2 账号创建触发时机
|
||||
|
||||
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|
||||
|----------|----------|--------|--------------|---------|
|
||||
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义(字母开头,6~30 位) | 平台运营自定义 |
|
||||
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统(Tenant Admin 触发) | 固定为员工手机号(11 位) | 系统统一初始密码(部署配置) |
|
||||
|
||||
---
|
||||
|
||||
## 六、关联约束与数据完整性
|
||||
|
||||
### 6.1 与 `org.Staff` 的关联规则
|
||||
|
||||
```
|
||||
org_staff (1) ──── (0..1) user_accounts
|
||||
```
|
||||
|
||||
- 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active
|
||||
- Tenant Admin:`staff_id` **可为空**(平台运营账号可不绑定实名档案)
|
||||
- 员工离职时(`org.Staff.status` → `resigned`),触发账号 `status` → `disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖)
|
||||
- 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留
|
||||
|
||||
### 6.2 跨 App 依赖方向
|
||||
|
||||
```
|
||||
accounts ──► org (单向依赖:accounts.UserAccount.staff_id → org.Staff)
|
||||
org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查)
|
||||
```
|
||||
|
||||
> **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。
|
||||
|
||||
---
|
||||
|
||||
## 七、迁移说明(Django Migrations)
|
||||
|
||||
### 初始迁移顺序
|
||||
|
||||
```
|
||||
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
|
||||
0002_login_attempts.py # LoginAttempt 表
|
||||
0003_password_reset_tokens.py # PasswordResetToken 表
|
||||
0004_password_histories.py # PasswordHistory 表
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `accounts` App 的迁移依赖 `org` App(`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org` 在 `accounts` 之前
|
||||
- 所有迁移均在**租户 Schema** 下执行(`django-tenants` 的 `migrate_schemas` 命令)
|
||||
- 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择)
|
||||
|
||||
---
|
||||
|
||||
## 八、设计决策说明(ADR)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 |
|
||||
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
|
||||
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
|
||||
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username) | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
|
||||
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
|
||||
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**状态**: Draft
|
||||
**作者**: 产品经理
|
||||
**最后更新**: 2026-04-24(v1.3 明确账号创建权限层级:Tenant Admin 可自定义用户名/密码;普通员工账号由 Tenant Admin 在新增员工时创建,用户名为手机号,初始密码固定,首次登录强制修改)
|
||||
**版本**: 1.3
|
||||
**最后更新**: 2026-04-25(v1.4 §5.5 后端数据模型迁移至独立文档 `DATA_MODEL/DATA_MODEL_LOGIN.md`)
|
||||
**版本**: 1.4
|
||||
**所属系统**: Fonrey 房产经纪管理系统
|
||||
**关联模块**: 组织人事管理、权限管理、系统管理
|
||||
|
||||
@@ -515,61 +515,19 @@ Response 200 (失败):
|
||||
|
||||
### 5.5 后端数据模型设计
|
||||
|
||||
#### 5.5.1 `auth` App 目录结构
|
||||
> **数据模型已迁移至独立文档**,请参阅:
|
||||
> **`Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`**
|
||||
|
||||
在现有 `fonrey/apps/` 目录下新增(或扩展 Django `auth` 系统):
|
||||
|
||||
```
|
||||
apps/
|
||||
└── accounts/ # 账号认证管理(租户级 App)
|
||||
├── models.py # UserAccount, LoginAttempt, PasswordResetToken
|
||||
├── views.py # 登录/登出/找回账号/找回密码视图
|
||||
├── urls.py
|
||||
├── serializers.py # API 序列化(如需 JSON 接口)
|
||||
└── services/
|
||||
├── auth.py # 认证逻辑(验证码校验、账号锁定判断)
|
||||
├── recovery.py # 找回密码/用户名逻辑
|
||||
└── tenant.py # Tenant 验证逻辑(属于 shared_apps)
|
||||
```
|
||||
|
||||
#### 5.5.2 `UserAccount` 核心字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BigAutoField | 主键 |
|
||||
| `username` | CharField(30) | 登录名,同租户唯一,不可变更;普通员工账号固定为手机号(11位数字),Tenant Admin 为自定义字符串 |
|
||||
| `password` | CharField | PBKDF2+SHA256 哈希,使用 Django `make_password` |
|
||||
| `email` | EmailField | 绑定邮箱,同租户唯一,选填;为空时无法自助找回密码 |
|
||||
| `phone` | CharField(11) | 绑定手机号,加密存储(`core.encryption`);普通员工必填(同时作为 username 来源),Tenant Admin 选填 |
|
||||
| `staff` | OneToOneField → `org.Staff` | 员工档案绑定;普通员工必须,Tenant Admin 可为空 |
|
||||
| `is_tenant_admin` | BooleanField | 标记是否为该租户的 Tenant Admin 账号,默认 False |
|
||||
| `status` | CharField | `active` / `disabled` / `locked` |
|
||||
| `is_initial_password` | BooleanField | True 时登录成功后强制跳转修改密码页,不可跳过 |
|
||||
| `last_login` | DateTimeField | 最后登录时间 |
|
||||
| `created_at` | DateTimeField | 账号创建时间 |
|
||||
| `created_by` | ForeignKey → self | 创建人(普通员工由 Tenant Admin 创建;Tenant Admin 由平台运营创建) |
|
||||
|
||||
#### 5.5.3 `LoginAttempt` 登录尝试记录
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `username` | CharField | 尝试登录的用户名 |
|
||||
| `ip_address` | GenericIPAddressField | 来源 IP |
|
||||
| `success` | BooleanField | 是否成功 |
|
||||
| `failure_reason` | CharField | `wrong_password` / `wrong_captcha` / `account_locked` 等 |
|
||||
| `attempted_at` | DateTimeField | 尝试时间 |
|
||||
|
||||
> **注意**:`LoginAttempt` 属于合规审计数据,保留周期建议 ≥ 90 天。
|
||||
|
||||
#### 5.5.4 `PasswordResetToken`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `user` | ForeignKey → `UserAccount` | 关联账号 |
|
||||
| `token` | CharField(64) | 加密 Token(`secrets.token_urlsafe(32)`) |
|
||||
| `expires_at` | DateTimeField | 过期时间(创建时间 + 30 分钟) |
|
||||
| `is_used` | BooleanField | 是否已使用(使用后立即标记为 True) |
|
||||
| `created_at` | DateTimeField | 创建时间 |
|
||||
该文档包含:
|
||||
- `user_accounts` 账号主表(完整字段定义、约束、索引、Django Model 代码)
|
||||
- `login_attempts` 登录审计表
|
||||
- `password_reset_tokens` 密码重置令牌表
|
||||
- `password_histories` 历史密码记录表
|
||||
- Redis 缓存结构说明
|
||||
- 账号状态机与创建流程
|
||||
- 与 `org.Staff` 的关联规则及跨 App 依赖设计
|
||||
- Django Migrations 迁移顺序说明
|
||||
- 架构决策说明(ADR)
|
||||
|
||||
---
|
||||
|
||||
@@ -686,3 +644,5 @@ apps/
|
||||
- 权限管理模块 PRD:`Project/fonrey/PRD/权限管理/权限管理模块PRD.md`
|
||||
- 系统管理模块 PRD:`Project/fonrey/PRD/系统管理/系统管理模块PRD.md`
|
||||
- 技术栈文档:`Project/fonrey/TECH_STACK/TECH_STACK.md`
|
||||
- **登录管理数据模型**:`Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`
|
||||
- **登录管理技术方案**:`Project/fonrey/TECH_STACK/登录管理技术方案.md`
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
||||
# Fonrey 登录管理系统技术方案
|
||||
|
||||
**版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery
|
||||
**关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
|
||||
**版本**: 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-25(v2.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.
|
||||
@@ -11,13 +13,26 @@
|
||||
|
||||
## 一、模块定位与架构边界
|
||||
|
||||
登录管理模块(`accounts` App)负责多租户环境下的身份识别、认证、账号安全及凭据找回。其架构边界如下:
|
||||
登录管理模块(`accounts` App)负责多租户环境下的身份识别、认证、账号安全及凭据找回。
|
||||
|
||||
### 架构层级边界
|
||||
|
||||
| 层级 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| Tenant ID 验证 | `shared_apps`(公共 Schema) | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 |
|
||||
| Tenant ID 验证 | `shared_apps`(Public Schema) | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 |
|
||||
| 账号认证、找回密码等 | 租户 Schema(Tenant Schema) | 通过请求域名 `{tenant_slug}.fonrey.com` 自动切换,`django-tenants` 中间件处理 |
|
||||
| Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session Token 管理、页面加载 |
|
||||
| Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session 管理、页面加载 |
|
||||
|
||||
### 模块依赖关系
|
||||
|
||||
```
|
||||
accounts
|
||||
├── 依赖 → org (Staff 实名绑定,单向依赖)
|
||||
├── 依赖 → core.encryption (手机号加密)
|
||||
├── 依赖 → core.cache (Redis 工具封装)
|
||||
├── 依赖 → shared.tenants (Tenant ID 验证,Public Schema)
|
||||
└── 被依赖 ← org (离职联动,通过 Service 层调用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -25,15 +40,15 @@
|
||||
|
||||
| 依赖项 | 版本/方案 | 用途 | 说明 |
|
||||
|--------|-----------|------|------|
|
||||
| `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`,**不直接使用** `User` 模型;username 唯一性约束在租户 Schema 维度生效,而非全局 |
|
||||
| `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`,**不直接使用** `User` 模型;username 唯一性约束在租户 Schema 维度生效 |
|
||||
| `django-tenants` | 已有 | 多租户隔离 | `UserAccount` 在租户 Schema;Tenant 验证接口在 `shared_apps` |
|
||||
| `PostgreSQL` | 已有 | 数据持久化 | Schema 级别隔离租户数据 |
|
||||
| `Redis` | 必须 | 多用途缓存 | 滑块验证 Token(TTL 3min)、登录失败计数(TTL 30min)、密码重置 Token 缓存 |
|
||||
| `Celery` | 必须 | 异步任务队列 | 邮件发送异步处理,防止登录/找回接口超时 |
|
||||
| `Redis` | 必须 | 多用途缓存 | 滑块验证 Token(TTL 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(32)` 生成密码重置 Token |
|
||||
| `secrets` (Python 标准库) | Python 内置 | Token 生成 | 使用 `secrets.token_urlsafe(64)` 生成密码重置 Token(86 字符) |
|
||||
|
||||
### 滑块验证码方案选型(待确认,见开放问题)
|
||||
|
||||
@@ -51,107 +66,301 @@
|
||||
```
|
||||
fonrey/apps/
|
||||
└── accounts/ # 账号认证管理(租户级 App)
|
||||
├── models.py # UserAccount, LoginAttempt, PasswordResetToken
|
||||
├── views.py # 登录/登出/找回账号/找回密码视图
|
||||
├── models.py # UserAccount, LoginAttempt, PasswordResetToken, PasswordHistory
|
||||
├── views/
|
||||
│ ├── auth.py # 登录/登出视图(HTMX 响应)
|
||||
│ ├── captcha.py # 滑块验证码视图
|
||||
│ └── recovery.py # 找回用户名/密码视图
|
||||
├── urls.py
|
||||
├── serializers.py # API 序列化(JSON 接口)
|
||||
├── 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 # 认证逻辑(验证码校验、账号锁定判断)
|
||||
├── recovery.py # 找回密码/用户名逻辑(含邮件发送 Celery 任务)
|
||||
└── tenant.py # Tenant 验证逻辑(属于 shared_apps,公共 Schema)
|
||||
├── auth.py # 认证逻辑:滑块验证、账号锁定、登录流程
|
||||
├── captcha.py # 验证码生成与校验(Pillow 或第三方)
|
||||
├── recovery.py # 找回用户名/密码逻辑(含 Celery 任务触发)
|
||||
├── password.py # 密码复杂度校验、历史密码比对
|
||||
└── tenant.py # Tenant 验证逻辑(属于 shared_apps,Public Schema)
|
||||
|
||||
fonrey/shared/ # Public Schema App(django-tenants shared_apps)
|
||||
└── tenants/
|
||||
├── models.py # TenantModel, Domain
|
||||
└── views.py # tenant/verify/ 接口(在公共 Schema 下)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据模型
|
||||
|
||||
### 4.1 `UserAccount`(核心账号表,位于租户 Schema)
|
||||
> **数据模型完整定义已迁移至** `Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`,本节仅保留技术实现视角的关键说明。
|
||||
|
||||
```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)
|
||||
### 4.1 表归属汇总
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
| 表名 | Schema | 说明 |
|
||||
|------|--------|------|
|
||||
| `user_accounts` | 租户 Schema | 账号主表,`username` 唯一性在 Schema 维度生效 |
|
||||
| `login_attempts` | 租户 Schema | 登录审计,保留 ≥ 90 天 |
|
||||
| `password_reset_tokens` | 租户 Schema | 一次性重置令牌,30 分钟过期 |
|
||||
| `password_histories` | 租户 Schema | 最近 3 次密码哈希,防重用 |
|
||||
|
||||
class Meta:
|
||||
unique_together = [('username',)] # Schema 内唯一,跨租户不冲突
|
||||
```
|
||||
### 4.2 关键约束汇总
|
||||
|
||||
**关键约束**:
|
||||
- `username` 唯一性约束仅在当前租户 Schema 内生效(`django-tenants` 隔离机制),不同租户可以有相同 username
|
||||
- `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(服务端计数)
|
||||
- `phone_enc` 字段使用 `core.encryption` AES-256-GCM 加密存储;`phone_hash` SHA-256 哈希用于唯一性校验
|
||||
- `locked_until` 字段持久化锁定到期时间,防止 Redis 故障导致锁定状态丢失
|
||||
|
||||
---
|
||||
|
||||
## 五、Redis Key 规范
|
||||
## 五、服务层设计(Service Layer)
|
||||
|
||||
| 用途 | 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 时拒绝请求 |
|
||||
### 5.1 `services/auth.py` — 核心认证服务
|
||||
|
||||
```python
|
||||
# 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` — 验证码服务
|
||||
|
||||
```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. 查询并校验 token(is_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 时删除最旧记录。
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、接口清单
|
||||
## 六、Celery 异步任务
|
||||
|
||||
| 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 限流规则 | 说明 |
|
||||
|------|------|------------|------------|---------|------|
|
||||
| `/api/auth/tenant/verify/` | POST | Public(shared) | 否 | 每 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 实现,微信扫码回调 |
|
||||
```python
|
||||
# apps/accounts/tasks.py
|
||||
|
||||
### Tenant 验证接口 Request/Response 规范
|
||||
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 | Public(shared) | 否 | 每 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/
|
||||
@@ -161,7 +370,7 @@ Request Body:
|
||||
"tenant_id": "202500010001" // 固定 12 位纯数字
|
||||
}
|
||||
|
||||
Response 200 (成功):
|
||||
Response 200 (验证通过):
|
||||
{
|
||||
"valid": true,
|
||||
"tenant_name": "XX房产经纪有限公司",
|
||||
@@ -169,7 +378,7 @@ Response 200 (成功):
|
||||
"login_url": "https://xxx.fonrey.com/auth/login/"
|
||||
}
|
||||
|
||||
Response 200 (失败):
|
||||
Response 200 (验证失败):
|
||||
{
|
||||
"valid": false,
|
||||
"error_code": "TENANT_NOT_FOUND",
|
||||
@@ -177,9 +386,9 @@ Response 200 (失败):
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:失败响应统一返回 HTTP 200,不区分"未找到"与"已禁用",防止枚举攻击。
|
||||
> 失败响应统一返回 HTTP 200,不区分「未找到」与「已禁用」,防止枚举攻击。
|
||||
|
||||
### 登录接口核心逻辑
|
||||
### 7.2 登录接口规范
|
||||
|
||||
```
|
||||
POST /api/auth/login/
|
||||
@@ -188,12 +397,12 @@ Request Body:
|
||||
{
|
||||
"username": "string",
|
||||
"password": "string",
|
||||
"captcha_token": "string", // 滑块验证通过后的一次性凭证
|
||||
"captcha_pass_token": "string"
|
||||
"captcha_pass_token": "string" // 滑块验证通过后的一次性凭证(UUID)
|
||||
}
|
||||
|
||||
Response 200 (成功):
|
||||
Response 200 (登录成功):
|
||||
{
|
||||
"success": true,
|
||||
"token": "...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
@@ -202,49 +411,216 @@ Response 200 (成功):
|
||||
"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)
|
||||
|
||||
### 7.1 滑块拼图验证码
|
||||
### 8.1 页面结构说明
|
||||
|
||||
登录相关页面均为**全页面渲染**(Server-Side Rendered),Electron 客户端通过 `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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、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 返回前端
|
||||
- **轨迹校验**:前端记录滑动过程的坐标序列 + 时间戳,提交至 `/api/auth/captcha/verify/`;服务端综合校验:
|
||||
- **位置偏差**:碎片最终位置与缺口中心偏差 ≤ ±5px
|
||||
- **轨迹特征**:存在加速→减速的非线性运动曲线;拒绝匀速/程序化轨迹
|
||||
- **独立性**:验证码失败**不计入**账号密码错误次数,两者独立计数
|
||||
- **有效期**:通过凭证(`captcha_pass_token`)TTL 3 分钟,单次有效
|
||||
- **缺口位置保护**:`gap_x`(水平位置)仅存于服务端 Redis,**不返回给前端**;前端通过 `slide_x` 提交,服务端对比 `gap_x` 校验
|
||||
- **轨迹校验**(双重判断):
|
||||
- **位置偏差**:`abs(slide_x - gap_x) ≤ 5px`
|
||||
- **轨迹特征**:速度变化曲线存在加速→减速(人类滑动特征),拒绝匀速/程序化轨迹
|
||||
- **独立计数**:验证码失败**不计入**账号密码错误次数,两者独立计数
|
||||
- **单次有效**:`captcha_pass_token` TTL 3 分钟,登录接口校验后立即删除
|
||||
|
||||
### 7.2 账号锁定机制
|
||||
### 10.2 账号锁定机制
|
||||
|
||||
- 同一账号(`login_fail:{tenant_id}:{username}`)连续密码错误 ≥ 5 次:
|
||||
- 账号状态置为 `locked`,持续 30 分钟
|
||||
- Redis TTL 30 分钟到期后自动恢复,同时 `status` 更新为 `active`
|
||||
- Tenant Admin 可在管理界面手动解锁(提前恢复)
|
||||
```
|
||||
同一账号连续密码错误 ≥ 5 次:
|
||||
1. Redis `login_fail:{tenant_id}:{username}` 计数达到阈值
|
||||
2. 更新 user_accounts.status = 'locked'
|
||||
3. 设置 user_accounts.locked_until = now() + 30min
|
||||
4. 锁定状态下,登录接口直接返回 ACCOUNT_LOCKED,不再校验密码
|
||||
|
||||
### 7.3 密码安全
|
||||
解锁条件(任一满足):
|
||||
A. locked_until 到期:应用层在下次登录时检测,自动恢复 status=active
|
||||
B. Tenant Admin 手动解锁:调用 AuthService.unlock_account()
|
||||
```
|
||||
|
||||
### 10.3 密码安全
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 存储哈希 | Django `PBKDF2+SHA256`(`make_password`) |
|
||||
| 传输安全 | 强制 HTTPS,前端**不加密**密码(HTTPS 层保证) |
|
||||
| 复杂度 | 长度 8~32 位,必须包含字母(区分大小写)+ 数字;建议特殊符号(非强制) |
|
||||
| 历史密码 | 不得与最近 3 次历史密码相同(含系统固定初始密码 `Fonrey@2025`) |
|
||||
| 历史密码 | 不得与最近 3 次历史密码哈希相同(含系统固定初始密码) |
|
||||
| Session 有效期 | 默认 8 小时;可由 Tenant Admin 在「系统设置」中调整 |
|
||||
|
||||
### 7.4 密码重置流程安全要点
|
||||
### 10.4 防枚举攻击设计
|
||||
|
||||
- Token 由 `secrets.token_urlsafe(32)` 生成,64 字符,全局唯一
|
||||
- 单次有效:使用后立即标记 `is_used=True`
|
||||
| 场景 | 防御措施 |
|
||||
|------|---------|
|
||||
| 登录失败 | 不区分「用户名错误」与「密码错误」,统一返回 `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`
|
||||
- 重置成功后:`is_initial_password = False`,写入 `PasswordHistory`
|
||||
|
||||
---
|
||||
|
||||
## 八、Electron 客户端约定
|
||||
## 十一、Electron 客户端约定
|
||||
|
||||
| 约定项 | 规格 |
|
||||
|--------|------|
|
||||
@@ -254,44 +630,73 @@ Response 200 (成功):
|
||||
| 多标签页 | 同一 `BrowserWindow` 内所有页面共享同一 Session Cookie |
|
||||
| 客户端登出 | 调用 `POST /api/auth/logout/` 使服务端 Session 失效 + 清除 Chromium Session Cookie |
|
||||
| 窗口关闭 | Session 保留(不自动登出),下次打开若 Session 未过期则直接进入系统 |
|
||||
| 强制更新 | 客户端版本低于服务端 `min_required_version` 时,阻断登录流程,展示更新提示 |
|
||||
| 强制更新 | 客户端版本低于服务端 `min_required_version` 时,阻断登录流程,展示更新提示(详见发布管理模块 PRD) |
|
||||
| Tenant ID 缓存校验 | 非首次启动时,客户端向服务端发起缓存 Tenant ID 有效性校验(`POST /api/auth/tenant/verify/`);无效则清除缓存,重新显示 Tenant 识别界面 |
|
||||
|
||||
---
|
||||
|
||||
## 九、多租户隔离要点
|
||||
## 十二、多租户隔离要点
|
||||
|
||||
- `UserAccount`、`LoginAttempt`、`PasswordResetToken` 均位于**租户 Schema 内**,数据完全隔离
|
||||
- `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
|
||||
|
||||
---
|
||||
|
||||
## 十、已知风险与缓解措施
|
||||
## 十三、错误处理规范
|
||||
|
||||
### 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`,管理员可在系统后台查询
|
||||
|
||||
---
|
||||
|
||||
## 十四、已知风险与缓解措施
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|---------|
|
||||
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹 |
|
||||
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分"未找到"与"已禁用" |
|
||||
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分「未找到」与「已禁用」 |
|
||||
| 密码重置 Token 泄露 | 低 | 高 | 单次有效 + 30 分钟过期 + HTTPS 传输 |
|
||||
| 邮件发送失败 | 中 | 中 | 异步任务失败写入告警日志;管理员可通过后台查看 Token 手动告知用户 |
|
||||
| 邮件发送失败 | 中 | 中 | 异步任务自动重试 3 次;失败写入 Sentry 告警;管理员可通过后台查看 Token 手动告知用户 |
|
||||
| 多端并发登录 | 高(正常场景) | 低 | 本期允许;v2 可在 Token 引入版本号实现踢出策略 |
|
||||
| Redis 故障导致锁定状态丢失 | 低 | 中 | `locked_until` 字段持久化至 PostgreSQL,Redis 故障不影响锁定判断 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、开放问题(开发启动前必须确认)
|
||||
## 十五、开放问题(开发启动前必须确认)
|
||||
|
||||
| 问题 | 负责人 | 截止 |
|
||||
|------|--------|------|
|
||||
| 邮件服务商选型:SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
|
||||
| 滑块验证码方案:自研(Pillow)还是第三方(极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
|
||||
| Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
|
||||
| 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
|
||||
| 历史密码校验范围:最近 3 次是否足够?是否增加"不得与用户名相同"规则? | 产品经理 | 开发启动前 |
|
||||
| # | 问题 | 负责人 | 截止 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 邮件服务商选型:SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
|
||||
| 2 | 滑块验证码方案:自研(Pillow)还是第三方(极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
|
||||
| 3 | Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
|
||||
| 4 | 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
|
||||
| 5 | 历史密码校验范围:最近 3 次是否足够?是否增加「不得与用户名相同」规则? | 产品经理 | 开发启动前 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、明确禁止
|
||||
## 十六、明确禁止
|
||||
|
||||
- ❌ 不得使用 Django 原生 `User` 模型,必须扩展 `AbstractBaseUser`
|
||||
- ❌ 不得在全局 Schema 创建 `UserAccount` 表(必须在租户 Schema 内)
|
||||
@@ -299,6 +704,8 @@ Response 200 (成功):
|
||||
- ❌ 不得在 `LoginAttempt` 记录中存储密码明文(含错误密码)
|
||||
- ❌ 不得在前端做密码哈希(HTTPS 层保证传输安全)
|
||||
- ❌ 不得将 Session Token 写入 Electron 磁盘明文文件
|
||||
- ❌ 不得在找回账号/密码响应中区分"邮箱存在"与"邮箱不存在"(防止枚举)
|
||||
- ❌ 不得在找回账号/密码响应中区分「邮箱存在」与「邮箱不存在」(防止枚举)
|
||||
- ❌ `PasswordResetToken` 不得重复使用(`is_used=True` 后立即失效)
|
||||
- ❌ 登录失败响应不得区分"用户名错误"与"密码错误"
|
||||
- ❌ 登录失败响应不得区分「用户名错误」与「密码错误」
|
||||
- ❌ 不得将 `gap_x`(缺口水平位置)返回给前端(防止绕过验证)
|
||||
- ❌ 耗时超过 500ms 的操作(如邮件发送)必须通过 Celery 异步执行,不得在请求线程中同步等待
|
||||
|
||||
Reference in New Issue
Block a user