添加用户鉴权
This commit is contained in:
@@ -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
|
||||
|
||||
1
mini-program/app/middleware/__init__.py
Normal file
1
mini-program/app/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
75
mini-program/app/middleware/auth.py
Normal file
75
mini-program/app/middleware/auth.py
Normal 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)
|
||||
95
mini-program/app/routers/auth.py
Normal file
95
mini-program/app/routers/auth.py
Normal 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"]),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
13
mini-program/app/schemas/auth.py
Normal file
13
mini-program/app/schemas/auth.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
64
mini-program/app/security.py
Normal file
64
mini-program/app/security.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user