feat: harden auth for public deploy + gantt, duration units, docker packaging

Security (fail-secure for production):
- Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless
  PPM_ALLOW_NO_AUTH=1 (local dev only)
- Add login rate limiting (5 failures / 60s per client), enforced inside
  SessionManager.login so it cannot be bypassed at the endpoint layer
- compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy
- Add .dockerignore to keep runtime data/out of the image

Features:
- Task timeline gantt on home page (week/month zoom, horizontal scroll,
  infinite-arrow for open-ended dates, CSS Grid layout)
- Duration metrics gain a second/minute/hour unit for input & display
  (canonical storage stays integer seconds)
- Per-metric progress bars on home page (no more merged average)
- Backfill historical records via date picker in the today-record card
- Admin panel: stats overview, JSON import/restore, full task list mgmt

Fixes:
- Nullable task startDate; summarize handles missing start date
- Task dialog cancel/close no longer submits the form
- Import validates whole backup first, rejects path traversal, rebuilds
  archives, and replaces stale files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MobKBK
2026-08-24 12:11:03 +08:00
parent bcc2abe623
commit 02b81f0113
16 changed files with 705 additions and 45 deletions

View File

@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 # 与登录 Cookie 的 max_age 一致
LOGIN_MAX_FAILURES = 5
LOGIN_LOCKOUT_SECONDS = 60
def make_password_hash(password: str, iterations: int = 310_000) -> str:
@@ -37,10 +39,19 @@ class Session:
class SessionManager:
def __init__(self, password_hash: str | None, ttl_seconds: float = SESSION_TTL_SECONDS):
def __init__(
self,
password_hash: str | None,
ttl_seconds: float = SESSION_TTL_SECONDS,
max_failures: int = LOGIN_MAX_FAILURES,
lockout_seconds: float = LOGIN_LOCKOUT_SECONDS,
):
self.password_hash = password_hash
self.ttl_seconds = ttl_seconds
self.max_failures = max_failures
self.lockout_seconds = lockout_seconds
self.sessions: dict[str, Session] = {}
self._failures: dict[str, list[float]] = {}
@property
def enabled(self) -> bool:
@@ -50,9 +61,21 @@ class SessionManager:
def from_environment(cls) -> "SessionManager":
encoded = os.getenv("PPM_PASSWORD_HASH", "").strip()
plain = os.getenv("PPM_PASSWORD", "")
allow_no_auth = os.getenv("PPM_ALLOW_NO_AUTH", "").strip().lower() in {"1", "true", "yes"}
if not encoded and plain:
encoded = make_password_hash(plain)
return cls(encoded or None)
if not encoded:
# 默认 fail-secure:没有密码就拒绝启动,除非显式豁免(仅本地开发)。
if allow_no_auth:
return cls(None)
raise RuntimeError(
"未配置访问密码,拒绝启动(fail-secure)。"
"请设置环境变量 PPM_PASSWORD 或 PPM_PASSWORD_HASH;"
"仅本地开发可设置 PPM_ALLOW_NO_AUTH=1 豁免认证。"
)
return cls(encoded)
def _expire(self) -> None:
cutoff = time.time() - self.ttl_seconds
@@ -60,10 +83,29 @@ class SessionManager:
for sid in expired:
self.sessions.pop(sid, None)
def login(self, password: str) -> tuple[str, Session] | None:
if not self.password_hash or not verify_password(password, self.password_hash):
def _prune_failures(self, now: float) -> None:
cutoff = now - self.lockout_seconds
for key in list(self._failures):
self._failures[key] = [ts for ts in self._failures[key] if ts > cutoff]
if not self._failures[key]:
self._failures.pop(key, None)
def is_login_locked(self, client_key: str) -> bool:
self._prune_failures(time.time())
return len(self._failures.get(client_key, [])) >= self.max_failures
def login(self, password: str, client_key: str = "") -> tuple[str, Session] | None:
if not self.password_hash:
return None
# 锁定检查内聚到 login:即便绕过 HTTP 端点直接调用也无法暴力破解。
if client_key and self.is_login_locked(client_key):
return None
if not verify_password(password, self.password_hash):
if client_key:
self._failures.setdefault(client_key, []).append(time.time())
return None
self._expire()
self._failures.pop(client_key, None)
session_id = secrets.token_urlsafe(32)
session = Session(csrf=secrets.token_urlsafe(32))
self.sessions[session_id] = session