feat(web): require login only for candidates and system
This commit is contained in:
1 parent
55c40db047
commit
6aefe9ce87
6 files changed
+130
-32
No files matched your search
@@ -18,7 +18,7 @@ from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||||
|
||||
from ..analytics import is_loopback
|
||||
from ..config import ConfigStore
|
||||
@@ -28,6 +28,8 @@ from .queries import DashboardQueries
|
||||
STATIC_ROOT = Path(__file__).resolve().parent / "static"
|
||||
COOKIE_NAME = "rb_session"
|
||||
SESSION_TTL = 7 * 86400
|
||||
PROTECTED_API = {"/api/candidates", "/api/system"}
|
||||
PROTECTED_PAGES = {"/candidates", "/system"}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -117,18 +119,26 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
})
|
||||
return
|
||||
|
||||
if path == "/api/session":
|
||||
self._send_json(HTTPStatus.OK, {
|
||||
"auth_enabled": bool(self.context.auth_token),
|
||||
"authenticated": self._authorized(),
|
||||
"preview": self.context.preview,
|
||||
})
|
||||
return
|
||||
|
||||
if path == "/login":
|
||||
if method == "POST":
|
||||
self._login_post()
|
||||
self._login_post(query)
|
||||
else:
|
||||
self._login_page()
|
||||
self._login_page(query)
|
||||
return
|
||||
if path == "/logout":
|
||||
self._logout()
|
||||
return
|
||||
|
||||
if path.startswith("/api/"):
|
||||
if not self._authorized():
|
||||
if path in PROTECTED_API and not self._authorized():
|
||||
self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录")
|
||||
return
|
||||
if method == "POST":
|
||||
@@ -141,8 +151,8 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
self._api(path, query, method)
|
||||
return
|
||||
|
||||
if not self._authorized():
|
||||
self._redirect("/login")
|
||||
if path in PROTECTED_PAGES and not self._authorized():
|
||||
self._redirect(f"/login?next={quote(path)}")
|
||||
return
|
||||
self._static(path, method)
|
||||
except ApiError as exc:
|
||||
@@ -203,10 +213,11 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
morsel = cookie.get(COOKIE_NAME)
|
||||
return morsel is not None and self._valid_session(morsel.value)
|
||||
|
||||
def _login_page(self) -> None:
|
||||
def _login_page(self, query: Mapping[str, list[str]]) -> None:
|
||||
if not self.context.auth_token:
|
||||
self._redirect("/")
|
||||
return
|
||||
next_path = _safe_next(_param(query, "next", ""))
|
||||
html = """<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -230,9 +241,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
||||
<input type="password" name="token" autocomplete="current-password" placeholder="访问令牌" required autofocus>
|
||||
<button type="submit">进入看板</button>
|
||||
</form></main></body></html>"""
|
||||
html = html.replace(
|
||||
'action="/login"', f'action="/login?next={quote(next_path)}"'
|
||||
)
|
||||
self._send_html(HTTPStatus.OK, html)
|
||||
|
||||
def _login_post(self) -> None:
|
||||
def _login_post(self, query: Mapping[str, list[str]]) -> None:
|
||||
if not self.context.auth_token:
|
||||
self._redirect("/")
|
||||
return
|
||||
@@ -250,8 +264,9 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
||||
return
|
||||
expires_at = int(self.context.clock()) + SESSION_TTL
|
||||
value = self._session_value(expires_at)
|
||||
target = _safe_next(_param(query, "next", ""))
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Location", target)
|
||||
self.send_header("Set-Cookie", (
|
||||
f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; "
|
||||
f"Max-Age={SESSION_TTL}"
|
||||
@@ -481,6 +496,13 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
||||
# ------------------------------------------------------------ helpers
|
||||
|
||||
|
||||
def _safe_next(value: str | None) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text.startswith("/") or text.startswith("//") or text.startswith("/login"):
|
||||
return "/"
|
||||
return text
|
||||
|
||||
|
||||
def _param(query: Mapping[str, list[str]], name: str, default: str) -> str:
|
||||
values = query.get(name)
|
||||
return default if not values else str(values[0])
|
||||
|
||||
@@ -50,3 +50,10 @@ export async function health() {
|
||||
const payload = await response.json();
|
||||
return payload?.data ?? payload;
|
||||
}
|
||||
|
||||
export async function session() {
|
||||
const response = await fetch("/api/session", { credentials: "same-origin" });
|
||||
if (!response.ok) throw new ApiError("无法连接看板服务", response.status);
|
||||
const payload = await response.json();
|
||||
return payload?.data ?? payload;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { health } from "./api.js";
|
||||
import { session } from "./api.js";
|
||||
import { disposeAll } from "./charts.js";
|
||||
import { escapeHtml } from "./format.js";
|
||||
import { onRoute, startRouter } from "./router.js";
|
||||
import { onRoute, parseLocation, startRouter } from "./router.js";
|
||||
import { renderCandidates } from "./views/candidates.js";
|
||||
import { renderFans } from "./views/fans.js";
|
||||
import { renderHome } from "./views/home.js";
|
||||
@@ -14,8 +13,8 @@ const NAV_ITEMS = [
|
||||
{ path: "/home", label: "首页", icon: "home" },
|
||||
{ path: "/rank", label: "榜单", icon: "rank" },
|
||||
{ path: "/fans", label: "粉丝", icon: "fans" },
|
||||
{ path: "/candidates", label: "候选", icon: "candidate" },
|
||||
{ path: "/system", label: "系统", icon: "system" },
|
||||
{ path: "/candidates", label: "候选", icon: "candidate", protected: true },
|
||||
{ path: "/system", label: "系统", icon: "system", protected: true },
|
||||
];
|
||||
|
||||
const ROUTES = [
|
||||
@@ -24,10 +23,32 @@ const ROUTES = [
|
||||
{ pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" },
|
||||
{ pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" },
|
||||
{ pattern: /^\/fans$/, view: renderFans, title: "粉丝" },
|
||||
{ pattern: /^\/candidates$/, view: renderCandidates, title: "候选池" },
|
||||
{ pattern: /^\/system$/, view: renderSystem, title: "系统状态" },
|
||||
{
|
||||
pattern: /^\/candidates$/, view: renderCandidates, title: "候选池",
|
||||
beforeEnter: requireAuth,
|
||||
},
|
||||
{
|
||||
pattern: /^\/system$/, view: renderSystem, title: "系统状态",
|
||||
beforeEnter: requireAuth,
|
||||
},
|
||||
];
|
||||
|
||||
let authState = { authEnabled: false, authenticated: true };
|
||||
|
||||
function canAccessProtected() {
|
||||
return !authState.authEnabled || authState.authenticated;
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (canAccessProtected()) return true;
|
||||
const { path, query } = parseLocation();
|
||||
const search = query.toString();
|
||||
window.location.assign(
|
||||
`/login?next=${encodeURIComponent(`${path}${search ? `?${search}` : ""}`)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function icon(name) {
|
||||
const paths = {
|
||||
home: '<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z"/>',
|
||||
@@ -42,7 +63,10 @@ function icon(name) {
|
||||
|
||||
function renderNav() {
|
||||
const nav = document.querySelector("#bottom-nav");
|
||||
nav.innerHTML = NAV_ITEMS.map((item) => `
|
||||
const items = NAV_ITEMS.filter(
|
||||
(item) => !item.protected || canAccessProtected(),
|
||||
);
|
||||
nav.innerHTML = items.map((item) => `
|
||||
<a class="nav-item" href="${item.path}" data-nav="${item.path}">
|
||||
${icon(item.icon)}<span class="nav-label">${item.label}</span>
|
||||
</a>`).join("");
|
||||
@@ -88,19 +112,26 @@ window.addEventListener("error", (event) => {
|
||||
}, true);
|
||||
|
||||
async function boot() {
|
||||
renderNav();
|
||||
const banner = document.querySelector("#preview-banner");
|
||||
const hint = document.querySelector("#topbar-hint");
|
||||
const authLink = document.querySelector("#topbar-auth");
|
||||
try {
|
||||
const payload = await health();
|
||||
const data = payload?.data || payload;
|
||||
const data = await session();
|
||||
authState = {
|
||||
authEnabled: Boolean(data?.auth_enabled),
|
||||
authenticated: data?.authenticated !== false,
|
||||
};
|
||||
if (data?.preview) {
|
||||
banner.hidden = false;
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
if (authLink && authState.authEnabled && !authState.authenticated) {
|
||||
authLink.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
toast("无法连接看板服务", "error");
|
||||
}
|
||||
renderNav();
|
||||
onRoute(({ path, route }) => {
|
||||
disposeAll();
|
||||
setTitle(route, path);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<small id="topbar-title">最新动态</small>
|
||||
</span>
|
||||
</a>
|
||||
<a class="topbar-hint" id="topbar-auth" href="/login" hidden>登录</a>
|
||||
<span class="topbar-hint" id="topbar-hint" hidden>预览</span>
|
||||
</header>
|
||||
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
|
||||
|
||||
@@ -125,6 +125,10 @@ async function render(options = {}) {
|
||||
finishRender();
|
||||
return;
|
||||
}
|
||||
if (matched.route.beforeEnter && matched.route.beforeEnter() === false) {
|
||||
finishRender();
|
||||
return;
|
||||
}
|
||||
current = { path, query, route: matched.route };
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
|
||||
+45
-12
@@ -65,6 +65,15 @@ def test_readonly_routes_and_static():
|
||||
assert status == 200 and health["ok"] is True
|
||||
assert health["data"]["preview"] is False
|
||||
|
||||
status, _, payload = running.json("/api/session")
|
||||
assert status == 200
|
||||
assert payload["data"]["auth_enabled"] is False
|
||||
assert payload["data"]["authenticated"] is True
|
||||
status, _, payload = running.json("/api/candidates")
|
||||
assert status == 200 and payload["ok"] is True
|
||||
status, _, payload = running.json("/api/system")
|
||||
assert status == 200 and payload["ok"] is True
|
||||
|
||||
status, _, overview = running.json("/api/overview")
|
||||
assert status == 200 and overview["ok"] is True
|
||||
assert overview["data"]["data_date"] == "2026-10-31"
|
||||
@@ -169,16 +178,38 @@ def test_readonly_routes_and_static():
|
||||
|
||||
def test_token_auth_and_login():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
running = RunningServer(tmp, token="secret-token")
|
||||
running = RunningServer(tmp, token="test-token")
|
||||
try:
|
||||
# 公共页面与公共 API 访客可见
|
||||
status, _, body = running.json("/api/overview")
|
||||
assert status == 200 and body["ok"] is True
|
||||
status, _, body = running.json("/api/videos")
|
||||
assert status == 200 and body["ok"] is True
|
||||
status, _, body = running.json("/api/fans?days=7")
|
||||
assert status == 200 and body["ok"] is True
|
||||
status, _, payload = running.request("/")
|
||||
assert status == 200 and b"<html" in payload.lower()
|
||||
status, _, payload = running.request("/rank")
|
||||
assert status == 200 and b"<html" in payload.lower()
|
||||
|
||||
# 候选与系统需要登录
|
||||
status, _, body = running.json("/api/candidates")
|
||||
assert status == 401 and body["error"]["code"] == "unauthorized"
|
||||
status, _, body = running.json("/api/system")
|
||||
assert status == 401
|
||||
status, headers, _ = running.request("/candidates")
|
||||
assert status == 303 and headers["Location"] == "/login?next=/candidates"
|
||||
status, headers, _ = running.request("/system")
|
||||
assert status == 303 and headers["Location"] == "/login?next=/system"
|
||||
|
||||
status, headers, _ = running.request("/")
|
||||
assert status == 303 and headers["Location"] == "/login"
|
||||
status, _, body = running.json("/api/session")
|
||||
assert status == 200
|
||||
assert body["data"]["auth_enabled"] is True
|
||||
assert body["data"]["authenticated"] is False
|
||||
|
||||
status, _, payload = running.request("/login")
|
||||
status, _, payload = running.request("/login?next=/candidates")
|
||||
assert status == 200 and b"web.auth_token" in payload
|
||||
assert b"action=\"/login?next=/candidates\"" in payload
|
||||
|
||||
status, _, _ = running.request(
|
||||
"/login",
|
||||
@@ -189,28 +220,30 @@ def test_token_auth_and_login():
|
||||
assert status == 401
|
||||
|
||||
status, headers, _ = running.request(
|
||||
"/login",
|
||||
"/login?next=/candidates",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
body="token=secret-token",
|
||||
body="token=test-token",
|
||||
)
|
||||
assert status == 303
|
||||
assert status == 303 and headers["Location"] == "/candidates"
|
||||
cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
assert cookie.startswith("rb_session=")
|
||||
|
||||
status, _, body = running.json("/api/overview", headers={"Cookie": cookie})
|
||||
status, _, body = running.json("/api/candidates", headers={"Cookie": cookie})
|
||||
assert status == 200 and body["ok"] is True
|
||||
status, _, body = running.json("/api/system", headers={"Cookie": cookie})
|
||||
assert status == 200 and body["ok"] is True
|
||||
|
||||
status, _, body = running.json(
|
||||
"/api/overview", headers={"Authorization": "Bearer secret-token"}
|
||||
"/api/candidates", headers={"Authorization": "Bearer test-token"}
|
||||
)
|
||||
assert status == 200
|
||||
|
||||
tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1")
|
||||
status, _, _ = running.json("/api/overview", headers={"Cookie": tampered})
|
||||
status, _, _ = running.json("/api/candidates", headers={"Cookie": tampered})
|
||||
assert status == 401
|
||||
status, _, body = running.json("/api/overview", headers={"Cookie": tampered})
|
||||
assert status == 200
|
||||
|
||||
# 健康检查始终匿名
|
||||
status, _, _ = running.json("/api/health")
|
||||
assert status == 200
|
||||
finally:
|
||||
|
||||
Reference in new issue
Block a user