"""只登录、不注册的鉴权 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