534 lines
14 KiB
Markdown
534 lines
14 KiB
Markdown
# 后端登录注册接口说明
|
||
|
||
本文档描述 `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` |
|