添加用户鉴权

This commit is contained in:
ChengCan
2026-03-26 11:06:10 +08:00
parent f7b264e6c8
commit 3e3f4390a6
12 changed files with 379 additions and 29 deletions

View File

@@ -9,3 +9,6 @@ DB_PORT=3306
DB_USER=root
DB_PASSWORD=change_me
DB_NAME=mini_program
JWT_SECRET=change_me_to_a_long_random_string
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60

View File

@@ -5,12 +5,16 @@ from fastapi import FastAPI
try:
# For module mode: `uvicorn app.main:app`
from app.db import check_db_connection, close_db_engine
from app.middleware.auth import install_auth_middleware
from app.routers.auth import router as auth_router
from app.routers.health import router as health_router
from app.routers.messages import router as messages_router
from app.settings import settings
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from db import check_db_connection, close_db_engine
from middleware.auth import install_auth_middleware
from routers.auth import router as auth_router
from routers.health import router as health_router
from routers.messages import router as messages_router
from settings import settings
@@ -31,6 +35,8 @@ def create_app() -> FastAPI:
def on_shutdown() -> None:
close_db_engine()
install_auth_middleware(app)
app.include_router(auth_router)
app.include_router(health_router)
app.include_router(messages_router)
return app

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,75 @@
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request
from sqlalchemy import text
try:
# For module mode: `uvicorn app.main:app`
from app.db import SessionLocal
from app.security import auth_error_response, decode_access_token
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from db import SessionLocal
from security import auth_error_response, decode_access_token
EXCLUDED_PATH_PREFIXES = (
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/auth/login",
)
def _is_excluded_path(path: str) -> bool:
for prefix in EXCLUDED_PATH_PREFIXES:
if path == prefix or path.startswith(f"{prefix}/"):
return True
return False
def install_auth_middleware(app: FastAPI) -> None:
@app.middleware("http")
async def auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable],
):
path = request.url.path
if request.method == "OPTIONS" or _is_excluded_path(path):
return await call_next(request)
auth_header = request.headers.get("Authorization")
if not auth_header:
return auth_error_response("missing authorization header")
parts = auth_header.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return auth_error_response("invalid authorization format")
try:
user_id = decode_access_token(parts[1].strip())
except HTTPException:
return auth_error_response("invalid or expired access token")
with SessionLocal() as db:
user_row = (
db.execute(
text(
"""
SELECT id, status
FROM chat_user
WHERE id = :user_id
LIMIT 1
"""
),
{"user_id": user_id},
)
.mappings()
.first()
)
if not user_row or int(user_row["status"]) != 1:
return auth_error_response("user not found or disabled")
request.state.user_id = user_id
return await call_next(request)

View File

@@ -0,0 +1,95 @@
import hashlib
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
try:
# For module mode: `uvicorn app.main:app`
from app.db import get_db
from app.schemas.auth import LoginRequest, LoginResponse
from app.security import create_access_token
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from db import get_db
from schemas.auth import LoginRequest, LoginResponse
from security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
with db.begin():
row = (
db.execute(
text(
"""
SELECT
ua.id AS user_auth_id,
ua.user_id,
ua.password_hash,
ua.status AS auth_status,
u.status AS user_status
FROM chat_user_auth ua
JOIN chat_user u ON u.id = ua.user_id
WHERE ua.auth_type = 6
AND ua.auth_identifier = :username
LIMIT 1
"""
),
{"username": payload.username},
)
.mappings()
.first()
)
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
if int(row["auth_status"]) != 1 or int(row["user_status"]) != 1:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="account is disabled",
)
# Seed data currently stores sha256; migrate to bcrypt/argon2 in production.
password_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest()
if not row["password_hash"] or password_hash != row["password_hash"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
db.execute(
text(
"""
UPDATE chat_user_auth
SET last_login_at = CURRENT_TIMESTAMP(3),
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = :user_auth_id
"""
),
{"user_auth_id": int(row["user_auth_id"])},
)
db.execute(
text(
"""
UPDATE chat_user
SET last_login_at = CURRENT_TIMESTAMP(3),
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = :user_id
"""
),
{"user_id": int(row["user_id"])},
)
access_token, expires_in = create_access_token(user_id=int(row["user_id"]))
return LoginResponse(
access_token=access_token,
expires_in=expires_in,
user_id=int(row["user_id"]),
)

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
try:
# For module mode: `uvicorn app.main:app`
from app.db import get_db
from app.security import get_current_user_id
from app.schemas.message import (
MessageCreateRequest,
MessageCreateResponse,
@@ -18,6 +19,7 @@ try:
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from db import get_db
from security import get_current_user_id
from schemas.message import (
MessageCreateRequest,
MessageCreateResponse,
@@ -106,14 +108,48 @@ def _get_existing_message(
)
def _assert_conversation_access(
db: Session,
conversation_id: int,
current_user_id: int,
) -> None:
conversation_row = (
db.execute(
text(
"""
SELECT id, user_low_id, user_high_id
FROM chat_conversation
WHERE id = :conversation_id
LIMIT 1
"""
),
{"conversation_id": conversation_id},
)
.mappings()
.first()
)
if not conversation_row:
raise HTTPException(status_code=404, detail="conversation not found")
if current_user_id not in (
int(conversation_row["user_low_id"]),
int(conversation_row["user_high_id"]),
):
raise HTTPException(status_code=403, detail="no permission for this conversation")
@router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED)
def create_message(
payload: MessageCreateRequest,
response: Response,
current_user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
) -> MessageCreateResponse:
sender_user_id = payload.sender_user_id
sender_user_id = current_user_id
peer_user_id = payload.peer_user_id
if sender_user_id == peer_user_id:
raise HTTPException(status_code=422, detail="peer_user_id cannot be same as current user")
user_low_id = min(sender_user_id, peer_user_id)
user_high_id = max(sender_user_id, peer_user_id)
@@ -303,17 +339,14 @@ def list_messages(
conversation_id: int = Query(gt=0),
cursor_seq: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=100),
current_user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
) -> MessageListResponse:
conversation_exists = (
db.execute(
text("SELECT 1 FROM chat_conversation WHERE id = :conversation_id LIMIT 1"),
{"conversation_id": conversation_id},
).first()
is not None
_assert_conversation_access(
db=db,
conversation_id=conversation_id,
current_user_id=current_user_id,
)
if not conversation_exists:
raise HTTPException(status_code=404, detail="conversation not found")
sql = """
SELECT

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=191)
password: str = Field(min_length=1, max_length=128)
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user_id: int

View File

@@ -5,7 +5,6 @@ from pydantic import BaseModel, Field, model_validator
class MessageCreateRequest(BaseModel):
sender_user_id: int = Field(gt=0)
peer_user_id: int = Field(gt=0)
client_msg_id: str = Field(min_length=1, max_length=64)
content_type: int = Field(description="1-text 2-audio 3-image 4-json")
@@ -16,9 +15,6 @@ class MessageCreateRequest(BaseModel):
@model_validator(mode="after")
def validate_message_fields(self) -> "MessageCreateRequest":
if self.sender_user_id == self.peer_user_id:
raise ValueError("sender_user_id and peer_user_id cannot be the same")
if self.content_type not in {1, 2, 3, 4}:
raise ValueError("content_type must be one of 1, 2, 3, 4")

View File

@@ -0,0 +1,64 @@
from datetime import UTC, datetime, timedelta
import jwt
from fastapi import HTTPException, Request, status
from starlette.responses import JSONResponse
try:
# For module mode: `uvicorn app.main:app`
from app.settings import settings
except ModuleNotFoundError:
# For script mode: `python app/main.py` or VS Code "Run Python File"
from settings import settings
def create_access_token(user_id: int) -> tuple[str, int]:
now = datetime.now(UTC)
expires_delta = timedelta(minutes=settings.jwt_access_token_expire_minutes)
expires_at = now + expires_delta
payload = {
"sub": str(user_id),
"token_type": "access",
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
}
token = jwt.encode(
payload=payload,
key=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
)
return token, int(expires_delta.total_seconds())
def decode_access_token(token: str) -> int:
try:
payload = jwt.decode(
jwt=token,
key=settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except jwt.PyJWTError as exc:
raise HTTPException(status_code=401, detail="invalid or expired access token") from exc
if payload.get("token_type") != "access":
raise HTTPException(status_code=401, detail="invalid token type")
sub = payload.get("sub")
if not isinstance(sub, str) or not sub.isdigit():
raise HTTPException(status_code=401, detail="invalid token subject")
return int(sub)
def auth_error_response(detail: str = "unauthorized") -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": detail},
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user_id(request: Request) -> int:
user_id = getattr(request.state, "user_id", None)
if not isinstance(user_id, int):
raise HTTPException(status_code=401, detail="unauthorized")
return user_id

View File

@@ -26,6 +26,15 @@ class Settings(BaseSettings):
db_user: str = Field(default="root", validation_alias="DB_USER")
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
db_name: str = Field(default="mini_program", validation_alias="DB_NAME")
jwt_secret: str = Field(
default="dev_only_change_jwt_secret",
validation_alias="JWT_SECRET",
)
jwt_algorithm: str = Field(default="HS256", validation_alias="JWT_ALGORITHM")
jwt_access_token_expire_minutes: int = Field(
default=60,
validation_alias="JWT_ACCESS_TOKEN_EXPIRE_MINUTES",
)
@property
def mysql_dsn(self) -> str:

View File

@@ -11,6 +11,11 @@ http://127.0.0.1:8001
- Swagger UI`/docs`
- ReDoc`/redoc`
鉴权说明:
-`GET /health``POST /auth/login``/docs``/redoc``/openapi.json` 外,其余接口都需要 Bearer Token。
- 请求头格式:`Authorization: Bearer <access_token>`
## 1. 健康检查
### `GET /health`
@@ -27,23 +32,63 @@ http://127.0.0.1:8001
}
```
## 2. 发送消息
## 2. 登录
### `POST /auth/login`
用途:
- 用户登录并获取访问令牌access token
- 当前示例账号:`test1/test2/test3`,密码均为 `123`
请求体:
```json
{
"username": "test1",
"password": "123"
}
```
成功响应(`200`
```json
{
"access_token": "<jwt-token>",
"token_type": "bearer",
"expires_in": 3600,
"user_id": 1
}
```
常见错误码:
- `401`:用户名或密码错误,或账号不可用
- `422`:请求参数校验失败
## 3. 发送消息
### `POST /messages`
用途:
- 保存单聊消息。
- 服务端会根据 `sender_user_id``peer_user_id` 自动查找或创建会话
- 服务端从 token 中识别发送者,不需要传 `sender_user_id`
- 服务端根据“当前用户 + peer_user_id”自动查找或创建会话。
- `client_msg_id` 用于幂等控制。
请求头:
```
Authorization: Bearer <access_token>
```
请求体:
```json
{
"sender_user_id": 1,
"peer_user_id": 2,
"client_msg_id": "msg-20260326-0001",
"client_msg_id": "msg-20260326-0002",
"content_type": 1,
"content_text": "hello"
}
@@ -51,8 +96,7 @@ http://127.0.0.1:8001
字段规则:
- `sender_user_id``int > 0`
- `peer_user_id``int > 0`,且不能与 `sender_user_id` 相同
- `peer_user_id``int > 0`,且不能与当前登录用户相同
- `client_msg_id``string`,长度 `1-64`
- `content_type``1=text 2=audio 3=image 4=json`
- `content_text``content_type=1` 时必填
@@ -66,9 +110,9 @@ http://127.0.0.1:8001
{
"idempotent": false,
"message": {
"id": 1006,
"id": 1008,
"conversation_id": 1,
"seq": 6,
"seq": 7,
"sender_user_id": 1,
"role": 1,
"content_type": 1,
@@ -76,8 +120,8 @@ http://127.0.0.1:8001
"content_json": null,
"media_file_key": null,
"media_duration_ms": null,
"client_msg_id": "msg-20260326-0001",
"created_at": "2026-03-26T10:40:00.123000"
"client_msg_id": "msg-20260326-0002",
"created_at": "2026-03-26T11:20:00.123000"
}
}
```
@@ -88,9 +132,9 @@ http://127.0.0.1:8001
{
"idempotent": true,
"message": {
"id": 1006,
"id": 1008,
"conversation_id": 1,
"seq": 6,
"seq": 7,
"sender_user_id": 1,
"role": 1,
"content_type": 1,
@@ -98,19 +142,20 @@ http://127.0.0.1:8001
"content_json": null,
"media_file_key": null,
"media_duration_ms": null,
"client_msg_id": "msg-20260326-0001",
"created_at": "2026-03-26T10:40:00.123000"
"client_msg_id": "msg-20260326-0002",
"created_at": "2026-03-26T11:20:00.123000"
}
}
```
常见错误码:
- `404`发送人或接收人不存在/不可用
- `401`未登录或 token 无效
- `404`:接收人不存在/不可用
- `409`:会话不是激活状态
- `422`:请求参数校验失败
## 3. 查询消息
## 4. 查询消息
### `GET /messages`
@@ -118,6 +163,13 @@ http://127.0.0.1:8001
- 按会话分页读取消息。
- 返回结果按 `seq` 正序排列。
- 仅会话参与者可读取。
请求头:
```
Authorization: Bearer <access_token>
```
Query 参数:
@@ -162,5 +214,7 @@ GET /messages?conversation_id=1&cursor_seq=50&limit=20
常见错误码:
- `401`:未登录或 token 无效
- `403`:无权限读取该会话
- `404`:会话不存在
- `422`:请求参数校验失败

View File

@@ -4,3 +4,4 @@ pydantic-settings==2.8.1
python-dotenv==1.0.1
SQLAlchemy==2.0.38
PyMySQL==1.1.1
PyJWT==2.10.1