Files
cms-SiteForge/app/security.py
T
浅念 3c57c167f2 chore: import SiteForge Factory V5 source (baseline)
导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 10:42:19 +08:00

105 lines
3.9 KiB
Python

"""One-owner authentication for the SiteForge Docker Web edition.
Session cookie is signed and HttpOnly. JSON mutations additionally require a
session-bound CSRF header; this is intentionally a small-team / single-admin
MVP, not a multi-tenant identity provider.
"""
import hashlib
import hmac
import os
import secrets
import time
from collections import defaultdict
from pathlib import Path
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from fastapi import HTTPException
COOKIE = 'siteforge_session'
MAX_AGE = 8 * 3600
SERIALIZER = None
PASSWORD_HASH = b''
PASSWORD_SALT = b''
CSRF_KEY = b''
COOKIE_SECURE = False
FAILURES = defaultdict(list)
def configure():
global SERIALIZER, PASSWORD_HASH, PASSWORD_SALT, CSRF_KEY, COOKIE_SECURE
password_file = os.environ.get('SITEFORGE_ADMIN_PASSWORD_FILE', '').strip()
if password_file:
password = Path(password_file).read_text(encoding='utf-8').strip('\r\n')
else:
password = os.environ.get('SITEFORGE_ADMIN_PASSWORD', '')
secret = os.environ.get('SITEFORGE_SESSION_SECRET', '')
weak = {'password', 'admin123456', 'change-me', 'replace-me', 'changeme', '123456789', 'REPLACE_WITH_STRONG_PASSWORD'}
if len(password) < 12 or password in weak:
raise RuntimeError('请设置至少 12 位的 SITEFORGE_ADMIN_PASSWORD,或配置 SITEFORGE_ADMIN_PASSWORD_FILE')
if len(secret) < 32 or secret in weak or secret == 'REPLACE_WITH_64_RANDOM_CHARACTERS':
raise RuntimeError('请配置至少 32 位的随机 SITEFORGE_SESSION_SECRET')
SERIALIZER = URLSafeTimedSerializer(secret_key=secret, salt='siteforge-web-v02')
# Keep the deployment secret out of memory after startup and make each
# verification deliberately expensive, consistent with team accounts.
PASSWORD_SALT = secrets.token_bytes(16)
PASSWORD_HASH = hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
CSRF_KEY = hashlib.sha256(('siteforge-csrf-v02:'+secret).encode()).digest()
COOKIE_SECURE = os.environ.get('SITEFORGE_COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes')
FAILURES.clear()
def check_password(password):
try:
candidate=hashlib.scrypt(password.encode('utf-8'),salt=PASSWORD_SALT,n=2**14,r=8,p=1,dklen=32)
return hmac.compare_digest(candidate,PASSWORD_HASH)
except (ValueError,TypeError,MemoryError):
return False
def sign_session():
session_id = secrets.token_urlsafe(32)
return SERIALIZER.dumps({'sub': 'admin', 'sid': session_id, 'v': 2})
def sign_user_session(user_id):
return SERIALIZER.dumps({'sub':'user','user_id':user_id,'sid':secrets.token_urlsafe(32),'v':3})
def read_session(cookie):
if not cookie or SERIALIZER is None:
return None
try:
value = SERIALIZER.loads(cookie, max_age=MAX_AGE)
if (value.get('sub'),value.get('v')) not in (('admin',2),('user',3)):
return None
return value
except (BadSignature, SignatureExpired, ValueError, TypeError):
return None
def csrf(session):
return hmac.new(CSRF_KEY, session['sid'].encode(), hashlib.sha256).hexdigest()
def verify_csrf(session, supplied):
return bool(supplied) and hmac.compare_digest(csrf(session), supplied)
def check_login_rate(remote):
now = time.monotonic()
# Drop expired buckets so spoofed source addresses cannot grow memory
# without bound on a long-running process.
for key in list(FAILURES):
recent=[t for t in FAILURES[key] if now-t < 900]
if recent:FAILURES[key]=recent
else:FAILURES.pop(key,None)
FAILURES[remote] = [t for t in FAILURES[remote] if now-t < 900]
if len(FAILURES[remote]) >= 8:
raise HTTPException(429, '登录尝试次数过多,请稍后重试')
def note_login_failure(remote):
FAILURES[remote].append(time.monotonic())
def note_login_success(remote):
FAILURES.pop(remote, None)