Files
stock/backend/app/auth_api.py
2026-09-09 11:35:02 +08:00

192 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""只登录、不注册的鉴权 API。"""
from __future__ import annotations
import time
from collections import deque
from datetime import timedelta
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
drop_session_cache,
get_auth_session,
new_session_token,
require_user,
session_expiry,
token_digest,
utcnow,
verify_dummy_password,
verify_password,
)
from .config import settings
from .db import get_session
from .models import AuthSession, User
from .schemas import CurrentUserOut, LoginRequest, LoginResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
def set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
max_age=settings.auth_session_hours * 60 * 60,
path="/api",
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
key=settings.auth_cookie_name,
path="/api",
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
# ---------- 登录按 IP 限速(进程内滑动窗口,无外部依赖) ----------
# 补充账户级锁定auth_max_failed_logins本层拦多 IP 分布爆破,也稀释
# 「故意输错 5 次锁死他人账户」的滥用面。反代部署时 host 是代理 IP需改读 X-Forwarded-For。
_LOGIN_WINDOW = 60.0
_LOGIN_MAX_PER_WINDOW = 15
_login_attempts: dict[str, deque[float]] = {}
_login_gc_at = 0.0
def _login_rate_limited(ip: str) -> bool:
"""超限返回 True未超限记录本次尝试成功失败都计"""
global _login_gc_at
now = time.monotonic()
q = _login_attempts.setdefault(ip, deque())
while q and q[0] <= now - _LOGIN_WINDOW:
q.popleft()
if len(q) >= _LOGIN_MAX_PER_WINDOW:
return True
q.append(now)
if now - _login_gc_at > 3600: # 顺手回收陈旧 entry防长跑内存增长
_login_gc_at = now
stale = now - _LOGIN_WINDOW * 10
for k in [k for k, v in _login_attempts.items() if not v or v[-1] <= stale]:
del _login_attempts[k]
return False
@router.post("/login", response_model=LoginResponse)
async def login(
payload: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
ip = request.client.host if request.client else "?"
if _login_rate_limited(ip):
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试")
now = utcnow()
username = payload.username.strip()
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
if user is None:
verify_dummy_password(payload.password)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not user.is_active:
verify_dummy_password(payload.password)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if user.locked_until is not None and user.locked_until > now:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"登录失败次数过多,请在 {user.locked_until.isoformat()} 后重试",
)
if not verify_password(user.password_hash, payload.password):
user.failed_login_count += 1
if user.failed_login_count >= settings.auth_max_failed_logins:
user.failed_login_count = 0
user.locked_until = now + timedelta(minutes=settings.auth_lock_minutes)
user.updated_at = now
await db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = now
user.updated_at = now
# 登录时顺便清理过期或已吊销会话,避免单用户长期运行累积垃圾数据。
await db.execute(
delete(AuthSession).where(
(AuthSession.expires_at <= now) | (AuthSession.revoked_at.is_not(None))
)
)
token = new_session_token()
expires_at = session_expiry()
db.add(
AuthSession(
user_id=user.id,
token_hash=token_digest(token),
created_at=now,
expires_at=expires_at,
last_seen_at=now,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:512] or None,
)
)
await db.commit()
set_session_cookie(response, token)
response.headers["Cache-Control"] = "no-store"
return LoginResponse(user=CurrentUserOut.model_validate(user), expires_at=expires_at)
@router.get("/me", response_model=CurrentUserOut)
async def me(user: User = Depends(require_user)) -> CurrentUserOut:
return CurrentUserOut.model_validate(user)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
db: AsyncSession = Depends(get_session),
) -> Response:
if stock_session:
await db.execute(
update(AuthSession)
.where(AuthSession.token_hash == token_digest(stock_session))
.values(revoked_at=utcnow())
)
await db.commit()
drop_session_cache(digest=token_digest(stock_session)) # 同进程立即失效,登出即时生效
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"
return response
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
async def logout_all(
response: Response,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_session),
) -> Response:
await db.execute(
update(AuthSession)
.where(AuthSession.user_id == user.id, AuthSession.revoked_at.is_(None))
.values(revoked_at=utcnow())
)
await db.commit()
drop_session_cache(user_id=user.id) # 该用户全部 token 的本地缓存立即失效
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"
return response