# 后端登录注册接口说明

本文档描述 `common-br-api` 项目中用户认证（注册、登录、Token 校验）的后端实现，便于前后端联调与部署排查。

---

## 1. 总览

| 项目 | 说明 |
|------|------|
| 路由前缀 | `/auth` |
| 框架 | FastAPI |
| 用户表 | `sys_users` |
| 会话表 | `user_sessions` |
| 密码算法 | PBKDF2-HMAC-SHA256（210000 次迭代） |
| Token 形式 | 自实现 JWT 风格 Bearer Token（HS256 签名） |
| 默认有效期 | 24 小时（1440 分钟） |

**当前已实现的 HTTP 接口：**

| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/auth/register` | 注册并自动登录 |
| `POST` | `/auth/login` | 账号密码登录 |

**尚未实现的接口（前端 UI 可能已预留）：**

- `/auth/logout`（登出 / 服务端吊销会话）
- `/auth/refresh`（刷新 Token）
- 手机号 / 短信验证码登录

---

## 2. 代码结构

```
app/
├── api/
│   └── auth.py              # 注册、登录、get_current_user 依赖
├── core/
│   └── security.py          # 密码哈希、Token 签发、Token 哈希
├── models/
│   └── auth.py              # User、UserSession ORM 模型
├── config/
│   └── settings.py          # JWT 密钥、过期时间等配置
└── main.py                  # 挂载 auth 路由

migrations/versions/
└── 7c91a64f2b2e_create_auth_tables.py   # 建表迁移
```

路由挂载（`app/main.py`）：

```python
app.include_router(auth_router)   # 无前缀叠加，router 自身 prefix="/auth"
```

因此完整 URL 为：

```text
http://<host>:8000/auth/register
http://<host>:8000/auth/login
```

经 Next.js 代理后（开发环境）也可访问：

```text
http://localhost:3000/auth/register
http://localhost:3000/auth/login
```

---

## 3. 认证流程

### 3.1 注册 / 登录发 Token

```mermaid
sequenceDiagram
    participant Client as 客户端
    participant API as /auth/*
    participant DB as PostgreSQL

    Client->>API: POST /auth/register 或 /auth/login
    API->>DB: 查重 / 校验密码
    API->>API: create_access_token(sub, account, jti)
    API->>DB: INSERT user_sessions(jti, token_hash, expires_at)
    API-->>Client: TokenResponse(access_token, expires_at, user)
```

### 3.2 受保护接口校验 Token

```mermaid
sequenceDiagram
    participant Client as 客户端
    participant API as 业务接口
    participant DB as PostgreSQL

    Client->>API: Authorization: Bearer <token>
    API->>API: 校验 JWT 签名与 exp
    API->>DB: JOIN sys_users + user_sessions<br/>匹配 user_id, jti, token_hash
    alt 会话有效
        API-->>Client: 200 + 业务数据
    else 无效或过期
        API-->>Client: 401 / 403
    end
```

**要点：**

1. Token 不仅校验 JWT 签名和过期时间，还必须在 `user_sessions` 表中有对应且未吊销的记录。
2. 数据库存的是 Token 的 **SHA256 哈希**，不是明文 Token。
3. 每个 Token 有唯一 `jti`（JWT ID），用于会话追踪。

---

## 4. 数据库设计

### 4.1 `sys_users`

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | UUID | PK | 用户 ID |
| `account` | VARCHAR(100) | UNIQUE, NOT NULL | 登录账号 |
| `password_hash` | TEXT | NOT NULL | PBKDF2 哈希串 |
| `name` | VARCHAR(100) | NOT NULL | 显示名称 |
| `email` | VARCHAR(255) | UNIQUE, NOT NULL | 邮箱（注册时转小写） |
| `phone` | VARCHAR(50) | NULL | 手机号（可选） |
| `created_at` | TIMESTAMPTZ | NOT NULL | 创建时间 |
| `updated_at` | TIMESTAMPTZ | NOT NULL | 更新时间 |

索引：

- `ux_users_account`（account 唯一）
- `ux_users_email`（email 唯一）

### 4.2 `user_sessions`

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | UUID | PK | 会话记录 ID |
| `user_id` | UUID | FK → sys_users.id, CASCADE | 所属用户 |
| `jti` | VARCHAR(64) | UNIQUE, NOT NULL | Token 会话 ID |
| `token_hash` | TEXT | NOT NULL | SHA256(access_token) |
| `expires_at` | TIMESTAMPTZ | NOT NULL | 过期时间 |
| `revoked_at` | TIMESTAMPTZ | NULL | 吊销时间（预留，当前无 logout 接口写入） |
| `created_at` | TIMESTAMPTZ | NOT NULL | 创建时间 |
| `updated_at` | TIMESTAMPTZ | NOT NULL | 更新时间 |

---

## 5. 环境配置

配置类：`app/config/settings.py`

| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `JWT_SECRET_KEY` | `change-me-in-production` | HMAC 签名密钥，**生产必须修改** |
| `JWT_ALGORITHM` | `HS256` | 写入 JWT Header（实际签名为 HMAC-SHA256） |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440`（24h） | Token 有效期（分钟） |
| `DATABASE_URL` | 见 `.env.example` | PostgreSQL 连接串 |

示例（`.env`）：

```env
JWT_SECRET_KEY=your-long-random-secret
DATABASE_URL=postgresql+asyncpg://user:pass@postgres:5432/brapi-python
```

---

## 6. 密码安全

实现文件：`app/core/security.py`

### 6.1 哈希格式

```
pbkdf2_sha256$210000$<salt_hex>$<digest_hex>
```

- 算法：`PBKDF2-HMAC-SHA256`
- 迭代次数：`210000`
- Salt：16 字节随机 hex（`secrets.token_hex(16)`）

### 6.2 注册时

```python
password_hash=hash_password(payload.password)
```

### 6.3 登录时

```python
verify_password(payload.password, user.password_hash)
```

校验失败统一返回 `401 Invalid account or password`，不区分「账号不存在」与「密码错误」。

---

## 7. Access Token 结构

Token 由 `create_access_token()` 生成，格式为三段式 Base64URL：

```text
<base64url(header)>.<base64url(payload)>.<base64url(signature)>
```

### 7.1 Header

```json
{"alg": "HS256", "typ": "JWT"}
```

### 7.2 Payload

```json
{
  "sub": "<user_uuid>",
  "account": "<login_account>",
  "jti": "<32_hex_chars>",
  "iat": 1710000000,
  "exp": 1710086400
}
```

| Claim | 含义 |
|-------|------|
| `sub` | 用户 UUID 字符串 |
| `account` | 登录账号 |
| `jti` | 会话唯一 ID |
| `iat` | 签发时间（Unix 秒） |
| `exp` | 过期时间（Unix 秒） |

### 7.3 签名

```python
HMAC-SHA256(key=JWT_SECRET_KEY, msg=f"{header_b64}.{payload_b64}")
```

> 注意：项目使用**自实现**的 JWT 编解码（`app/api/auth.py` + `app/core/security.py`），未依赖 PyJWT 库。

---

## 8. HTTP 接口详情

### 8.1 注册 `POST /auth/register`

**请求体 `RegisterRequest`：**

| 字段 | 类型 | 必填 | 校验 |
|------|------|------|------|
| `account` | string | 是 | 长度 3–100 |
| `password` | string | 是 | 长度 6–128 |
| `name` | string | 是 | 长度 1–100 |
| `email` | string | 是 | 长度 3–255，入库前转小写 |
| `phone` | string | 否 | 最大 50 |

**业务逻辑：**

1. `account`、`email` 去空格 / 小写处理
2. 查询 `sys_users`，若 `account` 或 `email` 已存在 → `409 Conflict`
3. 创建用户并 `flush`
4. 调用 `_issue_token()` 写入 `user_sessions` 并返回 Token

**成功响应：** `201 Created`，Body 为 `TokenResponse`

**curl 示例：**

```bash
curl -X POST http://127.0.0.1:8000/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "account": "demo_user",
    "password": "demo123456",
    "name": "演示用户",
    "email": "demo@example.com",
    "phone": "13800138000"
  }'
```

---

### 8.2 登录 `POST /auth/login`

**请求体 `LoginRequest`：**

| 字段 | 类型 | 必填 | 校验 |
|------|------|------|------|
| `account` | string | 是 | 长度 1–100 |
| `password` | string | 是 | 长度 1–128 |

**业务逻辑：**

1. 按 `account` 查用户
2. `verify_password` 失败 → `401 Unauthorized`
3. 成功则 `_issue_token()` 并返回

**成功响应：** `200 OK`，Body 为 `TokenResponse`

**curl 示例：**

```bash
curl -X POST http://127.0.0.1:8000/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "account": "demo_user",
    "password": "demo123456"
  }'
```

---

### 8.3 统一成功响应 `TokenResponse`

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
  "token_type": "bearer",
  "expires_at": "2026-05-27T10:00:00+00:00",
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "account": "demo_user",
    "name": "演示用户",
    "email": "demo@example.com",
    "phone": "13800138000"
  }
}
```

| 字段 | 说明 |
|------|------|
| `access_token` | Bearer Token 字符串 |
| `token_type` | 固定 `"bearer"` |
| `expires_at` | ISO8601 UTC 过期时间 |
| `user` | 当前用户公开信息（不含密码） |

---

### 8.4 错误响应格式

认证相关错误通过 `ApiError` 抛出，由 `app/main.py` 统一处理：

```json
{
  "message": "错误摘要",
  "detail": "错误摘要或详细信息"
}
```

| HTTP 状态码 | 场景 | message 示例 |
|-------------|------|----------------|
| `401` | 登录失败 | `Invalid account or password` |
| `401` | 缺少 Token | `Missing bearer token` |
| `401` | Token 无效 | `Invalid token signature` / `Session invalid or expired` |
| `403` | Token 过期 | `Token expired` |
| `409` | 注册冲突 | `Account or email already exists` |
| `422` | 参数校验失败 | `Validation failed`（detail 为字段错误列表） |

---

## 9. 受保护接口：`get_current_user`

定义位置：`app/api/auth.py`

```python
async def get_current_user(
    credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
    session: AsyncSession = Depends(get_db_session),
) -> User:
    ...
```

### 9.1 客户端如何携带 Token

```http
Authorization: Bearer <access_token>
```

### 9.2 校验步骤

1. 检查 `Authorization` 头是否存在且 scheme 为 `bearer`
2. `_decode_access_token()`：验证格式、HMAC 签名、`exp` 未过期
3. 从 payload 读取 `sub`（user_id）、`jti`
4. 数据库查询：

```sql
SELECT sys_users.*
FROM sys_users
JOIN user_sessions ON user_sessions.user_id = sys_users.id
WHERE sys_users.id = :user_id
  AND user_sessions.jti = :jti
  AND user_sessions.token_hash = sha256(:token)
  AND user_sessions.revoked_at IS NULL
  AND user_sessions.expires_at > now()
```

5. 查不到用户 → `401 Session invalid or expired`

### 9.3 哪些接口使用了 `get_current_user`

#### `/auth` 模块

无（register/login 本身不需要 Token）

#### `/api/dictionaries` 模块（`app/api/dictionaries.py`）

| 接口 | 是否需要登录 |
|------|-------------|
| `GET /api/dictionaries/crops` | 否 |
| `POST /api/dictionaries/crops` | **是** |
| `GET /api/dictionaries/persons` | 否 |
| `POST /api/dictionaries/persons` | 否 |
| `POST /api/dictionaries/countries` | **是** |
| `PUT /api/dictionaries/countries/{code}` | **是** |

#### `/brapi/v2` 模块（`app/api/router.py`）

- 多数 **GET** 接口：接收 `Authorization` 头但**不强制校验**（BrAPI 兼容占位）
- 多数 **POST / PUT / DELETE** 写操作：通过 `Depends(get_current_user)` **强制登录**

写操作成功后会将 `current_user.id` 写入业务表的 `auth_user_id` 字段，用于记录数据创建者。

> 上传接口 `POST /brapi/v2/upload/image` 当前**未**接入 `get_current_user`，前端可选择性携带 Token。

---

## 10. `_issue_token` 内部流程

```python
async def _issue_token(session: AsyncSession, user: User) -> TokenResponse:
    jti = uuid4().hex
    token, expires_at = create_access_token(
        subject=str(user.id),
        account=user.account,
        jti=jti,
    )
    session.add(UserSession(
        user_id=user.id,
        jti=jti,
        token_hash=hash_token(token),
        expires_at=expires_at,
    ))
    await session.commit()
    return TokenResponse(...)
```

每次登录/注册都会**新增一条** `user_sessions` 记录；旧会话不会自动吊销（除非手动清理数据库或未来实现 logout）。

---

## 11. 前端对接（简要）

| 文件 | 作用 |
|------|------|
| `frontend/src/services/authService.ts` | 封装 `loginWithPassword`、`registerAccount` |
| `frontend/src/lib/api/sdk.gen.ts` | OpenAPI 生成的 `/auth/login`、`/auth/register` 客户端 |
| `frontend/src/stores/modules/auth.ts` | Zustand 持久化 Token 与用户信息 |
| `frontend/src/utils/token.ts` | 从 `localStorage` 读取 `auth_token` |
| `frontend/src/lib/client.ts` | 请求拦截器自动附加 `Authorization: Bearer ...` |

登录成功后前端会：

1. 调用 `setSession(session)` 写入 Zustand + `localStorage.auth_token`
2. 跳转到 `/central-config/user/employee` 或 `redirect` 参数指定页

登出：仅前端 `logout()` 清除本地存储，**不会**通知后端吊销会话。

---

## 12. 本地调试

### 12.1 启动后端

```bash
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```

### 12.2 Swagger 文档

```text
http://127.0.0.1:8000/docs
```

在 Swagger 中可找到 `auth` 分组下的 `register`、`login`。

### 12.3 用 Token 访问受保护接口

```bash
TOKEN="<access_token>"

curl http://127.0.0.1:8000/api/dictionaries/crops \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"crop_name": "maize"}'
```

---

## 13. 部署注意事项

1. **必须修改** `JWT_SECRET_KEY`，不要使用默认值 `change-me-in-production`。
2. 首次部署需执行数据库迁移：`uv run alembic upgrade head`（包含 `sys_users`、`user_sessions` 建表）。
3. Docker / K8s 环境中，容器内 `API_BASE_URL=http://127.0.0.1:8000` 供 Next.js 代理；客户端浏览器仍通过同源 `/auth/*` 访问。
4. 当前无服务端 logout：Token 在过期前始终有效（只要 `user_sessions` 记录存在）。如需强制下线，需扩展 logout 接口并设置 `revoked_at`。

---

## 14. 后续可扩展项

| 功能 | 建议实现 |
|------|----------|
| 登出 | `POST /auth/logout`，将当前 `jti` 的 `revoked_at` 设为 now |
| 刷新 Token | `POST /auth/refresh`，验证旧 Token 后签发新 Token |
| 当前用户 | `GET /auth/me`，返回 `UserResponse` |
| 修改密码 | `POST /auth/change-password` |
| 手机号登录 | 独立短信验证码表 + 新 login 分支 |
| 上传鉴权 | `upload.py` 增加 `Depends(get_current_user)` |

---

## 15. 相关源码索引

| 内容 | 路径 |
|------|------|
| 路由与 DTO | `app/api/auth.py` |
| 密码 / Token 工具 | `app/core/security.py` |
| ORM 模型 | `app/models/auth.py` |
| 配置 | `app/config/settings.py` |
| 建表迁移 | `migrations/versions/7c91a64f2b2e_create_auth_tables.py` |
| 应用入口 | `app/main.py` |
| 前端封装 | `frontend/src/services/authService.ts` |
