fix: harden authentication system (JWT, cookies, rate limiting, password policy)
- Replace hardcoded JWT secret with randomly generated key persisted to file - Replace hardcoded default password with random password shown in logs - Migrate token storage from localStorage to HttpOnly SameSite=strict cookie - Add IP-based login rate limiter (5 attempts / 15 min, 429 on lockout) - Add token_version for JWT revocation on password change - Add password strength validation (min 6 chars, 3+ unique characters) - Inject decoded user payload into request.state.user in auth middleware - Add /api/auth/me and /api/auth/logout endpoints - Narrow auth middleware exception handling (JWTError only, not all Exception) - Update updated_at timestamp on password change - Remove localStorage token management from frontend (axios, router, store) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
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.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse
|
||||
from app.utils.auth import (
|
||||
hash_password, verify_password, create_access_token,
|
||||
get_current_user, set_default_password
|
||||
)
|
||||
from app.utils.datetime import utcnow
|
||||
from app.utils.rate_limiter import login_limiter
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: LoginRequest, db: Session = Depends(get_db)):
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
ip = _get_client_ip(request)
|
||||
|
||||
allowed, retry_after = login_limiter.check(ip)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"detail": f"登录尝试过于频繁,请 {retry_after // 60 + 1} 分钟后再试",
|
||||
"retry_after": retry_after,
|
||||
},
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
if not settings:
|
||||
settings = UserSettings(id=1)
|
||||
@@ -24,10 +47,39 @@ def login(data: LoginRequest, db: Session = Depends(get_db)):
|
||||
set_default_password(db, settings)
|
||||
|
||||
if not verify_password(data.password, settings.password_hash):
|
||||
login_limiter.record_failure(ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
token = create_access_token({"sub": str(settings.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
login_limiter.reset(ip)
|
||||
|
||||
token = create_access_token({
|
||||
"sub": str(settings.id),
|
||||
"tv": settings.token_version,
|
||||
})
|
||||
|
||||
response = JSONResponse(content={"message": "登录成功"})
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
response = JSONResponse(content={"message": "已退出登录"})
|
||||
response.delete_cookie("access_token", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthStatusResponse)
|
||||
def me(request: Request):
|
||||
user = get_current_user(request)
|
||||
return AuthStatusResponse(authenticated=True, user_id=user.get("sub", ""))
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@@ -36,7 +88,7 @@ def change_password(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
if not settings:
|
||||
@@ -46,6 +98,8 @@ def change_password(
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
settings.password_hash = hash_password(data.new_password)
|
||||
settings.token_version = (settings.token_version or 0) + 1
|
||||
settings.updated_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
Reference in New Issue
Block a user