fix:sample/plate 之前的开发
This commit is contained in:
533
docs/dev/backend/auth.md
Normal file
533
docs/dev/backend/auth.md
Normal file
@@ -0,0 +1,533 @@
|
||||
# 后端登录注册接口说明
|
||||
|
||||
本文档描述 `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` |
|
||||
Reference in New Issue
Block a user