Files
gaokao-volunteer-system/admin/config.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

127 lines
5.0 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.
"""配置加载 (T6.1).
所有运行时配置通过环境变量读取。提供默认值用于开发环境,但启动时
必须输出 WARN 提示生产环境必须显式覆盖。
"""
from __future__ import annotations
import os
import secrets
import string
from dataclasses import dataclass
from fastapi import Request
# 安全占位密钥(仅 dev 环境)。生产必须显式设置 GAOKAO_JWT_SECRET。
_DEV_JWT_SECRET = "dev-only-do-not-use-in-prod-please-override-via-env"
_DEFAULT_ADMIN_PASSWORD = "admin123"
_MIN_ADMIN_PASSWORD_LENGTH = 10
@dataclass(frozen=True)
class Settings:
"""运行时配置(不可变)。"""
env: str
db_path: str
orders_db_path: str # T6.2 — 订单数据 (data.orders.* 写入位置)
share_db_path: str # T7.5 — 短链接 SQLite
share_report_dir: str # T7.5 — report_id -> JSON 报告目录
jwt_secret: str
jwt_algorithm: str
jwt_expire_minutes: int
default_admin_username: str
default_admin_password: str
def load_settings() -> Settings:
"""从环境变量加载配置。
- GAOKAO_ENV : dev|prod默认 dev
- GAOKAO_DB_PATH : 管理后台 SQLite 文件路径,默认 data/orders/admin.db
- GAOKAO_ORDERS_DB_PATH : 订单 DB 路径 (与 data.orders.* 共享),默认 data/orders.db
- GAOKAO_SHARE_DB_PATH : 分享短链接 DB 路径,默认 data/share/short_links.db
- GAOKAO_SHARE_REPORT_DIR : 分享报告 JSON 目录,默认 data/share/reports
- GAOKAO_JWT_SECRET : HS256 密钥,默认 dev 占位(启动日志 WARN
- GAOKAO_JWT_EXP_MIN : JWT 过期时间(分钟),默认 60
- GAOKAO_ADMIN_USER : 默认管理员用户名,默认 admin
- GAOKAO_ADMIN_PASS : 默认管理员密码,默认 admin123仅本地开发占位
Returns:
Settings: 不可变配置实例
"""
return Settings(
env=os.getenv("GAOKAO_ENV", "dev"),
db_path=os.getenv("GAOKAO_DB_PATH", "data/orders/admin.db"),
orders_db_path=os.getenv("GAOKAO_ORDERS_DB_PATH", "data/orders.db"),
share_db_path=os.getenv("GAOKAO_SHARE_DB_PATH", "data/share/short_links.db"),
share_report_dir=os.getenv("GAOKAO_SHARE_REPORT_DIR", "data/share/reports"),
jwt_secret=os.getenv("GAOKAO_JWT_SECRET", _DEV_JWT_SECRET),
jwt_algorithm=os.getenv("GAOKAO_JWT_ALGORITHM", "HS256"),
jwt_expire_minutes=int(os.getenv("GAOKAO_JWT_EXP_MIN", "60")),
default_admin_username=os.getenv("GAOKAO_ADMIN_USER", "admin"),
default_admin_password=os.getenv("GAOKAO_ADMIN_PASS", _DEFAULT_ADMIN_PASSWORD),
)
def is_jwt_secret_secure(settings: Settings) -> tuple:
"""判断 JWT 密钥是否满足最低安全门槛。
Returns:
(is_secure, reason) : 不满足时给出原因字符串
"""
secret = settings.jwt_secret
if settings.env == "prod":
if secret == _DEV_JWT_SECRET:
return False, "生产环境禁止使用 dev 占位密钥"
if len(secret) < 32:
return False, "生产环境 JWT 密钥长度必须 >= 32 (当前 {})".format(
len(secret)
)
if secret == _DEV_JWT_SECRET and settings.env == "dev":
return False, "dev 环境使用占位密钥(仅本地开发可接受)"
if len(secret) < 32:
return False, "JWT 密钥长度必须 >= 32 (当前 {})".format(len(secret))
return True, "ok"
def is_default_admin_password_secure(settings: Settings) -> tuple[bool, str]:
"""判断默认管理员密码是否满足最低安全门槛。"""
password = settings.default_admin_password
if len(password) < _MIN_ADMIN_PASSWORD_LENGTH:
return False, (
f"默认管理员密码长度必须 >= {_MIN_ADMIN_PASSWORD_LENGTH} (当前 {len(password)})"
)
if settings.env == "prod" and password == _DEFAULT_ADMIN_PASSWORD:
return False, "生产环境禁止使用默认管理员密码 admin123"
if settings.env == "prod":
classes = sum((
any(ch.islower() for ch in password),
any(ch.isupper() for ch in password),
any(ch.isdigit() for ch in password),
any(ch in string.punctuation for ch in password),
))
if classes < 3:
return False, "生产环境默认管理员密码至少覆盖 3 类字符(大小写/数字/符号)"
if settings.env == "dev" and password == _DEFAULT_ADMIN_PASSWORD:
return False, "dev 环境仍在使用默认管理员密码(仅本地临时开发可接受)"
return True, "ok"
def generate_secure_secret() -> str:
"""生成 32-byte 十六进制密钥(用于初始化文档示例)。"""
return secrets.token_hex(32)
def get_settings_dep(request: Request) -> Settings:
"""FastAPI 依赖:从 app.state 取已加载的 Settings。
与 admin.auth.get_settings 重复,但放在 config 里便于其他路由直接引用。
"""
settings = getattr(request.app.state, "settings", None)
if settings is None: # pragma: no cover - 兜底
settings = load_settings()
return settings