File size: 8,428 Bytes
e0b68d2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | """
管理后台认证中间件
"""
from fastapi import Request, HTTPException, status
from typing import Optional
import secrets
import base64
import hmac
import json
import time
from app.core.config import settings
# Session 有效期(小时)
SESSION_EXPIRE_HOURS = 24
SESSION_COOKIE_NAME = "admin_session"
CSRF_COOKIE_NAME = "admin_csrf"
CSRF_HEADER_NAME = "X-CSRF-Token"
LOGIN_WINDOW_SECONDS = 300
LOGIN_MAX_ATTEMPTS = 5
_login_attempts: dict[str, list[float]] = {}
# ── Session 吊销黑名单(进程内存,重启后清空)─────────────────────────────
# 键: session token 原始字符串, 值: 吊销时间戳
_revoked_sessions: dict[str, float] = {}
_last_prune_time: float = 0.0
_PRUNE_INTERVAL = 60.0
def _sign_payload(payload: str) -> str:
secret = settings.SESSION_SECRET_KEY or ""
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), "sha256").hexdigest()
def _encode_session(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
b64 = base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
signature = _sign_payload(b64)
return f"{b64}.{signature}"
def _decode_session(token: str) -> Optional[dict]:
if not token or "." not in token:
return None
b64, signature = token.split(".", 1)
expected = _sign_payload(b64)
if not hmac.compare_digest(signature, expected):
return None
padded = b64 + "=" * (-len(b64) % 4)
try:
raw = base64.urlsafe_b64decode(padded.encode("utf-8"))
return json.loads(raw.decode("utf-8"))
except Exception:
return None
def _now() -> float:
return time.time()
def _prune_attempts(attempts: list[float]) -> list[float]:
cutoff = _now() - LOGIN_WINDOW_SECONDS
return [ts for ts in attempts if ts >= cutoff]
def check_login_rate_limit(client_id: str) -> bool:
attempts = _login_attempts.get(client_id, [])
attempts = _prune_attempts(attempts)
if attempts:
_login_attempts[client_id] = attempts
else:
_login_attempts.pop(client_id, None)
return len(attempts) < LOGIN_MAX_ATTEMPTS
def record_login_failure(client_id: str) -> None:
attempts = _login_attempts.get(client_id, [])
attempts = _prune_attempts(attempts)
attempts.append(_now())
_login_attempts[client_id] = attempts
def reset_login_failures(client_id: str) -> None:
_login_attempts.pop(client_id, None)
def create_session(password: str) -> Optional[str]:
"""
创建 session
Args:
password: 用户输入的密码
Returns:
session_token 或 None(密码错误)
"""
# 常数时间密码比较,防止时序侧信道
if not hmac.compare_digest(password.encode("utf-8"), settings.ADMIN_PASSWORD.encode("utf-8")):
return None
issued_at = int(_now())
expires_at = int((_now() + SESSION_EXPIRE_HOURS * 3600))
payload = {
"iat": issued_at,
"exp": expires_at,
"auth": True,
# 每个 session 携带唯一 jti,确保吊销时能精确匹配
"jti": secrets.token_hex(16),
}
return _encode_session(payload)
def verify_session(session_token: Optional[str]) -> bool:
"""
验证 session 是否有效
Args:
session_token: Session token
Returns:
是否已认证
"""
if not session_token:
return False
# 惰性清理:距上次清理超过 _PRUNE_INTERVAL 秒时顺便执行
now = _now()
global _last_prune_time
if now - _last_prune_time > _PRUNE_INTERVAL:
_prune_revoked_sessions()
_last_prune_time = now
# 检查吊销黑名单
if session_token in _revoked_sessions:
return False
session = _decode_session(session_token)
if not session:
return False
exp = session.get("exp")
if isinstance(exp, (int, float)) and _now() > float(exp):
return False
return bool(session.get("auth"))
def delete_session(session_token: Optional[str]) -> None:
"""将 session token 加入吊销黑名单(真正的登出)"""
if session_token:
_revoked_sessions[session_token] = _now()
_prune_revoked_sessions()
def _prune_revoked_sessions() -> None:
"""清理已过期的黑名单条目(避免内存无限增长)"""
cutoff = _now() - SESSION_EXPIRE_HOURS * 3600
expired_keys = [k for k, ts in _revoked_sessions.items() if ts < cutoff]
for k in expired_keys:
del _revoked_sessions[k]
def get_session_token_from_request(request: Request) -> Optional[str]:
"""从请求中获取 session token"""
return request.cookies.get(SESSION_COOKIE_NAME)
# ── CSRF: 绑定 Session 的 Double Submit Cookie ────────────────────────────
# 格式: <random_part>.<hmac_tag>
# hmac_tag = HMAC-SHA256(secret, random_part + ":" + session_token)[0:16]
# 这样即使攻击者能注入任意 Cookie,也无法伪造与当前 Session 绑定的 CSRF token
def generate_csrf_token(session_token: str) -> str:
"""生成与 Session 密码学绑定的 CSRF token"""
random_part = secrets.token_urlsafe(32)
secret = settings.SESSION_SECRET_KEY or ""
tag = hmac.new(
secret.encode("utf-8"),
f"{random_part}:{session_token}".encode("utf-8"),
"sha256",
).hexdigest()[:16]
return f"{random_part}.{tag}"
def verify_csrf_token(csrf_token: str, session_token: str) -> bool:
"""校验 CSRF token 是否与当前 Session 匹配"""
if not csrf_token or "." not in csrf_token:
return False
random_part, tag = csrf_token.rsplit(".", 1)
secret = settings.SESSION_SECRET_KEY or ""
expected_tag = hmac.new(
secret.encode("utf-8"),
f"{random_part}:{session_token}".encode("utf-8"),
"sha256",
).hexdigest()[:16]
return hmac.compare_digest(tag, expected_tag)
def get_csrf_token_from_request(request: Request) -> Optional[str]:
"""从请求中获取 CSRF token(Cookie)"""
return request.cookies.get(CSRF_COOKIE_NAME)
def get_csrf_header_from_request(request: Request) -> Optional[str]:
return request.headers.get(CSRF_HEADER_NAME)
async def require_auth(request: Request):
"""
认证依赖项:要求用户已登录
在路由中使用:
@router.get("/admin", dependencies=[Depends(require_auth)])
"""
session_token = get_session_token_from_request(request)
if not verify_session(session_token):
# 未认证,重定向到登录页
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
detail="未登录",
headers={"Location": "/admin/login"}
)
async def require_csrf(request: Request):
"""CSRF 保护,仅针对非安全方法(POST/PUT/DELETE/PATCH)"""
if request.method.upper() in {"GET", "HEAD", "OPTIONS"}:
return
session_token = get_session_token_from_request(request)
csrf_cookie = get_csrf_token_from_request(request)
csrf_header = get_csrf_header_from_request(request)
# header 必须与 cookie 一致(Double Submit)
if not csrf_cookie or not csrf_header or not hmac.compare_digest(csrf_cookie, csrf_header):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token",
)
# 进一步验证 token 是否与当前 Session 绑定(防 Cookie 注入绕过)
if not session_token or not verify_csrf_token(csrf_cookie, session_token):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token",
)
def get_authenticated_user(request: Request) -> bool:
"""
获取当前认证状态(用于模板)
Returns:
是否已认证
"""
session_token = get_session_token_from_request(request)
return verify_session(session_token)
def cleanup_expired_sessions():
"""清理过期的黑名单条目(定时任务调用)"""
_prune_revoked_sessions()
return len(_revoked_sessions)
|