iOSBuildServer/backend/routers/auth.py

85 lines
3.3 KiB
Python

"""认证 API"""
import jwt
import threading
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse
from ..config import (
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS,
LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS,
)
from ..deps import get_current_user
from ..database import get_db
from ..models import User
from ..security import hash_password, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_attempts: dict[str, tuple[int, float, float]] = {}
_login_attempts_lock = threading.Lock()
def _create_token(username: str, is_admin: bool) -> str:
"""生成 JWT token"""
payload = {
"sub": username,
"is_admin": is_admin,
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _check_login_rate_limit(client_ip: str) -> None:
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0))
if locked_until > now:
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
_login_attempts.pop(client_ip, None)
def _record_login_failure(client_ip: str) -> None:
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0))
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
attempts, first_failure = 0, now
attempts += 1
locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0
_login_attempts[client_ip] = (attempts, first_failure, locked_until)
def _clear_login_failures(client_ip: str) -> None:
with _login_attempts_lock:
_login_attempts.pop(client_ip, None)
@router.post("/login", response_model=LoginResponse)
async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""用户登录"""
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)
user = db.query(User).filter(User.username == credentials.username).first()
valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False)
if not user or not valid:
_record_login_failure(client_ip)
raise HTTPException(status_code=401, detail="用户名或密码错误")
if needs_upgrade:
user.password_hash = hash_password(credentials.password)
db.commit()
_clear_login_failures(client_ip)
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
@router.get("/me")
async def get_me(user: dict = Depends(get_current_user)):
"""校验 token 有效性,返回当前用户信息"""
return {"username": user["username"], "is_admin": user["is_admin"]}