# Auth 契约 — 认证与权限模型

## 认证机制

### Session 登录

登录成功后写入 `$_SESSION['user']`：

```php
[
    'admin_user_id'       => 1,          // 整数；超级管理员固定为 0
    'admin_user_username' => 'zhangsan',
    'admin_user_name'     => '张三',
]
```

键名与 `admin_user` 表字段名一致。

读取当前用户：`Auth::user()`（返回上面的数组或 null；没有 `admin_user_id` 键的旧快照也返回 null）
读取当前用户 ID：`Auth::id()`（返回 int 或 null）

### 记住我（Remember Me）

Cookie 名：`remember_token`，有效期 **30天**。
Cookie 值格式：`{selector}:{明文validator}`

安全设计：
- 数据库只存 `hash('sha256', $validator)`，不存明文
- 每次自动登录成功后轮换 validator（旧值作废）
- validator 不匹配时立即吊销该 selector 对应的数据库记录

相关表：`admin_remember_token`

**该表的 `admin_remember_token_lnk_admin_user_id` 故意没有外键**：超级管理员的 id 是 0、不在 `admin_user` 表里，
有外键他的令牌就插不进去（1452，表现为"登录成功了但抛异常"）。
代价是没有 `ON DELETE CASCADE`，删用户时由 `Users::gridRemove()` 在应用层先清令牌。
改这张表的结构前先看 `sql/migrate_drop_remember_token_fk.sql` 的说明。

### 超级管理员

用户名 `test.admin`，配在 **`pdc/config/common.php` 的 `superAdmin`**（本机可用 `common.local.php` 覆盖），**不入库**。
`admin_user_id = 0`（真实表里不可能出现的值），框架侧常量 `Auth::SUPER_ADMIN_ID`。
`Auth::isSuperAdmin()` 返回 true 时绕过所有权限检查。
配置里用户名或密码留空 = 停用这个账号（登录和"记住我"自动登录都走不通）。
他的 SESSION 快照由 `Auth::superAdminProfile()` 从配置生成——因为查不到 `admin_user` 表，
"记住我"自动登录也走这条路径。

---

## 权限快照

登录成功时，权限计算结果缓存到 `$_SESSION['permissions']`：

```php
[
    'isSuperAdmin' => false,
    'permissions'  => [
        'AB100' => ['view', 'edit'],  // 权限码 => 操作类型数组
    ],
    'depaIds'      => [1, 3],        // 当前用户所属部门 ID 列表
]
```

读取：`Auth::permissions()`
**注意：角色/部门/权限变更后，当前已登录用户需要重新登录才能生效（Session 快照不实时刷新）。**

---

## 登录校验流程（框架自动执行）

```
Mvc::dispatch()
  → Action::callBefore()
      → requiresLogin() 返回 false → 跳过
      → Auth::user() 不为 null → 通过
      → Auth::tryRememberLogin() 成功 → 通过
      → 否则：
          Ajax 请求（X-Requested-With: XMLHttpRequest）→ 返回 401 JSON
          普通页面请求 → 302 跳转 /login/index
```

### requiresLogin() 覆盖规则

以下 Action 必须覆盖 `requiresLogin()` 返回 `false`：
- `loginAction`（登录页、doLogin、captcha、forgotPassword、resetPassword、doResetPassword）
- 其他无需登录的公开接口

```php
protected function requiresLogin(): bool
{
    return false;
}
```

---

## 密码规范

| 规则 | 值 |
|------|----|
| 哈希算法 | `password_hash($plain, PASSWORD_DEFAULT)` |
| 验证 | `password_verify($input, $hash)` |
| 强度要求 | 大于8位，含大写字母、小写字母、数字、特殊符号（`PasswordPolicy::isValid()`） |
| 过期提醒 | 超过 **90天** 未修改，登录后跳转提示页 |

修改密码的两个入口（互不干扰）：
- **找回密码 / 自己改**：`Users::updatePasswordById()`
- **后台管理员改他人**：`gridUpdate()` 内走 `gridUpdate` 流程（有密码才更新）

---

## IP 登录限流

类：`LoginThrottle`
- 密码错误后计数
- 达到阈值后锁定 N 分钟（具体阈值见 `LoginThrottle` 类实现）
- 登录成功后重置计数

---

## 验证码规则

- 第一次登录不要求验证码
- 密码错误一次后写 `$_SESSION['requireCaptcha'] = true`，后续必须填
- 登录成功后 `$_SESSION['requireCaptcha'] = false`

---

## 权限码格式

5位，仅大写字母与数字（`/^[A-Z0-9]{5}$/`）。

层级规则（看第3-5位，0-indexed 的下标 2、3、4）：

| 样例 | 第3位 | 第4位 | 第5位 | 层级 |
|------|-------|-------|-------|------|
| `AB000` | 0 | 0 | 0 | 1级（顶级） |
| `AB100` | 非0 | 0 | 0 | 2级 |
| `AB110` | 非0 | 非0 | 0 | 3级 |
| `AB111` | 非0 | 非0 | 非0 | 4级 |

父级编码 = 当前层级位起往后全部置0（由 `Permission::deriveLevelAndParent()` 自动计算，不手填）。

---

## 商品库数据权限（2026-09-28，P1）

按角色限制能看到的**商品品牌**（参考站 B600）和**经营单元**（参考站 B700「事业部」）：

| 表 | 角色页的列 |
|----|-----------|
| `sys_role_pro_brand` | 可见商品品牌（`sys_role_pro_brand_ids`） |
| `sys_role_pro_unit` | 可见经营单元（`sys_role_pro_unit_ids`） |

- 单个角色：没有行 = 不限制
- 用户的有效角色 = 自己绑定的 ∪ 所在部门绑定的（同 `PermissionResolver`）
- **合并只看设置了范围的角色**：有行的取并集；一个都没设 = 不限制。没设范围的角色不会把别的角色的限制放开
- 超级管理员不限制
- 解析在业务层 `model/ProDataScope.php`：`brandIdsFor($adminId)` / `unitIdsFor($adminId)`，`null` = 不限制；Action 传 `Auth::id()`
- **不进 SESSION 权限快照**（快照在框架层），每次现算：和权限码不同，改了角色范围**立即生效**，不用重新登录
- 商品列表（P3 / P4 / P5）按它过滤；品牌、单元的配置管理页本身不过滤
