# 003 身份认证与租户隔离技术决策

对应 Issue：B-40（`docs/issues/003-p0-auth-and-tenant-isolation.md`）。

## 密码散列：标准库 PBKDF2-HMAC-SHA256

- 只使用 Python 标准库（`hashlib.pbkdf2_hmac` + `hmac.compare_digest` +
  `secrets`），不引入 bcrypt/argon2 等第三方依赖，与离线内网部署约束一致。
- 260 000 次迭代、16 字节随机盐，存储格式
  `pbkdf2_sha256$<迭代数>$<盐hex>$<摘要hex>`，自描述、可平滑升级参数。
- 明文密码永不入库、永不进日志、永不写入 `audit_log`；
  `password_hash` 字段从不出现在任何 API 响应中（有测试断言）。

## 会话：数据库保存令牌摘要，8 小时绝对过期

- 令牌为 `secrets.token_urlsafe(32)`，数据库只存其 SHA-256 摘要；
  数据库泄露不直接暴露可用令牌。
- 绝对过期 8 小时（一个工作班次），不做滑动续期，语义简单可测。
- 会话可吊销：退出登录、停用账号、重置密码都会立即吊销该用户全部会话。
- Cookie 名 `cw_session`，`HttpOnly; SameSite=Lax; Path=/`。
  当前是纯 HTTP 的局域网部署，**刻意不加 `Secure`**（加了浏览器会直接拒发）；
  若未来上 HTTPS，应补上 `Secure` 并配置反向代理。

## 登录限流：同一 (账号, IP) 10 分钟内失败 5 次即锁定

- 计数来自 `login_attempts` 表，窗口为滚动 10 分钟；触发后返回 429，
  且在窗口内不再记录新尝试，行为确定、可测试。
- 失败提示统一为「账号或密码不正确」，不泄露是哪一部分错误。
- 运维开关：环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
  时完全跳过限流检查，仅限内网测试环境临时使用；默认未设置，保持
  「5 次失败锁 10 分钟」的生产安全策略不变。

## 角色与公司绑定

- `users.role` 仅 `admin` / `company`；数据库 CHECK 约束强制
  公司账号必须绑定公司、管理员不得绑定公司。
- 登录时前端选择工作端口（portal），服务端校验 portal 与角色一致，
  不匹配返回 403「账号与该工作端口不匹配」。
- 管理员创建公司账号时，初始密码为随机一次性密码并置
  `must_change_password=1`；首次登录必须改密，改密前所有业务 API 返回 403。
  初始密码只在创建成功响应中显示一次，永不入库明文、永不写日志；
  创建用户的审计 detail 只记录公司，不重复账号名。
- 「重置密码」同样生成随机一次性密码并吊销既有会话。

## 租户隔离在服务端强制，404 优于 403

- 公司用户的批次列表、行明细、CSV 导出、上传归属全部由服务端按
  会话中的 `company_id` 过滤；请求体/参数里的 `company_id` 对公司用户
  一律忽略（跨公司上传防护）。
- 访问他公司批次返回 404 而非 403：不暴露「该批次存在但属于别人」这一事实，
  避免 IDOR 探测。导出时显式指定他公司 `company_id` 仍返回 403，
  因为用户已声明知道该公司存在，此时给出明确拒绝更有操作性。
- `GET /admin.html`、`/company.html` 的 302 跳转只是 UX 层引导，
  不是权限边界；真正的边界全部在 API 上。

## 引导管理员（bootstrap）

- 服务启动迁移后若无任何 admin 账号，创建 `APP_ADMIN_USERNAME`
  （默认 `group-admin`）；密码取 `APP_BOOTSTRAP_ADMIN_PASSWORD`，
  未设置则生成随机初始密码并**只打印一次到 stdout**（不写日志文件），
  `must_change_password=1`。
- 不在迁移或代码中预置任何公司或用户；公司与公司账号全部由管理员
  通过 `/api/admin/companies`、`/api/admin/users` 动态创建。

## 审计

- `audit_log` 记录登录成功/失败、退出、改密、上传、导出（含公司范围与行数）、
  建公司、建/停/启用户、重置密码等动作，含操作者、目标、IP、时间。
- 初始密码、新旧密码均不进入审计内容。
