登录模块审核

This commit is contained in:
Shen Wei
2026-04-30 18:40:55 +08:00
parent 4030a91100
commit 57600598ac
34 changed files with 2544 additions and 2431 deletions

View File

@@ -4,7 +4,7 @@
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v2.0)
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
---
@@ -13,17 +13,18 @@
### 核心概念
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一和普通员工账号username 固定为手机号)。
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一username 固定为联系人手机号和普通员工账号username 固定为员工手机号)。
- **LoginAttempt登录尝试记录**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
- **PasswordResetToken密码重置令牌**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效
- **PasswordResetToken密码重置令牌**~~通过邮件找回密码时生成的一次性令牌~~ 已废弃,详见 `SmsOtpRecord`
- **SmsOtpRecord短信验证码记录**:找回密码和手机验证码登录时向用户手机号发送的 6 位一次性验证码。用 `scene` 字段区分场景(`password_reset` / `login`),有效期按场景不同(找回密码 10 分钟,验证码登录 5 分钟);找回密码验证通过后颁发 `sms_reset_token`(有效期 15 分钟),验证码登录验证通过后直接颁发 Session Token使用后立即作废。
- **PasswordHistory历史密码记录**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
### 关键业务规则
1. **账号与员工强绑定**:每个登录账号 **必须**`org.Staff` 中的员工档案 1:1 绑定Tenant Admin 例外,可不绑定)。
2. **用户名规则差异化**
- Tenant Admin由平台运营自定义字母开头6~30 位,含字母/数字/下划线)
- 普通员工:**固定为员工手机号**11 位数字),创建后不可变更
2. **用户名规则统一为手机号**
- Tenant Admin**固定为该租户联系人的手机号**11 位数字),来源于 `public.tenants.contact_phone`,全局唯一,创建后不可更改
- 普通员工:**固定为员工手机号**11 位数字),同租户内唯一,创建后不可变更
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`30 分钟后自动恢复Tenant Admin 可提前手动解锁。
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
@@ -38,7 +39,7 @@ UserAccount
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
├── 1:N ── LoginAttempt (登录审计记录)
├── 1:N ── PasswordResetToken (密码重置令牌)
├── 1:N ── SmsOtpRecord (短信验证码记录,找回密码用)
├── 1:N ── PasswordHistory (历史密码记录)
└── M:1 ── UserAccount.created_by (创建人自引用)
```
@@ -49,10 +50,10 @@ UserAccount
|----|------------|------|
| `user_accounts` | 租户 Schema | 账号数据按租户隔离username 唯一性在 Schema 维度生效 |
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
| `sms_otp_records` | 租户 Schema | 短信验证码记录,找回密码用 |
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
> **注意**Tenant ID 验证相关逻辑在 **Public Schema**`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema
> **注意**Tenant Code 验证相关逻辑在 **Public Schema**`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema
---
@@ -69,7 +70,7 @@ UserAccount
| `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` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
| `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 可为空 |
@@ -122,8 +123,8 @@ class UserAccountManager(BaseUserManager):
class UserAccount(AbstractBaseUser):
"""
租户级用户账号。
- 普通员工username 固定为手机号11 位数字)
- Tenant Adminusername 由平台运营自定义字母开头6~30 位
- 普通员工username 固定为员工手机号11 位数字)
- Tenant Adminusername 固定为该租户联系人手机号(来源于 public.tenants.contact_phone
注意:此表位于租户 Schemausername 唯一性约束在 Schema 维度生效。
"""
username = models.CharField(max_length=30)
@@ -212,7 +213,8 @@ CREATE TABLE login_attempts (
failure_reason VARCHAR(30)
CHECK (failure_reason IS NULL OR failure_reason IN (
'wrong_password','wrong_captcha','account_locked',
'account_disabled','tenant_not_found'
'account_disabled','tenant_not_found',
'wrong_otp','otp_expired'
)),
PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (attempted_at);
@@ -233,6 +235,8 @@ CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT;
| `account_locked` | 账号已锁定 |
| `account_disabled` | 账号已停用 |
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
| `wrong_otp` | 短信验证码错误 |
| `otp_expired` | 短信验证码已过期 |
#### 索引
@@ -259,6 +263,8 @@ class LoginAttempt(models.Model):
('account_locked', '账号已锁定'),
('account_disabled', '账号已停用'),
('tenant_not_found', '租户不存在'),
('wrong_otp', '短信验证码错误'),
('otp_expired', '短信验证码已过期'),
]
username = models.CharField(max_length=30)
@@ -284,9 +290,9 @@ class LoginAttempt(models.Model):
---
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema
### 3.3 `sms_otp_records` — 短信验证码记录表(租户 Schema
**表说明**用于通过邮件找回密码的一次性令牌。单次有效30 分钟过期
**表说明**记录找回密码和手机验证码登录两个场景中,向用户手机号发送的短信验证码及其状态。用 `scene` 字段区分场景(`password_reset` / `login`),有效期和发送频率上限按场景独立控制。原 `password_reset_tokens`(邮件令牌)已废弃,由本表替代
#### 字段定义
@@ -294,45 +300,61 @@ class LoginAttempt(models.Model):
|--------|------|------|--------|------|
| `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防止重放攻击 |
| `phone_hash` | `VARCHAR(64)` | `NOT NULL` | — | 收码手机号 SHA-256 哈希(与账号的 `phone_hash` 一致,冗余存储便于限频查询) |
| `scene` | `VARCHAR(20)` | `NOT NULL` | — | 使用场景:`password_reset`(找回密码)或 `login`(验证码登录 |
| `otp_hash` | `VARCHAR(128)` | `NOT NULL` | — | 验证码 PBKDF2 哈希(禁止明文存储,服务端校验时重新哈希比对) |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`password_reset``created_at + 10 分钟``login``created_at + 5 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已验证通过;通过后立即置 True防止重放攻击 |
| `verify_attempts` | `SMALLINT` | `NOT NULL` | `0` | 已尝试验证次数;≥ 5 次后本条记录强制作废(`is_used = 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;
CREATE INDEX idx_sms_otp_user ON sms_otp_records (user_id, created_at DESC);
CREATE INDEX idx_sms_otp_phone ON sms_otp_records (phone_hash, created_at DESC);
CREATE INDEX idx_sms_otp_active ON sms_otp_records (user_id, expires_at) WHERE is_used = FALSE;
```
#### Django Model 定义
```python
class PasswordResetToken(models.Model):
class SmsOtpRecord(models.Model):
"""
密码重置令牌
短信验证码记录(找回密码 + 验证码登录共用)
安全约束:
- Token 单次有效is_used=True 后立即失效)
- 有效期 30 分钟
- 同一号 1 小时内最多生成 3 个服务层限频Redis 计数
- OTP 明文仅在发送短信时生成,不持久化;仅存 PBKDF2 哈希
- 单条记录验证次数 ≥ 5 次后强制作废is_used=True
- 同一手机号 1 小时内按 scene 独立限频password_reset ≤ 5 次login ≤ 10 次
"""
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)
SCENE_CHOICES = [
('password_reset', '找回密码'),
('login', '验证码登录'),
]
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='sms_otp_records')
phone_hash = models.CharField(max_length=64) # SHA-256 哈希,不暴露手机号原文
scene = models.CharField(max_length=20, choices=SCENE_CHOICES) # 区分场景
otp_hash = models.CharField(max_length=128) # PBKDF2 哈希,禁止明文存储
expires_at = models.DateTimeField() # password_reset: +10min; login: +5min
is_used = models.BooleanField(default=False)
verify_attempts = models.SmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_reset_tokens'
db_table = 'sms_otp_records'
indexes = [
models.Index(fields=['user_id']),
models.Index(fields=['user', '-created_at']),
models.Index(fields=['phone_hash', '-created_at']),
]
def is_valid(self) -> bool:
from django.utils import timezone
return not self.is_used and timezone.now() < self.expires_at
return not self.is_used and self.verify_attempts < 5 and timezone.now() < self.expires_at
def mark_used(self):
self.is_used = True
self.save(update_fields=['is_used'])
```
---
@@ -387,8 +409,9 @@ class PasswordHistory(models.Model):
| `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 次/小时 |
| `sms_limit:password_reset:{phone}` | STRING计数 | 1 小时 | 找回密码场景:同一手机号短信发送次数;上限 **5 次**/小时 |
| `sms_limit:login:{phone}` | STRING计数 | 1 小时 | 验证码登录场景:同一手机号短信发送次数;上限 **10 次**/小时 |
| `sms_reset_token:{token}` | STRINGuser_id | 15 分钟 | 验证码通过后颁发的一次性重置凭证;用于步骤三提交新密码;使用后立即删除 |
| `tenant_verify_ip:{ip}` | STRING计数 | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
@@ -435,8 +458,8 @@ class PasswordHistory(models.Model):
### 5.2 账号创建触发时机
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义字母开头6~30 位) | 平台运营自定义 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | **固定为该租户联系人手机号**11 位数字,来源于 `public.tenants.contact_phone` | **系统统一固定初始密码**(如 `Fonrey@2025`,由平台部署配置设定) |
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统Tenant Admin 触发) | 固定为员工手机号11 位) | 系统统一初始密码(部署配置) |
---
@@ -472,7 +495,7 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
```
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
0002_login_attempts.py # LoginAttempt 表
0003_password_reset_tokens.py # PasswordResetToken
0003_sms_otp_records.py # SmsOtpRecord 表(替代原 password_reset_tokens
0004_password_histories.py # PasswordHistory 表
```
@@ -492,5 +515,6 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
| 找回密码机制 | 短信验证码(`SmsOtpRecord` | 经纪人普遍无邮箱手机号是本系统唯一已知联系方式短信核验更直接废弃邮件找回路径减少外部依赖SMTP |
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |