From 37330453414e7e26ee72b8ef4b072b8392a44363 Mon Sep 17 00:00:00 2001 From: ylhan Date: Mon, 21 Sep 2026 21:54:52 +0800 Subject: [PATCH] =?UTF-8?q?auth:=20=E4=BB=8E=20Caddy=20basic=5Fauth=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E4=B8=BA=E5=BA=94=E7=94=A8=E5=86=85=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E7=99=BB=E5=BD=95(cookie=E4=BC=9A=E8=AF=9D)=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=BE=AE=E4=BF=A1=E5=86=85=E7=BD=AE=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Caddyfile | 3 - docker-compose.yml | 4 + xinquan/app.py | 181 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 172 insertions(+), 16 deletions(-) diff --git a/Caddyfile b/Caddyfile index 2593d60..ddce513 100644 --- a/Caddyfile +++ b/Caddyfile @@ -17,9 +17,6 @@ hyonglin.top { path /xinquandashboard/* } handle @dq { - basic_auth { - xqzk $2a$14$GrTqs4WiYHepvOq/0CnOU.Zq2.cA4B2FcilgGIv/6Jbi9BZX/8rNK - } uri strip_prefix /xinquandashboard reverse_proxy xinquan:8000 } diff --git a/docker-compose.yml b/docker-compose.yml index 95351af..b29c389 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: build: ./xinquan container_name: xinquan restart: unless-stopped + environment: + - XQ_USER=xqzk + - XQ_PASS=66668888 + - XQ_SECRET=bb8335b291300ea338373feea74af915e3346aedd52d7ac2fb66721a16b394e8 volumes: - ./xinquan_data:/data expose: diff --git a/xinquan/app.py b/xinquan/app.py index eb5aad3..944d900 100644 --- a/xinquan/app.py +++ b/xinquan/app.py @@ -16,6 +16,8 @@ 鉴权:由 Caddy basic_auth 在网关卡负责,本应用不鉴权。 """ import base64 +import hashlib +import hmac import json import os import re @@ -35,6 +37,33 @@ ALLOWED_IMG_EXT = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml"} +# ---- 应用内表单登录(替代 Caddy basic_auth,兼容微信内置浏览器)---- +AUTH_USER = os.environ.get("XQ_USER", "xqzk") +AUTH_PASS = os.environ.get("XQ_PASS", "66668888") +AUTH_SECRET = os.environ.get("XQ_SECRET", "xinquan-session-secret-change-me") +SESSION_DAYS = 7 + +RE_AUTH = re.compile(r"__AUTH__") + + +def _sign_token(user, exp): + payload = f"{user}|{exp}".encode() + sig = hmac.new(AUTH_SECRET.encode(), payload, hashlib.sha256).hexdigest() + return base64.urlsafe_b64encode(payload + b"|" + sig.encode()).decode() + + +def _verify_token(token): + try: + raw = base64.urlsafe_b64decode(token.encode()).decode() + user, exp, sig = raw.split("|") + if int(exp) < int(time.time()): + return None + expect = hmac.new(AUTH_SECRET.encode(), f"{user}|{exp}".encode(), + hashlib.sha256).hexdigest() + return user if hmac.compare_digest(expect, sig) else None + except Exception: + return None + def _ensure_dirs(): os.makedirs(DATA_DIR, exist_ok=True) @@ -110,6 +139,17 @@ INDEX_HTML = r""" display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .tile time{display:block;margin-top:10px;font-size:12px;color:var(--muted)} .empty{text-align:center;color:var(--muted);padding:60px 0;line-height:2} + .login{max-width:380px;margin:10vh auto 0;background:var(--card);border:1px solid var(--border); + border-radius:20px;padding:32px 30px;box-shadow:0 10px 30px rgba(20,30,70,.08)} + .login .lg{width:52px;height:52px;border-radius:14px;background:var(--grad);display:grid;place-items:center; + color:#fff;font-weight:800;font-size:24px;margin:0 auto 16px} + .login h2{text-align:center;font-size:20px;margin-bottom:22px} + .login input{width:100%;padding:11px 12px;margin-bottom:12px;border-radius:10px; + border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:15px;outline:none} + .login input:focus{border-color:var(--brand)} + .login button{width:100%;padding:11px;border:none;border-radius:10px;background:var(--grad); + color:#fff;font-size:15px;font-weight:600;cursor:pointer} + .login .err{color:#ef4444;font-size:13px;text-align:center;height:18px;margin-bottom:8px} .page{padding-top:8px} .page .title{font-size:26px;font-weight:800;letter-spacing:-.02em;margin-bottom:4px} .page .meta{color:var(--muted);font-size:13px;margin-bottom:18px} @@ -148,6 +188,7 @@ INDEX_HTML = r""" const BASE = (()=>{const p=location.pathname;const i=p.indexOf('/xinquandashboard'); return i>=0?p.slice(0,i+'/xinquandashboard'.length):'';})(); const $ = s => document.querySelector(s); +const AUTH = __AUTH__; function api(path, opts){ return fetch(BASE+path, opts).then(async r=>{ @@ -171,7 +212,8 @@ async function loadPages(){ renderList(); } function renderList(){ - $('#topActions').innerHTML = ``; + $('#topActions').innerHTML = ` + `; const a=$('#app'); if(!state.pages.length){ a.innerHTML=`
还没有内容。
`; return; } a.innerHTML = `
${state.pages.map(p=>` @@ -253,8 +295,45 @@ async function delPage(id){ } function cancelEdit(){ loadPages(); } +async function login(u,p){ + try{ + const r = await fetch(BASE+'/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:u,password:p})}); + if(!r.ok){ const j=await r.json().catch(()=>({})); return j.error||'登录失败'; } + return null; + }catch(e){ return '网络错误'; } +} +function renderLogin(){ + $('#topActions').innerHTML=''; + $('#app').innerHTML=``; + const doIt=async ()=>{ + const err=$('#err'); err.textContent=''; + const msg=await login($('#u').value.trim(), $('#p').value); + if(msg){ err.textContent=msg; return; } + $('#topActions').innerHTML=''; + loadPages(); + }; + $('#go').onclick=doIt; + $('#p').onkeydown=e=>{ if(e.key==='Enter') doIt(); }; + $('#u').focus(); +} +async function logout(){ + await fetch(BASE+'/logout',{method:'POST'}); + location.reload(); +} +if(!AUTH){ renderLogin(); } +else { onAuth(); } +function onAuth(){ + $('#topActions').innerHTML=''; + loadPages(); +} if(umarked){ document.addEventListener('DOMContentLoaded',()=>toast('Markdown 渲染库加载失败,回退为纯文本')); } -loadPages(); + @@ -306,33 +385,107 @@ class Handler(BaseHTTPRequestHandler): path = "/" + path return path + # ---- 会话 ---- + def _user(self): + cookies = self.headers.get("Cookie") or "" + tok = None + for pair in cookies.split(";"): + if not pair: + continue + kv = pair.strip().split("=", 1) + if len(kv) == 2 and kv[0] == "xq_auth": + tok = kv[1] + return _verify_token(tok) if tok else None + + def _secure_cookie(self): + return (self.headers.get("X-Forwarded-Proto", "") == "https") + + def _unauthed(self): + """返回 True 表示未登录(调用方应返回 401)。""" + return True + + def _login_ok(self, user): + # 生成 7 天会话 + exp = int(time.time()) + SESSION_DAYS * 86400 + tok = _sign_token(user, exp) + cookie = f"xq_auth={tok}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_DAYS*86400}" + if self._secure_cookie(): + cookie += "; Secure" + self._send(200, b'{"ok":true}', extra={"Set-Cookie": cookie}) + + def _login_fail(self): + self._json({"error": "用户名或密码错误"}, 401) + + def _auth_denied(self): + if self.path.startswith("/media"): + return self._send(401, b"unauthorized", "text/plain; charset=utf-8") + return self._json({"error": "unauthorized"}, 401) + + # ---- 路由 ---- + def _split(self): + path = self.path.split("?", 1)[0] + for pre in _PREFIXES: + if path.startswith(pre): + path = path[len(pre):] or "/" + break + if not path.startswith("/"): + path = "/" + path + return path + def do_GET(self): path = self._split() - if path == "/media/health": + if path == "/health": return self._json({"ok": True}) + user = self._user() + if path.startswith("/api/"): + if not user: + return self._auth_denied() + if path.startswith("/api/pages/"): + pid = path[len("/api/pages/"):] + return self._get_page(pid) if pid else self._json({"error": "missing id"}, 400) + if path == "/api/pages": + return self._list_pages() + return self._json({"error": "not found"}, 404) if path.startswith("/media/"): + if not user: + return self._auth_denied() return self._media(path[len("/media/"):]) - if path.startswith("/api/pages/"): - pid = path[len("/api/pages/"):] - if pid: - return self._get_page(pid) - return self._json({"error": "missing id"}, 400) - if path == "/api/pages": - return self._list_pages() - return self._send(200, self._index_html(), "text/html; charset=utf-8") + # 其余(/, /login, SPA 内部路由):总是渲染外壳;未登录则显示登录表单 + return self._send(200, self._index_html(bool(user)), + "text/html; charset=utf-8") - def _index_html(self): - return INDEX_HTML.replace("__MARKED__", BOOTSTRAP).encode("utf-8") + def _index_html(self, authed=True): + flag = "true" if authed else "false" + html = INDEX_HTML.replace("__MARKED__", BOOTSTRAP) + # 注入登录态,避免未授权时闪出内容 + result = RE_AUTH.sub(flag, html, count=1) + return result.encode("utf-8") def do_POST(self): path = self._split() + if path == "/login": + data = self._read_json() + if data and data.get("username") == AUTH_USER and \ + data.get("password") == AUTH_PASS: + return self._login_ok(AUTH_USER) + return self._login_fail() + if path == "/logout": + return self._logout() + if not self._user(): + return self._auth_denied() if path == "/api/pages": return self._create_page() if path == "/api/upload": return self._upload() self._json({"error": "not found"}, 404) + def _logout(self): + cookie = "xq_auth=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + self._send(200, b'{"ok":true}', extra={"Set-Cookie": cookie}) + def do_PUT(self): + if not self._user(): + return self._auth_denied() path = self._split() if path.startswith("/api/pages/"): pid = path[len("/api/pages/"):] @@ -341,6 +494,8 @@ class Handler(BaseHTTPRequestHandler): self._json({"error": "not found"}, 404) def do_DELETE(self): + if not self._user(): + return self._auth_denied() path = self._split() if path.startswith("/api/pages/"): pid = path[len("/api/pages/"):]