feat: add JWT authentication and AGENTS.md
This commit is contained in:
@@ -27,3 +27,7 @@ DEFAULT_PAGE_SIZE = 20
|
||||
# 服务配置
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 23994
|
||||
|
||||
# JWT 认证配置
|
||||
JWT_SECRET = "elysia-todo-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
|
||||
from app.database import init_db
|
||||
from app.routers import api_router
|
||||
from app.utils.logger import logger
|
||||
from app.utils.auth import decode_access_token
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -89,6 +90,28 @@ async def log_requests(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health)
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# 不拦截:健康检查、静态文件、auth 路由
|
||||
if path == "/health" or not path.startswith("/api/") or path.startswith("/api/auth/"):
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
if not token:
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录"})
|
||||
|
||||
try:
|
||||
decode_access_token(token)
|
||||
except Exception:
|
||||
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -31,6 +31,9 @@ class UserSettings(Base):
|
||||
default_sort_by = Column(String(20), default="created_at")
|
||||
default_sort_order = Column(String(10), default="desc")
|
||||
|
||||
# 认证
|
||||
password_hash = Column(String(255), default="")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts, auth
|
||||
|
||||
# 创建主路由
|
||||
api_router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(tasks.router)
|
||||
api_router.include_router(categories.router)
|
||||
api_router.include_router(tags.router)
|
||||
|
||||
51
api/app/routers/auth.py
Normal file
51
api/app/routers/auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, ChangePasswordRequest
|
||||
from app.utils.auth import (
|
||||
hash_password, verify_password, create_access_token,
|
||||
get_current_user, set_default_password
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: LoginRequest, db: Session = Depends(get_db)):
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
if not settings:
|
||||
settings = UserSettings(id=1)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
set_default_password(db, settings)
|
||||
|
||||
if not verify_password(data.password, settings.password_hash):
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
token = create_access_token({"sub": str(settings.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
get_current_user(request)
|
||||
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
if not settings:
|
||||
raise HTTPException(status_code=500, detail="用户设置不存在")
|
||||
|
||||
if not verify_password(data.old_password, settings.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
settings.password_hash = hash_password(data.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
15
api/app/schemas/auth.py
Normal file
15
api/app/schemas/auth.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(..., min_length=1, max_length=100)
|
||||
new_password: str = Field(..., min_length=1, max_length=100)
|
||||
51
api/app/utils/auth.py
Normal file
51
api/app/utils/auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Request, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
from app.database import get_db
|
||||
from app.models.user_settings import UserSettings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> dict:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||
|
||||
|
||||
def set_default_password(db: Session, settings: UserSettings):
|
||||
if not settings.password_hash:
|
||||
settings.password_hash = hash_password("elysia")
|
||||
db.commit()
|
||||
@@ -1,16 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-DP1ITMxF.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-BDYzX3N-.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-CAICPA8-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DE0sVD5v.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DOz3B-pr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user